Python · Syntax · Advanced

Testing with pytest

10 tasks

Write reliable tests using pytest. Covers test functions, fixtures, parametrize, monkeypatch, and testing exceptions.

pytest basics, assertions, parametrize, and exception testing

#
**Why testing matters and why pytest** Tests are executable documentation. They prove your code does what you think it does, and they catch regressions when you change something later. pytest is the de facto standard: less boilerplate than `unittest`, powerful parametrize, and an enormous plugin ecosystem. **Writing your first pytest test** Any function starting with `test_` in a file starting with `test_` is discovered automatically: ```python # test_math.py def add(a, b): return a + b def test_add_integers(): assert add(2, 3) == 5 def test_add_floats(): assert add(0.1, 0.2) == pytest.approx(0.3) # float comparison ``` Run with: `pytest test_math.py` or just `pytest` to discover all tests. **pytest assertions vs unittest** pytest rewrites `assert` statements to show detailed diffs on failure. No special assert methods needed: ```python # unittest style: self.assertEqual(result, [1, 2, 3]) self.assertIn('key', mapping) self.assertRaises(ValueError, func, bad_arg) # pytest style — plain Python: assert result == [1, 2, 3] assert 'key' in mapping with pytest.raises(ValueError): func(bad_arg) ``` **`@pytest.mark.parametrize` — testing multiple inputs** Instead of copy-pasting tests for each case, parametrize runs one test function with many different inputs: ```python import pytest @pytest.mark.parametrize('a, b, expected', [ (1, 2, 3), (0, 0, 0), (-1, 1, 0), (100, -50, 50), ]) def test_add(a, b, expected): assert add(a, b) == expected # pytest reports each case separately: # test_add[1-2-3] PASSED # test_add[0-0-0] PASSED # test_add[-1-1-0] PASSED # test_add[100--50-50] PASSED ``` **Testing exceptions with context** ```python def divide(a, b): if b == 0: raise ZeroDivisionError('cannot divide by zero') return a / b def test_divide_by_zero(): with pytest.raises(ZeroDivisionError, match='cannot divide'): divide(10, 0) def test_divide_by_zero_type(): with pytest.raises(ZeroDivisionError) as exc_info: divide(10, 0) assert 'zero' in str(exc_info.value) ```

Fixtures, scope, conftest.py, mock, and monkeypatch

#
**Fixtures — reusable test setup** A fixture is a function decorated with `@pytest.fixture` that sets up shared state for tests. pytest injects fixtures by parameter name: ```python import pytest @pytest.fixture def user(): return {'name': 'Alice', 'role': 'admin'} def test_admin_access(user): # pytest injects 'user' automatically assert user['role'] == 'admin' def test_user_name(user): assert user['name'] == 'Alice' ``` **Fixture teardown with `yield`** ```python @pytest.fixture def db_connection(): conn = connect_to_test_db() yield conn # setup returns here conn.close() # teardown runs after test completes conn.delete_all_rows() # cleanup ``` **Fixture scope** By default fixtures are created fresh for each test (`scope='function'`). You can share one instance across multiple tests to avoid expensive setup: ```python @pytest.fixture(scope='module') # one instance per test module def expensive_resource(): return setup_slow_external_service() @pytest.fixture(scope='session') # one instance for the whole test run def db_schema(): return create_schema() ``` **`conftest.py` — shared fixtures across files** Put fixtures in `conftest.py` and they're automatically available to all tests in the same directory and subdirectories — no import needed: ``` tests/ conftest.py <- fixtures defined here test_users.py <- uses fixtures from conftest test_orders.py <- also uses fixtures from conftest api/ conftest.py <- additional fixtures for just this subdir test_routes.py ``` **`unittest.mock` — replacing dependencies** Mock lets you replace parts of your system with fake objects during tests: ```python from unittest.mock import Mock, patch, MagicMock # patch: temporarily replaces an attribute/function @patch('mymodule.requests.get') def test_fetch_data(mock_get): mock_get.return_value.status_code = 200 mock_get.return_value.json.return_value = {'key': 'value'} result = fetch_data('https://api.example.com') assert result == {'key': 'value'} mock_get.assert_called_once_with('https://api.example.com') ``` **`monkeypatch` — pytest's built-in patcher** For simple cases, pytest's `monkeypatch` fixture is cleaner than `@patch`: ```python def test_home_dir(monkeypatch, tmp_path): monkeypatch.setenv('HOME', str(tmp_path)) monkeypatch.setattr(os.path, 'exists', lambda p: True) # changes are automatically reverted after the test assert get_config_dir() == str(tmp_path / '.config') ```

Test isolation, factory fixtures, marks, coverage, CLI reference

