unittest & pytest

0/4 in this phase0/54 across the roadmap

📖 Concept

Python has two primary testing frameworks: the built-in unittest module and the third-party pytest library. Understanding both is essential because legacy codebases often use unittest while modern projects overwhelmingly prefer pytest for its concise syntax and powerful features.

unittest follows the xUnit pattern (inspired by JUnit). Tests are organized into classes that inherit from unittest.TestCase, and assertion methods like assertEqual, assertTrue, and assertRaises are provided as instance methods. Setup and teardown logic is handled through setUp(), tearDown(), setUpClass(), and tearDownClass() methods.

pytest takes a radically different approach — test functions are plain functions (no class required), assertions use the native assert statement with introspection that provides detailed failure messages automatically, and fixtures replace setup/teardown with a dependency injection model.

Key pytest features:

  • Fixtures — reusable setup/teardown via @pytest.fixture with configurable scopes (function, class, module, session)
  • Parametrize — run the same test with multiple inputs via @pytest.mark.parametrize
  • Marks — tag tests with @pytest.mark.slow, @pytest.mark.integration, etc. for selective execution
  • conftest.py — shared fixtures and hooks, automatically discovered by pytest without imports
  • Plugins — rich ecosystem: pytest-cov (coverage), pytest-xdist (parallel), pytest-asyncio (async tests), pytest-mock (mocking)

Test discovery: pytest automatically finds files matching test_*.py or *_test.py, classes prefixed with Test, and functions prefixed with test_. This convention-over-configuration approach reduces boilerplate dramatically.

In production environments, pytest's fixture system is what sets it apart. Fixtures can depend on other fixtures forming a dependency graph, they can yield (for setup + teardown in one function), and conftest.py files at different directory levels enable hierarchical fixture sharing across test suites.

💻 Code Example

codeTap to expand ⛶
1# ============================================================
2# unittest basics — test_calculator_unittest.py
3# ============================================================
4import unittest
5from decimal import Decimal
6
7
8class Calculator:
9 """Production calculator with error handling."""
10
11 def add(self, a, b):
12 return a + b
13
14 def divide(self, a, b):
15 if b == 0:
16 raise ValueError("Cannot divide by zero")
17 return a / b
18
19 def percentage(self, value, total):
20 if total == 0:
21 raise ValueError("Total cannot be zero")
22 return Decimal(str(value)) / Decimal(str(total)) * 100
23
24
25class TestCalculator(unittest.TestCase):
26 """unittest-style tests for Calculator."""
27
28 @classmethod
29 def setUpClass(cls):
30 """Run once before all tests in this class."""
31 cls.calc = Calculator()
32 print("\nCalculator test suite starting...")
33
34 def setUp(self):
35 """Run before each individual test method."""
36 self.test_data = [(2, 3, 5), (0, 0, 0), (-1, 1, 0)]
37
38 def test_add_positive_numbers(self):
39 self.assertEqual(self.calc.add(2, 3), 5)
40
41 def test_add_negative_numbers(self):
42 self.assertEqual(self.calc.add(-1, -2), -3)
43
44 def test_divide_normal(self):
45 self.assertAlmostEqual(self.calc.divide(10, 3), 3.3333, places=4)
46
47 def test_divide_by_zero_raises(self):
48 with self.assertRaises(ValueError) as ctx:
49 self.calc.divide(10, 0)
50 self.assertIn("Cannot divide by zero", str(ctx.exception))
51
52 def test_percentage_calculation(self):
53 result = self.calc.percentage(25, 200)
54 self.assertEqual(result, Decimal("12.5"))
55
56 def tearDown(self):
57 """Run after each individual test method."""
58 self.test_data = None
59
60 @classmethod
61 def tearDownClass(cls):
62 """Run once after all tests in this class."""
63 print("Calculator test suite complete.")
64
65
66# ============================================================
67# pytest fundamentals — test_calculator_pytest.py
68# ============================================================
69import pytest
70
71
72# --- Fixtures: reusable setup with dependency injection ---
73@pytest.fixture
74def calculator():
75 """Provides a fresh Calculator instance for each test."""
76 return Calculator()
77
78
79@pytest.fixture
80def sample_data():
81 """Provides test data as a fixture."""
82 return {
83 "positive_pairs": [(1, 2, 3), (10, 20, 30), (100, 200, 300)],
84 "negative_pairs": [(-1, -2, -3), (-10, 20, 10)],
85 "zero_pairs": [(0, 5, 5), (5, 0, 5)],
86 }
87
88
89@pytest.fixture(scope="module")
90def expensive_resource():
91 """
92 Module-scoped fixture — created once per test module.
93 Scope options: function (default), class, module, session.
94 """
95 print("\n[SETUP] Creating expensive resource...")
96 resource = {"connection": "db://localhost", "pool_size": 5}
97 yield resource # yield turns this into a setup + teardown fixture
98 print("\n[TEARDOWN] Releasing expensive resource...")
99
100
101# --- Basic pytest tests: plain functions + assert ---
102def test_add_basic(calculator):
103 """Fixtures are injected by name — no inheritance needed."""
104 assert calculator.add(2, 3) == 5
105
106
107def test_add_with_floats(calculator):
108 result = calculator.add(0.1, 0.2)
109 assert result == pytest.approx(0.3) # handles float comparison
110
111
112def test_divide_by_zero(calculator):
113 with pytest.raises(ValueError, match="Cannot divide by zero"):
114 calculator.divide(10, 0)
115
116
117# --- Parametrize: run same test with multiple inputs ---
118@pytest.mark.parametrize(
119 "a, b, expected",
120 [
121 (2, 3, 5),
122 (0, 0, 0),
123 (-1, 1, 0),
124 (100, -50, 50),
125 (1.5, 2.5, 4.0),
126 ],
127 ids=["positive", "zeros", "mixed", "large", "floats"],
128)
129def test_add_parametrized(calculator, a, b, expected):
130 assert calculator.add(a, b) == expected
131
132
133@pytest.mark.parametrize(
134 "value, total, expected",
135 [
136 (50, 200, Decimal("25")),
137 (1, 3, Decimal("33.33333333333333333333333333")),
138 (0, 100, Decimal("0")),
139 ],
140)
141def test_percentage_parametrized(calculator, value, total, expected):
142 result = calculator.percentage(value, total)
143 assert result == expected
144
145
146# --- Marks: tag and filter tests ---
147@pytest.mark.slow
148def test_heavy_computation(calculator):
149 """Run with: pytest -m slow"""
150 total = sum(calculator.add(i, i) for i in range(100_000))
151 assert total == 9_999_900_000
152
153
154@pytest.mark.skip(reason="Feature not yet implemented")
155def test_future_feature():
156 pass
157
158
159@pytest.mark.skipif(
160 not hasattr(Calculator, "sqrt"),
161 reason="sqrt method not available",
162)
163def test_sqrt_method():
164 pass
165
166
167@pytest.mark.xfail(reason="Known float precision issue", strict=True)
168def test_known_float_issue(calculator):
169 assert calculator.add(0.1, 0.2) == 0.3 # fails without approx
170
171
172# --- Using fixtures with yield (setup + teardown) ---
173@pytest.fixture
174def temp_file(tmp_path):
175 """tmp_path is a built-in pytest fixture providing a temp directory."""
176 file_path = tmp_path / "test_output.txt"
177 file_path.write_text("initial content")
178 yield file_path
179 # Teardown: cleanup happens after the test
180 if file_path.exists():
181 file_path.unlink()
182
183
184def test_file_operations(temp_file):
185 assert temp_file.read_text() == "initial content"
186 temp_file.write_text("modified content")
187 assert temp_file.read_text() == "modified content"
188
189
190# ============================================================
191# conftest.py — shared fixtures across test modules
192# ============================================================
193# File: tests/conftest.py
194# No imports needed — pytest auto-discovers conftest.py files
195
196# @pytest.fixture(scope="session")
197# def db_connection():
198# """Session-scoped: one connection for the entire test run."""
199# conn = create_connection("sqlite:///:memory:")
200# conn.execute("CREATE TABLE users (id INT, name TEXT)")
201# yield conn
202# conn.close()
203#
204# @pytest.fixture(autouse=True)
205# def reset_db(db_connection):
206# """autouse=True means this runs for EVERY test automatically."""
207# yield
208# db_connection.execute("DELETE FROM users")
209
210
211# ============================================================
212# Running tests
213# ============================================================
214# pytest # run all tests
215# pytest test_file.py # run specific file
216# pytest test_file.py::test_func # run specific test
217# pytest -m slow # run only @pytest.mark.slow
218# pytest -m "not slow" # skip slow tests
219# pytest -k "add" # run tests matching "add"
220# pytest -v # verbose output
221# pytest -x # stop on first failure
222# pytest --tb=short # shorter tracebacks
223# pytest --cov=src --cov-report=html # coverage report
224# pytest -n auto # parallel with pytest-xdist
225
226if __name__ == "__main__":
227 unittest.main()

