Testing and Debugging


Writing code is just the first step; ensuring it works correctly, is reliable, and remains maintainable is where testing and debugging come in. This module will equip you with the fundamental skills to write robust, professional-grade Python code.

 

Unit Testing (unittest module / pytest introduction)

Unit testing is a software testing method where individual units or components of a software are tested. The purpose is to validate that each unit of the software code performs as expected. In Python, this is most commonly done using the built-in unittest framework or a popular third-party library like pytest.

Note: Understanding Python unit testing is crucial for professional development. Frameworks like unittest and pytest help you write automated tests, which is a key practice in continuous integration and delivery (CI/CD). Learning how to test Python code effectively will make your applications more reliable and easier to maintain. pytest is often preferred for its simpler syntax and powerful features, but unittest is included with Python and is a great starting point.

 

Example 1: A Basic Test with unittest

Code:

# save_as: test_calculator_simple.py
import unittest

# The function we want to test
def add(a, b):
    """A simple function to add two numbers."""
    return a + b

# The test class, which inherits from unittest.TestCase
class TestAddFunction(unittest.TestCase):
    """Test suite for the add function."""

    def test_add_positive_numbers(self):
        """Test adding two positive numbers."""
        # The assertion checks if the function's output is what we expect.
        self.assertEqual(add(5, 10), 15)

# This block allows the tests to be run from the command line
if __name__ == '__main__':
    unittest.main()

Explanation: This example demonstrates the basic structure of a unittest test. We import the unittest library, define a function add to test, and create a test class TestAddFunction that inherits from unittest.TestCase. Inside the class, we define a test method test_add_positive_numbers. The method name must start with test_. The core of the test is the self.assertEqual() assertion, which checks if the result of add(5, 10) is equal to 15. The if __name__ == '__main__': block allows you to run the tests by executing the Python script directly from your terminal (python test_calculator_simple.py).

 

Example 2: unittest with Multiple Test Methods

Code:

# save_as: test_calculator_extended.py
import unittest

# A simple Calculator class to test
class Calculator:
    """A simple calculator class."""
    def add(self, a, b):
        return a + b

    def subtract(self, a, b):
        return a - b

class TestCalculator(unittest.TestCase):
    """Test suite for the Calculator class."""

    def test_add(self):
        """Test the add method with various inputs."""
        calc = Calculator()
        # Test with two positive numbers
        self.assertEqual(calc.add(2, 3), 5)
        # Test with a negative and a positive number
        self.assertEqual(calc.add(-1, 1), 0)
        # Test with two negative numbers
        self.assertEqual(calc.add(-5, -5), -10)
        # Test with zero
        self.assertEqual(calc.add(10, 0), 10)

    def test_subtract(self):
        """Test the subtract method."""
        calc = Calculator()
        self.assertEqual(calc.subtract(10, 5), 5)
        self.assertEqual(calc.subtract(-1, 1), -2)
        self.assertEqual(calc.subtract(5, 10), -5)

if __name__ == '__main__':
    unittest.main()

Explanation: This example expands on the first by testing a Calculator class with two methods: add and subtract. We have a separate test method in our TestCalculator class for each function we want to test (test_add and test_subtract). This separation makes it clear which functionality is being tested in each method and helps pinpoint failures more easily. Within test_add, we use multiple assertions to cover different scenarios (positive, negative, and zero inputs).

 

Example 3: Introduction to pytest

Code:

# save_as: test_pytest_simple.py

# The function to be tested. No imports needed for the function itself.
def multiply(a, b):
    """Multiplies two numbers."""
    return a * b

# The test function. Note the simple `assert` statement.
# pytest automatically discovers test functions that start with `test_`.
def test_multiply_positive_numbers():
    """Tests multiplication of two positive numbers."""
    assert multiply(3, 4) == 12

def test_multiply_with_negative():
    """Tests multiplication with a negative number."""
    assert multiply(-2, 5) == -10

Explanation: This example shows how to write the same kind of tests using pytest. Notice the differences: there's no need to import a special library for the test itself (like unittest), no need to create a class that inherits from anything, and we can use a simple assert statement. pytest's syntax is more concise and often considered more "Pythonic." To run these tests, you would save the file (e.g., test_pytest_simple.py) and then run the pytest command in your terminal. pytest automatically discovers files named test_*.py or *_test.py and functions within them named test_*.

 

