Testing Python Applications with pytest: Patterns for Real Code

Testing Python Applications with pytest: Patterns for Real Code

Introduction

In the previous article we finished building the runtime behaviour of FastURL: async I/O, a background inspector, and a single error envelope for every failure. The application works. The question now is how we know it works, and how we keep knowing it after the next fifty commits.

Most testing tutorials show one assert against one function and stop there. Real projects raise harder questions. Which tests should run on every pull request, and which only when several branches come together? How do you test code that calls a database, a remote web page, or a random number generator? What exactly is a fixture, and why does every pytest project seem to have a conftest.py?

This article answers those questions with the 79 tests in the FastURL repository. Every concept points to a real test you can open and run.

You should read this article if:

  • You write tests but are unsure where a unit test ends and an integration test begins, or why some tests pass alone and fail together.
  • You still use unittest and want a concrete reason to move to pytest.
  • You have seen Mock, AsyncMock, and @pytest.fixture in other people’s code but never understood what they actually do.

This article is part of the Modern Python Application series and the FastURL sub-series.

Not All Tests Are the Same

Think of a car manufacturer. Every bolt and brake pad is checked on its own bench as soon as it is made: the check takes seconds and runs thousands of times a day. Once a week, the assembled engine runs on a test rig with real fuel and real exhaust. Before a model goes on sale, a driver takes the whole car on a track. Nobody would put every bolt on the track, and nobody would sign off a car because its bolts passed.

Software tests follow the same split. They differ in what they isolate, how long they take, and when they run.

Unit tests: one piece of logic, in milliseconds

A unit test checks one piece of business logic in isolation. Everything the logic depends on (the database, the network, the clock) is replaced by a stand-in the test controls. The result is a test that runs in a few milliseconds and fails for exactly one reason: the logic is wrong.

In FastURL, the unit tests live in tests/unit/. The LinkService tests check the domain rules (custom alias collisions, the self-redirect guard, link expiry, the retry loop for auto-generated codes) without opening a single database connection. The inspector tests feed raw HTML strings to the title and OpenGraph extractors without making any HTTP call.

This is what the whole unit suite costs on my laptop:

uv run pytest tests/unit -q

47 passed in 0.06s

Forty-seven tests in sixty milliseconds. A suite this fast can run on every pull request, and on every save in your editor if you want. That speed is the whole point. A developer who waits two seconds for feedback runs the tests constantly; a developer who waits ten minutes runs them before lunch.

A unit test isolates business logic from everything it talks to. Its job is to be fast enough to run on every change.

Integration tests: the pieces working together

Unit tests prove each piece works alone. They cannot prove the pieces fit. A LinkService that passes all its unit tests can still break in production because the SQL generated by the repository does not match the real schema, or because a timezone conversion behaves differently in PostgreSQL than in the mock.

Integration tests close that gap. They wire the real components together (router, service, repository, database) and sometimes real third-party systems. They are slower and need infrastructure, so they typically run when the work of several pull requests is merged into the main branch, not on every single commit.

FastURL’s integration tests live in tests/integration/. A test like this one, from test_links_api.py, crosses every layer of the application:

async def test_duplicate_alias_returns_409(self, api_client: httpx.AsyncClient) -> None:
    """Creating a link with an already-used custom alias returns 409."""
    payload: dict[str, str] = {"target_url": "https://example.com", "custom_code": "mybrand2"}
    await api_client.post(url="/api/v1/links", json=payload)
    response = await api_client.post(url="/api/v1/links", json=payload)
    assert response.status_code == 409
    assert response.json()["error"] == "ALIAS_ALREADY_TAKEN"

A unit test in test_link_service.py also covers the same rule (a duplicate alias is rejected). The difference is what each test proves. The unit test proves the rule is right. The integration test proves the rule is still enforced once the request has gone through FastAPI validation, the router, the dependency injection system, a real SQL SELECT, and the global exception handler that turns AliasAlreadyTakenError into a 409 envelope.

The price is visible in the timing:

uv run pytest tests/integration -q

32 passed in 2.65s

Each integration test creates a fresh database, runs real SQL, and some of them let the background inspector make a real HTTP call to example.com, which is why the slowest ones take around 150 ms each. Two seconds is still fast, but the gap is a factor of thirty, and it grows quickly as a project adds more infrastructure.

End-to-end tests: the user’s point of view

