1. Tests are the ideal bug reproduction mode
When a bug shows up “sometimes,” you really want to do what any healthy person does: cry a little, then add ten fmt.Printf, then delete nine of them (because they get in the way of living), and then… accidentally fix the wrong thing. Tests save you by turning the problem into a repeatable scenario: fixed input → fixed code → predictable crash or wrong result.
A test as a debugging tool is convenient in a very human way. First, it runs fast: you are not clicking through menus, not typing parameters by hand, not trying to remember “did I enter 10 or 100 last time?”. Second, a test is almost bug documentation: if tomorrow you forget what exactly was broken, the test will remind you (sometimes very loudly). Third, tests give you a narrow corridor for experiments: you change 1–2 lines, rerun a single test, and immediately see the effect.
And most importantly: tests pair really well with what you already know. If a test fails with a panic, you get a stack trace and use the exact same skill of “find my file/line.” If a test fails on a got/want comparison, you can surgically add t.Logf or a temporary fmt.Printf. If it is really tough, a test becomes a convenient entry point for the debugger.
2. Controlling go test execution: -run, -v, and -count=1
To make debugging through tests pleasant, you need to “tame” the test run a bit. By default, go test tries to be useful for the whole project at once: it builds the package, runs tests, may use the cache, and keeps output relatively compact. For an ordinary day, that is great. For debugging, sometimes you want something else: run only one scenario, see more details, and be sure the test actually reran instead of coming from cache.
During debugging, it is useful to consciously control three things: scope (all tests or one), verbosity (quiet or detailed), and repeatability (did the test really execute again?). Once you start controlling this, debugging stops being “magic” and becomes a craft.
-run: speeding up the “fix → verify” loop
Once a project becomes even a little alive, there are lots of tests. That is good, but during debugging, running everything is expensive in time and nerves. That is exactly where the -run flag comes in.
Formally, -run takes a regular expression. In plain terms: -run is a filter by test name. You say, “Run only this,” and go test runs only what matches.
It is important to remember two things.
First: -run is a regular expression, not an “exact name.” So -run Test will run all tests whose names contain Test. And -run ^TestFoo$ will run exactly TestFoo, because ^ is the start of the string and $ is the end of the string.
Second: when subtests appear (t.Run), their names also participate in filtering, and they are written with /. That means you can target not just the test, but a specific scenario inside it.
A small cheat sheet:
| What we want to do | Command (idea) | Why |
|---|---|---|
| Run only one test | |
“pins down” the regex so it does not match extra names |
| Run a group of similar tests | |
Matches everything that contains |
| Run a specific subtest | |
Subtest names go after |
If regexes feel like a separate form of magic (they kind of do), do not worry. In practice, three modes are usually enough: “a word,” “^exact$,” and “test/subtest.”
-v: seeing what actually ran
The -v flag shows detailed progress for tests and subtests: what exactly ran and in what order. This is especially useful when your -run matches differently than you think.
Also, t.Logf is usually visible either when the test fails or in verbose mode—so -v helps you “see with your eyes” what you are logging.
-count=1: disabling the cache while debugging
go test can cache successful results. That is great when you just want to quickly confirm everything is green. But during debugging you sometimes need to guarantee the test runs again. Then you say “do not use the cache” via -count=1.
A battle-tested command for focused debugging
Once you already know which scenario fails, it is convenient to combine flags into one command:
go test -run TestFindByID/not_found -v -count=1 ./...
3. Subtests as a microscope: one case out of ten
When you write table-driven tests, you usually run many cases inside one TestXxx. That is good: the structure is compact, scenarios live next to each other. But during debugging, sometimes you need to run exactly one failing case, not the whole table.
That is why it is important to give subtests good names. Not case1, not case2, and definitely not aaa. Use something like not_found, empty_title, bad_id, unicode, so that a week later you will say “thanks, past me.”
I will show this with a small educational mini-app called Tasker (a fictional task manager). We have a slice of tasks, and we want to find a task by ID.
Buggy code: mixing up ID and index
Imagine someone (we will not point fingers) wrote this:
package task
type Task struct {
ID int
Title string
}
func FindByID(tasks []Task, id int) (Task, bool) {
t := tasks[id] // BUG: id is not an index!
return t, true
}
This code looks plausible as long as id accidentally matches the index in the slice. Then reality happens, and tests start screaming.
A test with subtests: found and not_found
package task
import "testing"
func TestFindByID(t *testing.T) {
tasks := []Task{{ID: 10, Title: "buy milk"}, {ID: 20, Title: "learn Go"}}
t.Run("found", func(t *testing.T) {
_, ok := FindByID(tasks, 10)
if !ok {
t.Fatalf("expected ok=true")
}
})
t.Run("not_found", func(t *testing.T) {
_, ok := FindByID(tasks, 999)
if ok {
t.Fatalf("expected ok=false")
}
})
}
Even if you already noticed that the found test is also weird (we search for id=10, but the slice does not have index 10), that is exactly the point of debugging: tests highlight mismatches between expectations and reality.
If only not_found fails, you can run only it:
go test -run TestFindByID/not_found ./...
And this drastically speeds up your life. You do not need to wait for the rest of the project tests to run. You work like with a laser pointer: shine exactly where it hurts.
4. Minimal reproducible example
It is very easy to fall into a trap: the bug shows up in a big scenario, and you try to debug the entire big scenario as a whole. That is like fixing a car without opening the hood: possible, but usually slow and a bit theatrical.
A minimal reproducible example (often abbreviated as MRE) is the idea of keeping only what is necessary for the bug to reproduce and throwing away everything else. Ideally, you get to a situation where you have 10 lines of code and on line 7 everything breaks. And then life becomes surprisingly simple.
The nicest part: you can usually make a minimal example right inside a test. Had complex input from a file? Replace it with a string constant. Had to load a bunch of tasks? Keep two. Had a complex chain of functions? Call only the one that actually breaks and pass the minimal input.
It helps to think of this as a “world compression” process. The flow looks roughly like this:
flowchart TD
A[Bug occurs in a large scenario] --> B[Lock in reproduction with a test]
B --> C[Remove everything extra: input, data, steps]
C --> D{Is the bug still there?}
D -- yes --> E[Remove more]
D -- no --> F[Bring back the last removed piece]
E --> D
F --> G[Minimal reproducible example is ready]
There is no “secret step” here. It is just discipline: remove → check → remove → check.
And yes, it sounds boring. But at the moment when instead of 500 lines you see 12 and the bug still reproduces, you feel almost physical relief. Like someone turned off the background noise in your head.
5. Practical scenario: from failure to fix on a single subtest
Now we will assemble everything into one sequential scenario, as close as possible to how it happens in real development. It is important not only to know the commands, but to get used to the rhythm: “narrow the run → get a signal → narrow the input → find the cause.”
Run the full suite and get the first signal
You run tests as usual:
go test ./...
They fail. If the failure is a panic, you will see a stack trace and use the skill of “find the first line that belongs to my code.” If the failure is a t.Fatalf, you will see the message.
In our case, it is very likely you will get panic: runtime error: index out of range, because tasks[id] with id=999 will go out of bounds.
Narrow down to one test
So you do not run everything:
go test -run ^TestFindByID$ -v -count=1 ./...
Now you see which subtests started and where exactly things crashed.
Narrow even more: one subtest
If not_found fails, work only with it:
go test -run TestFindByID/not_found -v -count=1 ./...
At this stage, you have an almost perfect “sandbox”: you can change code, rerun, and get feedback in seconds.
Compress the input to the minimum
If we had dozens of tasks, we would keep two (as in the test). If there were complex generation, we would replace it with a literal. We are already close to the minimal version.
And now it becomes clear: we are using id as an index. So the implementation must search by the ID field.
Fix the implementation
package task
func FindByID(tasks []Task, id int) (Task, bool) {
for _, t := range tasks {
if t.ID == id {
return t, true
}
}
return Task{}, false
}
After that, again:
go test -run TestFindByID -v -count=1 ./...
If the tests are green, you did not “take it on faith”—you verified it.
Bonus: t.Helper so errors point to the “right line”
As tests get more complex, you start extracting repeated checks into helper functions. And then an annoying moment appears: the test fails, but the message points to a line inside the helper, not where the helper was called.
Go has a solution: t.Helper().
Example:
package task
import "testing"
func mustFind(t *testing.T, tasks []Task, id int) Task {
t.Helper()
got, ok := FindByID(tasks, id)
if !ok {
t.Fatalf("task id=%d not found", id)
}
return got
}
Now if mustFind fails, go test will point to the line in the test where you called mustFind, not to the helper internals. For debugging, it is noticeably nicer.
6. Common mistakes when debugging tests
Mistake #1: running the whole project when only one test fails.
This looks harmless (“let it run everything”), but in practice you pay with time and attention. While the whole suite is running, you forget what exactly you wanted to check, and your brain starts entertaining itself—for example, by worrying. During debugging, it is better to narrow down to a single test via -run and quickly spin the change loop.
Mistake #2: not “pinning down” the test name and accidentally running extra tests.
-run TestFind can match more than you think: for example, TestFindByID and TestFindByTitle. Then you see a failure and are sure it is “your” test, but it is the neighboring one. If you need exactly one test, the ^...$ habit saves nerves.
Mistake #3: fighting the cache without realizing it exists.
Sometimes you changed code, but the output looks the same. A common cause is caching successful results. For debugging, add -count=1 so each run is honest and not “from memory.” This is especially relevant when you are debugging output, execution time, or environment-dependent behavior.
Mistake #4: turning a test into a “big story” instead of minimal reproduction.
There can be a temptation: “now I will write a test that fully repeats the real scenario.” As a result, the test itself becomes more complex than the bug. The right thought is different: during debugging, the test is your laboratory. First, keep the minimal input and minimal call that breaks. Big scenarios are good, but later.
Mistake #5: adding a ton of diagnostic output and forgetting to remove it.
fmt.Printf is a great scalpel, but a bad lifestyle. If you leave diagnostic prints forever, tests start making noise, and the next person (often you, a week later) stops understanding what matters. While debugging—fine. Afterward—either remove it, or move it to t.Logf where it actually helps.
GO TO FULL VERSION