Skip to content

    Forward Deployed Engineer

    Testing with pytest: Fixtures, Parametrize, FastAPI

    Test a FastAPI service with pytest: fixtures and scopes, parametrized cases, TestClient for endpoints, and test doubles for the model provider.

    By Published 8 min read

    Share

    This lesson is part of Forward Deployed Engineer: Python and FastAPI Foundations. Enroll for free, then sign in, to read it in full.

    What you'll learn: how the capstone's offline tests are organised in layers, how pytest fixtures and scopes replace setup code and dependency wiring, how one parametrized test covers a table of cases, how TestClient calls your FastAPI app without a server, and how a scripted provider stands in for the model.

    At a glance

    Figure 1. The capstone's test layers, from cheapest to most expensive

    flowchart TB accTitle: Test layers of the capstone accDescr: Five layers run offline and one does not. Unit tests check pure functions with no fixtures. Component tests use the real catalog and index with the mock provider. Pipeline tests run a request to a response in code, without HTTP. API tests call the whole app in process through TestClient. The evaluation run scores 36 golden questions. A live model check is not part of the automatic tests and needs your own key. U["Unit tests: pure functions, no fixtures"] --> C["Component tests: real catalog and index, mock provider"] C --> P["Pipeline tests: request to response in code, no HTTP"] P --> A["API tests: whole app in process through TestClient"] A --> E["Evaluation run: 36 golden questions, in-sample regression numbers"] E --> L["Live model check: not automatic, run it with your own key"]

    How to read this: each layer is slower and wider than the one above it, so put most checks in the cheap layers and only a few in the expensive ones.

    • A fixture builds what a test needs; a test asks for it by naming it as an argument.
    • A fixture's scope says how often it is rebuilt: per test (the default), per module or per session.
    • @pytest.mark.parametrize turns one test into a table of cases, one result each.
    • TestClient calls the app in process from a plain def test: no server, no port, no await.
    • Replace the model with a scripted or mock provider, so tests are fast, free and repeatable.

    Scenario

    Composite scenario, not a real company. Lumen Industrial Supply has a partner-only product line that public customers must never see. On a Friday afternoon a developer changes the retrieval filter to speed it up. The reviewer asks: "How do you know a public user still cannot get a partner product?" The only good answer is a test that fails when the filter breaks, runs in seconds and needs no model. All numbers here come from the capstone's own test run.

    What you will be able to do

    • Place a check in the right test layer and say why.
    • Write a fixture with set-up and tear-down and choose its scope.
    • Cover a table of cases with one parametrized test and readable ids.
    • Test an endpoint with TestClient and replace the model provider with a test double.

    Prerequisites

    Core ideas

    What each test layer is for

    Idea Java pytest
    A test a @Test method a function whose name starts with test_
    One test, many inputs @ParameterizedTest with @ValueSource or @CsvSource @pytest.mark.parametrize
    Calling the web layer MockMvc: Spring MVC request handling with mock request and response objects, no server TestClient: the app called in process
    A fake collaborator a Mockito mock a small class written by hand, or monkeypatch
    Set-up before each test a @BeforeEach method a fixture, shared through conftest.py

    The capstone has eight test files. Ask which layer a bug belongs to before you write the test.

    Layer Example in tests/ What it protects
    Unit test_token_estimate_is_words_times_a_constant a pure function
    Component retrieval and catalog tests with real data search and filters
    Whole pipeline test_public_user_never_sees_partner_products_or_documents the access rule, from request to response, without HTTP
    API test_validation_errors_are_mapped_and_never_echo_input status codes, the error shape, headers

    The evaluation run (36 golden questions) measures behaviour; it does not assert one. The capstone's report says its numbers are regression numbers on questions written by the same author, not an accuracy estimate.

    Fixtures: set-up, tear-down and scope

    Predict first. Two tests run in turn. The first sets client.app.state.marker on its app; the second checks whether the marker exists. Does the second see it?

    Verified: capstone/fde-product-discovery-copilot/lessons/M1-L6/test_m1_l6_fixtures_tour.py

    def test_the_client_fixture_gives_every_test_a_fresh_app(client: TestClient) -> None:
        client.app.state.marker = "set by this test"  # type: ignore[attr-defined]
        assert client.get("/api/v1/health").status_code == 200
    
    
    def test_that_marker_does_not_leak_into_the_next_test(client: TestClient) -> None:
        assert not hasattr(client.app.state, "marker")
    Answer

    No. The client fixture has the default function scope, so pytest builds a new app for every test. The price is that expensive objects need a wider scope, as the next fixtures show.

    The pytest documentation: tests request fixtures as arguments; the default scope is function (the others: class, module, package, session); teardown code goes after a yield. The capstone's own conftest.py:

    Verified: capstone/fde-product-discovery-copilot/tests/conftest.py

    @pytest.fixture(scope="session")
    def settings() -> Settings:
        return Settings()
    
    
    @pytest.fixture(scope="session")
    def services(settings: Settings) -> Services:
        return Services.build(settings)
    
    
    @pytest.fixture()
    def client(settings: Settings, services: Services) -> Iterator[TestClient]:
        app = create_app(settings, services)
        with TestClient(app, raise_server_exceptions=False) as c:
            yield c

    Loading 30 documents and building the search index is the slow part, so services is built once per session. The app and client are cheap, so every test gets a new one. Spring does this job with beans and scopes: pytest wires by argument name, and a wide scope plays the singleton.

    Figure 2. Fixtures set up in dependency order and tear down in reverse

    sequenceDiagram accTitle: Fixture set-up and tear-down order accDescr: A test asks for a repository fixture, which depends on a database fixture. Pytest sets up the database first, then the repository, runs the test, then tears down the repository, then the database. autonumber participant P as pytest participant D as database fixture participant R as repository fixture participant T as test P->>D: set up P->>R: set up, needs database P->>T: run test_one(repository) P->>R: tear down P->>D: tear down

    How to read this: the order is measured, not assumed; a lesson test runs a throw-away file in a child pytest and checks it.

    A fixture in conftest.py is visible only to tests in that folder and below. The lesson folder's conftest.py imports the capstone's three fixtures.

    In the interview: "How do you keep tests independent but fast?" A good answer: build expensive, read-only objects once at a wide scope, build anything a test can change per test, never depend on order.

    Parametrize: one test, a table of cases

    The pytest documentation shows @pytest.mark.parametrize("test_input,expected", [...]) and a failure reported as test_eval[6*9-42]: by default the id is built from the values. The ids argument gives readable names instead:

    Verified: capstone/fde-product-discovery-copilot/lessons/M1-L6/test_m1_l6_solution_clarify.py

    @pytest.mark.parametrize(
        "fields, expected",
        [
            ({}, False),  # nothing at all
            ({"category": "sensors"}, False),  # a category alone is not enough
            ({"keywords": ["vibration"]}, False),  # a detail without a category is not enough
            ({"category": "sensors", "keywords": ["vibration"]}, True),
            ({"category": "gateways", "protocols": ["OPC UA"]}, True),
            ({"skus": ["LUM-VS-100"]}, True),  # a named product is enough on its own
        ],
        ids=["empty", "category-only", "detail-only", "category-and-keyword", "category-and-protocol", "named-sku"],
    )
    def test_is_specific_enough(fields: dict[str, object], expected: bool) -> None:
        assert is_specific_enough(Requirements(**fields)) is expected  # type: ignore[arg-type]

    When the detail-only case fails, the report names it. Add a case by adding a line, not a test.

    Endpoints, and a model you control

    TestClient is built on httpx. FastAPI's documentation stresses that the tests are plain def and the calls are not awaited.

    NOTE

    The capstone passes raise_server_exceptions=False, so a bug inside the app shows up as the 500 a real caller would see, not as an exception in the test. A lesson test proves both modes.

    For the model, the capstone's tests use hand-written doubles in tests/helpers.py, with no mocking framework:

    Verified: capstone/fde-product-discovery-copilot/tests/helpers.py

    class ScriptedProvider:
        """Returns the given texts in order, whatever the prompt. Records every call."""
    
        name = "scripted"
    
        def __init__(self, texts: list[str]) -> None:
            self.texts = list(texts)
            self.calls: list[list[Message]] = []
    
        def complete(self, messages: Sequence[Message], schema: dict[str, Any] | None = None) -> Completion:
            self.calls.append(list(messages))
            return Completion(self.texts.pop(0), 10, 5)

    texts scripts a model that returns broken JSON first and valid JSON second; calls shows what the pipeline sent. FastAPI also offers app.dependency_overrides; the capstone does not use Depends, so it passes its replacement in when it builds Services.

    Worked example

    A developer breaks three things. Which test fails, and in which layer?

    The change First test to fail Layer
    estimate_tokens uses 1.5 instead of 1.3 words per token test_token_estimate_is_words_times_a_constant unit
    the retrieval filter lets a partner-only document into the context test_public_user_never_sees_partner_products_or_documents whole pipeline
    the validation handler echoes the submitted text test_validation_errors_are_mapped_and_never_echo_input API

    Assumptions: test names are from the capstone; the three changes are thought experiments, not edits made to it.

    Run the capstone's suite and read the last line (count as of 2026-11-02; the time depends on your machine):

    ./.venv/Scripts/python -m pytest tests
    151 passed in 11.96s

    Build it

    From capstone/fde-product-discovery-copilot:

    ./.venv/Scripts/python -m pytest lessons/M1-L6
    1. In test_m1_l6_fixtures_tour.py find the fixture-order test: it runs a child pytest on a small file.
    2. Change scope="module" in the CHILD source to the default and see which assertion fails.
    3. Add a seventh case to test_is_specific_enough, break is_specific_enough on purpose and read the failure's id.
    4. Run one test by name: pytest lessons/M1-L6 -k fresh_app.

    Exercise

    Write tests for app/clarify.py at three layers, without changing app/.

    Acceptance criteria:

    • One parametrized test for is_specific_enough: at least five cases, readable ids.
    • Tests that the first question asks for a category when none is known, that never more than MAX_QUESTIONS questions come back, and that a known quantity and budget are not asked again.
    • One API test with the client fixture: a vague request returns 200, no recommendations and one to three open questions.

    Done when all the tests pass, and at least one fails if you change is_specific_enough to return True for a bare category.

    Hint 1: which fixtures do you need?

    Only the API test needs client; the others test plain functions. You need Requirements(...) objects and bearer(PUBLIC) from tests/conftest.py for the header.

    Hint 2: how do you check the question limit?

    An empty Requirements() triggers every question, and the list is capped at MAX_QUESTIONS: compare with that constant, not a typed number. Reference solution: lessons/M1-L6/test_m1_l6_solution_clarify.py.

    Common mistakes

    • Tests that call the real network or model. They are slow, cost money and fail on a bad day. Replace the provider and pass a fake transport.
    • Everything at session scope. A shared object a test mutates passes alone and fails in another order. Widen scope only for read-only objects.
    • One giant test with many asserts. The first failing assert hides the rest. Parametrize so every case reports.
    • Asserting on a model's exact words. Assert on structure, ids and rules; the mock is a rule-based stand-in, not a model.

    Knowledge check

    1. Why is the `services` fixture session-scoped and the `client` fixture function-scoped?

    Building the services is slow and read-only, so one copy per run is safe. The app and client are cheap and tests can change them, so each test gets a fresh one.

    2. What does `raise_server_exceptions=False` change, and when would you leave it at the default?

    With False, a bug inside the app becomes the 500 a real caller would see, so you can assert on the status and body. With the default True the exception reaches the test; use it when you want a loud failure with a traceback.

    3. A parametrized test has five cases and one fails. What do you see, and why does that beat a loop with five asserts?

    pytest reports one failing test whose id names the case, and the other four still run. A loop stops at the first failed assert, so you learn one problem at a time.

    4. Why is a handwritten `ScriptedProvider` enough in place of a mocking framework here?

    The provider has one method, and a test needs a scripted reply and a record of what was asked. A small class does both in a dozen lines. A framework pays off when the collaborator has many methods.

    5. The pipeline test passes with the mock provider. What does that prove and not prove?

    It proves the plumbing and rules behave as specified: the filter, the access check, the response shape. It does not prove a real model behaves well, because the mock is a rule-based stand-in. That needs an evaluation on questions you did not tune against.

    You can now

    • Choose the cheapest test layer that can catch a given bug.
    • Write fixtures with the right scope and tear-down.
    • Cover a table of cases with one parametrized test and readable ids.
    • Test an endpoint with TestClient and replace the model provider.

    Further reading

    Last verified: 2026-11-02

    Next

    This is the last core lesson of module M1. Next is module M2, which starts with How an LLM application works.

    Enroll to keep reading

    Lesson 9 of 9 · Forward Deployed Engineer: Python and FastAPI Foundations

    Test a FastAPI service with pytest: fixtures and scopes, parametrized cases, TestClient for endpoints, and test doubles for the model provider.

    In the rest of this lesson

    • Worked example
    • Build it
    • Exercise
    • Common mistakes
    • Knowledge check
    • You can now

    Enrolling is free. It unlocks every lesson in Forward Deployed Engineer: Python and FastAPI Foundations, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments