skip to content

How do you build a Go test binary you can step through, and how do you pass `-run` to it once compiled?

level: middleimportance: should knowfreq 40%

answer

  1. compile the tests, do not run them
  2. one package per invocation
  3. the flags gain a namespace
  4. go test chose your working directory for you
  5. a paused process still burns the clock

basics

~10 s

Compile the package's tests without running them: go test -c -gcflags="all=-N -l" -o decode.test ./internal/fixture. Running that binary directly, the testing flags carry a test. prefix, so filtering is -test.run rather than -run.

solid answer

~40 s

`go test -c` compiles the test binary for one package and writes it out instead of executing it; add `-gcflags="all=-N -l"` so the code inside is actually inspectable, and `-o` to name the output. Once you run that executable yourself, `go test`'s flags are gone — the binary exposes the `testing` package's own flags, which are `test.`-prefixed: `-test.run TestDecodeFixture`, `-test.v`, `-test.count`, `-test.timeout`. Two gotchas bite immediately. First, `go test` runs each test with the package directory as the working directory, but your compiled binary inherits whatever directory you launch it from, so relative `testdata/...` paths break unless you cd into the package. Second, if you debug through `go test` instead, its default ten-minute timeout will kill the process while you sit on a breakpoint, so pass `-timeout 0`.

code

go · 18 lines
go
type fixture struct {
	Name  string `json:"name"`
	Count int    `json:"count"`
}

func TestDecodeFixture(t *testing.T) {
	data, err := os.ReadFile("testdata/one.json")
	if err != nil {
		t.Fatal(err)
	}
	var f fixture
	if err := json.Unmarshal(data, &f); err != nil {
		t.Fatal(err)
	}
	if f.Count == 0 {
		t.Fatalf("Count not populated: %+v", f)
	}
}

go deeper

for a junior

Know that go test -c compiles the tests into an executable instead of running them, and that -o names it. Be ready to say why you would want an artefact rather than letting go test run and discard one.

for a middle

Explain the flag namespace: the compiled binary exposes the testing package's own test.-prefixed flags, so -run becomes -test.run. Be ready to explain the working-directory difference that breaks relative testdata paths.

for a senior

Demonstrate the practical loop end to end: narrow to one test, set the working directory, disable the timeout so a paused process is not killed, and recognise which behaviours (deadlines, heartbeats, races) a breakpoint changes rather than reveals.

for a principal

Decide how much of this is worth institutionalising. A one-command debug target that new engineers can run beats a wiki page of flags, and it is worth agreeing when a team reaches for stepping at all versus a focused failing test.

