Go Tooling: go vet, gofmt, and go test
A practical guide to Go's built-in tooling trio: gofmt for formatting, go vet for static checks, and go test for unit, benchmark, and coverage workflows.
What you'll learn
- ✓How gofmt enforces a single canonical style
- ✓What go vet catches that the compiler does not
- ✓Writing table-driven tests with go test
- ✓Benchmarks, coverage, and race detection
- ✓Wiring the trio into pre-commit and CI
Prerequisites
- •Familiar with shell
- •Basic Go syntax and packages
What and Why
Go ships with an opinionated toolchain that handles most of what other ecosystems leave to third-party packages. Three commands cover formatting, static analysis, and testing:
gofmt(and its supersetgoimports) enforces canonical formatting,go vetreports suspicious constructs the compiler accepts,go testruns unit tests, benchmarks, and coverage analysis.
Together they cover the everyday quality loop without YAML configs or plugin matrices.
Mental Model
Think of each tool as a layer in a pipeline. The compiler ensures your code is syntactically and type-correct. gofmt ensures it looks the same as everyone else’s. go vet flags semantically suspicious patterns that compile but probably misbehave. go test proves runtime behavior. Failures at any layer should block a commit.
Because the rules are baked into the toolchain, there is no debate over braces or trailing commas. You spend code review on logic, not whitespace.
Hands-on Example
Consider a small package calc with a buggy printf call and missing tests.
// calc/calc.go
package calc
import "fmt"
func Add(a, b int) int { return a + b }
func Describe(a, b int) string {
// Bug: %d expects int but we pass a string
return fmt.Sprintf("sum of %d and %d is %s", a, b, Add(a, b))
}
Run the trio:
$ gofmt -l ./...
calc/calc.go # listed means needs reformatting
$ gofmt -w ./... # rewrite in place
$ go vet ./...
calc/calc.go:9:9: fmt.Sprintf format %s has arg Add(a, b) of wrong type int
go vet caught the format string mismatch the compiler ignored. Fix it:
return fmt.Sprintf("sum of %d and %d is %d", a, b, Add(a, b))
Now add a table-driven test:
// calc/calc_test.go
package calc
import "testing"
func TestAdd(t *testing.T) {
cases := []struct {
name string
a, b int
want int
}{
{"zeros", 0, 0, 0},
{"positive", 2, 3, 5},
{"negative", -4, 1, -3},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := Add(tc.a, tc.b); got != tc.want {
t.Errorf("Add(%d,%d)=%d, want %d", tc.a, tc.b, got, tc.want)
}
})
}
}
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = Add(123, 456)
}
}
Run tests with coverage and the race detector:
$ go test ./... -race -cover
ok example.com/calc 0.812s coverage: 100.0% of statements
$ go test ./calc -bench=. -benchmem
BenchmarkAdd-10 1000000000 0.31 ns/op 0 B/op 0 allocs/op
edit code
|
v
gofmt -w --> canonical layout
|
v
go vet --> suspicious constructs
|
v
go test --> behavior + coverage + race
|
v
commit / push Common Pitfalls
- Skipping
gofmtin editors. Some IDEs only format on save for the current file. Rungofmt -l ./...in CI; a non-empty list should fail the build. - Assuming
go vetis a linter. It is narrow on purpose: only high-confidence checks. For broader linting usestaticcheckorgolangci-lint, but treat them as complements, not replacements. - Forgetting
-race. Race detection is off by default and finds bugs no other check will. Always run race in CI. - Untestable code. Tests that depend on time, files, or network drift become flaky. Inject dependencies via interfaces; a tiny
Clockinterface beatstime.Now()sprinkled everywhere. - Coverage as a goal. Chasing 100% coverage encourages tests that exercise lines without asserting behavior. Use coverage to find gaps, not to grade quality.
Practical Tips
- Add a
Makefileortasktarget:check: gofmt -l . && go vet ./... && go test -race ./.... One command, every time. - Use
goimports -wto auto-add and reorder imports as you save. - Mark slow tests with
if testing.Short() { t.Skip() }and rungo test -shortlocally for quick loops. - Capture failing inputs with
t.Logfinstead offmt.Println. The output is suppressed on success and grouped per test on failure. - For table-driven tests, always wrap cases in
t.Run(tc.name, ...). Sub-test names show up in failure output and allowgo test -run TestAdd/negativeto target a single case.
Wrap-up
Go’s toolchain pushes formatting, static checks, and testing into a frictionless default workflow. Use gofmt to end style debates, go vet as a cheap second opinion the compiler can’t give, and go test with race and coverage to keep behavior honest. Wire all three into pre-commit and CI, and quality becomes a property of the build, not a discipline you have to remember.
Related articles
- Go Go Build Tags Explained
Use Go build tags to include or exclude files per OS, architecture, or custom condition. Learn the new //go:build syntax, common patterns, and how tags interact with the test runner.
- Go Go Context Cancellation Patterns
Master Go's context package: propagate deadlines, cancel goroutines safely, and avoid leaks with practical patterns for HTTP, database, and pipeline code.
- Go Go defer, panic, and recover Tutorial
Learn how Go's defer, panic, and recover work together, when to use each, and how to write resilient code without abusing exceptions.
- Go Go Modules and Package Management
A practical guide to Go modules: go.mod, go.sum, semantic versioning, replace directives, and the commands you actually need day to day.