3 min read
Tests Without Mocks
A real, migrated database in every test
Part seven of the boring stack. The database is SQLite and the schema migrates itself. That makes the testing decision easy.
A common way to test a service is to replace its database with a mock: a fake object that returns whatever the test tells it to. The test then checks that the service called the fake with the right arguments.
That test proves the service calls a function. It doesn’t prove the query works, that the column exists, that the constraint allows the insert or that the result comes back in the order you expect. Those are exactly the bugs I want tests to find.
My tests have no mocks. They run against a real database.
A real database in milliseconds
SQLite can run entirely in memory. Each test opens its own in-memory database, runs the real migrations against it and gets a fresh, empty schema identical to production. That takes milliseconds.
One detail is easy to get wrong: a plain :memory: connection string gives every connection its own, separate, empty database. Go’s database pool opens several connections, so one connection would see the migrated tables and the next one wouldn’t. The fix is a named, shared in-memory database per test, like file:<test-name>?mode=memory&cache=shared, or a pool limited to one connection.
What gets tested how
- Repositories and services are tested directly against that database. A service test creates real rows, calls the service and checks the real result.
- Handlers are tested through the full router, with the complete middleware chain, using Go’s
httptest. Not by calling the handler function directly. That way the test also covers routing, CSRF protection, authentication and everything the middleware puts into the request context. A route that isn’t registered fails the test. - Pure rule code (prices, permissions, schedules) does no I/O and takes the current time as a parameter. Those functions get exhaustive table tests, including the edge cases around midnight and month ends, because the test can pass in any point in time.
Tests are written with testify: require when a failure makes the rest of the test pointless, assert for the rest, and tables of cases where there are several.
Test the mapping to HTTP status codes
One kind of test earns its own rule. Services return defined errors, and handlers turn them into HTTP status codes: a missing record becomes a 404, a forbidden action a 403, and anything unexpected a 500. When an error gets wrapped on its way up, that mapping can silently fall through to a 500 while every other test still passes. Every mapping gets its own test.
What it costs
- Tests are slower than pure unit tests. A few milliseconds per test instead of microseconds. A full suite still runs in seconds.
- Setting up data takes more code. Instead of telling a mock what to return, you insert real rows. Small helper functions keep that short, and the setup documents what the test assumes.
- External services still need a seam. Payment providers or email can’t run in a test. Those sit behind a small interface with a test implementation, and that’s the only place where something is faked.
When I’d choose differently
If the database were a remote server that takes seconds to set up, the trade-off would shift. That’s one more reason SQLite fits: it makes the honest test the cheap one.
Next: the development loop.