tel: +48 728 438 076
email: piotr.hyzy@eviden.com
Początek¶
Rundka - sprawdzenie mikrofonów¶
Kilka zasad¶
- W razie problemów => chat, potem SMS i telefon, NIE mail
- Materiały szkoleniowe
- Wszystkie pytania sÄ… ok
- Reguła Vegas
- SÅ‚uchawki
- Kamerki
- Chat
- Zgłaszamy wyjścia na początku danego dnia, także pożary, wszystko na chacie
- By default mute podczas wykład
- Przerwy (praca 08:30 - 16:30)
- blok 09:00 - 10:00
- kawowa 10:00 - 10:15 (15')
- blok 10:15 - 11:45
- kawowa 11:45 - 12:00
- blok 12:00 - 13:30
- obiad 13:30 - 14:00
- blok 14:00 - 15:00
- kawowa 15:00 - 15:10
- blok 15:10 - 16:00
- wszystkie czasy sÄ… plus/minus 10'
- Jak zadawać pytanie? 1) przerwanie 2) pytanie na chacie 3) podniesienie wirtualnej ręki
- IDE => dowolne
- Każde ćwiczenie w osobnym pliku/Notebooku
- Nie zapraszamy innych osób
- Zaczynamy punktualnie
- Ćwiczenia w dwójkach, rotacje, ask for help
Pytest¶
pytest Fundamentals¶
Pytest is a powerful and easy-to-use testing framework for Python. This module will introduce the basics of using Pytest, including setting up test cases, running them, and interpreting the results. By the end of this section, you'll understand the foundational concepts of Pytest and be able to write your first test cases.
! pip install pytest
Requirement already satisfied: pytest in ./.venv/lib/python3.12/site-packages (8.2.2) Requirement already satisfied: iniconfig in ./.venv/lib/python3.12/site-packages (from pytest) (2.0.0) Requirement already satisfied: packaging in ./.venv/lib/python3.12/site-packages (from pytest) (24.1) Requirement already satisfied: pluggy<2.0,>=1.5 in ./.venv/lib/python3.12/site-packages (from pytest) (1.5.0)
System Under Test (SUT): factorial(num)
¶
The System Under Test (SUT) refers to the specific function or module being tested. In this case, it's the factorial(num)
function, which calculates the factorial of a number.
# %%writefile mathutils.py
def factorial(num: int):
if not isinstance(num, int):
raise TypeError('Argument must be int')
if num == 0:
return 1
else:
return factorial(num-1) * num
# n! = (n-1)! * n
# 3! = 2! * 3 = 1! * 2 * 3 = 0! * 1 * 2 * 3 = 1 * 1 * 2 * 3 = 6
factorial(0)
1
factorial(1)
1
factorial(3)
6
Understanding the SUT is critical because test cases are built to validate its behavior against expected outcomes.
Example Project Structure¶
Organizing your project correctly is essential for writing maintainable and scalable tests. A typical Pytest-compatible project structure might look like this:
mathutils/
__init__.py # Package initialization
factorial_utils.py # Factorial-related utilities
optimization.py # Optimization-related code
domain/
__init__.py # another module
vector.py # Vector Model
base.py # Base code
tests/
__init__.py # Package initialization for tests
test_factorial_utils.py # Tests for factorial utilities
test_optimization.py # Tests for optimization
requirements.txt # Test Dependency management
main.py # Main app file
setup.py # Project metadata and installation setup
requirements.txt # Dependency management
Key Points:
- Code files are stored in a
mathutils/
directory. - Test files are in the
tests/
directory and follow thetest_*.py
naming convention. setup.py
andrequirements.txt
help manage project dependencies and installation.
Writing Assertions¶
An assertion is a condition that must evaluate to True
for the test to pass:
cond = True
assert cond, 'cond is True'
# This is equivalent to:
if not cond:
raise AssertionError('cond is True')
Basic Tests¶
Tests in Pytest are written as functions. Assertions are used to verify that the code behaves as expected.
%%writefile pytest_fundamentals.py
import pytest
from mathutils import factorial
def test_factorial_of_one():
assert factorial(1) == 1
def test_factorial_of_three():
got = factorial(3)
expected = 7 # intentionally wrong value to show how test failing, the right value is 6
assert expected == got, 'my error message'
def test_raises_typererror_for_float():
with pytest.raises(TypeError):
factorial(3.5) # Expecting a TypeError for non-integer input
Overwriting pytest_fundamentals.py
Explanation:
test_factorial_of_one
: Verifies the factorial of 1.test_factorial_of_three
: Contains a deliberate failure (expected
is incorrect) to demonstrate test results.test_raises_typeerror_for_invalid_argument
: Validates that the function raises the correct exception for invalid input.
Launching Tests¶
Pytest provides a simple command-line interface to execute tests. To run tests in a specific file:
! pytest pytest_fundamentals.py
============================= test session starts ============================== platform darwin -- Python 3.12.6, pytest-8.2.2, pluggy-1.5.0 rootdir: /Users/a563420/python_training/testing plugins: cov-6.0.0, anyio-4.4.0 collected 3 items pytest_fundamentals.py .F. [100%] =================================== FAILURES =================================== ___________________________ test_factorial_of_three ____________________________ def test_factorial_of_three(): got = factorial(3) expected = 7 # intentionally wrong value to show how test failing, the right value is 6 > assert expected == got, 'my error message' E AssertionError: my error message E assert 7 == 6 pytest_fundamentals.py:11: AssertionError =========================== short test summary info ============================ FAILED pytest_fundamentals.py::test_factorial_of_three - AssertionError: my error message ========================= 1 failed, 2 passed in 0.57s ==========================
Launching Tests with -q
¶
The -q
flag provides a more concise output:
! pytest pytest_fundamentals.py -q
.F. [100%] =================================== FAILURES =================================== ___________________________ test_factorial_of_three ____________________________ def test_factorial_of_three(): got = factorial(3) expected = 7 # intentionally wrong value to show how test failing, the right value is 6 > assert expected == got, 'my error message' E AssertionError: my error message E assert 7 == 6 pytest_fundamentals.py:11: AssertionError =========================== short test summary info ============================ FAILED pytest_fundamentals.py::test_factorial_of_three - AssertionError: my error message 1 failed, 2 passed in 0.46s
Useful Switches¶
Pytest includes several useful command-line options to enhance debugging:
Stop after the first failure (-x
):
! pytest pytest_fundamentals.py -x
This stops the test run as soon as a failure is encountered.
! pytest pytest_fundamentals.py -x
============================= test session starts ============================== platform darwin -- Python 3.12.6, pytest-8.2.2, pluggy-1.5.0 rootdir: /Users/a563420/python_training/testing plugins: cov-6.0.0, anyio-4.4.0 collected 3 items pytest_fundamentals.py .F =================================== FAILURES =================================== ___________________________ test_factorial_of_three ____________________________ def test_factorial_of_three(): got = factorial(3) expected = 7 # intentionally wrong value to show how test failing, the right value is 6 > assert expected == got, 'my error message' E AssertionError: my error message E assert 7 == 6 pytest_fundamentals.py:11: AssertionError =========================== short test summary info ============================ FAILED pytest_fundamentals.py::test_factorial_of_three - AssertionError: my error message !!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!! ========================= 1 failed, 1 passed in 0.35s ==========================
Print local variables for failing tests (-l
):
! pytest pytest_fundamentals.py -l
This displays the values of local variables to help debug.
! pytest pytest_fundamentals.py -l
============================= test session starts ============================== platform darwin -- Python 3.12.6, pytest-8.2.2, pluggy-1.5.0 rootdir: /Users/a563420/python_training/testing plugins: cov-6.0.0, anyio-4.4.0 collected 3 items pytest_fundamentals.py .F. [100%] =================================== FAILURES =================================== ___________________________ test_factorial_of_three ____________________________ def test_factorial_of_three(): got = factorial(3) expected = 7 # intentionally wrong value to show how test failing, the right value is 6 > assert expected == got, 'my error message' E AssertionError: my error message E assert 7 == 6 expected = 7 got = 6 pytest_fundamentals.py:11: AssertionError =========================== short test summary info ============================ FAILED pytest_fundamentals.py::test_factorial_of_three - AssertionError: my error message ========================= 1 failed, 2 passed in 0.35s ==========================
Autodiscover All Tests¶
Pytest can automatically discover and execute all tests in a project. Test files must follow the test_*.py
naming convention.
To autodiscover and run tests:
! pytest
! pytest
============================= test session starts ============================== platform darwin -- Python 3.12.6, pytest-8.2.2, pluggy-1.5.0 rootdir: /Users/a563420/python_training/testing plugins: cov-6.0.0, anyio-4.4.0 collected 0 items ============================ no tests ran in 0.02s =============================
Tips:
- Ensure all test files follow the
test_*.py
naming pattern. - Use directories like
tests/
to organize your test files.
Exercise: 🏠 Dividing¶
Write as many tests as possible for the function below.
def div(a, b):
return a / b
Solution¶
%%writefile pytest_div.py
"""
### Naming Test Functions
When creating test functions in Pytest, it's a good practice to name the function in a way that clearly communicates:
1. The **action** being tested (e.g., a function call, operation, or scenario).
2. The **expected output** or result of that action.
#### Naming Convention Example
- **Test function name**: `test_dividing_two_integers_should_give_a_float`
- **Action**: "dividing two integers" (describes the operation being tested)
- **Expected Output**: "should give a float" (clarifies the expected result)
This naming approach improves readability, making it easier for others to understand what the test is verifying.
#### Test Name Structure
The typical structure for a test function name is:
`test_<action>_<expected_output>`
#### Examples:
- `test_dividing_4_by_2_gives_2`: Testing that dividing 4 by 2 results in 2.
- `test_dividing_two_integers_should_give_a_float`: Testing that dividing two integers always returns a float.
- `test_raises_error_on_division_by_zero`: Testing that dividing by zero raises an error.
"""
import math
import pytest
# Define a simple division function to be tested
def div(a, b):
return a / b
# Test that dividing 4 by 2 returns 2
def test_dividing_4_by_2_gives_2():
assert div(4, 2) == 2
# Test that dividing two integers results in a float
def test_dividing_two_integers_should_give_a_float():
# given / arrange - set up any necessary context (not needed here)
pass
# when / action - perform the operation
got = div(3, 2)
# then / assert - check that the result matches the expectation
expected = 1.5
assert got == expected
# Test that dividing two integers always returns a float, even if the result is an integer
def test_dividing_two_integers_returns_a_float_even_when_result_is_integer():
assert isinstance(div(4, 2), float)
# Test that dividing by zero raises a ZeroDivisionError
def test_raises_error_on_division_by_zero():
with pytest.raises(ZeroDivisionError):
div(2, 0)
# Test that dividing two negative numbers gives a positive result
def test_dividing_negative_numbers():
assert div(-3, -2) == 1.5
# Test that dividing infinity by infinity results in NaN (not a number)
def test_dividing_infinities_gives_nan():
# Check using math.isnan since direct comparison with NaN doesn't work
assert math.isnan(div(float('inf'), float('inf')))
# Test that dividing infinity by a finite number still results in infinity
def test_dividing_infinity_gives_infinity():
inf = float('inf')
assert div(inf, 2) == inf
# Test that dividing infinity by zero raises a ZeroDivisionError
def test_dividing_infinity_by_zero_raises_an_error():
inf = float('inf')
with pytest.raises(ZeroDivisionError):
div(inf, 0)
# Test that dividing a boolean value works, with True treated as 1 and False as 0
def test_dividing_boolean_works():
assert div(True, 2) == 0.5
# Test that dividing a very small number (epsilon) by 2 results in 0
def test_dividing_epsilon_by_two_gives_zero():
assert div(5e-324, 2) == 0.0
# Test that dividing a huge number by a small number results in infinity
def test_dividing_huge_numbers_results_in_infinity():
assert div(1e308, 0.5) == float('inf')
# Test that attempting to divide two lists raises a TypeError
def test_dividing_two_lists_raises_an_error():
with pytest.raises(TypeError):
div([3, 2, 5], [1, 2, 3])
# Test that the division operator can be overridden in custom classes
def test_you_can_override_division_operator():
class Dividable:
def __truediv__(self, other):
return 42 # Custom division logic
d = Dividable()
assert div(d, d) == 42 # Confirm overridden behavior
# Test that dividing two instances of a custom object without division logic raises a TypeError
def test_dividing_custom_objects_raises_TypeError():
class MyClass:
pass
m = MyClass()
with pytest.raises(TypeError):
div(m, m)
Overwriting pytest_div.py
! pytest pytest_div.py -q
============================= test session starts ============================== platform darwin -- Python 3.12.6, pytest-8.2.2, pluggy-1.5.0 rootdir: /Users/a563420/python_training/testing plugins: cov-6.0.0, anyio-4.4.0 collected 14 items pytest_div.py .............. [100%] ============================== 14 passed in 0.17s ==============================
from contextlib import contextmanager
import io, sys
@contextmanager
def suppress_output():
stdout = sys.stdout
sys.stdout = None
try:
yield stdout
finally:
sys.stdout = stdout
print("A")
with suppress_output() as s:
print("B")
s.write("asdf\n")
print("C")
A asdf C
Exercise: 🏠 @suppress_output¶
Implementation:¶
# %%writefile suppress_output_module.py
from contextlib import contextmanager
import io, sys
@contextmanager
def suppress_output():
stdout = sys.stdout
sys.stdout = None
try:
yield stdout
finally:
sys.stdout = stdout
Example Usage¶
print("A")
with suppress_output() as s:
print("B")
s.write("asdf\n")
print("C")
What to Test?¶
When testing the functionality involving sys.stdout
and error handling, consider the following scenarios:
Changing
sys.stdout
in PointB
- Verify that
sys.stdout
is correctly changed within the scope ofB
.
- Verify that
Error Handling in Point
B
- Ensure that if an error is raised in
B
, thesuppress_output
mechanism does not suppress or alter the exception.
- Ensure that if an error is raised in
Restoring
sys.stdout
in PointC
- Confirm that
sys.stdout
is restored to its original within the scope ofC
.
- Confirm that
Restoring
sys.stdout
in PointC
even in case of an error inB
- Test that
sys.stdout
is restored even ifC
encounters an error during execution of scope `B.
- Test that
Ensuring
s
Matches the Originalsys.stdout
- Validate that the variable
s
holds a reference to the originalsys.stdout
.
- Validate that the variable
Solution¶
%%writefile pytest_suppressoutput.py
import sys
import pytest
from suppress_output_module import suppress_output
def test_stdout_should_change_inside_suppress_output():
before = sys.stdout # Save the original sys.stdout for comparison
with suppress_output():
# assert sys.stdout is None # This may give a false positive (FP) because the assertion is too strong.
assert sys.stdout is not before # This may give a false negative (FN) because the assertion is too weak
def test_stdout_should_be_resotred_after_suppress_output():
before = sys.stdout
with suppress_output():
pass
assert sys.stdout is before
def test_suppress_output_propagates_exceptions():
class ExampleException(Exception):
pass
with pytest.raises(ExampleException):
with suppress_output():
raise ExampleException
def test_suppress_output_restores_original_stdout_even_in_case_of_an_exception():
class ExampleException(Exception):
pass
before = sys.stdout
try:
with suppress_output():
raise ExampleException
except ExampleException:
pass
assert sys.stdout is before
def test_suppress_output_returns_original_stdout():
before = sys.stdout
with suppress_output() as s:
assert s is before
Here's a detailed explanation of the comments in the test function:
Test Function Code¶
def test_stdout_should_change_inside_suppress_output():
before = sys.stdout # Save the original sys.stdout for comparison
with suppress_output():
# assert sys.stdout is None # This may give a false positive (FP) because the assertion is too strong.
assert sys.stdout is not before # This may give a false negative (FN) because the assertion is too weak
Explanation of Comments¶
Comment 1:¶
# assert sys.stdout is None # This may give a false positive (FP) because the assertion is too strong.
- Reason: The code inside
suppress_output()
setssys.stdout
toNone
. However, directly asserting thatsys.stdout
isNone
may not always be reliable in broader contexts:- If the implementation changes slightly (e.g.,
sys.stdout
is replaced with a dummyio.StringIO
object instead ofNone
), this assertion would fail, even though the behavior of suppressing output remains correct. - This makes the assertion too strict and prone to failing unnecessarily in valid cases.
- If the implementation changes slightly (e.g.,
Comment 2:¶
# assert sys.stdout is not before # This may give a false negative (FN) because the assertion is too weak
- Reason: This assertion checks only that
sys.stdout
has changed from its original state (before
). While this ensuressys.stdout
is modified, it doesn't confirm how it has been changed or whether it matches the intended behavior of thesuppress_output()
function:- If
sys.stdout
is set to a different value (e.g., a mock object), this assertion would still pass, even if the behavior of suppressing output is incorrect. - This makes the assertion too lenient and may miss issues (false negatives).
- If
Suggested Improvement¶
To balance the strictness and flexibility of the assertions, you could test for specific behavior rather than relying on the exact value of sys.stdout
:
def test_stdout_should_change_inside_suppress_output():
before = sys.stdout
with suppress_output() as captured_stdout:
assert sys.stdout is not before # Ensure sys.stdout changes
assert sys.stdout is None or isinstance(sys.stdout, io.TextIOWrapper) # Allow some flexibility
assert captured_stdout is before # Confirm the original stdout is captured correctly
Key Points:¶
- Testing specific behavior (e.g.,
sys.stdout
changes and the original is captured) is often more robust than asserting exact values. - The balance between strict and lenient assertions is critical for avoiding both false positives and false negatives.
Fixtures¶
Fixtures are a key feature in Pytest that allow you to set up and tear down resources required for your tests. They provide a way to share setup code across multiple tests, making them more efficient and maintainable. Unlike traditional setUp
and tearDown
methods in unit testing frameworks like unittest
, fixtures in Pytest are more flexible and reusable.
What Are Fixtures?¶
- Setup Code: Fixtures are functions that prepare some state or resources required for your tests.
- Teardown Code: Pytest ensures that resources are properly cleaned up after the test, even if it fails.
- Reusability: Fixtures can be shared across multiple test functions, classes, or modules.
- Scope: Fixtures can have different scopes (
function
,class
,module
,session
), determining their lifespan.
Unique Temporary Directory¶
Pytest provides a built-in fixture called tmpdir
that creates a unique temporary directory for each test function. This is useful for testing file-related operations.
%%writefile pytest_tmpdir.py
import pytest
def test_needsfiles(tmpdir):
print(tmpdir)
print(type(tmpdir))
assert False
Overwriting pytest_tmpdir.py
! pytest pytest_tmpdir.py -q
F [100%] =================================== FAILURES =================================== _______________________________ test_needsfiles ________________________________ tmpdir = local('/private/var/folders/w9/9hmtpfzj64v0x0j841ny9y280000gn/T/pytest-of-a563420/pytest-5/test_needsfiles0') def test_needsfiles(tmpdir): print(tmpdir) print(type(tmpdir)) > assert False E assert False pytest_tmpdir.py:6: AssertionError ----------------------------- Captured stdout call ----------------------------- /private/var/folders/w9/9hmtpfzj64v0x0j841ny9y280000gn/T/pytest-of-a563420/pytest-5/test_needsfiles0 <class '_pytest._py.path.LocalPath'> =========================== short test summary info ============================ FAILED pytest_tmpdir.py::test_needsfiles - assert False 1 failed in 0.42s
Output:
- The
tmpdir
fixture provides a temporary directory as apy.path.local.LocalPath
object. - The directory is unique for each test run and is cleaned up automatically after the test.
Key Points:
tmpdir
is isolated for each test, preventing side effects between tests.- It simplifies testing code that interacts with the file system.
Listing All Fixtures¶
Pytest allows you to view all available fixtures using the --fixtures
command.
! pytest --fixtures
============================= test session starts ============================== platform darwin -- Python 3.12.6, pytest-8.2.2, pluggy-1.5.0 rootdir: /Users/a563420/python_training/testing plugins: cov-6.0.0, anyio-4.4.0 collected 0 items cache -- .venv/lib/python3.12/site-packages/_pytest/cacheprovider.py:560 Return a cache object that can persist state between testing sessions. capsysbinary -- .venv/lib/python3.12/site-packages/_pytest/capture.py:1003 Enable bytes capturing of writes to ``sys.stdout`` and ``sys.stderr``. capfd -- .venv/lib/python3.12/site-packages/_pytest/capture.py:1030 Enable text capturing of writes to file descriptors ``1`` and ``2``. capfdbinary -- .venv/lib/python3.12/site-packages/_pytest/capture.py:1057 Enable bytes capturing of writes to file descriptors ``1`` and ``2``. capsys -- .venv/lib/python3.12/site-packages/_pytest/capture.py:976 Enable text capturing of writes to ``sys.stdout`` and ``sys.stderr``. doctest_namespace [session scope] -- .venv/lib/python3.12/site-packages/_pytest/doctest.py:738 Fixture that returns a :py:class:`dict` that will be injected into the namespace of doctests. pytestconfig [session scope] -- .venv/lib/python3.12/site-packages/_pytest/fixtures.py:1338 Session-scoped fixture that returns the session's :class:`pytest.Config` object. record_property -- .venv/lib/python3.12/site-packages/_pytest/junitxml.py:284 Add extra properties to the calling test. record_xml_attribute -- .venv/lib/python3.12/site-packages/_pytest/junitxml.py:307 Add extra xml attributes to the tag for the calling test. record_testsuite_property [session scope] -- .venv/lib/python3.12/site-packages/_pytest/junitxml.py:345 Record a new ``<property>`` tag as child of the root ``<testsuite>``. tmpdir_factory [session scope] -- .venv/lib/python3.12/site-packages/_pytest/legacypath.py:303 Return a :class:`pytest.TempdirFactory` instance for the test session. tmpdir -- .venv/lib/python3.12/site-packages/_pytest/legacypath.py:310 Return a temporary directory path object which is unique to each test function invocation, created as a sub directory of the base temporary directory. caplog -- .venv/lib/python3.12/site-packages/_pytest/logging.py:602 Access and control log capturing. monkeypatch -- .venv/lib/python3.12/site-packages/_pytest/monkeypatch.py:33 A convenient fixture for monkey-patching. recwarn -- .venv/lib/python3.12/site-packages/_pytest/recwarn.py:32 Return a :class:`WarningsRecorder` instance that records all warnings emitted by test functions. tmp_path_factory [session scope] -- .venv/lib/python3.12/site-packages/_pytest/tmpdir.py:242 Return a :class:`pytest.TempPathFactory` instance for the test session. tmp_path -- .venv/lib/python3.12/site-packages/_pytest/tmpdir.py:257 Return a temporary directory path object which is unique to each test function invocation, created as a sub directory of the base temporary directory. ------------------ fixtures defined from anyio.pytest_plugin ------------------- anyio_backend [module scope] -- .venv/lib/python3.12/site-packages/anyio/pytest_plugin.py:132 no docstring available anyio_backend_name -- .venv/lib/python3.12/site-packages/anyio/pytest_plugin.py:137 no docstring available anyio_backend_options -- .venv/lib/python3.12/site-packages/anyio/pytest_plugin.py:145 no docstring available ------------------- fixtures defined from pytest_cov.plugin -------------------- no_cover -- .venv/lib/python3.12/site-packages/pytest_cov/plugin.py:429 A pytest fixture to disable coverage. cov -- .venv/lib/python3.12/site-packages/pytest_cov/plugin.py:434 A pytest fixture to provide access to the underlying coverage object. ============================ no tests ran in 0.07s =============================
Common Built-in Fixtures:¶
capsys
: Captures output written tosys.stdout
andsys.stderr
.monkeypatch
: Allows you to modify or replace code for testing.tmpdir
: Provides a unique temporary directory for each test.pytestconfig
: Gives access to the Pytest configuration object.
Benefits:
- Helps discover useful fixtures provided by Pytest and third-party plugins.
- Saves time by reusing existing functionality.
Using capsys
¶
The capsys
fixture allows you to capture text written to sys.stdout
and sys.stderr
during a test.
%%writefile test_capsys.py
def test_using_capsys(capsys):
print('asdf')
out, err = capsys.readouterr()
print('out', out)
assert out == 'asdf\n'
Overwriting test_capsys.py
! pytest test_capsys.py
============================= test session starts ============================== platform darwin -- Python 3.12.6, pytest-8.2.2, pluggy-1.5.0 rootdir: /Users/a563420/python_training/testing plugins: cov-6.0.0, anyio-4.4.0 collected 1 item test_capsys.py . [100%] ============================== 1 passed in 0.07s ===============================
Key Points:
- Use
capsys.readouterr()
to capture and read the output. - Separate captured
stdout
(out
) andstderr
(err
). - Useful for testing CLI tools or functions that print output.
Implementing Fixtures¶
You can define your own fixtures using the @pytest.fixture
decorator. Fixtures encapsulate setup logic, allowing you to create reusable components for test preparation. Pytest will automatically execute fixtures before the test functions that request them.
Steps to Create Your Own Fixture¶
- Import
pytest
:- Ensure you have
pytest
imported in your test file.
- Ensure you have
- Define a Function with the Setup Logic:
- Create a function that contains the necessary setup steps for your tests.
- Annotate with
@pytest.fixture
:- Use the
@pytest.fixture
decorator to mark the function as a fixture.
- Use the
- Return the Required Object:
- The function should return the object or resource that will be passed to the test.
%%writefile pytest_implementing_fixtures.py
from time import sleep
import pytest
@pytest.fixture
def empty_list():
print('preparing database')
sleep(2)
return []
def test_a(empty_list):
print(empty_list)
empty_list.append(2)
print(empty_list)
def test_b(empty_list):
print(empty_list)
empty_list.append(2)
print(empty_list)
Overwriting pytest_implementing_fixtures.py
! pytest pytest_implementing_fixtures.py -sv
============================= test session starts ============================== platform darwin -- Python 3.12.6, pytest-8.2.2, pluggy-1.5.0 -- /Users/a563420/python_training/testing/.venv/bin/python cachedir: .pytest_cache rootdir: /Users/a563420/python_training/testing plugins: cov-6.0.0, anyio-4.4.0 collected 2 items pytest_implementing_fixtures.py::test_a preparing database [] [2] PASSED pytest_implementing_fixtures.py::test_b preparing database [] [2] PASSED ============================== 2 passed in 4.07s ===============================
Key Points:
- Fixtures are instantiated before the test runs.
- The
return
value of the fixture is passed to the test function as an argument.
Sharing Fixture Instances¶
You can control how often a fixture is created by setting its scope. The scope determines the lifespan of a fixture and how many times it is invoked during a test session. Pytest provides four built-in scopes:
Fixture Scopes¶
function
(Default)- Definition: A new instance of the fixture is created for each test function that uses it.
- Use Case: Ideal for tests that require an isolated, fresh setup for every test.
- Example:
%%writefile pytest_fixture_scope_function.py
import pytest
@pytest.fixture(scope='function')
def resource():
print("Setting up resource for each test")
return "function_resource"
def test_a(resource):
assert resource == "function_resource"
resource = 'something else'
def test_b(resource):
assert resource == "function_resource"
resource = 'something else'
Overwriting pytest_fixture_scope_function.py
! pytest pytest_fixture_scope_function.py -sv
============================= test session starts ============================== platform darwin -- Python 3.12.6, pytest-8.2.2, pluggy-1.5.0 -- /Users/a563420/python_training/testing/.venv/bin/python cachedir: .pytest_cache rootdir: /Users/a563420/python_training/testing plugins: cov-6.0.0, anyio-4.4.0 collected 2 items pytest_fixture_scope_function.py::test_a Setting up resource for each test PASSED pytest_fixture_scope_function.py::test_b Setting up resource for each test PASSED ============================== 2 passed in 0.08s ===============================
class
- Definition: A single instance of the fixture is created and shared among all tests in a class.
- Use Case: Useful for tests within a class that share the same setup but need isolation from other classes.
- Example:
%%writefile pytest_fixture_scope_class.py
import pytest
@pytest.fixture(scope='class')
def resource():
print("Setting up resource for the class")
return 'class_resource'
class TestExample:
def test_a(self, resource):
print(id(resource))
assert resource == 'class_resource'
resource = 'dddddd' # create local copy if resource variable, function scope
def test_b(self, resource):
assert resource == 'class_resource'
Overwriting pytest_fixture_scope_class.py
! pytest pytest_fixture_scope_class.py -sv
============================= test session starts ============================== platform darwin -- Python 3.12.6, pytest-8.2.2, pluggy-1.5.0 -- /Users/a563420/python_training/testing/.venv/bin/python cachedir: .pytest_cache rootdir: /Users/a563420/python_training/testing plugins: cov-6.0.0, anyio-4.4.0 collected 2 items pytest_fixture_scope_class.py::TestExample::test_a Setting up resource for the class 4365607152 PASSED pytest_fixture_scope_class.py::TestExample::test_b PASSED ============================== 2 passed in 0.07s ===============================
%%writefile pytest_fixture_scope_class1.py
import pytest
@pytest.fixture(scope='class')
def resource():
print("Setting up resource for the class")
return {"class_resource": "class_resource"}
class TestExample1:
def test_a(self, resource):
assert resource == {"class_resource": "class_resource"}
# resource.update(new_key="new_value")
def test_b(self, resource):
assert resource == {"class_resource": "class_resource"}
resource.update(new_key="new_value")
class TestExample2:
def test_a(self, resource):
assert resource == {"class_resource": "class_resource"}
Overwriting pytest_fixture_scope_class1.py
! pytest pytest_fixture_scope_class1.py -sv
============================= test session starts ============================== platform darwin -- Python 3.12.6, pytest-8.2.2, pluggy-1.5.0 -- /Users/a563420/python_training/testing/.venv/bin/python cachedir: .pytest_cache rootdir: /Users/a563420/python_training/testing plugins: cov-6.0.0, anyio-4.4.0 collected 3 items pytest_fixture_scope_class1.py::TestExample1::test_a Setting up resource for the class PASSED pytest_fixture_scope_class1.py::TestExample1::test_b PASSED pytest_fixture_scope_class1.py::TestExample2::test_a Setting up resource for the class PASSED ============================== 3 passed in 0.09s ===============================
module
- Definition: A single instance of the fixture is created and shared across all tests in a module.
- Use Case: Suitable for module-level resources that are expensive to set up but can be shared safely among tests.
- Example:
%%writefile pytest_fixture_scope_module.py
import pytest
# fixture might be in separate file
@pytest.fixture(scope='module')
def resource():
print("Setting up resource for the module")
return "module_resource"
def test_a(resource):
assert resource == "module_resource"
def test_b(resource):
assert resource == "module_resource"
Overwriting pytest_fixture_scope_module.py
! pytest pytest_fixture_scope_module.py -sv
============================= test session starts ============================== platform darwin -- Python 3.12.6, pytest-8.2.2, pluggy-1.5.0 -- /Users/a563420/python_training/testing/.venv/bin/python cachedir: .pytest_cache rootdir: /Users/a563420/python_training/testing plugins: cov-6.0.0, anyio-4.4.0 collected 2 items pytest_fixture_scope_module.py::test_a Setting up resource for the module PASSED pytest_fixture_scope_module.py::test_b PASSED ============================== 2 passed in 0.08s ===============================
session
- Definition: A single instance of the fixture is created and shared across the entire test session, spanning multiple modules.
- Use Case: Best for global resources that need to be initialized once and reused across all tests (e.g., database connections, external services).
- Example:
%%writefile conftest.py
import pytest
@pytest.fixture(scope='session')
def resource():
print("Setting up resource for the session")
return "session_resource"
Overwriting conftest.py
%%writefile test_fixture_scope_session1.py
import pytest
def test_a(resource):
assert resource == "session_resource"
def test_b(resource):
assert resource == "session_resource"
Overwriting test_fixture_scope_session1.py
%%writefile test_fixture_scope_session2.py
import pytest
def test_c(resource):
assert resource == "session_resource"
def test_d(resource):
assert resource == "session_resource"
Overwriting test_fixture_scope_session2.py
! pytest ./ -sv
============================= test session starts ============================== platform darwin -- Python 3.12.6, pytest-8.2.2, pluggy-1.5.0 -- /Users/a563420/python_training/testing/.venv/bin/python cachedir: .pytest_cache rootdir: /Users/a563420/python_training/testing plugins: cov-6.0.0, anyio-4.4.0 collected 5 items test_capsys.py::test_using_capsys out asdf PASSED test_fixture_scope_session1.py::test_a Setting up resource for the session PASSED test_fixture_scope_session1.py::test_b PASSED test_fixture_scope_session2.py::test_c PASSED test_fixture_scope_session2.py::test_d PASSED ============================== 5 passed in 0.09s ===============================
Practical Example: Sharing Fixture Instances¶
Exercise: 🏠 DB¶
Tip
A fixture, can use other fixture
### Two slow!!!
create_database()
test_a()
create_database()
test_b()
create_database()
test_c()
### Optimal
create_database()
reset_database()
test_a()
reset_database()
test_b()
reset_database()
test_c()
Code:¶
# %%writefile pytest_db.py
import pytest
from time import sleep
# Tego kodu nie modyfiklujemy!
def create_database():
print("create_database")
sleep(2)
return []
def reset_database(db):
print("reset_database")
del db[:]
# Wasze fixtures
....
# Wasze trzy testy: test_a, test_b i test_c
def test_one(db):
print('test_one')
db.append(2)
assert db == [2]
def test_two(db):
print('test_two')
assert db == []
def test_three(db):
print('test_three')
db.append(5)
assert db == [5]
%%writefile pytest_db.py
import pytest
from time import sleep
# Tego kodu nie modyfiklujemy!
def create_database():
print("create_database")
sleep(2)
return []
def reset_database(db):
print("reset_database")
del db[:]
# Wasze fixtures
@pytest.fixture(scope='session')
def shared_db():
return create_database()
@pytest.fixture
def db(shared_db):
reset_database(shared_db)
return shared_db
# Wasze trzy testy: test_a, test_b i test_c
def test_one(db):
print('test_one')
db.append(2)
assert db == [2]
def test_two(db):
print('test_two')
assert db == []
def test_three(db):
print('test_three')
db.append(5)
assert db == [5]
Writing pytest_db.py
! pytest pytest_db.py -qsvv
============================= test session starts ============================== platform darwin -- Python 3.12.6, pytest-8.2.2, pluggy-1.5.0 -- /Users/a563420/python_training/testing/.venv/bin/python cachedir: .pytest_cache rootdir: /Users/a563420/python_training/testing plugins: cov-6.0.0, anyio-4.4.0 collected 3 items pytest_db.py::test_one create_database reset_database test_one PASSED pytest_db.py::test_two reset_database test_two PASSED pytest_db.py::test_three reset_database test_three PASSED ============================== 3 passed in 2.04s ===============================
Fixture Finalization¶
%%writefile pytest_fixture_finalization.py
import pytest
@pytest.fixture
def csv_file_stream():
stream = open("people.csv")
print('stream opened')
yield stream
# no need for try-finally
stream.close()
print('stream closed')
def test_people(csv_file_stream):
print('running test')
assert False
# def test_people1(csv_file_stream):
# print('running test')
# assert False
Overwriting pytest_fixture_finalization.py
! pytest pytest_fixture_finalization.py -q
F [100%] =================================== FAILURES =================================== _________________________________ test_people __________________________________ csv_file_stream = <_io.TextIOWrapper name='people.csv' mode='r' encoding='UTF-8'> def test_people(csv_file_stream): print('running test') > assert False E assert False pytest_fixture_finalization.py:15: AssertionError ---------------------------- Captured stdout setup ----------------------------- stream opened ----------------------------- Captured stdout call ----------------------------- running test --------------------------- Captured stdout teardown --------------------------- stream closed =========================== short test summary info ============================ FAILED pytest_fixture_finalization.py::test_people - assert False 1 failed in 0.38s
%%writefile pytest_fixture_finalization_2.py
import pytest
@pytest.fixture
def a(b):
print('setup a')
yield 42
print('teardown a')
@pytest.fixture
def b():
print('setup b')
yield 22
print('teardown b')
@pytest.fixture
def c():
print('setup c')
yield 22
print('teardown c')
def test_people(a):
print('running test')
assert False
Overwriting pytest_fixture_finalization_2.py
! pytest pytest_fixture_finalization_2.py -q
F [100%] =================================== FAILURES =================================== _________________________________ test_people __________________________________ a = 42 def test_people(a): print('running test') > assert False E assert False pytest_fixture_finalization_2.py:24: AssertionError ---------------------------- Captured stdout setup ----------------------------- setup b setup a ----------------------------- Captured stdout call ----------------------------- running test --------------------------- Captured stdout teardown --------------------------- teardown a teardown b =========================== short test summary info ============================ FAILED pytest_fixture_finalization_2.py::test_people - assert False 1 failed in 0.50s
Exercise: 🏠 Advanced Fixture Creation and Verification¶
You are building a test suite for an application that processes data using a shared resource. Your task is to:
Implement a custom fixture that:
- Sets up a
ResourceManager
object before tests run. - The
ResourceManager
should:- Keep track of how many times it has been initialized.
- Provide an
increment
method to increase an internal counter. - Provide a
value
method to return the current counter value.
- Implement teardown logic that resets the counter to zero after tests are complete.
- Sets up a
Write test cases to:
- Verify that the fixture is created only once when using
session
scope. - Confirm that the internal counter is shared across tests when using
session
scope. - Ensure that the counter value is as expected after each test.
- Test that the teardown logic properly resets the counter after all tests have run.
- Verify that the fixture is created only once when using
Challenge:
- Modify the fixture to use
function
scope and update the tests accordingly to reflect the change in behavior. - Ensure that with
function
scope, the fixture is created anew for each test, and the counter does not retain its value between tests.
- Modify the fixture to use
Initial Code¶
# %%writefile test_resource_manager.py
import pytest
class ResourceManager:
initialization_count = 0
def __init__(self):
type(self).initialization_count += 1
self.counter = 0
def increment(self):
self.counter += 1
def value(self):
return self.counter
def reset(self):
self.counter = 0
# TODO: Implement the fixture
# @pytest.fixture(scope='session')
# def resource_manager():
# pass
# TODO: Write the tests
# def test_increment_1(resource_manager):
# pass
# def test_increment_2(resource_manager):
# pass
# def test_initialization_count():
# pass
# def test_teardown(resource_manager):
# pass
Solution¶
%%writefile test_resource_manager.py
import pytest
class ResourceManager:
initialization_count = 0
def __init__(self):
type(self).initialization_count += 1
self.counter = 0
def increment(self):
self.counter += 1
def value(self):
return self.counter
def reset(self):
self.counter = 0
# Implement the fixture with 'session' scope
@pytest.fixture(scope='session')
def resource_manager():
"""
Fixture that provides a ResourceManager instance.
"""
rm = ResourceManager()
yield rm
# Teardown logic
rm.reset()
def test_increment_1(resource_manager):
"""
Test that increments the counter and checks its value.
"""
resource_manager.increment()
assert resource_manager.value() == 1, "Counter should be 1 after first increment"
def test_increment_2(resource_manager):
"""
Another test that increments the counter and checks its value.
"""
resource_manager.increment()
assert resource_manager.value() == 2, "Counter should be 2 after second increment"
def test_initialization_count():
"""
Test that the ResourceManager was initialized only once.
"""
assert ResourceManager.initialization_count == 1, f"Initialization count should be 1, got {ResourceManager.initialization_count}"
def test_teardown(resource_manager):
"""
Test that verifies the teardown logic reset the counter.
"""
# Teardown occurs after the last test using the fixture
# Since we're in the last test, the counter should still be 2
assert resource_manager.value() == 2, "Counter should be 2 before teardown"
# After the test, the teardown will reset the counter
Writing test_resource_manager.py
! pytest test_resource_manager.py -qsvv
============================= test session starts ============================== platform darwin -- Python 3.12.6, pytest-8.2.2, pluggy-1.5.0 -- /Users/a563420/python_training/testing/.venv/bin/python cachedir: .pytest_cache rootdir: /Users/a563420/python_training/testing plugins: cov-6.0.0, anyio-4.4.0 collected 4 items test_resource_manager.py::test_increment_1 PASSED test_resource_manager.py::test_increment_2 PASSED test_resource_manager.py::test_initialization_count PASSED test_resource_manager.py::test_teardown PASSED ============================== 4 passed in 0.10s ===============================
%%writefile test_resource_manager.py
import pytest
class ResourceManager:
initialization_count = 0
def __init__(self):
type(self).initialization_count += 1
self.counter = 0
def increment(self):
self.counter += 1
def value(self):
return self.counter
def reset(self):
self.counter = 0
# Challenge: Modify the fixture to 'function' scope
@pytest.fixture(scope='function')
def resource_manager_function():
"""
Fixture that provides a new ResourceManager instance for each test.
"""
rm = ResourceManager()
yield rm
# Teardown logic
rm.reset()
def test_increment_function_1(resource_manager_function):
"""
Test that increments the counter and checks its value with function scope.
"""
resource_manager_function.increment()
assert resource_manager_function.value() == 1, "Counter should be 1 after increment in function-scoped fixture"
def test_increment_function_2(resource_manager_function):
"""
Another test that increments the counter and checks its value with function scope.
"""
resource_manager_function.increment()
assert resource_manager_function.value() == 1, "Counter should be 1 in a new instance of function-scoped fixture"
def test_initialization_count_function():
"""
Test that the ResourceManager was initialized multiple times.
"""
# Should be 3 initializations: 1 from session-scoped fixture, 2 from function-scoped fixtures
assert ResourceManager.initialization_count == 2, f"Initialization count should be 2, got {ResourceManager.initialization_count}"
def test_teardown_function(resource_manager_function):
"""
Test that verifies the teardown logic reset the counter in function scope.
"""
resource_manager_function.increment()
assert resource_manager_function.value() == 1, "Counter should be 1 before teardown in function-scoped fixture"
# After the test, the teardown will reset the counter
Overwriting test_resource_manager.py
! pytest test_resource_manager.py -qsvv
============================= test session starts ============================== platform darwin -- Python 3.12.6, pytest-8.2.2, pluggy-1.5.0 -- /Users/a563420/python_training/testing/.venv/bin/python cachedir: .pytest_cache rootdir: /Users/a563420/python_training/testing plugins: cov-6.0.0, anyio-4.4.0 collected 4 items test_resource_manager.py::test_increment_1 PASSED test_resource_manager.py::test_increment_2 PASSED test_resource_manager.py::test_initialization_count PASSED test_resource_manager.py::test_teardown PASSED ============================== 4 passed in 0.15s ===============================
%%writefile pytest_fixture_finalization.py
import pytest
@pytest.fixture
def csv_file_stream():
# stream = open("people.csv")
# print('stream opened')
# yield stream
# # no need for try-finally
# stream.close()
# print('stream closed')
# or simpler:
with open("people.csv") as stream:
print("stream opened")
yield stream
# implicit stream.close()
print("stream closed")
def test_people(csv_file_stream):
print('running test')
assert False
Overwriting pytest_fixture_finalization.py
! pytest pytest_fixture_finalization.py -q
F [100%] =================================== FAILURES =================================== _________________________________ test_people __________________________________ csv_file_stream = <_io.TextIOWrapper name='people.csv' mode='r' encoding='UTF-8'> def test_people(csv_file_stream): print('running test') > assert False E assert False pytest_fixture_finalization.py:22: AssertionError ---------------------------- Captured stdout setup ----------------------------- stream opened ----------------------------- Captured stdout call ----------------------------- running test --------------------------- Captured stdout teardown --------------------------- stream closed =========================== short test summary info ============================ FAILED pytest_fixture_finalization.py::test_people - assert False 1 failed in 0.53s
Parametrizing Fixtures¶
Parametrizing fixtures in Pytest allows you to run the same test function multiple times with different inputs. This is especially useful when you want to test the same functionality with various configurations or data sets without writing separate tests for each case.
@pytest.fixture(params=[...])
: Theparams
argument in the@pytest.fixture
decorator specifies a list of parameter values that the fixture will be called with.request.param
: Within the fixture function, Pytest provides a specialrequest
object, which has aparam
attribute. This attribute holds the current parameter value for each invocation of the fixture.
For each parameter value specified in params
, Pytest:
- Calls the fixture function with
request.param
set to that value. - Executes all tests that use this fixture once for each parameter value.
%%writefile people1.csv
first_name,last_name
John,Smith
Alice,Wilson
Overwriting people1.csv
%%writefile people2.csv
first_name,last_name
Overwriting people2.csv
%%writefile pytest_parametrized_fixtures.py
import pytest
@pytest.fixture(params=['people1.csv', 'people2.csv'])
def people_csv_stream(request):
print(f'opening {request.param}')
with open(request.param) as stream:
yield stream
def test_people(people_csv_stream):
print('test_people')
print(people_csv_stream.read())
Overwriting pytest_parametrized_fixtures.py
! pytest pytest_parametrized_fixtures.py -qs
opening people1.csv test_people first_name,last_name John,Smith Alice,Wilson .opening people2.csv test_people first_name,last_name . 2 passed in 0.07s
Factory Fixtures¶
In the previous section, we explored how to use parametrized fixtures to run the same test function multiple times with different inputs. While parametrized fixtures are powerful, they have limitations when you need to generate test data dynamically or when the inputs cannot be predetermined. This is where factory fixtures come into play.
Factory fixtures allow you to pass arguments to a fixture at test time, giving you greater flexibility in setting up your test data. They are particularly useful when you need to create multiple test data instances with varying attributes within a single test function.
- Factory Fixture: A fixture that returns a function (the factory) which can accept arguments to create test data dynamically.
- Why Use Factory Fixtures?
- Dynamic Data Creation: When test data cannot be predefined and needs to be generated on the fly.
- Flexibility: Allows tests to specify exactly what data they need.
- Reusability: Centralizes the data creation logic, making it reusable across different tests.
%%writefile pytest_factory_fixture.py
import pytest
class Customer:
def __init__(self, first_name, last_name, email, **kwargs):
self.first_name = first_name
self.last_name = last_name
self.email = email
for key, value in kwargs.items():
setattr(self, key, value)
# Factory fixture that creates Customer instances
@pytest.fixture
def make_customer():
def _make_customer(
first_name="John",
last_name="Doe",
email="john.doe@example.com",
**extra_attrs
):
customer = Customer(
first_name=first_name,
last_name=last_name,
email=email,
**extra_attrs
)
return customer
return _make_customer
def test_create_default_customer(make_customer):
customer = make_customer()
assert customer.first_name == "John"
assert customer.last_name == "Doe"
assert customer.email == "john.doe@example.com"
def test_create_custom_customer(make_customer):
customer = make_customer(first_name="Alice", last_name="Smith", email="alice.smith@example.com")
assert customer.first_name == "Alice"
assert customer.last_name == "Smith"
assert customer.email == "alice.smith@example.com"
def test_create_customer_with_extra_attrs(make_customer):
customer = make_customer(age=30, country="USA")
assert customer.age == 30
assert customer.country == "USA"
Overwriting pytest_factory_fixture.py
! pytest pytest_factory_fixture.py -qs
... 3 passed in 0.07s
Composing Fixtures¶
In Pytest, fixtures are a powerful way to manage test setup and teardown. One of the key strengths of fixtures is their ability to depend on other fixtures. This allows you to compose complex test scenarios by building upon simpler, reusable components. By composing fixtures, you can avoid redundant code and create more maintainable and scalable test suites.
Example: Composing Fixtures in an E-commerce Application¶
Suppose you're testing an e-commerce application with classes Customer
, Sale
, and Transaction
. Each of these classes depends on the others:
- A
Transaction
involves aSale
and aCustomer
. - A
Sale
is made by aCustomer
.
Instead of creating a large fixture that sets up everything at once, you can create individual fixtures for each component and compose them.
%%writefile pytest_composing_fixture.py
import pytest
class Customer:
def __init__(self, first_name, last_name, email):
self.first_name = first_name
self.last_name = last_name
self.email = email
class Sale:
def __init__(self, amount, sku, customer):
self.amount = amount
self.sku = sku
self.customer = customer
class Transaction:
def __init__(self, transaction_id, sale, customer):
self.transaction_id = transaction_id
self.sale = sale
self.customer = customer
@pytest.fixture
def make_customer():
def _make_customer(
first_name="John",
last_name="Doe",
email="john.doe@example.com",
**extra_attrs
):
customer = Customer(
first_name=first_name,
last_name=last_name,
email=email,
**extra_attrs
)
return customer
return _make_customer
@pytest.fixture
def make_sale(make_customer):
def _make_sale(amount=100.0, sku="ABC123", customer=None):
if customer is None:
customer = make_customer()
sale = Sale(amount=amount, sku=sku, customer=customer)
return sale
return _make_sale
@pytest.fixture
def make_transaction(make_sale, make_customer):
def _make_transaction(transaction_id, sale=None, customer=None):
if sale is None:
sale = make_sale(customer=customer)
if customer is None:
customer = sale.customer
transaction = Transaction(
transaction_id=transaction_id,
sale=sale,
customer=customer,
)
return transaction
return _make_transaction
def test_transaction_creation(make_transaction):
transaction = make_transaction(transaction_id="TXN1001")
assert transaction.transaction_id == "TXN1001"
assert transaction.sale.amount == 100.0
assert transaction.customer.first_name == "John"
def test_transaction_with_custom_customer(make_transaction, make_customer):
custom_customer = make_customer(first_name="Alice", last_name="Smith")
transaction = make_transaction(transaction_id="TXN1002", customer=custom_customer)
assert transaction.customer.first_name == "Alice"
assert transaction.customer.last_name == "Smith"
Exercise: 🏠 Advanced Factory and Composed Fixtures¶
Description¶
You are tasked with testing a complex Library Management System. The system consists of several interconnected classes:
Book
: Represents a book with attributes such astitle
,author
,isbn
, and availability status.Member
: Represents a library member with attributes likename
,member_id
, and a list ofborrowed_books
.Library
: Manages collections of books and members, and provides methods for borrowing and returning books.
Your objectives are:
- Create factory fixtures for
Book
andMember
that allow dynamic creation of instances with customizable attributes. - Compose fixtures to create a
Library
instance that depends on theBook
andMember
fixtures. - Write tests that:
- Verify that a member can borrow a book successfully.
- Ensure that a book's availability status updates correctly when borrowed and returned.
- Confirm that a member cannot borrow more books than the allowed limit.
- Implement fixture scopes appropriately to optimize test performance and ensure proper isolation between tests.
- Challenge: Modify the fixtures to handle parameterization, allowing tests to run with different configurations (e.g., varying the maximum number of books a member can borrow).
Initial Code¶
# %%writefile test_library_system.py
import pytest
class Book:
def __init__(self, title, author, isbn):
self.title = title
self.author = author
self.isbn = isbn
self.is_available = True
class Member:
def __init__(self, name, member_id, max_books=3):
self.name = name
self.member_id = member_id
self.borrowed_books = []
self.max_books = max_books
class Library:
def __init__(self):
self.books = []
self.members = []
def add_book(self, book):
# Add book to the library collection
pass # TODO: Implement this method
def register_member(self, member):
# Register a new library member
pass # TODO: Implement this method
def borrow_book(self, member_id, isbn):
# Member borrows a book
pass # TODO: Implement this method
def return_book(self, member_id, isbn):
# Member returns a book
pass # TODO: Implement this method
# TODO: Implement factory fixtures for Book and Member
# @pytest.fixture
# def make_book():
# pass
# @pytest.fixture
# def make_member():
# pass
# TODO: Implement a composed fixture for Library
# @pytest.fixture
# def library(make_book, make_member):
# pass
# TODO: Write tests using the fixtures
# def test_member_can_borrow_book(library, make_book, make_member):
# pass
# def test_book_availability_updates(library, make_book, make_member):
# pass
# def test_member_borrow_limit(library, make_book, make_member):
# pass