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
TestClientcalls 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
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.parametrizeturns one test into a table of cases, one result each.TestClientcalls the app in process from a plaindeftest: no server, no port, noawait.- 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
TestClientand replace the model provider with a test double.
Prerequisites
- FastAPI: an AI-ready service for the app object and routes.
- Errors, configuration and logging: several tests below check the error shape and the log lines described there.
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