The layers of feedback
The practices in this series keep circling back to feedback, for a reason: the speed of your feedback loop sets the speed of your learning, and the speed of your learning sets almost everything else about how effective you are.
Feedback loops come in layers, and the right fix is different at each one.
- Editor feedback. Compiler errors,
go vet,staticcheck, inline warnings. Zero latency, the tightest loop that exists. Investing in it, stricter linting, more complete type coverage, pays back with compounding interest. - Unit tests in watch mode. Sub-second, on the file you just changed. If your unit tests don’t run automatically on save (
gotestsum --watch, or your editor’s built-in runner), you’re leaving real developer experience on the table. - Local rebuild-and-run. A change reflected without a full redeploy. Trivial to script with
airorreflex, and often just left off. - Integration tests. Should run in seconds, not minutes. A slow test doesn’t get run. That’s not a discipline problem, it’s an incentives problem: a four-minute suite gives engineers a reason to skip it before pushing.
- CI. Parallelize aggressively. A twenty-minute pipeline is a morale tax paid on every single PR, and the cost isn’t just the twenty minutes, it’s the context switch of sitting there waiting.
The heuristic: if running tests takes willpower, the tests are too slow. Running them should be the path of least resistance, not a deliberate act you have to talk yourself into.
Diagnosing a slow suite
Don’t guess which tests are slow — go test will tell you. Run go test -v ./... and look at the per-test timing in the output, or use gotestsum for a cleaner slow-test report; go test -json ./... | gotestsum tool slowest prints your slowest tests by name, sorted. Run that before you touch anything. Guessing “it’s probably the ledger tests” and refactoring them first, when the real cost turns out to be three tests re-seeding a fixture from scratch, wastes the afternoon on the wrong file.
Once you have the list, the slow tests are almost always one of these:
A real network or database call standing in for something that should be faked.
// 340ms — makes a real HTTP call to the card-network sandbox on every run
func TestCharge_RejectsExpiredCard(t *testing.T) {
result, err := cardNetworkClient.Charge(ctx, ChargeRequest{Card: expiredCard, AmountCents: 1000})
if err != nil || result.Status != "declined" {
t.Errorf("Charge(expired card) = %+v, %v, want status=declined", result, err)
}
}
// 2ms — same assertion, no network round trip
func TestCharge_RejectsExpiredCard(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(ChargeResult{Status: "declined"})
}))
defer srv.Close()
client := NewCardNetworkClient(srv.URL)
result, err := client.Charge(ctx, ChargeRequest{Card: expiredCard, AmountCents: 1000})
if err != nil || result.Status != "declined" {
t.Errorf("Charge(expired card) = %+v, %v, want status=declined", result, err)
}
}
The first version is testing the sandbox’s latency plus your assertion; the network call was never the thing you meant to verify.
Serial execution when the tests don’t actually depend on each other. go test parallelizes across packages by default, and t.Parallel() opts individual tests into running concurrently within a package — but a shared resource, one test database, one fixture file, silently forces serialization back on. If two tests write to the same accounts table without cleaning up, marking them t.Parallel() produces flaky failures instead of speed, and the “fix” people reach for is removing t.Parallel() entirely rather than isolating the tests.
Heavy setup running per-test instead of per-suite. A setup helper called at the top of every test that recreates a full fixture graph — a customer, an account, three cards, ten transactions — because one test at the bottom needs all of it, is now the tax every other test in the file pays. Move the expensive, shared setup into TestMain or a package-level sync.Once, and let individual tests that need a different state build only the delta.
None of this is exotic tooling. It’s mostly noticing that a test is slow for a reason that has nothing to do with what it’s testing, and cutting that reason out.
Deterministic dev seeds
One feedback loop accelerator gets overlooked constantly. Being able to reset to a known state instantly changes how you work. Without it, testing a specific scenario means manually navigating the app to set up preconditions, a multi-minute chore most engineers will dodge if they can. A seed command that takes five seconds makes scenario-specific development tight instead of loose. Nothing kills a feedback loop faster than “let me set up the scenario again.”
Say you’re fixing the expired-hold bug from learning a codebase with no docs: a fraud hold stuck active past its ExpiresAt, still blocking the funds it was holding. Reproducing that by hand means submitting a real transfer, then either waiting for the hold window to lapse or fiddling with your system clock, every single time you want to check whether your fix actually releases the funds. That’s a five-minute round trip on every iteration, which means you’ll test it twice and then start “just trusting” the fix instead of five hundred round trips down to zero effort.
Instead, make the scenario a named seed:
// seeds/scenarios/expired_hold.go
func SeedExpiredHold(ctx context.Context, db *Queries) (TransferID, error) {
transfer, err := db.CreateTransfer(ctx, CreateTransferParams{Status: "pending"})
if err != nil {
return TransferID{}, err
}
_, err = db.CreateHold(ctx, CreateHoldParams{
TransferID: transfer.ID,
Status: "active",
ExpiresAt: time.Now().Add(-5 * time.Minute), // already expired, deterministically
})
return transfer.ID, err
}
go run ./cmd/dbreset && go run ./cmd/seed -scenario expired_hold
Two commands, a few hundred milliseconds, and you’re staring at exactly the broken state every time — not “roughly the broken state, if the timing lines up.” Now the iteration loop on the actual fix is: run the cleanup job, query the hold, see it flip to expired, repeat. Seconds, not minutes, and every run starts from the identical state the last one did.
This isn’t free forever — a seed is code, and code rots the same way any other code does. Add a required SettlementNetwork field to Transfer and every seed that constructs a transfer without it starts failing to compile, or, worse, compiles fine but silently produces a transfer that violates an assumption the rest of the app makes. Treat that failure as a feature: a seed breaking on schema change is the seed doing its job, forcing you to decide what the new field should be in each named scenario instead of finding out three weeks later that expired_hold has quietly been seeding transfers the settlement flow can’t actually produce anymore. Keep seeds in version control, reviewed in the same PR as the migration that changes their shape — not a script living on one engineer’s machine that only they know how to keep working.