Example 4: Testing for Exceptions with pytest

Code:

# save_as: test_pytest_exceptions.py
import pytest

def divide(a, b):
    """Divides two numbers, raises ZeroDivisionError if b is 0."""
    if b == 0:
        raise ZeroDivisionError("Cannot divide by zero")
    return a / b

def test_divide_by_zero():
    """Tests that dividing by zero raises the correct exception."""
    # pytest.raises is a context manager that checks for an exception.
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)

def test_divide_successful():
    """Tests a successful division."""
    assert divide(10, 2) == 5

Explanation: A crucial part of testing is verifying that your code fails correctly. This example demonstrates how pytest handles expected exceptions. The pytest.raises() context manager is used to assert that a specific block of code should raise a particular exception. The test test_divide_by_zero will pass only if divide(10, 0) raises a ZeroDivisionError. If it raises a different exception or no exception at all, the test will fail. This is the standard way to test for expected errors in pytest.

 

Example 5: Comparing unittest and pytest for Class Testing

Code:

# This file contains the class to be tested
# save_as: person.py
class Person:
    def __init__(self, name, age):
        if not isinstance(name, str) or len(name) == 0:
            raise ValueError("Name must be a non-empty string.")
        if not isinstance(age, int) or age < 0:
            raise ValueError("Age must be a non-negative integer.")
        self.name = name
        self.age = age

    def get_info(self):
        return f"{self.name}, {self.age}"

# ===== Pytest Version =====
# save_as: test_person_pytest.py
import pytest
from person import Person

class TestPersonPytest:
    """A test class for the Person object using pytest."""
    def test_creation(self):
        """Test successful creation."""
        p = Person("Alice", 30)
        assert p.name == "Alice"
        assert p.age == 30

    def test_get_info(self):
        """Test the info string."""
        p = Person("Bob", 25)
        assert p.get_info() == "Bob, 25"

    def test_invalid_age(self):
        """Test that a negative age raises an error."""
        with pytest.raises(ValueError):
            Person("Charlie", -5)

# ===== Unittest Version =====
# save_as: test_person_unittest.py
import unittest
from person import Person

class TestPersonUnittest(unittest.TestCase):
    """A test class for the Person object using unittest."""
    def test_creation(self):
        """Test successful creation."""
        p = Person("Alice", 30)
        self.assertEqual(p.name, "Alice")
        self.assertEqual(p.age, 30)

    def test_get_info(self):
        """Test the info string."""
        p = Person("Bob", 25)
        self.assertEqual(p.get_info(), "Bob, 25")

    def test_invalid_age(self):
        """Test that a negative age raises an error."""
        with self.assertRaises(ValueError):
            Person("Charlie", -5)

Explanation: This advanced example provides a side-by-side comparison for testing a simple Person class. Both pytest and unittest can be structured using classes to group related tests. The core logic is similar: test object creation, test a method's return value, and test for an expected exception. However, the syntax highlights the key differences: pytest uses the standard assert keyword and its own pytest.raises context manager, while unittest uses specialized assertion methods like self.assertEqual() and its own self.assertRaises context manager. Many developers find the pytest version more readable and less verbose.

 

Writing Test Cases

Writing good test cases is an art. A test case is a set of conditions under which a tester will determine whether an application is working correctly. A good test case is specific, covers both expected and unexpected inputs, and is easy to understand.

Note: When writing Python test cases, think about "happy paths" (normal, expected inputs) and "edge cases" (inputs at the boundaries of what's allowed, like empty strings, zeros, or large numbers). Effective testing in Python involves covering all logical paths in your code. Test-driven development (TDD) is a practice where you write the test cases before you write the code, which can help clarify requirements and improve design.

 

Example 1: Testing a Simple String Function

Code:

# save_as: string_utils.py
def to_uppercase(s):
    """Converts a string to uppercase."""
    if not isinstance(s, str):
        raise TypeError("Input must be a string")
    return s.upper()

# save_as: test_string_utils.py
import pytest
from string_utils import to_uppercase

def test_to_uppercase_normal_string():
    """Test with a standard lowercase string (Happy Path)."""
    assert to_uppercase("hello world") == "HELLO WORLD"

def test_to_uppercase_already_upper():
    """Test with a string that is already uppercase."""
    assert to_uppercase("PYTHON") == "PYTHON"

Explanation: This is a beginner-friendly example of writing test cases. We are testing a simple function to_uppercase. The first test, test_to_uppercase_normal_string, covers the "happy path"—the most common and expected use case. The second test, test_to_uppercase_already_upper, checks another valid scenario to ensure the function behaves correctly even if the string is already in the desired state.

 

Example 2: Testing Edge Cases

Code:

# Use the same string_utils.py from the previous example

# save_as: test_string_utils_edge_cases.py
import pytest
from string_utils import to_uppercase

def test_to_uppercase_empty_string():
    """Test with an empty string, an important edge case."""
    assert to_uppercase("") == ""

def test_to_uppercase_with_numbers_and_symbols():
    """Test a string containing numbers and symbols."""
    assert to_uppercase("Pyth0n!_#1") == "PYTH0N!_#1"

def test_to_uppercase_non_string_input():
    """Test that a non-string input raises a TypeError."""
    with pytest.raises(TypeError):
        to_uppercase(123)

Explanation: This example focuses on "edge cases." We test an empty string ("") because empty inputs are a common source of bugs. We also test a mixed string to ensure that characters without an uppercase equivalent (like numbers and symbols) are handled gracefully. Finally, and most importantly, we test for invalid input types. The test_to_uppercase_non_string_input case ensures our function fails in a predictable way (by raising a TypeError) when given an integer instead of a string, which is crucial for robust code.

 

Example 3: Testing a Class and Its State

Code:

# save_as: shopping_cart.py
class ShoppingCart:
    def __init__(self):
        self.items = {}

    def add_item(self, item_name, price, quantity=1):
        if price <= 0 or quantity <= 0:
            raise ValueError("Price and quantity must be positive.")
        if item_name in self.items:
            self.items[item_name]['quantity'] += quantity
        else:
            self.items[item_name] = {'price': price, 'quantity': quantity}

    def get_total(self):
        total = 0
        for item in self.items.values():
            total += item['price'] * item['quantity']
        return total

# save_as: test_shopping_cart.py
import pytest
from shopping_cart import ShoppingCart

def test_add_new_item():
    """Test adding a single new item."""
    cart = ShoppingCart()
    cart.add_item("apple", 1.50, 2)
    assert "apple" in cart.items
    assert cart.items["apple"]["quantity"] == 2

def test_add_existing_item():
    """Test adding more of an existing item."""
    cart = ShoppingCart()
    cart.add_item("banana", 0.50, 3)
    cart.add_item("banana", 0.50, 2) # Add more bananas
    assert cart.items["banana"]["quantity"] == 5

def test_get_total_price():
    """Test the total price calculation."""
    cart = ShoppingCart()
    cart.add_item("apple", 1.50, 2)  # 3.0
    cart.add_item("banana", 0.50, 5) # 2.5
    assert cart.get_total() == 5.50

def test_add_item_invalid_price():
    """Test adding an item with a zero or negative price."""
    cart = ShoppingCart()
    with pytest.raises(ValueError):
        cart.add_item("orange", -1.00)

Explanation: Testing classes requires checking how the object's internal state changes after methods are called. Here, we test the ShoppingCart class. The tests verify that add_item correctly adds a new item, updates the quantity of an existing item, and that get_total computes the final price correctly. We are not just checking return values; we are inspecting the cart.items dictionary to confirm the object's state is what we expect after each action. We also include a test for invalid input to ensure our validation logic works.

 

Example 4: Grouping Tests with a Test Class (pytest)

Code:

# Use the same shopping_cart.py from the previous example

# save_as: test_shopping_cart_class.py
import pytest
from shopping_cart import ShoppingCart

class TestShoppingCart:
    """A class to group all shopping cart tests."""

    def test_initial_state(self):
        """Test that a new cart is empty."""
        cart = ShoppingCart()
        assert cart.items == {}
        assert cart.get_total() == 0

    def test_add_and_total(self):
        """Test adding multiple items and checking the total."""
        cart = ShoppingCart()
        cart.add_item("ebook", 15.00)
        cart.add_item("coffee", 3.50, 2)
        assert cart.get_total() == (15.00 + 3.50 * 2)

    def test_add_invalid_quantity(self):
        """Test adding an item with zero quantity."""
        cart = ShoppingCart()
        with pytest.raises(ValueError, match="Price and quantity must be positive."):
            cart.add_item("water", 1.00, 0)

Explanation: While pytest allows for simple test functions, grouping them inside a class (conventionally named Test...) can be a great way to organize tests related to a specific feature or class. This doesn't require inheriting from unittest.TestCase. It's purely an organizational tool. Here, TestShoppingCart groups all tests related to our cart. The match argument in pytest.raises is an advanced feature that allows you to assert that the error message contains a specific substring, making your tests even more precise.

 

Example 5: Parameterizing Tests with pytest

Code:

# save_as: test_parameterization.py
import pytest

def is_palindrome(s):
    """Checks if a string is a palindrome."""
    return s.lower() == s.lower()[::-1]

# The @pytest.mark.parametrize decorator runs the test function multiple times
# with different arguments.
@pytest.mark.parametrize("test_input, expected_output", [
    ("madam", True),
    ("racecar", True),
    ("hello", False),
    ("Aibohphobia", True), # Test case-insensitivity
    ("", True),             # Test edge case: empty string
    ("12321", True),        # Test with numbers
])
def test_is_palindrome(test_input, expected_output):
    """Test the is_palindrome function with various inputs."""
    assert is_palindrome(test_input) == expected_output

Explanation: This advanced example showcases one of pytest's most powerful features: parameterization. Instead of writing a separate test function for every input you want to check, you can use the @pytest.mark.parametrize decorator. You provide a list of tuples, where each tuple contains the arguments to be passed to the test function for one run. In this case, test_is_palindrome will be executed six times, once for each pair of (test_input, expected_output). This makes your test suite much more concise, readable, and easier to extend (you just add a new tuple to the list). This is the "Don't Repeat Yourself" (DRY) principle applied to testing.

 

Assertions

An assertion is a statement in a program that checks if a condition is true at a particular point in the code. If the condition is true, the program continues. If it's false, the assertion fails, and the program will halt (or, in a testing context, the test will fail).

Note: In Python testing, assertions are the core mechanism for verifying outcomes. In unittest, you use specific assertion methods like assertEqual(), assertTrue(), or assertRaises(). In pytest, you can use the standard Python assert keyword, which pytest enhances to provide detailed failure reports. Understanding which assertion to use is key to writing clear and effective tests. For example, use assertIn to check for membership, and assertIsInstance to check an object's type.

 

Example 1: Basic Assertions in unittest

Code:

# save_as: test_basic_assertions.py
import unittest

class BasicAssertionsTest(unittest.TestCase):
    def test_equality(self):
        """Using assertEqual to check for expected values."""
        self.assertEqual(2 + 2, 4)
        self.assertNotEqual(2 + 2, 5)

    def test_boolean_and_identity(self):
        """Using assertTrue/False for boolean checks and assertIs for identity."""
        is_feature_enabled = True
        self.assertTrue(is_feature_enabled)
        
        a = [1, 2, 3]
        b = a # b is the same object as a
        c = [1, 2, 3] # c has the same content, but is a different object
        self.assertIs(a, b)
        self.assertIsNot(a, c)

if __name__ == '__main__':
    unittest.main()

Explanation: This example introduces the most common unittest assertions.

assertEqual(a, b): Checks if a == b. This is the most frequently used assertion.

assertNotEqual(a, b): Checks if a != b.

assertTrue(x): Checks if bool(x) is True.

assertIs(a, b): Checks if a and b are the exact same object in memory (i.e., a is b). This is stricter than assertEqual.

assertIsNot(a, b): Checks if a and b are not the same object.

 

Example 2: Asserting Exceptions in unittest

Code:

# save_as: test_exception_assertions.py
import unittest

def get_by_index(data, index):
    """Returns an element from a list by its index."""
    return data[index]

class ExceptionAssertionTest(unittest.TestCase):
    def test_raises(self):
        """Using assertRaises to check for expected exceptions."""
        my_list = [10, 20, 30]
        
        # This asserts that calling the function with these arguments
        # will raise an IndexError.
        with self.assertRaises(IndexError):
            get_by_index(my_list, 5)

    def test_raises_with_message(self):
        """An advanced use: check the exception's message."""
        with self.assertRaisesRegex(ValueError, "must be positive"):
            raise ValueError("Value must be positive to continue.")

if __name__ == '__main__':
    unittest.main()

Explanation: This shows the unittest way of testing for exceptions. The assertRaises() method can be used as a context manager (with the with statement). The code inside the with block is executed, and the test passes if an IndexError is raised. If a different exception is raised, or none at all, the test fails. The assertRaisesRegex is even more specific, allowing you to check that the exception's error message matches a regular expression pattern.

 

Example 3: Membership and Type Assertions in unittest

Code:

# save_as: test_container_assertions.py
import unittest

class ContainerAssertionsTest(unittest.TestCase):
    def test_membership(self):
        """Using assertIn and assertNotIn."""
        my_collection = {'a', 'b', 'c'}
        self.assertIn('b', my_collection)
        self.assertNotIn('d', my_collection)

    def test_type(self):
        """Using assertIsInstance and assertNotIsInstance."""
        result = 42
        self.assertIsInstance(result, int)
        self.assertNotIsInstance(result, str)
        
        # Test for None
        empty_val = None
        self.assertIsNone(empty_val)
        self.assertIsNotNone(result)

if __name__ == '__main__':
    unittest.main()

Explanation: This example demonstrates assertions useful for checking collections and data types.

assertIn(a, b): Checks if a is a member of the container b (i.e., a in b).

assertNotIn(a, b): Checks if a is not a member of b.

assertIsInstance(obj, cls): Checks if obj is an instance of cls or a subclass of it. This is the correct way to check an object's type.

assertIsNone(x): Checks if x is None.

 

Example 4: pytest Assertions and Introspection

Code:

# save_as: test_pytest_assertions.py

def get_user_permissions():
    """A dummy function returning a dictionary."""
    return {"user": "admin", "permissions": ["read", "write", "execute"]}

def test_user_permissions():
    """Use simple `assert` statements with pytest."""
    permissions = get_user_permissions()
    
    # Simple equality check
    assert permissions["user"] == "admin"
    
    # Membership check
    assert "write" in permissions["permissions"]
    
    # Type check
    assert isinstance(permissions["permissions"], list)
    
    # A failing assertion to show pytest's output
    assert "delete" in permissions["permissions"]

Explanation: pytest philosophy is to use the standard Python assert keyword. What makes pytest powerful is its "assertion introspection." When an assert statement fails, pytest inspects the expression and provides a very detailed report showing the values of the variables involved. If you run this file with pytest, the final assertion will fail, and the output will clearly show that the list permissions['permissions'] contains ['read', 'write', 'execute'] and that 'delete' was not found in it. This makes debugging failed tests much faster than with unittest's simpler "AssertionError" message.

 

Example 5: Advanced pytest Assertions (Floats, Dictionaries)

Code:

# save_as: test_pytest_advanced_assertions.py
import pytest
from pytest import approx

def calculate_growth(initial, rate):
    """Calculates a value after applying a growth rate."""
    return initial * (1 + rate)

def test_float_comparison():
    """Floating point numbers should be compared with a tolerance."""
    # Direct comparison can fail due to precision issues: 0.1 + 0.2 != 0.3
    # assert 0.1 + 0.2 == 0.3 # This would fail!
    
    # Use pytest.approx to compare floats
    assert calculate_growth(100, 0.05) == approx(105.0)
    assert 0.1 + 0.2 == approx(0.3)

def test_dict_subset():
    """Check if one dictionary is a subset of another."""
    expected_subset = {'id': 101, 'active': True}
    full_response = {'id': 101, 'name': 'test-user', 'active': True, 'email': 'a@b.com'}
    
    # A clever way to assert a subset using dictionary view objects
    assert expected_subset.items() <= full_response.items()

Explanation: This example covers more advanced scenarios.

Floating Point Numbers: Comparing floating-point numbers for exact equality is dangerous due to how they are represented in binary. The result of 0.1 + 0.2 is not exactly 0.3. pytest.approx allows you to assert that two numbers are "close enough" to each other within a certain tolerance.

Dictionary Subsets: A common testing need is to verify that a dictionary contains certain key-value pairs, without caring about other keys it might also contain. The expression expected_subset.items() <= full_response.items() is a Pythonic way to check if all items in expected_subset are present in full_response. This is a powerful pattern for testing API responses, where you only care about a few specific fields.

 

Test Fixtures

A test fixture is something that provides a fixed baseline upon which tests can reliably and repeatedly execute. Fixtures are used to set up a "pre-test" state and, if necessary, perform "post-test" cleanup. This could involve creating a temporary database connection, a temporary file, or an instance of a class.

Note: Test fixtures are essential for writing clean, maintainable, and non-repetitive tests. In unittest, this is handled with setUp() and tearDown() methods. pytest offers a more powerful and flexible fixture system using functions marked with a @pytest.fixture decorator. Proper use of fixtures helps you follow the DRY (Don't Repeat Yourself) principle in your test code.

 

Example 1: Basic setUp and tearDown in unittest

Code:

# save_as: test_fixtures_unittest.py
import unittest

class MyResource:
    """A sample resource that needs to be set up and torn down."""
    def __init__(self):
        print("\n[Resource Created]")
        self.data = [1, 2, 3]
    
    def cleanup(self):
        print("[Resource Cleaned Up]")
        self.data = []

class TestWithFixtures(unittest.TestCase):
    def setUp(self):
        """This method is run before each test."""
        print("\n--- Running setUp ---")
        self.resource = MyResource()

    def tearDown(self):
        """This method is run after each test, even if the test fails."""
        print("--- Running tearDown ---")
        self.resource.cleanup()

    def test_data_length(self):
        print("-> Running test_data_length")
        self.assertEqual(len(self.resource.data), 3)

    def test_data_content(self):
        print("-> Running test_data_content")
        self.assertIn(2, self.resource.data)

if __name__ == '__main__':
    unittest.main()

Explanation: In unittest, the setUp() method is automatically called before every single test method in the class runs. The tearDown() method is called after every single test method finishes, regardless of whether it passed or failed. This ensures a clean state for each test. In this example, a new MyResource object is created and assigned to self.resource before test_data_length runs, and then it's cleaned up. The process repeats entirely for test_data_content, guaranteeing that the tests are isolated from each other.

 

Example 2: Class-Level Fixtures in unittest

Code:

# save_as: test_class_fixtures_unittest.py
import unittest

class TestWithClassFixtures(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        """This method is run once, before all tests in this class."""
        print("\n--- Running setUpClass ---")
        cls.expensive_resource = {"status": "initialized"}

    @classmethod
    def tearDownClass(cls):
        """This method is run once, after all tests in this class are done."""
        print("\n--- Running tearDownClass ---")
        cls.expensive_resource = None

    def test_one(self):
        print("-> Running test_one")
        self.assertEqual(self.expensive_resource["status"], "initialized")
        # Let's modify the resource for demonstration
        self.expensive_resource["status"] = "modified by test_one"

    def test_two(self):
        print("-> Running test_two")
        # This test will see the modification from test_one,
        # proving the resource is shared.
        self.assertEqual(self.expensive_resource["status"], "modified by test_one")

if __name__ == '__main__':
    unittest.main()

Explanation: Sometimes setting up a resource is very slow (e.g., establishing a database connection). If the tests don't modify the resource, it's inefficient to set it up and tear it down for every single test. unittest provides the @classmethod decorators setUpClass and tearDownClass for this purpose. These methods run only once for the entire class, not for each method. The resource is shared across all tests in the class, so be aware that tests are no longer perfectly isolated. Notice that test_two sees the change made by test_one.

 

Example 3: Basic pytest Fixture

Code:

# save_as: test_pytest_fixture.py
import pytest

# A fixture is just a function decorated with @pytest.fixture
@pytest.fixture
def sample_list():
    """A fixture that provides a sample list to tests."""
    print("\n(Creating sample_list fixture)")
    return [10, 20, 30, 40]

# To use a fixture, a test function can simply accept it as an argument.
# The argument name must match the fixture function name.
def test_length_with_fixture(sample_list):
    """Test the length of the list from the fixture."""
    assert len(sample_list) == 4

def test_content_with_fixture(sample_list):
    """Test the content of the list from the fixture."""
    assert 30 in sample_list

Explanation: This example shows the elegance of pytest fixtures. You define a function (sample_list) and decorate it with @pytest.fixture. This function sets up and returns the resource. Then, any test function that wants to use this fixture simply declares it as a parameter. pytest sees the parameter name, finds the fixture with the same name, runs the fixture function, and passes the return value into the test function. By default, fixtures are run separately for each test that requests them, ensuring test isolation.

 

Example 4: pytest Fixture with Teardown (yield)

Code:

# save_as: test_pytest_yield_fixture.py
import pytest
import os

@pytest.fixture
def temp_file():
    """A fixture to create and clean up a temporary file."""
    # --- Setup part ---
    file_path = "temp_test_file.txt"
    print(f"\n(Creating temporary file: {file_path})")
    with open(file_path, "w") as f:
        f.write("hello pytest")
    
    # The `yield` keyword passes the resource to the test.
    # Code after the yield is the teardown part.
    yield file_path
    
    # --- Teardown part ---
    print(f"\n(Cleaning up temporary file: {file_path})")
    os.remove(file_path)

def test_file_exists(temp_file):
    """Test that the fixture-created file exists."""
    assert os.path.exists(temp_file)

def test_file_content(temp_file):
    """Test the content of the fixture-created file."""
    with open(temp_file, "r") as f:
        content = f.read()
    assert content == "hello pytest"

Explanation: This is the standard pytest pattern for fixtures that require cleanup. Instead of return, the fixture function uses yield. The code before the yield is the setup phase. The yield passes control and the yielded value to the test function. After the test function completes (pass or fail), execution resumes in the fixture function right after the yield, and the teardown code is run. This is a much more explicit and flexible way to handle setup and teardown compared to unittest's setUp/tearDown methods.

 

Example 5: Advanced pytest Fixture Scopes

Code:

# save_as: test_pytest_scopes.py
import pytest
import time

# Scope can be 'function' (default), 'class', 'module', or 'session'.
@pytest.fixture(scope="session")
def database_connection():
    """A very expensive, session-scoped fixture."""
    print("\n--- (SESSION) Connecting to database... ---")
    connection = {"id": int(time.time()), "data": {}}
    yield connection
    print("\n--- (SESSION) Disconnecting from database. ---")

@pytest.fixture(scope="module")
def module_setup(database_connection):
    """A module-scoped fixture that uses the session fixture."""
    print(f"\n-- (MODULE) Setting up module with DB conn {database_connection['id']} --")
    yield
    print("\n-- (MODULE) Tearing down module. --")

@pytest.fixture(scope="function")
def fresh_data():
    """A function-scoped fixture, runs for each test."""
    print("\n- (FUNCTION) Creating fresh data -")
    return {"value": 1}

def test_alpha(database_connection, module_setup, fresh_data):
    print("-> Running test_alpha")
    assert database_connection["id"] is not None
    assert fresh_data["value"] == 1

def test_beta(database_connection, module_setup, fresh_data):
    print("-> Running test_beta")
    assert database_connection["id"] is not None
    assert fresh_data["value"] == 1

Explanation: This advanced example demonstrates the power of fixture scopes in pytest, which control how often a fixture is set up and torn down.

scope="function" (Default): The fixture runs once per test function. fresh_data is an example.

scope="class": The fixture runs once per test class.

scope="module": The fixture runs once per module (i.e., per .py file). module_setup is an example.

scope="session": The fixture runs only once for the entire test session (i.e., when you run pytest). This is perfect for very expensive resources like a database connection or a web driver that can be shared across all tests.

Notice how the print statements in the output will show database_connection being set up only once, module_setup once, and fresh_data twice (once for test_alpha and once for test_beta). Fixtures can also depend on other fixtures, as module_setup depends on database_connection.