#
**Test isolation principles** Each test should be fully independent — it should set up its own state, run, and clean up. Tests that share mutable state cause flaky results (tests pass in isolation but fail when run together). ```python # BAD: tests share module-level state items = [] def test_add_item(): items.append('a') assert len(items) == 1 def test_count(): # FAILS when run after test_add_item assert len(items) == 0 # GOOD: fixture creates fresh state per test @pytest.fixture def items(): return [] # new list for each test ``` **Factory fixtures** A fixture can return a function that creates objects on demand — useful when tests need different variations of the same thing: ```python @pytest.fixture def make_user(): def _make(name='Alice', role='user', active=True): return User(name=name, role=role, active=active) return _make def test_admin(make_user): admin = make_user(role='admin') assert admin.can_delete() def test_inactive(make_user): user = make_user(active=False) assert not user.can_login() ``` **pytest marks — skipping and marking tests** ```python import pytest @pytest.mark.skip(reason='not implemented yet') def test_future_feature(): ... @pytest.mark.skipif(sys.platform == 'win32', reason='Unix-only') def test_unix_socket(): ... @pytest.mark.xfail(reason='known bug in upstream') def test_known_broken(): ... # expected to fail; XPASS (unexpected pass) also reported # Custom marks for grouping: @pytest.mark.slow def test_full_integration(): ... # Run only slow tests: pytest -m slow # Skip slow tests: pytest -m 'not slow' ``` **Coverage — measuring what's tested** ```bash pip install pytest-cov pytest --cov=mymodule --cov-report=term-missing ``` Coverage shows which lines were never reached by any test. Aim for high coverage on core logic, but don't chase 100% — some code paths (startup, error handlers) are legitimately hard to test. **Quick reference — pytest CLI** | Command | What it does | |---|---| | `pytest` | Run all tests | | `pytest test_foo.py` | Run one file | | `pytest test_foo.py::test_bar` | Run one test | | `pytest -k 'add'` | Run tests matching 'add' | | `pytest -v` | Verbose output | | `pytest -x` | Stop at first failure | | `pytest --lf` | Re-run last failed tests only | | `pytest -s` | Don't capture stdout (show print()) |
01

Write basic test functions

#

Write three pytest test functions for a `add(a, b)` function: `test_add_positive`, `test_add_negative`, and `test_add_zero`. Each should call `add()` and use `assert` to check the result.

def add(a: int, b: int) -> int:
    return a + b


def test_add_positive():
    pass

def test_add_negative():
    pass

def test_add_zero():
    pass


# Run: pytest this_file.py
Solution
def add(a: int, b: int) -> int:
    return a + b


def test_add_positive():
    assert add(2, 3) == 5

def test_add_negative():
    assert add(-1, -2) == -3

def test_add_zero():
    assert add(0, 100) == 100
02

Test that exceptions are raised

#

Write tests for a `divide(a, b)` function. Test that `divide(10, 2)` returns `5.0`, and that `divide(10, 0)` raises a `ZeroDivisionError` using `pytest.raises`.

import pytest

def divide(a: float, b: float) -> float:
    return a / b


def test_divide_normal():
    pass

def test_divide_by_zero():
    pass
Solution
import pytest

def divide(a: float, b: float) -> float:
    return a / b


def test_divide_normal():
    assert divide(10, 2) == 5.0

def test_divide_by_zero():
    with pytest.raises(ZeroDivisionError):
        divide(10, 0)
03

Create a pytest fixture

#

Write a `@pytest.fixture` called `sample_list` that returns `[3, 1, 4, 1, 5, 9]`. Write two tests that use it: `test_length` checks `len == 6`, and `test_max` checks `max == 9`.

import pytest

@pytest.fixture
def sample_list():
    pass


def test_length(sample_list):
    pass

def test_max(sample_list):
    pass
Solution
import pytest

@pytest.fixture
def sample_list():
    return [3, 1, 4, 1, 5, 9]


def test_length(sample_list):
    assert len(sample_list) == 6

def test_max(sample_list):
    assert max(sample_list) == 9
04

Parametrize a test

#

Use `@pytest.mark.parametrize` to test `is_palindrome(s)` with five cases: `'racecar'` → True, `'hello'` → False, `'madam'` → True, `'a'` → True, `'ab'` → False. Write all five as a single parametrized test.

import pytest

def is_palindrome(s: str) -> bool:
    return s == s[::-1]


@pytest.mark.parametrize('s, expected', [
    # fill in the 5 cases
])
def test_is_palindrome(s, expected):
    pass
Solution
import pytest

def is_palindrome(s: str) -> bool:
    return s == s[::-1]


@pytest.mark.parametrize('s, expected', [
    ('racecar', True),
    ('hello', False),
    ('madam', True),
    ('a', True),
    ('ab', False),
])
def test_is_palindrome(s, expected):
    assert is_palindrome(s) == expected
05

monkeypatch environment variable

#

Write a function `get_debug_mode() -> bool` that reads the `DEBUG` environment variable and returns `True` if it equals `'1'`, else `False`. Write two tests using `monkeypatch.setenv`: one where `DEBUG=1` → True, one where `DEBUG=0` → False.

