Stage 7 · Master
Testing Fieldwork
Unit & Table-Driven Tests
Use small seams, fake dependencies, and dense test tables so core behavior is cheap to prove and easy to extend.
Why I Did Not Start with End-to-End Tests
When a backend is still taking shape, there is a strong temptation to jump straight to end-to-end tests because they feel closest to user behavior. I did not start there for Fieldwork. The reason was not ideological purity about testing pyramids; it was feedback speed. While task routes, membership rules, and DTO validation were still evolving, I needed tests that could tell me in seconds whether I broke a decision, not five minutes later after containers booted and fixtures loaded.
Unit tests gave me that speed, but only once I drew clean seams. A Gin handler should be testable with httptest and a fake service. A service should be testable with a fake repository and clock. If a so-called unit test still needs a real Postgres instance to answer whether an empty task title is rejected, then the boundary is wrong. Fieldwork's early modules needed fast feedback on validation and orchestration logic more than they needed database realism on every assertion.
They do not replace integration tests. They answer a narrower question: did this handler or service make the decision we expected when its collaborators behaved in known ways? That is still a valuable question when design is moving quickly.
Table-Driven Tests Fit Fieldwork's Rules
Fieldwork's domain has lots of rule matrices: a task may be archived only in certain states, a membership invite may require specific permissions, a cursor parameter may be optional but bounded. Those are exactly the kinds of rules that turn noisy if every case becomes a separate test function. Table-driven tests keep the variation visible. One scan down the test table tells me which inputs differ and which outcomes should change.
I rejected the style where table tests become giant anonymous structs with twenty fields and no story. A table should clarify the rule, not hide it. That means naming cases in business language, keeping only the inputs that matter to the rule under test, and using subtests so a failure tells me precisely which decision regressed. If the table becomes unreadable, that is a sign the production code probably needs a smaller seam too.
Testing Handlers Without a Live Server
The HTTP layer needed the same discipline. I did not want to spin up the entire app just to verify that POST /tasks returns 400 when a required field is missing. Gin plus net/http/httptest is enough for that. The handler test should exercise binding, error envelope shape, status codes, and delegation to the service. It should not depend on a running listener, database, or reverse proxy unless the behavior under test actually crosses those boundaries.
This separation is what keeps failure messages useful. If a unit handler test fails, I want to know whether the DTO binding or response contract changed. If the same test also depends on migrations, container startup, and JWT signing configuration, the failure signal gets buried under setup noise. Small tests preserve causality.
Testing Services with Fakes, Not Infrastructure
A service test becomes easier to trust when the fake underneath it is hand-written and tiny. Fieldwork's services mostly depend on repositories, clocks, token issuers, or outbound clients. For unit tests, I prefer replacing those with explicit fakes instead of hidden helpers or real infrastructure. The test then reads like a decision record: when the repository returns X, the service should respond with Y.
I also try to keep fakes honest by making them record what mattered. If a service is supposed to write the tenant ID into a task record before calling Create, the fake repo should capture the task and let the test assert on it. That gives stronger signal than only asserting that the method returned no error. A unit test should inspect the contract at the seam, not just the final status.
| Test double | Why it seems useful | Where I use it in Fieldwork |
|---|---|---|
| Real database | High realism | Integration tests for queries, transactions, and migrations |
| Generated mock with expectation API | Precise interaction assertions | Rarely, when an interface is large and interaction order matters |
| Hand-written fake | Readable and cheap to adapt | Default for unit testing handlers and services |
- Name table cases after business rules, not just input shapes.
- Test handler contracts with httptest, not a full server process.
- Prefer small hand-written fakes so the seam under test stays visible.
The Unit Testing Shape We Kept
Fieldwork uses table-driven unit tests for fast-moving service and handler logic, with httptest at the HTTP edge and hand-written fakes at repository or client seams. The goal is not to simulate production perfectly. The goal is to keep validation, orchestration, and error-contract decisions under fast, focused pressure while the code is still changing.
We rejected an everything-through-integration approach because it would have made ordinary rule changes expensive to verify. Integration tests still matter, and the next lesson uses them heavily for the database layer. But for unit-level behavior, small table-driven tests were the better fit: faster feedback, clearer failures, and a tighter connection between the rule we intended and the code we actually wrote.
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.