Before this:What is a unit test?
Anatomy of a test
Key takeaways Nearly every good test has the same three-beat shape: arrange the inputs and world, act by calling the code under test once, assert that the result matches the prediction. Each test should verify one behavior, its name should state that behavior, and its failure message should hand the reader the got-vs-want facts. Write tests so that when one fails at 2 a.m. six months from now, the failure alone tells the story.
Tests are code, and like all code they can be well or badly built. The good news: unlike most code, good tests follow one near-universal shape. Learn it once and you can read — and write — tests in any language.
Arrange, act, assert
Every test answers given this situation, when this happens, then this should be true. In code, those three beats are called arrange, act, assert (you’ll also hear the equivalent given/when/then):
func TestFrequencyFormat(t *testing.T) {
// Arrange: build the exact input situation.
hz := int64(851_012_500)
// Act: call the code under test — once.
got := FormatFrequency(hz)
// Assert: compare against the prediction.
want := "851.0125 MHz"
if got != want {
t.Errorf("FormatFrequency(%d) = %q, want %q", hz, got, want)
}
}
Keeping the beats in order and visually distinct isn’t ceremony — it’s what makes a test skimmable. A reader (including future-you) should identify the situation, the action, and the expectation in five seconds. When a test interleaves setup with assertions, or acts three times with checks in between, understanding a failure means mentally executing the test — the very cost tests exist to remove.
One behavior per test
The tempting anti-pattern is the mega-test: one function that arranges everything and asserts twenty facts. It seems efficient. It’s a trap, for two reasons:
- Failures blur. Go stops reporting a test at its first fatal failure, so a mega-test failing on assertion #3 tells you nothing about #4–#20. Twenty small tests fail independently and give you the full picture in one run.
- Names lie. A test checking twenty things can only be called
TestEverything, which tells a reader nothing when it turns red.
“One behavior” doesn’t mean literally one if — checking that a returned
struct’s three fields are all correct is one behavior. It means one claim: a
test should be summarizable in a sentence, and that sentence should be its name.
Names are documentation
Test names are the one place Go convention encourages long names. The pattern
worth adopting: Test<Thing>_<Situation>_<Expectation>, flexed to taste.
| Weak name | Strong name |
|---|---|
TestDecode |
TestDecode_TruncatedMessage_ReturnsError |
TestParse2 |
TestParseFrequency_MHzSuffix_ConvertsToHz |
TestEdgeCase |
TestChunkAudio_EmptyInput_ReturnsNoChunks |
Run go test, and a failing strong name is the bug report: you know what
broke, under which conditions, before reading a line of code. A directory of
strong test names doubles as a specification of the package — many developers
read a package’s _test.go files before its documentation, because tests can’t
drift out of date without failing.
Failure messages: write for the 2 a.m. reader
The assert beat deserves extra care, because its output is read at the worst possible time — when something is broken. The Go convention is the got/want message, and its three ingredients are exactly what a diagnosis needs:
t.Errorf("FormatFrequency(%d) = %q, want %q", hz, got, want)
That prints what was called with which input (FormatFrequency(851012500)),
what came back ("851.01 MHz"), and what was expected ("851.0125 MHz").
Compare t.Errorf("wrong output") — true, and useless. The difference between
those two messages is the difference between a thirty-second fix and a
debugging session.
Rule of thumb: a failure message should let someone diagnose the problem without opening the test file. Input, got, want — all three, every time.
One more Go-specific choice: t.Errorf records a failure and keeps going;
t.Fatalf records it and stops the test immediately. Use Fatalf when
continuing makes no sense (the arrange step itself failed, or the thing you’re
about to dereference is nil); use Errorf otherwise, so one run reports every
broken expectation.
Quick check: which failure message best serves the person who reads it?
Recap
- Tests follow arrange → act → assert: set up the situation, call the code once, check the prediction.
- One behavior per test — failures stay sharp, and every test is summarizable in a sentence.
- That sentence is the name:
TestThing_Situation_Expectationturns a red test into a bug report. - Failure messages carry input, got, and want — diagnosable without opening the file.
t.Errorfcontinues,t.Fatalfstops — use fatal only when continuing is meaningless.
Next up: Your first Go test