Running a program once tells you what happened for one input. A test records behavior that should remain true after the next edit. Good tests concentrate on the contract callers depend on: accepted inputs, rejected inputs, boundary values, and observable results. They should help you change the implementation without accidentally changing that contract.
Go includes the testing package and the go test command. Test files end in _test.go and are not included in ordinary application builds. A test function starts with Test, takes *testing.T, and reports failures through that value. The testing reference describes naming and execution rules.
Build a package to test
Create a fresh directory named go-counts, enter it, and run go mod init example.com/go-counts. Save this source as count.go. It is a library package, so it does not need a main function:
package counts
import (
"errors"
"fmt"
"strconv"
)
var ErrOutOfRange = errors.New("count must be from 1 to 10")
func ParseCount(raw string) (int, error) {
value, err := strconv.Atoi(raw)
if err != nil {
return 0, fmt.Errorf("parse count: %w", err)
}
if value < 1 || value > 10 {
return 0, ErrOutOfRange
}
return value, nil
}
This API accepts a decimal integer from one through ten. Parsing syntax comes from strconv.Atoi; the range rule belongs to our package. Keeping these separate lets callers distinguish malformed text from a well-formed number outside the allowed range.
Write a table of behaviors
Save the following alongside it as count_test.go:
package counts
import (
"errors"
"strconv"
"testing"
)
func TestParseCount(t *testing.T) {
tests := []struct {
name string
input string
want int
wantErr error
}{
{"minimum", "1", 1, nil},
{"maximum", "10", 10, nil},
{"below range", "0", 0, ErrOutOfRange},
{"above range", "11", 0, ErrOutOfRange},
{"empty", "", 0, strconv.ErrSyntax},
{"text", "many", 0, strconv.ErrSyntax},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := ParseCount(test.input)
if !errors.Is(err, test.wantErr) {
t.Fatalf("ParseCount(%q) error = %v; want %v", test.input, err, test.wantErr)
}
if got != test.want {
t.Errorf("ParseCount(%q) = %d; want %d", test.input, got, test.want)
}
})
}
}
func FuzzParseCount(f *testing.F) {
for _, seed := range []string{"", "3", "100", "seven"} {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, raw string) {
value, err := ParseCount(raw)
if err != nil {
return
}
if value < 1 || value > 10 {
t.Fatalf("successful result outside range: %d", value)
}
again, err := ParseCount(strconv.Itoa(value))
if err != nil || again != value {
t.Fatalf("round trip changed %d into %d, %v", value, again, err)
}
})
}
Run go test ./.... It should pass. A table-driven test gives each input, expected result, and expected error a named row. t.Run creates a subtest, so a failure identifies the specific case. Fatalf stops the current test or subtest; Errorf records failure and continues. We inspect wrapped error identity with errors.Is instead of depending on diagnostic wording.
Add properties with fuzzing
A fuzz test supplies seed inputs and then, when explicitly requested, lets the tool generate additional inputs. The property here is that every successful parse is in range and survives conversion to decimal text and back. Returning early on invalid input is intentional: invalid input is an allowed outcome of this parser.
Run the bounded fuzz session:
go test -fuzz=FuzzParseCount -fuzztime=5s
The execution count and generated inputs depend on your environment; no particular throughput is expected. Fuzzing can find crashes and violated properties, but the property itself must express the intended behavior. Our table tests still matter because “reject every input” would otherwise satisfy this success-only property. The official fuzzing tutorial explains seeds and saved failing inputs.
Use each tool for its own question
Run these commands from the module root:
go fmt ./...
go vet ./...
go build ./...
go test -run '^TestParseCount$' -v
go test -cover ./...
go test -race ./...
Formatting standardizes layout. Vet reports suspicious constructs. Building checks that normal packages compile. Focused tests shorten feedback when one behavior is changing. Coverage reports which statements ran; it does not show whether the assertions were meaningful. The race detector checks executed memory accesses for data races on supported platforms; this particular package has no concurrent state. See the race detector guide for platform and tool requirements.
For tests involving resources, register cleanup with t.Cleanup so failures still release them. Avoid real public network services in unit tests; use local fakes or httptest for HTTP behavior. Do not use time.Sleep as proof that a goroutine finished: coordinate with a channel, a WaitGroup, or an API designed for testing time-dependent work.
Exercise: make a regression visible
Temporarily change the upper-bound check from value > 10 to value >= 10, run the tests, and explain the failure. Restore the intended rule, then add cases for "-1" and " 3 ".
Solution guidance
The maximum case must fail during the intentional regression because ten is valid by contract. Restore the rule before continuing. Negative one should match ErrOutOfRange. Space-padded input should match strconv.ErrSyntax because this function does not trim whitespace. If you decide trimming is desirable later, change the documented contract and its tests together.
Previous: Concurrency and Context. Next: Build a Go HTTP API.

