The login rate limiter in my timer application had tests, but they called it from a single goroutine. Adding -race to CI wouldn’t exercise concurrent access to the limiter. The tests needed concurrent callers too.
The same testing work uncovered another gap: handler tests bypassed the application router, so they didn’t check whether those handlers required authentication. Both cases had tests for individual operations without tests for how the application used them.
Give the race detector concurrent work
The limiter keeps a shared map of login-attempt counts. Concurrent requests need to update those counts safely. Without synchronization, two requests can read the same count and overwrite each other’s updates, potentially allowing more attempts than the configured limit.
The new tests have concurrent callers compete for five permits per key. The rate-limit window is long enough to stay open throughout the test, and the assertion checks that each key grants exactly five permits.
That tests the limiter’s behavior alongside the race detector’s checks for unsynchronized memory access. Neither replaces the other. A race report identifies unsafe access; the permit count checks whether the limiter enforced its budget during that run.
Go’s race detector only finds races exercised at runtime. A passing run doesn’t prove that every possible interleaving is safe, and adding more goroutines doesn’t guarantee a particular bug will appear. But sequential calls alone don’t test contention on this shared state.
CI now runs with -race, and the limiter tests give it concurrent access to inspect.
Test authentication through the application router
The existing handler tests mounted sub-routers directly or called handler functions. That is useful for testing responses, but it bypassed the application router, where authentication was attached:
r.Group(func(r chi.Router) {
r.Use(auth.Middleware)
// ...
})
Registering a protected route outside that group could leave it accessible without a session. A test that calls the handler directly can’t distinguish correct registration from incorrect registration.
The new test uses chi.Walk to enumerate registered routes and sends unauthenticated requests through the application router. Protected /api routes must return 401. This makes new routes part of the check without maintaining a separate copy of the entire route table.
Public routes still need explicit exceptions, and those exceptions get checked too:
- Every exception must correspond to a registered route.
- A route listed as public must not start returning 401 for the test request.
The test also checks the expected set of routes outside /api, so adding another top-level route requires an explicit update.
Good handler coverage didn’t establish that authentication was wired correctly. The missing test was a request through the assembled application.
Make the database fail on purpose
The error-handling tests had a similar gap. Their in-memory SQLite setup didn’t exercise the database failures needed to reach the server-error responses. An in-memory database can fail, but these tests weren’t making it fail.
A connector that refuses connections gives the tests a controlled failure. They can then check that the response returns a server error without exposing raw database-driver text, which can contain internal details.
The logging assertion needed to be specific to each request as well. An early version used one shared flag to record whether an error had been logged. Once any route set that flag, another route could pass without logging its own failure.
Each route now checks for its own log record. The test needs evidence that this request was logged, not that logging worked somewhere in the suite.
These are the questions I want tests to answer: can concurrent callers exceed the limit, can an unauthenticated request reach a protected route, and what does a client receive when the database fails?
Handler tests are useful, but they don’t tell me whether the application requires authentication. For that, the test needs to send a request through the same router the application uses.
Sources
- Go: Data Race Detector — runtime detection and the limits of unexercised code paths
- chi router documentation — walking the registered routing table
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].