tel: +48 728 438 076
email: piotr.hyzy@eviden.com

Początek

Rundka - sprawdzenie mikrofonów

Kilka zasad

  1. W razie problemów => chat, potem SMS i telefon, NIE mail
  2. Materiały szkoleniowe
  3. Wszystkie pytania sÄ… ok
  4. Reguła Vegas
  5. SÅ‚uchawki
  6. Kamerki
  7. Chat
  8. Zgłaszamy wyjścia na początku danego dnia, także pożary, wszystko na chacie
  9. By default mute podczas wykład
  10. 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
  11. wszystkie czasy sÄ… plus/minus 10'
  12. Jak zadawać pytanie? 1) przerwanie 2) pytanie na chacie 3) podniesienie wirtualnej ręki
  13. IDE => dowolne
  14. Każde ćwiczenie w osobnym pliku/Notebooku
  15. Nie zapraszamy innych osób
  16. Zaczynamy punktualnie
  17. Ć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 the test_*.py naming convention.
  • setup.py and requirements.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:

  1. test_factorial_of_one: Verifies the factorial of 1.
  2. test_factorial_of_three: Contains a deliberate failure (expected is incorrect) to demonstrate test results.
  3. 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:

  1. Changing sys.stdout in Point B

    • Verify that sys.stdout is correctly changed within the scope of B.
  2. Error Handling in Point B

    • Ensure that if an error is raised in B, the suppress_output mechanism does not suppress or alter the exception.
  3. Restoring sys.stdout in Point C

    • Confirm that sys.stdout is restored to its original within the scope of C.
  4. Restoring sys.stdout in Point C even in case of an error inB

    • Test that sys.stdout is restored even if C encounters an error during execution of scope `B.
  5. Ensuring s Matches the Original sys.stdout

    • Validate that the variable s holds a reference to the original sys.stdout.

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() sets sys.stdout to None. However, directly asserting that sys.stdout is None may not always be reliable in broader contexts:
    • If the implementation changes slightly (e.g., sys.stdout is replaced with a dummy io.StringIO object instead of None), 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.

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 ensures sys.stdout is modified, it doesn't confirm how it has been changed or whether it matches the intended behavior of the suppress_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).

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 a py.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 to sys.stdout and sys.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) and stderr (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

  1. Import pytest:
    • Ensure you have pytest imported in your test file.
  2. Define a Function with the Setup Logic:
    • Create a function that contains the necessary setup steps for your tests.
  3. Annotate with @pytest.fixture:
    • Use the @pytest.fixture decorator to mark the function as a fixture.
  4. 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

  1. 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 ===============================
  1. 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 ===============================
  1. 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 ===============================
  1. 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:

  1. 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.
  2. 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.
  3. 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.

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=[...]): The params 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 special request object, which has a param attribute. This attribute holds the current parameter value for each invocation of the fixture.

For each parameter value specified in params, Pytest:

  1. Calls the fixture function with request.param set to that value.
  2. 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 a Sale and a Customer.
  • A Sale is made by a Customer.

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 as title, author, isbn, and availability status.
  • Member: Represents a library member with attributes like name, member_id, and a list of borrowed_books.
  • Library: Manages collections of books and members, and provides methods for borrowing and returning books.

Your objectives are:

  1. Create factory fixtures for Book and Member that allow dynamic creation of instances with customizable attributes.
  2. Compose fixtures to create a Library instance that depends on the Book and Member fixtures.
  3. 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.
  4. Implement fixture scopes appropriately to optimize test performance and ensure proper isolation between tests.
  5. 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

Solution