An end-to-end (or functional) test exercises a complete user scenario from the outside: an HTTP request goes in, an HTTP response comes out, and the test checks what a real client would see. In a browser application this usually means a tool like Playwright clicking through pages. In an API, it means calling the endpoints the way a client would.

The difference from an integration test is where the application runs. An integration test drives the code in-process, with test fixtures in control of the database and the configuration. An end-to-end test talks to a deployed instance (a container, a test environment) exactly as a real client would, with no access to its internals.

In FastURL, the end-to-end layer is an extension of the integration layer, to be added only when it becomes necessary. The api_client fixture (which we will dissect later) already drives the real FastAPI application through its public HTTP surface. The redirect tests in test_redirect_api.py are a good example: they seed a link, call GET /{code} like a browser would, and assert on the 307 status and the Location header.

The whole suite runs in two seconds and the user scenarios are few and all covered, so a second layer would run the same scenarios twice for no extra confidence.

That trade-off changes as a project grows. When the integration suite takes many minutes, or when what matters is the deployed artefact (the Docker image, the configuration of a real environment, authentication in front of the API), a separate end-to-end suite earns its place.

That suite then runs against the official test environments, when several features are promoted there together, rather than on every merge. FastURL’s test strategy puts that step at the milestone that introduces authentication and containerised deployment.

Performance tests: benchmark, load, and stress

Functional tests answer “does it work?”. Performance tests answer “does it work fast enough, and for how many users?”. They come in three flavours:

  • Benchmark tests apply a fixed load for a fixed duration and record throughput and latency percentiles. Their value is the comparison: each run becomes a baseline for the next change.
  • Load tests simulate the traffic you expect in production for a sustained period and check that response times stay within the agreed targets.
  • Stress tests push traffic beyond the expected peak until something breaks, to learn how the system fails and whether it recovers.

FastURL has benchmark tests, but they do not live in the pytest suite: they run against a live server with an external load generator. Performance testing deserves its own article, so I will not go deeper here. The point for this article is where it runs: never on a pull request, always against a running server, on demand or at infrastructure milestones.

Where each test runs

  Unit Integration End-to-end Performance
Isolates One piece of logic Nothing: real layers and DB, in-process Nothing: a deployed instance, from outside A running server under load
Speed Milliseconds per test Tens to hundreds of ms per test Seconds per scenario Minutes per run
Infrastructure None Database, sometimes third-party APIs A deployed test environment A server, its database, a load generator
Runs Every commit and every PR When the work of several PRs is integrated When features are promoted to the official test environments On demand, at infrastructure milestones
In FastURL tests/unit/ tests/integration/ Covered by tests/integration/ for now hey, documented in docs/benchmarks.md

Test types mapped to FastURL

The shape matters: many fast unit tests at the bottom, fewer slower integration tests above, and a handful of expensive tests at the top. FastURL follows it with 47 unit tests and 32 integration tests.

unittest vs pytest

In the first article of this series I used unittest, the framework that ships with Python. It works, and you can build a serious test suite with it. But most of the Python community has moved on: in the 2024 Python Developers Survey by the PSF and JetBrains, 53% of respondents use pytest and 23% use unittest.

The easiest way to see why is to write the same FastURL test in both styles. Here is the “missing code raises LinkNotFoundError” test written with unittest:

import unittest
from unittest.mock import AsyncMock, Mock

from fasturl.core.exceptions import LinkNotFoundError
from fasturl.services.link_service import LinkService


class TestGetLink(unittest.IsolatedAsyncioTestCase):
    def setUp(self) -> None:
        self.repository = AsyncMock()
        self.service = LinkService(repository=self.repository, inspector=Mock())

    async def test_missing_code_raises_link_not_found(self) -> None:
        self.repository.get_by_code.return_value = None
        with self.assertRaises(LinkNotFoundError):
            await self.service.get_link(code="xxxxxxx")

And here is the version that actually lives in FastURL:

async def test_missing_code_raises_link_not_found(self, mock_repository: AsyncMock) -> None:
    """A code that does not exist raises LinkNotFoundError."""
    mock_repository.get_by_code.return_value = None
    service: LinkService = _make_service(mock_repository)

    with pytest.raises(LinkNotFoundError):
        await service.get_link(code="xxxxxxx")

The pytest version needs no base class, no special async test case, and no setUp method. The mock repository arrives as a function argument called mock_repository. That argument is a fixture, and it is the single most important idea in pytest. It gets its own section below.

