Testing in Python
The framework question is settled — pytest, for new code, without much argument. What is worth having an opinion about is what each test layer is for and where the suite stops paying for itself.
The layers, and what each catches
| Layer | Catches | Cost |
|---|---|---|
| Unit | logic errors in one unit | milliseconds |
| Integration | wrong assumptions between units | seconds |
| End-to-end | wiring, config, deployment | minutes, flaky |
The ratio matters more than the definitions: many unit, some integration, few end-to-end. Inverting it produces a suite that takes twenty minutes, fails intermittently, and gets disabled. See Test Strategy and the Test Pyramid.
The other kinds — performance, security, acceptance — are not pyramid layers. They answer different questions and run on a different cadence.
unittest versus pytest
Both discover tests, both run in CI. The difference is how much ceremony sits between you and the assertion.
import unittest
class TestMath(unittest.TestCase):
def setUp(self):
self.a, self.b = 10, 5
def test_addition(self):
self.assertEqual(self.a + self.b, 15)The same thing in pytest — a function, a plain assert, and setup expressed as
an argument:
import pytest
@pytest.fixture
def pair():
return 10, 5
def test_addition(pair):
a, b = pair
assert a + b == 15| unittest | pytest | |
|---|---|---|
| In the stdlib | yes | no |
| Test shape | TestCase subclass |
plain function |
| Assertions | assertEqual, ~30 more |
assert |
| Setup | setUp per class |
fixtures, composable |
| Ecosystem | small | large |
The fixture system is the real difference, not the syntax. setUp runs for
every test in the class whether that test needs it or not; a fixture runs
because a test asked for it by name, and fixtures compose into each other.
Rule of thumb: use
unittestwhen you cannot add a dependency, or the codebase is already built on it. Use pytest otherwise. pytest runsunittest.TestCaseclasses unchanged, so migration is incremental rather than a rewrite.
The pytest features worth knowing by name
Parametrize, which turns one test into many cases with real names:
@pytest.mark.parametrize(
"value,expected",
[(0, "zero"), (1, "one"), (-1, "negative")],
)
def test_describe(value, expected):
assert describe(value) == expectedA failure reports test_describe[-1-negative], so you know which case broke
without reading the body.
Assertion rewriting is why the bare assert is enough. pytest rewrites the
expression at import so a failure shows both sides:
E assert {'a': 1, 'b': 3} == {'a': 1, 'b': 2}
E Differing items:
E {'b': 3} != {'b': 2}Fixture scope is the performance lever people miss. A database container built per test is the usual reason a suite is slow:
@pytest.fixture(scope="session")
def db():
with Postgres() as p: # once for the whole run
yield pWiden the scope and you must keep tests from leaking state into each other — usually a transaction rolled back per test on top of a session-scoped connection.
What makes a test worth keeping
- One behaviour per test, named so the failure line explains itself.
- Assert on behaviour, not implementation. A test that breaks on every refactor is measuring the wrong thing.
- Deterministic. No real clock, no network, no ordering dependency between tests.
- Fast enough to run on save. A suite you run once a day catches things a day late.
Gotcha: 100% coverage is not the goal. Coverage records which lines executed, not which behaviours were verified — a test with no assertion at all still covers its lines. Use it to find untested areas, not as a target.
Related
Interview angle 5
- “unittest or pytest?” - pytest for new code: plain functions, bare
assertwith rewritten failure output, and a fixture system that composes. unittest when you cannot take a dependency or the codebase already uses it — and pytest runs those classes unchanged, so migrating is incremental. - “What does the test pyramid say?” - many fast unit tests, fewer integration tests, very few end-to-end. Inverting it gives a slow, flaky suite nobody runs, which is worse than fewer tests.
- “What makes a good unit test?” - one behaviour, deterministic, fast, and named so a failure tells you what broke without reading the body. Tests asserting implementation detail rather than behaviour break on every refactor.
- “Is 100% coverage the goal?” - no. Coverage shows what was executed, not what was verified; a test with no assertions still covers lines. Use it to find untested areas, not as a target.
- “Your suite takes 20 minutes. Where do you look first?” - fixture scope. A container or database built per test rather than per session is the usual cause, and the fix is a session-scoped resource with a per-test transaction rolled back.