🏋️ Practice Exercise

Exercises:

  1. Write a BankAccount class with deposit, withdraw, and get_balance methods. Create a comprehensive test suite using pytest with at least 8 tests covering normal operations, edge cases (negative amounts, overdraft), and expected exceptions.

  2. Create a conftest.py with three fixtures: a bank_account fixture (function scope), a funded_account fixture that depends on bank_account (deposits $1000), and a db_session fixture (module scope) with yield-based teardown.

  3. Use @pytest.mark.parametrize to test a validate_email() function with at least 10 different inputs (valid and invalid emails). Include custom ids for readable test output.

  4. Set up marks: create tests tagged with @pytest.mark.unit, @pytest.mark.integration, and @pytest.mark.slow. Register them in pytest.ini or pyproject.toml and demonstrate running specific groups.

  5. Port a unittest.TestCase class to pytest-style functions. Compare the line count and readability of both versions. Note which patterns (like assertRaises, setUpClass) map to which pytest equivalents.

  6. Configure pytest-cov to generate an HTML coverage report. Analyze uncovered lines and write tests to achieve at least 95% coverage on your BankAccount module.

⚠️ Common Mistakes

  • Sharing mutable state across tests without proper fixture isolation. Each test must be independent — use function-scoped fixtures or reset state in teardown. Tests that depend on execution order are fragile and mask bugs.

  • Using assertEqual or assertTrue in pytest instead of plain assert. Pytest's assertion introspection provides superior error messages with assert, showing actual vs expected values automatically.

  • Putting test logic in conftest.py — it should only contain fixtures and hooks, never actual test functions. Pytest will silently ignore test functions defined in conftest files.

  • Forgetting that @pytest.fixture(autouse=True) applies to ALL tests in scope, which can cause unexpected behavior. Use autouse sparingly and prefer explicit fixture injection.

  • Not parameterizing tests that differ only in input data. Copy-pasted tests with different values are a maintenance burden. Use @pytest.mark.parametrize to express the pattern once.

💼 Interview Questions

🎤 Mock Interview

Practice a live interview for unittest & pytest