The differences go beyond syntax:

  unittest pytest
Test structure Class inheriting from TestCase is mandatory Plain functions; classes are optional, for grouping only
Assertions self.assertEqual, self.assertIn, self.assertTrue, … Plain assert with detailed failure output
Setup and teardown setUp/tearDown, setUpClass, setUpModule Fixtures, reusable across files and composable
Data-driven tests Loops or subTest @pytest.mark.parametrize
Ecosystem Standard library only 1,000+ plugins (pytest-asyncio, pytest-cov, pytest-xdist, …)

One more point removes any migration risk: pytest runs unittest test classes unchanged. You can switch the runner today and convert old tests one file at a time.

Plain assert with useful failures

With unittest, you have to remember which of the dozens of assert* methods fits each check, because a plain assert would only say AssertionError with no detail. pytest rewrites assert statements when it loads the test module, so a plain assert produces a report of what each side evaluated to. If the alias test returned the wrong code, the output would look like this:

>       assert result.code == "mybrand1"
E       AssertionError: assert 'abcdefg' == 'mybrand1'
E
E         - mybrand1
E         + abcdefg

You read the failure and you already know what went wrong, without adding a print and rerunning.

Parametrize: one test, many inputs

FastURL’s TestExtractTitle class has five methods that differ only in the HTML input and the expected output. pytest has a cleaner way to express that: write the test once and give it a table of cases.

@pytest.mark.parametrize(
    ("html", "expected"),
    [
        ("<html><head><title>Hello World</title></head></html>", "Hello World"),
        ("<title>  Padded Title  </title>", "Padded Title"),
        ('<title lang="en">Attributed Title</title>', "Attributed Title"),
        ("<html><head></head><body>No title here</body></html>", None),
        ("", None),
    ],
    ids=["simple", "whitespace", "attributes", "missing", "empty"],
)
def test_extract_title(html: str, expected: str | None) -> None:
    assert _extract_title(html) == expected

Breaking down the decorator:

  • ("html", "expected") — the names of the arguments the test function receives
  • the list of tuples — one tuple per test case; pytest generates a separate test for each one
  • ids=[...] — readable names, so a failure is reported as test_extract_title[whitespace] instead of test_extract_title[html1-expected1]

Each case is still a separate test: if one fails, the other four still run and report. Adding a sixth case is one line in the table, not a new method with a new docstring.

Mocks: Testing What You Cannot Control

Imagine testing a weather app by checking that it shows “sunny”. The test passes today, fails tomorrow, and nothing in your code has changed. The problem is not the app; it is that the test depends on data that changes on its own.

FastURL is full of data like that:

  • Random values. FastURL generates short codes randomly. A test cannot predict which code it will get, let alone force a collision to test the retry loop.
  • The clock. Whether a link is expired depends on datetime.now(), which is different every time the test runs.
  • Remote content. The inspector reads the <title> of the target page. example.com can change its title, go offline, or be unreachable from a CI runner behind a firewall.
  • The database. Its content depends on what earlier tests or earlier runs left behind.

A test double is an object that replaces a real dependency during a test so that the test controls exactly what the dependency returns. The most common kind in Python is the mock, from the standard library’s unittest.mock module. Both unittest and pytest use it.

A mock replaces a dependency the test cannot control with one that returns exactly what the test tells it to, and records how it was called.

Mock and AsyncMock

A Mock object accepts any attribute access and any call. Calling mock.anything() returns another Mock, and every call is recorded. AsyncMock does the same but its methods are coroutines, so they can be awaited. FastURL’s repository is async, so the LinkService tests use AsyncMock:

async def test_duplicate_alias_raises_alias_already_taken(self, mock_repository: AsyncMock) -> None:
    """If the custom alias already exists, AliasAlreadyTakenError is raised."""
    mock_repository.code_exists.return_value = True
    service: LinkService = _make_service(mock_repository)

    with pytest.raises(AliasAlreadyTakenError) as exc_info:
        await service.create_link(
            target_url="https://example.com",
            custom_code="mybrand1",
            expires_at=None,
            scheduler=Mock(),
        )
    assert "mybrand1" in exc_info.value.details[0]

The line mock_repository.code_exists.return_value = True is the whole trick. When LinkService awaits self._repository.code_exists("mybrand1"), it gets True back, as if the alias were already in the database. No database exists. The test checks one thing only: what the service does with that answer.

