1. What is a regression test
A regression test is not “just another test for the collection”, but a small insurance policy against repeating a nightmare you have already lived through. In real development, bugs often return not because someone is being malicious, but because the code changes, the context gets forgotten, and an old condition becomes wrong again. A regression test makes the bug reproducible at the push of a button.
Imagine you fixed an error, exhaled, shipped the change… and a week later someone (including you) “simplified the code a bit”, and the problem returned. Only now it shows up not in your IDE, but for a user, at night, on a Friday. You need a regression test so that the next time the code changes, it tells you itself: “stop, we’ve already stepped on this rake.”
There’s one more important point: fixing one bug can be “correct” by itself, yet accidentally break neighboring behavior. Tests are one of the main ways to keep behavior compatible during change: in large projects, this is exactly how it’s done — you run the existing test suite so changes do not break what used to work.
2. Practice: from symptom to test
A regression test rarely appears out of thin air. Usually it is born from a normal bug life cycle: first there is a symptom (“sometimes it crashes”, “with empty input it behaves weirdly”, “it calculates percentages incorrectly”), then you make the problem reproducible, find the cause, fix it — and this is where it’s important not to stop.
In this process it helps to think in a cycle: you’re not just “closing a ticket”, you’re adding a fuse to the project to prevent the bug from coming back.
Regression cycle
flowchart TD
A[Found a bug / saw a panic] --> B[Made a minimal reproducible example]
B --> C[Wrote a test that fails]
C --> D[Fixed the code]
D --> E[Test turned green]
E --> F[Kept the test forever so the bug doesn’t return]
In this cycle there’s an important psychological trap: “I already fixed it, why write a test?” In practice, a test is not bureaucracy but a way to preserve knowledge: what exactly was considered a bug, which inputs trigger it, and which behavior we consider correct.
Example: the todo mini-package and division by zero
To stay grounded in reality, let’s continue a training “mini-project” as a package with logic (no CLI and no files). Let it be a small core for tasks: a Task struct and a function that calculates completion progress.
Let’s build the base: the task struct.
package todo
type Task struct {
ID int
Title string
Done bool
}
Now let’s add the ProgressPercent function, which returns the percentage of completed tasks (0..100). And this is where it’s easy to introduce a bug: if there are no tasks, len(tasks) == 0, we divide by zero.
Buggy version
package todo
func ProgressPercent(tasks []Task) int {
done := 0
for _, t := range tasks {
if t.Done {
done++
}
}
return done * 100 / len(tasks) // BUG: panic when len(tasks)==0
}
If tasks is empty, we’ll get a runtime panic “integer divide by zero”. This is that classic case where the program crashes, and then you read the stack trace and sadly remember lectures about invariants.
Regression test: lock in behavior for empty input
The principle matters here: we write the test so that it reproduces the problem. And yes, with the current code this test must “fail” (a panic also counts as “failed”).
But the goal is not “catch a panic and celebrate”, but to lock in correct behavior: for an empty list it’s reasonable to treat progress as 0%.
Let’s write a test that says: on empty input there must be no panic, and the result must be 0.
package todo
import "testing"
func TestProgressPercent_empty_isZero_andDoesNotPanic(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("unexpected panic: %v", r)
}
}()
got := ProgressPercent(nil)
if got != 0 {
t.Fatalf("ProgressPercent(nil)=%d, want 0", got)
}
}
The key technical point here: recover() works only inside a defer function — this is core Go mechanics, and it is intentionally made a “narrow tool” rather than a universal try/catch.
In tests this technique is acceptable: we’re not “fixing” business logic with recover; we’re simply making the test robust and turning a “panic” into a clear failure message.
With the buggy implementation, this test will fail with unexpected panic: runtime error: integer divide by zero. Great: we got stable reproducibility.
Fix the code minimally and transparently
Now for the fix. In Go, simple guard clauses are very common: “if a condition violates an invariant — return early.”
package todo
func ProgressPercent(tasks []Task) int {
if len(tasks) == 0 {
return 0
}
done := 0
for _, t := range tasks {
if t.Done {
done++
}
}
return done * 100 / len(tasks)
}
After this, the test will turn green. And that’s the main point: now if someone “optimizes” the function in a month and accidentally removes the check, the test will turn red again and stop you before a user sees the crash.
3. Qualities of a good regression test
It’s easy to ruin a regression test, even with good intentions. A common beginner mistake is to make the test huge: create ten tasks, run a bunch of logic, and check five different conditions at once. Such a test is hard to read, and when it fails it’s unclear what exactly broke.
A good regression test usually looks like a small story in three parts: input data, action, expectation. The shorter and more specific that story is, the better.
In our example, “an empty list” is the ideal minimal input. It immediately tells you which scenario we are protecting. And there is only one expectation: 0% and “no crash.”
It helps to think of a regression test as a fixed contract: “for input X, behavior must be Y.” And the contract is not necessarily “what percentage should we get.” Sometimes the contract is “it must return an error instead of panicking”, or “it must return (nil, false) rather than creating a fake object.”
4. Regression without panic: a logical error
Not all bugs crash. The nastiest ones are those that “work” but produce the wrong result. For such bugs you don’t need recover; you need a test that checks got/want.
Suppose we wanted to count completed tasks and wrote a function — and somewhere we got the condition wrong (the classic: == instead of !=, or mixing up a branch).
Here’s a simple function:
package todo
func DoneCount(tasks []Task) int {
done := 0
for _, t := range tasks {
if t.Done {
done++
}
}
return done
}
And a test:
package todo
import "testing"
func TestDoneCount_basic(t *testing.T) {
tasks := []Task{
{ID: 1, Title: "Read", Done: true},
{ID: 2, Title: "Write", Done: false},
}
if got := DoneCount(tasks); got != 1 {
t.Fatalf("DoneCount(...)=%d, want 1", got)
}
}
If you once fixed an error in the counting logic, a test like this becomes a regression test: it will guard exactly the scenario that was broken at some point.
5. The mustNotPanic helper in tests
When a project ends up with more than one test that must verify “does not panic”, copying defer + recover everywhere gets boring. And people refactor boring code… sometimes unsuccessfully.
You can make a small helper. The main thing is not to turn it into magic and not to hide the meaning.
package todo
import "testing"
func mustNotPanic(t *testing.T, f func()) {
t.Helper()
defer func() {
if r := recover(); r != nil {
t.Fatalf("unexpected panic: %v", r)
}
}()
f()
}
Now the test reads more simply:
package todo
import "testing"
func TestProgressPercent_empty_isZero(t *testing.T) {
mustNotPanic(t, func() {
got := ProgressPercent(nil)
if got != 0 {
t.Fatalf("got %d, want 0", got)
}
})
}
Here we use t.Helper() so that when the test fails, Go shows the call site line in the test, not the line inside the helper. This is not “mandatory magic”, but it makes diagnostics nicer.
6. What to lock in with a test: result, error, absence of panic
After fixing a bug, sometimes you want to test “everything at once”: the result, internal struct fields, iteration order, and string formatting. But a regression test is valuable precisely because it protects the essence of the bug, not incidental implementation details.
Here’s a handy short table to avoid missing what to assert:
| Bug symptom | What we lock in with the test | Why |
|---|---|---|
| The program crashed with panic | “does not panic” + result/error contract | Panic is the most dangerous case; we turn it into a test failure |
| It returned an incorrect value | got/want | This is pure logic; no recover is needed |
| The error was “the wrong type/message” | compare error (or part of the message) | We lock in the error UX/contract, not internals |
| The issue was in an “edge case” (empty, 0, index boundary) | minimal input that reproduces the case | Edges break most often; the test should be as simple as possible |
7. How to name regression tests
A test name is not a formality. In a month you’ll forget the details, but the test will remain. If the test is named TestBugFix, in a month it’s “a test of something.” If it’s named TestProgressPercent_empty_isZero_andDoesNotPanic, that’s already behavioral documentation.
A good style for regression tests is to reflect the input and expected behavior in the name. You don’t have to write a novel, but at minimum empty, nil, out_of_range, invalid, and doesNotPanic often save you.
8. Common mistakes
Mistake #1: “I fixed it — no test needed.”
This is the most expensive self-deception. The fix lives in your memory for a couple of days, but the code lives for years. Without a test, you didn’t preserve the knowledge of what exactly was considered a bug and why the chosen behavior matters.
Mistake #2: the regression test is too big and checks everything at once.
When a test builds a huge dataset and checks five conditions, it stops being “an alarm for a specific bug.” When it fails, it’s unclear which part of the behavior broke, and you spend time digging instead of fixing quickly.
Mistake #3: the test catches a panic but does not verify the meaning of the fix.
Sometimes people write a test like “as long as it doesn’t crash.” That’s useful, but often not enough. If the bug was “division by zero”, then it’s important not only that there is no panic, but also that there is a reasonable result contract (for example, 0%). Otherwise you can return 42 and formally “not crash.”
Mistake #4: using recover as a way to “fix” business logic.
In a test, recover is appropriate as a protective mechanism that turns a panic into an understandable test failure. But in normal code, recover should almost never be a way to handle errors: in Go it is idiomatic to return an error, and keep recover for the application boundary. The mechanics of recover are intentionally limited to deferred functions precisely so it is not used as an everyday try/catch.
Mistake #5: the test is tied to implementation details rather than the contract.
If you test internal temporary variables or iteration order, then during a normal refactor the test will start “falsely turning red.” A regression test should hold the agreement: input → output/error/no panic, not the internal kitchen of the function.
GO TO FULL VERSION