Python · Syntax · Advanced
Testing with pytest
Write reliable tests using pytest. Covers test functions, fixtures, parametrize, monkeypatch, and testing exceptions.
Quick topic start and explanations before exercises (exercises below):
Fixtures, scope, conftest.py, mock, and monkeypatch
#Test isolation, factory fixtures, marks, coverage, CLI reference
#Exercises:
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
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)
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
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
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
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'
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
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'
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
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)