side_effect: scripting a sequence of answers

return_value gives the same answer every time. To test the retry loop for auto-generated codes, the test needs the repository to say “taken, taken, free”. side_effect accepts a list and returns one element per call:

async def test_retries_until_unique_code_found(self, mock_repository: AsyncMock) -> None:
    """Repository is called again after a collision on an auto-generated code."""
    link = _make_link(code="abcdefg")
    # First two candidates collide; third succeeds
    mock_repository.code_exists.side_effect = [True, True, False]
    mock_repository.create.return_value = link
    service: LinkService = _make_service(mock_repository)

    result: LinkResponse = await service.create_link(
        target_url="https://example.com",
        custom_code=None,
        expires_at=None,
        scheduler=Mock(),
    )

    assert mock_repository.code_exists.call_count == 3
    assert result.code == "abcdefg"

This is a test you cannot write against a real database. With 62 possible characters across 7 positions, you would need about 3.5 trillion links in the table to get a collision by chance. The mock turns a practically unreachable branch into a deterministic, 1 ms test. side_effect also accepts an exception class or instance: mock.method.side_effect = TimeoutError() makes the call raise, which is how you test error paths.

Verifying interactions

A mock records every call, so a test can also check how a dependency was used. The dispatch_inspection test in test_inspector_service.py does not care what the background scheduler returns. It cares that the inspection was scheduled with the right arguments:

mock_scheduler.add_task.assert_called_once()
call_kwargs = mock_scheduler.add_task.call_args.kwargs
assert call_kwargs["code"] == "abc1234"
assert call_kwargs["target_url"] == "https://example.com"
assert call_kwargs["http_client"] is http_client

The most useful assertion methods are:

  • assert_called_once() — the method was called exactly one time
  • assert_called_once_with(...) — exactly one call, with exactly these arguments
  • assert_awaited_once() — the AsyncMock method was awaited, not only called (a coroutine that is created but never awaited never runs)
  • call_count and call_args — the raw data, when you need a custom check

Faking a remote web page with httpx.MockTransport

The inspector calls a real URL and parses the HTML it gets back. FastURL could mock the whole httpx.AsyncClient, but httpx offers something better: a real client whose network layer is replaced by a function you write. The integration test in test_links_api.py uses it to simulate both a healthy page and a timeout:

html_body = "<html><head><title>Example</title></head></html>"
mock_response: httpx.Response = httpx.Response(status_code=200, text=html_body)
mock_client: httpx.AsyncClient = httpx.AsyncClient(
    transport=httpx.MockTransport(handler=lambda req: mock_response)
)
def _raise_timeout(request: httpx.Request) -> httpx.Response:
    raise httpx.ConnectTimeout(message="timeout", request=request)

mock_client: httpx.AsyncClient = httpx.AsyncClient(transport=httpx.MockTransport(handler=_raise_timeout))

The inspector receives a perfectly normal httpx.AsyncClient. It sends a request, gets a response or an exception, and parses the result exactly as it does in production. Only the bytes on the wire are invented. This works without any patching because inspect_link receives the client as a parameter instead of creating it, a design choice made in the previous article that pays off here.

Patching what you cannot inject

Sometimes the dependency is not a parameter: it is a module-level object the code imports. FastURL’s database module reads a global settings object and calls create_async_engine directly. For these cases unittest.mock.patch temporarily replaces a name inside a module, and restores it when the with block ends:

with patch("fasturl.core.database.create_async_engine") as mock_create_engine:
    ...
    await init_db()

    mock_create_engine.assert_called_once()
    _, kwargs = mock_create_engine.call_args
    assert kwargs.get("pool_size") == 15

This test_database.py test verifies that PostgreSQL gets the right pool settings without starting PostgreSQL. Note the target string: you patch the name where it is used (fasturl.core.database.create_async_engine), not where it is defined (sqlalchemy.ext.asyncio.create_async_engine), because database.py has already imported its own reference.

How much to mock

Mocks are powerful and easy to overuse. Every mock encodes an assumption about how the real dependency behaves, and if that assumption is wrong, the test passes while production fails. A few rules keep that risk under control:

  • Mock at the boundaries you own. FastURL mocks LinkRepository (its own interface), not SQLAlchemy internals.
  • Prefer injection over patching. If a test needs patch, the code under test might benefit from taking that dependency as a parameter.
  • Back every mock with an integration test. The unit test says “if the repository returns True, the service raises 409”. The integration test proves the real repository actually returns True for a duplicate alias.

