unittest & pytest
📖 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.fixturewith 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
1# ============================================================2# unittest basics — test_calculator_unittest.py3# ============================================================4import unittest5from decimal import Decimal678class Calculator:9 """Production calculator with error handling."""1011 def add(self, a, b):12 return a + b1314 def divide(self, a, b):15 if b == 0:16 raise ValueError("Cannot divide by zero")17 return a / b1819 def percentage(self, value, total):20 if total == 0:21 raise ValueError("Total cannot be zero")22 return Decimal(str(value)) / Decimal(str(total)) * 100232425class TestCalculator(unittest.TestCase):26 """unittest-style tests for Calculator."""2728 @classmethod29 def setUpClass(cls):30 """Run once before all tests in this class."""31 cls.calc = Calculator()32 print("\nCalculator test suite starting...")3334 def setUp(self):35 """Run before each individual test method."""36 self.test_data = [(2, 3, 5), (0, 0, 0), (-1, 1, 0)]3738 def test_add_positive_numbers(self):39 self.assertEqual(self.calc.add(2, 3), 5)4041 def test_add_negative_numbers(self):42 self.assertEqual(self.calc.add(-1, -2), -3)4344 def test_divide_normal(self):45 self.assertAlmostEqual(self.calc.divide(10, 3), 3.3333, places=4)4647 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))5152 def test_percentage_calculation(self):53 result = self.calc.percentage(25, 200)54 self.assertEqual(result, Decimal("12.5"))5556 def tearDown(self):57 """Run after each individual test method."""58 self.test_data = None5960 @classmethod61 def tearDownClass(cls):62 """Run once after all tests in this class."""63 print("Calculator test suite complete.")646566# ============================================================67# pytest fundamentals — test_calculator_pytest.py68# ============================================================69import pytest707172# --- Fixtures: reusable setup with dependency injection ---73@pytest.fixture74def calculator():75 """Provides a fresh Calculator instance for each test."""76 return Calculator()777879@pytest.fixture80def 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 }878889@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 fixture98 print("\n[TEARDOWN] Releasing expensive resource...")99100101# --- 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) == 5105106107def test_add_with_floats(calculator):108 result = calculator.add(0.1, 0.2)109 assert result == pytest.approx(0.3) # handles float comparison110111112def test_divide_by_zero(calculator):113 with pytest.raises(ValueError, match="Cannot divide by zero"):114 calculator.divide(10, 0)115116117# --- 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) == expected131132133@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 == expected144145146# --- Marks: tag and filter tests ---147@pytest.mark.slow148def 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_000152153154@pytest.mark.skip(reason="Feature not yet implemented")155def test_future_feature():156 pass157158159@pytest.mark.skipif(160 not hasattr(Calculator, "sqrt"),161 reason="sqrt method not available",162)163def test_sqrt_method():164 pass165166167@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 approx170171172# --- Using fixtures with yield (setup + teardown) ---173@pytest.fixture174def 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_path179 # Teardown: cleanup happens after the test180 if file_path.exists():181 file_path.unlink()182183184def 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"188189190# ============================================================191# conftest.py — shared fixtures across test modules192# ============================================================193# File: tests/conftest.py194# No imports needed — pytest auto-discovers conftest.py files195196# @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 conn202# 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# yield208# db_connection.execute("DELETE FROM users")209210211# ============================================================212# Running tests213# ============================================================214# pytest # run all tests215# pytest test_file.py # run specific file216# pytest test_file.py::test_func # run specific test217# pytest -m slow # run only @pytest.mark.slow218# pytest -m "not slow" # skip slow tests219# pytest -k "add" # run tests matching "add"220# pytest -v # verbose output221# pytest -x # stop on first failure222# pytest --tb=short # shorter tracebacks223# pytest --cov=src --cov-report=html # coverage report224# pytest -n auto # parallel with pytest-xdist225226if __name__ == "__main__":227 unittest.main()
🏋️ Practice Exercise
Exercises:
Write a
BankAccountclass withdeposit,withdraw, andget_balancemethods. Create a comprehensive test suite using pytest with at least 8 tests covering normal operations, edge cases (negative amounts, overdraft), and expected exceptions.Create a
conftest.pywith three fixtures: abank_accountfixture (function scope), afunded_accountfixture that depends onbank_account(deposits $1000), and adb_sessionfixture (module scope) with yield-based teardown.Use
@pytest.mark.parametrizeto test avalidate_email()function with at least 10 different inputs (valid and invalid emails). Include customidsfor readable test output.Set up marks: create tests tagged with
@pytest.mark.unit,@pytest.mark.integration, and@pytest.mark.slow. Register them inpytest.iniorpyproject.tomland demonstrate running specific groups.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.Configure
pytest-covto generate an HTML coverage report. Analyze uncovered lines and write tests to achieve at least 95% coverage on yourBankAccountmodule.
⚠️ 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
assertEqualorassertTruein pytest instead of plainassert. Pytest's assertion introspection provides superior error messages withassert, 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.parametrizeto express the pattern once.
💼 Interview Questions
🎤 Mock Interview
Practice a live interview for unittest & pytest