## The problem `go test -c` solves `go test` is a wrapper. It generates a small `main` package that registers your `Test*` functions, compiles it together with the package under test, runs the resulting executable, and then cleans it up. That is convenient and completely unhelpful when you want to stop inside one of those tests: there is no artefact for a debugger to launch, and the process you would want to attach to has already exited. `-c` breaks the wrapper apart. It compiles the test binary and stops: ``` go test -c -gcflags="all=-N -l" -o decode.test ./internal/fixture ``` You now have an ordinary executable you can start under a debugger like any other program. Three details matter in that command: - **`-c` works on one package at a time.** It produces a single executable, so pointing it at a pattern that matches several packages is an error. Debug one package's tests at a time. - **`-gcflags="all=-N -l"`** applies here for exactly the reasons it applies to any debug build: without it, locals may have no address and the helper you meant to step into may have been inlined into its caller. The `all=` pattern matters especially in a test binary, because half of what you want to watch is inside standard-library code such as a decoder. - **`-o`** names the output. Without it the file is written into the current directory as `<pkg>.test`. ## The flag prefix, which trips everyone once Running the compiled binary, you are no longer talking to the `go` command. The flags you get are the ones the `testing` package registers, and those are namespaced with a `test.` prefix so that they cannot collide with flags your own package defines: | Through `go test` | On the compiled binary | |---|---| | `-run TestDecodeFixture` | `-test.run TestDecodeFixture` | | `-v` | `-test.v` | | `-count 1` | `-test.count 1` | | `-timeout 30s` | `-test.timeout 30s` | | `-bench .` | `-test.bench .` | Passing `-run` to the compiled binary produces a flag-not-defined error, and the usual reaction is to assume the build is broken. It is not; the prefix is the whole story. Narrowing with `-test.run` is worth doing anyway: a breakpoint stops the process for whichever test reaches it, so filtering down to the single test you care about removes a lot of noise. ## The working-directory trap `go test` runs each package's tests with that package's source directory as the working directory. That is why `os.ReadFile("testdata/one.json")` works in a test without anyone thinking about it. Your compiled binary has no such courtesy — it inherits the directory you launched it from. Start `./decode.test` from the repository root and every relative fixture path fails, usually with a confusing "no such file or directory" that looks like a bug in the code you were about to debug. The fix is to launch the binary with the package directory as its working directory, or to configure that in whatever runs it. It is worth knowing before you start, because the failure appears *before* your breakpoint and sends you hunting in the wrong place. ## Timeouts while you are stopped If you debug by running `go test` under a debugger rather than compiling first, remember that the `go` command passes a default ten-minute timeout down to the test binary. Sitting on a breakpoint reading a struct is wall-clock time like any other, so a long session ends with the test binary panicking on timeout and dumping every goroutine's stack over your session. Pass `-timeout 0` to disable it. When you run the compiled binary yourself you control `-test.timeout` directly and can simply leave it off. The same reasoning applies to deadlines inside the code: a `context.Context` created with a timeout, a server read deadline, a heartbeat — all of them expire while you are stopped. If a test's behaviour depends on one, stepping through it changes the outcome. ## When `-race` is involved If the bug only reproduces under the race detector, build the test binary with `-race` as well. Be aware that this is a different binary with different timing and considerably more memory and CPU overhead, and that the detector reports races that actually occur on the paths you exercise — a clean stepped run proves nothing about the paths you skipped. ## The whole loop 1. `go test -c -gcflags="all=-N -l" -o decode.test ./internal/fixture` 2. Start `decode.test` under a debugger, working directory set to `internal/fixture`. 3. Pass `-test.run TestDecodeFixture -test.v` so exactly one test runs. 4. Break inside the decode call and watch the tagged struct fields being populated. 5. Delete the binary afterwards — it is a build artefact and belongs in `.gitignore`, not in a commit.

  • You run the compiled test binary with `-run TestDecodeFixture` and it refuses to start. Why?
    Because that flag belongs to the `go test` command, not to the binary. The `testing` package registers its own flags under a `test.` prefix so they cannot collide with flags the package under test defines, so the compiled binary wants `-test.run TestDecodeFixture`. The same applies to `-test.v`, `-test.count` and `-test.timeout`.
  • Why does a test that passes under `go test` fail with a missing file when you run the compiled binary?
    `go test` runs each test with the package's own source directory as the working directory, which is why relative `testdata/...` paths work. A compiled test binary inherits whatever directory you launched it from, so those paths resolve against the wrong root. Launch it with the package directory as its working directory.
  • You debug through `go test` and the process dies part-way through your session. What happened?
    The `go` command passes a default ten-minute timeout to the test binary, and time spent stopped on a breakpoint counts. The binary panics on timeout and dumps every goroutine's stack. Pass `-timeout 0` when debugging, or compile with `-c` and control `-test.timeout` on the binary yourself.

saying these in an interview costs you the question

  • Passes -run to a compiled test binary and calls it broken
  • Points go test -c at a pattern matching many packages
  • Forgets -gcflags and then cannot inspect anything
  • Runs the test binary from the repository root and blames testdata
  • Lets the default test timeout kill a debugging session
  • Commits the generated .test binary