Fixtures: Setup and Teardown Done Right

Think of a theatre. Before each performance, the stage crew sets out the furniture and props. The actors walk on, perform, and walk off. The crew then clears the stage so the next show starts from a clean set. The actors never move furniture; they expect it to be there.

A fixture is the stage crew of a test. It prepares what the test needs (a mock, a database session, an HTTP client), hands it over, and cleans up afterwards. The test only declares what it needs.

From setUp to a fixture

In unittest, preparation lives in a setUp method on the test class, and the test accesses it through self. In pytest, preparation lives in a function decorated with @pytest.fixture. Here is the simplest fixture in FastURL’s conftest.py:

@pytest.fixture()
def mock_repository() -> AsyncMock:
    """Return an AsyncMock that stands in for LinkRepository in unit tests."""
    return AsyncMock()

A test receives it by naming it as an argument:

async def test_missing_code_raises_link_not_found(self, mock_repository: AsyncMock) -> None:

Before running the test, pytest reads the function signature, sees a parameter called mock_repository, finds a fixture with that name, calls it, and passes the return value in. This is dependency injection, the same idea FastAPI uses with Depends, applied to tests.

A fixture is a function that prepares something a test needs. The test asks for it by name, and pytest calls it and passes the result in.

Two properties make this better than setUp. First, each test asks for exactly what it needs: a test that only needs a mock does not pay for creating a database. With setUp, every test in the class pays for everything. Second, fixtures are not tied to a class, so the same mock_repository serves every test file in the project.

yield fixtures: setup, test, teardown in one function

Many resources need cleaning up: a database must be dropped, a connection closed, a global restored. A fixture handles this with yield instead of return. Everything before yield is setup, the yielded value goes to the test, and everything after yield is teardown. This is FastURL’s database fixture, built on the SQLAlchemy engine and session factory from the database layer article:

@pytest.fixture()
async def db_session() -> AsyncIterator[AsyncSession]:
    """Yield an AsyncSession backed by a fresh test database (``TEST_DATABASE_URL``).

    Creates all tables before the test and drops them after, ensuring full
    isolation between tests.
    """
    engine: AsyncEngine = create_async_engine(url=TEST_DATABASE_URL, echo=False)
    async with engine.begin() as conn:
        await conn.run_sync(fn=Base.metadata.create_all)

    factory: async_sessionmaker[AsyncSession] = async_sessionmaker(
        bind=engine, class_=AsyncSession, expire_on_commit=False
    )
    async with factory() as session:
        yield session

    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)
    await engine.dispose()

Reading it top to bottom:

  1. Setup — create an engine and all the tables.
  2. yield session — the test runs here, with a live session.
  3. Teardown — when the test finishes, drop every table and dispose of the engine.

The important guarantee is that teardown runs even if the test fails. An assertion error inside the test is raised at the yield point, but pytest still finishes the fixture before reporting the failure. A failing test never leaves a dirty database behind for the next one.

The fixture is async def and the tests are async def too, without any decorator. That works because FastURL’s pyproject.toml sets asyncio_mode = "auto", which tells pytest-asyncio to run every async test and async fixture on an event loop automatically.

Fixtures that use other fixtures

A fixture can request other fixtures, exactly like a test does. FastURL’s api_client needs a database session, so it asks for db_session. It then swaps the production session for the test one through the same get_db dependency introduced in the layered architecture article:

