Backend / Python core / 14_testing.md

Testing in Python

Updated 5 interview angles 4 min read source
On this page6
  1. The layers, and what each catches
  2. unittest versus pytest
  3. The pytest features worth knowing by name
  4. What makes a test worth keeping
  5. Related
  6. Interview angle

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.

python
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:

python
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 unittest when you cannot add a dependency, or the codebase is already built on it. Use pytest otherwise. pytest runs unittest.TestCase classes 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:

python
@pytest.mark.parametrize(
    "value,expected",
    [(0, "zero"), (1, "one"), (-1, "negative")],
)
def test_describe(value, expected):
    assert describe(value) == expected

A 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:

text
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:

python
@pytest.fixture(scope="session")
def db():
    with Postgres() as p:      # once for the whole run
        yield p

Widen 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.

Interview angle 5

  • “unittest or pytest?” - pytest for new code: plain functions, bare assert with 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.