Testing Strategies
Every backend interview loop asks some version of "how would you test this," sometimes as its own question, more often as a follow-up right after you write code on a whiteboard. This lesson covers the testing pyramid, pytest unit tests, integration tests with fixtures, testing async code, mocking at a system boundary, the mock-versus-stub distinction, and writing tests toward a coverage target.
The testing pyramid
The pyramid is a guide to how much of each test type to write, based on speed and cost:
/\
/ \ E2E (few): slow, brittle, high confidence in the whole system
/----\
/ \ Integration (some): real DB/cache, one service boundary at a time
/--------\
/ \ Unit (many): fast, isolated, one function/class at a time
/------------\- Unit tests check one function or class in isolation, with its dependencies mocked or faked. Each one runs in milliseconds. These should be most of your suite.
- Integration tests check how your code talks to a real dependency, a real Postgres through a test container or a rolled-back transaction, or a real Redis. Write fewer of these, since they're slower and need real infrastructure to run.
- End-to-end tests hit the whole system through its real interface, usually HTTP, sometimes across several services. These are the slowest and the most brittle, since any unrelated change can break one, but they catch integration bugs unit tests structurally can't see. Keep them to your critical user flows only.
If asked "what's wrong with only unit tests" or "only end-to-end tests": all-unit gives fast feedback but no confidence the pieces actually work together, since a mock can quietly drift from what the real dependency does. All-end-to-end gives high confidence but a slow, flaky suite that's expensive to maintain and hard to debug when something fails. The pyramid's shape is a deliberate trade-off between speed and confidence, not an arbitrary ratio.
Remember: unit tests are fast but can't catch integration bugs; end-to-end tests catch those bugs but are slow and brittle. Write mostly unit tests, some integration tests, and only a few end-to-end tests for the flows that matter most.
Unit tests with pytest
# app/pricing.py
def apply_discount(price: float, percent: float) -> float:
if not 0 <= percent <= 100:
raise ValueError("percent must be between 0 and 100")
return round(price * (1 - percent / 100), 2)# tests/test_pricing.py
import pytest
from app.pricing import apply_discount
def test_apply_discount_basic():
assert apply_discount(100, 20) == 80.0
def test_apply_discount_zero_percent():
assert apply_discount(50, 0) == 50.0
def test_apply_discount_full_discount():
assert apply_discount(50, 100) == 0.0
@pytest.mark.parametrize("bad_percent", [-1, 101, 150])
def test_apply_discount_rejects_out_of_range(bad_percent):
with pytest.raises(ValueError):
apply_discount(100, bad_percent)
@pytest.mark.parametrize(
"price, percent, expected",
[
(99.99, 10, 89.99),
(10, 33.33, 6.67),
(0, 50, 0.0),
],
)
def test_apply_discount_table(price, percent, expected):
assert apply_discount(price, percent) == expectedpytest.mark.parametrize is the tool most candidates under-use. It replaces N nearly identical test functions with one table of cases, and each row shows up as its own separate result in the test report, so a failure points straight at the exact input that broke.
Good unit tests cover the happy path, boundary values (zero, negative, the maximum), and invalid input that should raise. Test only the happy path in an interview, and expect to get asked "what about X" as a follow-up, so cover it yourself first.
Remember:
pytest.mark.parametrizeturns a pile of copy-pasted test functions into one table, with each row reported separately on failure. Always cover the happy path, the boundaries, and the invalid input that should raise.
Integration tests with fixtures
Fixtures set up and tear down shared state, a DB connection, a test client, seed data, so every test starts from the same known baseline.
# tests/conftest.py
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.db import Base
from app.models import User
TEST_DB_URL = "postgresql://test:test@localhost:5432/test_db"
@pytest.fixture(scope="session")
def engine():
engine = create_engine(TEST_DB_URL)
Base.metadata.create_all(engine)
yield engine
Base.metadata.drop_all(engine)
@pytest.fixture
def db_session(engine):
connection = engine.connect()
transaction = connection.begin()
Session = sessionmaker(bind=connection)
session = Session()
yield session
# Roll back everything the test did: next test starts clean,
# and we never pay the cost of recreating the schema per test.
session.close()
transaction.rollback()
connection.close()
@pytest.fixture
def sample_user(db_session):
user = User(username="alice", email="alice@mindforge.test")
db_session.add(user)
db_session.commit()
return user# tests/test_user_repository.py
from app.repository import get_user_by_username, deactivate_user
def test_get_user_by_username(db_session, sample_user):
found = get_user_by_username(db_session, "alice")
assert found.id == sample_user.id
def test_deactivate_user(db_session, sample_user):
deactivate_user(db_session, sample_user.id)
db_session.refresh(sample_user)
assert sample_user.is_active is FalseThe transaction-rollback pattern in db_session is the detail that shows real experience: it gives every test a clean database state without paying the cost of dropping and recreating the schema per test, by wrapping the whole test in a transaction that never commits.
For a FastAPI app, an integration test usually also spins up a TestClient and overrides the DB dependency to point at the test session:
from fastapi.testclient import TestClient
from app.main import app
from app.dependencies import get_db
@pytest.fixture
def client(db_session):
def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
yield TestClient(app)
app.dependency_overrides.clear()
def test_create_user_endpoint(client):
response = client.post("/users", json={"username": "bob", "email": "bob@mindforge.test"})
assert response.status_code == 201
assert response.json()["username"] == "bob"Remember: wrap each test in a transaction that rolls back at the end. That gives every test a clean database without the cost of rebuilding the schema between tests.
Testing async code
pytest-asyncio is the standard plugin. Two things trip people up: marking the test as async, and mocking async dependencies correctly.
# pip install pytest-asyncio
import pytest
from app.services import fetch_user_profile
pytestmark = pytest.mark.asyncio # apply to every test in this module
async def test_fetch_user_profile_success(mocker):
mock_http = mocker.AsyncMock()
mock_http.get.return_value.json = mocker.AsyncMock(return_value={"id": 1, "name": "Alice"})
mocker.patch("app.services.http_client", mock_http)
profile = await fetch_user_profile(user_id=1)
assert profile["name"] == "Alice"
mock_http.get.assert_awaited_once_with("/users/1")Use AsyncMock, not Mock, for anything your code will await. A plain Mock returns another Mock object when called, not something awaitable, so await some_mock() raises TypeError: object Mock can't be used in 'await' expression. AsyncMock, built into unittest.mock since Python 3.8, returns a coroutine automatically.
Testing concurrency itself, not just one async function in isolation, needs a different approach: run the coroutines together with asyncio.gather and assert on timing or on the order events happened in, rather than only on the final state.
import asyncio
import time
async def test_requests_run_concurrently():
async def slow_call():
await asyncio.sleep(0.2)
return "done"
start = time.monotonic()
results = await asyncio.gather(slow_call(), slow_call(), slow_call())
elapsed = time.monotonic() - start
assert results == ["done", "done", "done"]
assert elapsed < 0.5 # concurrent, not 0.6s sequentialRemember: mock anything the code under test will
awaitwithAsyncMock, never a plainMock. A plain Mock's return value isn't awaitable and will raise a TypeError the moment it's awaited.
Mocking at the boundary
The rule to state out loud in an interview: mock at the boundary, the external dependency, not the function you're actually testing. A payment gateway client is a good boundary to mock; the business logic that calls it is what you want to actually exercise.
from unittest.mock import patch, MagicMock
import pytest
from orders.services import charge_and_create_order
@patch("orders.services.payment_gateway")
def test_charge_and_create_order_success(mock_gateway):
mock_gateway.charge.return_value = MagicMock(id="ch_123", status="succeeded")
order = charge_and_create_order(user_id=1, amount_cents=2500)
mock_gateway.charge.assert_called_once_with(amount_cents=2500, user_id=1)
assert order.status == "paid"
assert order.charge_id == "ch_123"
@patch("orders.services.payment_gateway")
def test_charge_and_create_order_gateway_failure_rolls_back(mock_gateway):
mock_gateway.charge.side_effect = Exception("card declined")
with pytest.raises(Exception, match="card declined"):
charge_and_create_order(user_id=1, amount_cents=2500)
# verify no partial order was persisted: the transaction rolled back
from orders.models import Order
assert not Order.objects.filter(user_id=1).exists()The second test is worth more in an interview than a second happy-path test would be: it proves a failed external call doesn't leave a half-created order behind, that the database write genuinely rolled back. Testing a real failure mode beats testing the happy path twice.
Remember: mock the external dependency at the boundary, never the function you're actually testing. Testing that a failure rolls back cleanly is worth more than a second happy-path test.
Mock vs stub: the interview answer
Both are "test doubles," fake objects standing in for a real dependency, but they answer different questions.
| Stub | Mock | |
|---|---|---|
| Purpose | Provide canned responses so the test can run | Verify interactions happened as expected |
| What it checks | Nothing on its own; you assert on the code under test's output | Asserts the code under test called it correctly (assert_called_with, assert_awaited_once) |
| Typical use | "When this dependency is called, return X" | "Prove we called send_email exactly once with these args" |
# Stub: just returns canned data, no assertion on how it was called
def test_get_user_greeting_stub(mocker):
stub_repo = mocker.Mock()
stub_repo.get_user.return_value = {"name": "Alice"}
greeting = get_user_greeting(stub_repo, user_id=1)
assert greeting == "Hello, Alice!"
# We don't care HOW get_user was called, just that we got usable data back.
# Mock: asserts the interaction itself
def test_send_welcome_email_mock(mocker):
mock_mailer = mocker.Mock()
send_welcome_email(mock_mailer, "alice@mindforge.test")
mock_mailer.send.assert_called_once_with(
to="alice@mindforge.test", subject="Welcome!"
)
# We DO care that send() was called with exactly these args:
# that's the whole behavior being tested here.Rule of thumb: reach for a stub when testing what a function returns, and reach for a mock when testing that a function calls something correctly, where the call itself is the side effect you care about, like sending an email or publishing an event, with no return value to assert on instead.
Remember: a stub answers "what did the function return." A mock answers "did the function call the right thing, the right number of times, with the right arguments."
Writing tests to a coverage target
Coverage measures which lines executed during a test run. It's a useful floor, not a quality signal by itself, since 100% coverage with no real assertions on behavior proves nothing.
pip install pytest-cov
pytest --cov=app --cov-report=term-missing --cov-fail-under=80Name Stmts Miss Cover Missing
--------------------------------------------------
app/pricing.py 12 0 100%
app/repository.py 28 4 86% 45-48
app/services.py 40 12 70% 88-99
--------------------------------------------------
TOTAL 80 16 80%--cov-report=term-missing prints the exact uncovered line numbers, so you know exactly which branch to target next, usually error-handling paths and edge cases, since the happy path tends to get covered first almost by accident. --cov-fail-under=80 makes CI fail below the threshold, which is what turns "80% coverage" from an aspiration into an enforced gate.
To hit a target deliberately: run coverage once, read the Missing column, and write one test per uncovered branch, rather than one large test that happens to touch more lines. A test aimed directly at a specific branch is the kind that actually catches a regression there later; incidental coverage from a broad test often isn't asserting anything meaningful about the branch it happens to execute.
Remember: 100% coverage with weak assertions proves nothing. Read the Missing column and write one targeted test per uncovered branch, favoring error paths, which are the ones the happy path never touches.