@pytest.fixture()
async def api_client(db_session: AsyncSession) -> AsyncIterator[httpx.AsyncClient]:
    async def _override_get_db() -> AsyncIterator[AsyncSession]:
        yield db_session

    app.dependency_overrides[get_db] = _override_get_db
    app.state.http_client = httpx.AsyncClient()

    transport: httpx.ASGITransport = httpx.ASGITransport(app=app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        yield client

    app.dependency_overrides.clear()
    await app.state.http_client.aclose()

Breaking down what it prepares:

  • app.dependency_overrides[get_db] = _override_get_db — every request handled by the app gets the test session instead of a session from the production engine
  • app.state.http_client = httpx.AsyncClient() — the lifespan function normally creates the shared client, but it does not run in this setup, so the fixture creates it by hand
  • httpx.ASGITransport(app=app) — requests go straight into the FastAPI app in memory; no server, no port, no network
  • the teardown — removes the override and closes the client, so the next test starts from an untouched app

When a test requests api_client, pytest resolves the chain: it creates db_session first, then api_client. Teardown runs in reverse order: api_client cleans up first, then db_session drops the tables.

Fixture setup and teardown chain

There is one more detail that surprises people. Within a single test, each fixture is created once and shared. Look at this redirect test:

async def test_click_count_incremented_after_redirect(
    self,
    api_client: httpx.AsyncClient,
    db_session: AsyncSession,
) -> None:
    await _seed_link(db_session, code="redir02", target_url="https://example.com")

    await api_client.get(url="/redir02", follow_redirects=False)
    ...

The test asks for both api_client and db_session, and api_client also asks for db_session. pytest does not create two sessions: both receive the same object. That is why the test can insert a link directly through db_session and then find it through the API. They are looking at the same database.

conftest.py: fixtures without imports

None of FastURL’s test files import db_session or mock_repository. pytest finds them automatically in any file named conftest.py, and makes them available to every test in that directory and its subdirectories. FastURL has a single tests/conftest.py with all four fixtures.

As a project grows, you can split it by layer: tests/unit/conftest.py for mocks, tests/integration/conftest.py for the database and the API client. Unit tests then cannot accidentally request a database, because the fixture is not visible from their folder.

Factory fixtures: when each test needs different data

So far every fixture returns one ready-made object. That breaks down when tests need variations of the same thing: an active link, an expired link, a soft-deleted link. FastURL solves this today with a module-level helper in test_link_service.py, _make_link(code=..., is_active=..., expires_at=...), which each test calls with the arguments it cares about.

A fixture can do the same job by returning a function instead of an object. This pattern is called a factory fixture:

@pytest.fixture()
def make_link() -> Callable[..., LinkDAO]:
    """Return a function that builds LinkDAO instances with sensible defaults."""

    def _make(**overrides: Any) -> LinkDAO:
        now: datetime = datetime.now(tz=UTC).replace(tzinfo=None)
        fields: dict[str, Any] = {
            "id": 1,
            "code": "abcdefg",
            "target_url": "https://example.com",
            "is_active": True,
            "expires_at": None,
            "inspection_status": "pending_analysis",
            "clicks_count": 0,
            "created_at": now,
            "updated_at": now,
        }
        return LinkDAO(**(fields | overrides))

    return _make

A test asks for make_link and calls it as many times as it needs, overriding only what matters for that case:

async def test_inactive_link_raises_link_not_found(
    self, mock_repository: AsyncMock, make_link: Callable[..., LinkDAO]
) -> None:
    mock_repository.get_by_code.return_value = make_link(is_active=False)
    ...

Compared to the helper function, the factory lives in conftest.py, so every test file can use it without imports. Because it is a fixture, it can also request other fixtures, for example db_session, to insert each link into the database and keep track of it for cleanup. That is exactly what the integration tests’ _seed_link helper does by hand today.

Fixture scope: per test, per module, per suite

By default a fixture has function scope: it is created for each test and torn down after it. That is what FastURL uses everywhere, and it is the safest choice. Sometimes, though, setup is too expensive to repeat for every test: starting a PostgreSQL container takes several seconds, and you do not want to pay that 32 times.

The scope argument controls how long a fixture lives:

Scope Created Torn down unittest equivalent
function (default) Before each test After each test setUp / tearDown
class Before the first test in a class After the last test in the class setUpClass / tearDownClass
module Before the first test in a file After the last test in the file setUpModule / tearDownModule
session Once, before the first test of the run Once, after the last test No direct equivalent

FastURL can already run its integration tests against PostgreSQL through the TEST_DATABASE_URL environment variable, but someone has to start the database first. A session-scoped fixture can do that for the whole suite. The following sketch is not in the repository yet; it uses testcontainers to start one PostgreSQL container per test run:

from collections.abc import Iterator

import pytest
from testcontainers.postgres import PostgresContainer


@pytest.fixture(scope="session")
def postgres_url() -> Iterator[str]:
    """Start one PostgreSQL container for the whole test session."""
    with PostgresContainer("postgres:17-alpine", driver="asyncpg") as postgres:
        yield postgres.get_connection_url()

db_session would then request postgres_url instead of reading the environment variable. The container starts once, every test still gets fresh tables from the function-scoped db_session, and the container stops after the last test. Expensive things are shared; anything that holds test data is still recreated for each test.

A fixture can only request fixtures with the same or a wider scope. A session fixture asking for a function fixture fails with ScopeMismatch, because the function-level object would be destroyed while the session-level one still uses it. Async fixtures with a wider scope also need a matching event loop in pytest-asyncio: declare them with @pytest_asyncio.fixture(scope="session", loop_scope="session"). Older tutorials override an event_loop fixture for this; that fixture was removed in pytest-asyncio 1.0 and no longer works.

Fixtures vs setUp and tearDown at a glance

Taken one at a time, each fixture feature looks like a small convenience. Side by side with unittest, they add up to a different model. unittest organises preparation around the class: every test in it gets the same setup. pytest organises it around the test: each one declares what it needs, and gets only that.

  unittest pytest fixtures
Where setup lives Methods of the TestCase class Standalone functions, shared through conftest.py
How a test gets a resource Reads self.<attribute> set in setUp Names the fixture as an argument
What runs before a test The whole setUp, including parts the test never uses Only the fixtures the test requests, plus the ones they request
Composition One setUp per class; reuse through inheritance and super().setUp() A fixture requests other fixtures; pytest resolves the chain
Teardown A separate tearDown method, kept in sync with setUp by hand The code after yield, in the same function as the setup
Teardown order Written by hand in tearDown, or registered with addCleanup Reverse of setup, automatically
Lifetime A different pair of methods per level: setUp/tearDown, setUpClass/tearDownClass, setUpModule/tearDownModule One decorator: scope="function", "class", "module", or "session"
Shared across the whole run No equivalent scope="session"
Variations of the same data Subclasses or helper functions Factory fixtures
Reuse across files Base classes or mixins, imported explicitly conftest.py, no imports

The rows describe three ideas. Fixtures are on demand: a test that asks only for mock_repository never creates a database. They are composable: api_client builds on db_session instead of repeating it. And they carry their scope with them: the same @pytest.fixture decorator replaces all six unittest setup and teardown methods.

setUp prepares everything for every test in a class. A fixture prepares one thing, only for the tests that ask for it, for as long as its scope says.

Test Independence: Each Test Starts From Zero

A test suite is reliable only if each test produces the same result whether it runs alone, first, last, or in parallel with the others. When that is not true, you get the worst kind of failure: a test that passes on your machine, fails in CI, and passes again when you rerun it.

The cause is almost always shared state: a row left in the database, a global variable changed and not restored, a dependency override that was never cleared. One test modifies the world, and another test unknowingly depends on the modification.

FastURL’s redirect tests show what independence looks like in practice. Two different tests seed a link with the same code, redir03:

async def test_last_clicked_at_is_utc(self, api_client, db_session) -> None:
    await _seed_link(db_session, code="redir03", target_url="https://example.com")
    ...

async def test_inactive_link_returns_404(self, api_client, db_session) -> None:
    await _seed_link(db_session, code="redir03", is_active=False)
    ...

The code column is unique, so if both tests shared a database, the second insert would fail. It never does, because db_session drops every table after each test. Each test runs on an empty database it fully controls, so reusing a code is harmless. (It is still worth renaming one of them, since the duplicate looks like a copy-paste slip to the next reader.)

Every test creates the state it needs and leaves nothing behind. If a test depends on what another test did, both tests are wrong.

A real leak, and how a fixture fixes it

Independence is easy to break by accident. Look at this test from FastURL’s test_database.py:

@pytest.mark.asyncio
async def test_get_db_uninitialised_raises_error() -> None:
    """Calling get_db when factory is None raises RuntimeError."""
    db_module._session_factory = None

    with pytest.raises(RuntimeError, match="Database not initialised"):
        gen = get_db()
        await anext(gen)

The @pytest.mark.asyncio marker is redundant here, because asyncio_mode = "auto" already handles async tests, but it is harmless. The real problem is elsewhere. The test overwrites a module-level global, _session_factory, and never restores it. The next test in the file sets it to a mock and resets it to None on its last line, but only if every assertion before that line passes. If one fails, the mock stays in the module for the rest of the run, and any later test that touches get_db sees a fake factory it never asked for.

pytest ships a built-in fixture for exactly this situation: monkeypatch. It changes attributes, environment variables, or dictionary entries, and undoes every change in its own teardown, whether the test passed or failed:

async def test_get_db_uninitialised_raises_error(monkeypatch: pytest.MonkeyPatch) -> None:
    """Calling get_db when factory is None raises RuntimeError."""
    monkeypatch.setattr(db_module, "_session_factory", None)

    with pytest.raises(RuntimeError, match="Database not initialised"):
        await anext(get_db())

The same reasoning explains why api_client calls app.dependency_overrides.clear() after its yield. The FastAPI app is a module-level object shared by every test. An override that is not cleared would silently route the next test’s requests to a session that has already been closed. Whenever a test changes something that outlives it, the cleanup belongs in a fixture’s teardown, never on the last line of the test.

A cheap way to find hidden dependencies is to change the execution order. The pytest-randomly plugin shuffles tests on every run, and prints the seed so you can reproduce a failing order.

Running the Suites

FastURL’s pyproject.toml already defines a task for each test type, using Poe the Poet:

[tool.poe.tasks]
test-unit        = "pytest tests/unit -v"
test-integration = "pytest tests/integration -v"
test             = "pytest tests/ -v"
test-cov         = "pytest tests/ --cov --cov-branch --cov-report=term-missing"

The fast suite is the one to keep running while you code:

uv run poe test-unit

The integration suite can run against the real database engine instead of SQLite:

TEST_DATABASE_URL=postgresql+asyncpg://fasturl_user:<password>@127.0.0.1:5432/fasturl_test uv run poe test-integration

conftest.py reads the TEST_DATABASE_URL variable. Without it, the integration tests fall back to in-memory SQLite, which is fast but not identical to PostgreSQL (timezone handling and concurrency behave differently). The database must be dedicated to tests, since db_session drops every table after each test.

FastURL’s CI workflow currently runs the whole suite on every pull request, once on SQLite and once on a PostgreSQL 17 service container. With 79 tests that finish in two seconds, there is no reason to split them yet. The split described at the start of the article becomes worth it as the integration suite grows.

During development, a few flags make the feedback loop much shorter:

uv run pytest tests/unit -x --lf --durations=5

Breaking down the options:

  • -x — stop at the first failure instead of running the whole suite
  • --lf — run only the tests that failed last time (“last failed”), then everything once they pass
  • --durations=5 — list the five slowest tests, which is how I found the 150 ms integration tests mentioned earlier

Finally, test-cov measures which lines and branches the tests actually execute:

  • --cov — enable coverage measurement through the pytest-cov plugin
  • --cov-branch — measure branch coverage, not only line coverage, so an if whose else path never runs is reported
  • --cov-report=term-missing — print the report in the terminal, with the line numbers that no test executed

The [tool.coverage.report] section sets fail_under = 85, so the command fails if coverage drops below 85%. Treat that number as a floor that stops coverage from silently eroding, not as a target to maximise: a test written only to raise the percentage rarely catches a bug.

Conclusion

In this article we covered:

  • The difference between unit, integration, end-to-end, and performance tests, and when each one should run in a pipeline: 47 FastURL unit tests run in 0.06 s, while 32 integration tests take about 2 s, a gap that grows with every piece of infrastructure.
  • Why, in FastURL, end-to-end tests are an extension of the integration suite to add only when it is needed, and why performance tests run outside the pytest suite.
  • Why pytest has replaced unittest for most Python developers: plain functions, plain assert with detailed failure reports, parametrized tests, and fixtures.
  • How Mock, AsyncMock, return_value, and side_effect let a test control data it cannot control in reality (random codes, the clock, remote pages), and how httpx.MockTransport fakes the network while keeping a real client.
  • What a fixture is: a function that prepares what a test needs, injected by name, with yield for teardown that runs even when the test fails, composed into chains, turned into factories, and scoped from a single test up to the whole session.
  • Why test independence matters, how FastURL achieves it with a fresh database per test, and how monkeypatch fixes a real state leak in the repository.

The next article applies these ideas to the problems specific to FastAPI: async tests with pytest-asyncio 1.x, the lifespan gap, isolating the network, choosing a test database, meaningful coverage, and a CI pipeline that splits fast and slow tests.


If you enjoyed this article, don’t forget to give it a clap 👏, share it with your friends 🔗, and follow me for more tips and tutorials on software development 📘. Your support helps me create more content like this — thank you! 🙌

Note: English is not my native language, and this article was drafted with the assistance of AI. However, all the ideas, projects, concepts, and content are entirely my own.

Get your free eBook

Subscribe to the Code4Projects newsletter to get the eBook and new tutorials.