import os
import pytest

def get_debug_mode() -> bool:
    return os.environ.get('DEBUG', '0') == '1'


def test_debug_on(monkeypatch):
    pass

def test_debug_off(monkeypatch):
    pass
Solution
import os
import pytest

def get_debug_mode() -> bool:
    return os.environ.get('DEBUG', '0') == '1'


def test_debug_on(monkeypatch):
    monkeypatch.setenv('DEBUG', '1')
    assert get_debug_mode() is True

def test_debug_off(monkeypatch):
    monkeypatch.setenv('DEBUG', '0')
    assert get_debug_mode() is False
06

Mock a function with monkeypatch.setattr

#

A function `get_username()` calls `os.getlogin()` which may not work in CI. Use `monkeypatch.setattr` to replace `os.getlogin` with a lambda that returns `'testuser'`. Verify that `get_username()` returns `'testuser'`.

import os
import pytest

def get_username() -> str:
    return os.getlogin()


def test_get_username(monkeypatch):
    pass
Solution
import os
import pytest

def get_username() -> str:
    return os.getlogin()


def test_get_username(monkeypatch):
    monkeypatch.setattr(os, 'getlogin', lambda: 'testuser')
    assert get_username() == 'testuser'
07

Fixture with setup and teardown

#

Write a fixture `temp_list` that creates a list `[1, 2, 3]`, yields it, then clears it after the test (teardown using `yield`). Write a test `test_append` that appends `4` and checks `len == 4`. After the test, the fixture's teardown should clear the list.

import pytest

@pytest.fixture
def temp_list():
    data = [1, 2, 3]
    yield data
    # teardown: clear after test
    pass


def test_append(temp_list):
    temp_list.append(4)
    assert len(temp_list) == 4
Solution
import pytest

@pytest.fixture
def temp_list():
    data = [1, 2, 3]
    yield data
    data.clear()


def test_append(temp_list):
    temp_list.append(4)
    assert len(temp_list) == 4
08

Test file I/O with tmp_path

#

Write a function `write_and_read(path, text)` that writes `text` to `path`, reads it back, and returns the content. Use the built-in `tmp_path` fixture to test it without touching the real filesystem.

from pathlib import Path

def write_and_read(path: Path, text: str) -> str:
    path.write_text(text)
    return path.read_text()


def test_write_and_read(tmp_path):
    file = tmp_path / 'test.txt'
    result = write_and_read(file, 'hello')
    assert result == 'hello'
Solution
from pathlib import Path

def write_and_read(path: Path, text: str) -> str:
    path.write_text(text)
    return path.read_text()


def test_write_and_read(tmp_path):
    file = tmp_path / 'test.txt'
    result = write_and_read(file, 'hello')
    assert result == 'hello'
09

Test a class with a fixture

#

Given a `Counter` class with `increment()`, `decrement()`, and `value` property, write a fixture `counter` that returns a fresh `Counter()`. Write tests: `test_initial_value` (value == 0), `test_increment` (value == 1 after one increment), `test_decrement` (value == -1 after one decrement).

import pytest

class Counter:
    def __init__(self):
        self._value = 0

    def increment(self) -> None:
        self._value += 1

    def decrement(self) -> None:
        self._value -= 1

    @property
    def value(self) -> int:
        return self._value


@pytest.fixture
def counter():
    pass

def test_initial_value(counter):
    pass

def test_increment(counter):
    pass

def test_decrement(counter):
    pass
Solution
import pytest

class Counter:
    def __init__(self):
        self._value = 0

    def increment(self) -> None:
        self._value += 1

    def decrement(self) -> None:
        self._value -= 1

    @property
    def value(self) -> int:
        return self._value


@pytest.fixture
def counter():
    return Counter()

def test_initial_value(counter):
    assert counter.value == 0

def test_increment(counter):
    counter.increment()
    assert counter.value == 1

def test_decrement(counter):
    counter.decrement()
    assert counter.value == -1
10

Test floating-point values with pytest.approx

#

Write tests for a `circle_area(r)` function (returns `math.pi * r ** 2`). Use `pytest.approx` to compare the result — plain `==` fails for floats. Test with `r=1` (expected ~3.14159), `r=2` (expected ~12.566), and `r=0` (expected 0).

import math
import pytest

def circle_area(r: float) -> float:
    return math.pi * r ** 2


def test_area_r1():
    pass

def test_area_r2():
    pass

def test_area_r0():
    pass
Solution
import math
import pytest

def circle_area(r: float) -> float:
    return math.pi * r ** 2


def test_area_r1():
    assert circle_area(1) == pytest.approx(math.pi)

def test_area_r2():
    assert circle_area(2) == pytest.approx(4 * math.pi)

def test_area_r0():
    assert circle_area(0) == pytest.approx(0)