Writing Unit Tests in Go: A Practical Guide
Reviewer’s note: This guide shows how to write unit tests in Go that hold up in a real codebase: how to structure a test, check error returns, isolate a unit with a fake, test time-dependent code, and read a coverage report. After reading you can test a component end to end, not just a single add function. Every example is compile-tested on Go 1.24.7 with the real output shown.
Works with Go 1.21+ (all examples tested on Go 1.24.7). errors.Is needs Go 1.13+. If you are new to the language, start with the complete Go tutorial; for the wider testing picture (integration, benchmarks, fuzzing) see the Go testing pillar.
What counts as a unit test in Go
A unit test exercises one piece of your code in isolation: a function, a method, a small component. It does not open a socket, hit a database, or read a file. It runs in microseconds, gives the same result every time, and fails only when your logic is wrong. That last property is what makes a test suite worth keeping. A test that flakes or needs a running Postgres is an integration test, and it belongs in integration tests, not here.
Go ships testing in the standard toolchain. There is no test runner to install, no framework to choose. You write functions in a _test.go file, and go test compiles and runs them. The whole mechanism is deliberately small, which is a design choice worth understanding before you fight it.
The testing package and your first Test function
A test lives in a file ending in _test.go, in the same package as the code it tests. It is a function named TestXxx that takes one argument, *testing.T. Here is a pure function and a test for it.
// discount.go
package basic
// applyPercentOff returns price reduced by percent (0-100).
func applyPercentOff(price int, percent int) int {
return price - price*percent/100
}
// discount_test.go
package basic
import "testing"
func TestApplyPercentOff(t *testing.T) {
got := applyPercentOff(1000, 20)
want := 800
if got != want {
t.Errorf("applyPercentOff(1000, 20) = %d, want %d", got, want)
}
}
Run it with go test:
PASS
ok example.com/shop/basic 0.002s
Three things are already worth naming. The test is in package basic, the same package as the code, so it can call the unexported applyPercentOff directly. The got/want naming is the near-universal Go convention: compute the actual value, name the expected value, compare. And the failure path uses t.Errorf, not panic and not a returned error. t.Errorf marks the test failed and keeps running; t.Fatalf marks it failed and stops the current test function immediately (use it when continuing would just crash on a nil value).
Arrange, act, assert, without an assertion library
Most Go tests follow arrange-act-assert: set up the inputs, call the thing, check the result. Go has no built-in assertEqual. That surprises people coming from JUnit or pytest, and it is intentional. The Go team’s position, stated in the testing package docs and the FAQ, is that a helper like assertEqual(t, got, want) hides what actually happened and encourages tests that print nothing useful when they break. Go pushes you to write the comparison and the message yourself so the failure explains itself.
That trade is real, and here is why it pays off. Change want to a wrong value and rerun:
--- FAIL: TestApplyPercentOff (0.00s)
discount_test.go:9: applyPercentOff(1000, 20) = 800, want 799
FAIL
The message tells you the inputs, the actual value, and the expected value, on the line where it failed. That is the entire point of writing t.Errorf by hand. A good failure message includes the input that produced the result, because six months from now you will be reading this line in CI with no other context. Write messages in the got X, want Y shape and include the arguments. Skip messages like t.Error("failed"): they tell you nothing you did not already know from the red output.
If you genuinely miss assertion helpers, the community standard is testify, but it needs a third-party import and it changes how failures read. For most code the standard library is enough, and staying with it keeps your tests dependency-free.
Testing error returns with the want-err pattern
Most real functions return (value, error). A unit test has to check both, and checking the error correctly is where a lot of test suites go wrong. Consider a coupon function that rejects an out-of-range percent:
// coupon.go
package coupon
import (
"errors"
"fmt"
)
// ErrInvalidPercent is returned when a percent is outside 0-100.
var ErrInvalidPercent = errors.New("percent out of range")
// discountedPrice applies percent off cents. percent must be 0-100.
func discountedPrice(cents, percent int) (int, error) {
if percent < 0 || percent > 100 {
return 0, fmt.Errorf("discount %d%%: %w", percent, ErrInvalidPercent)
}
return cents - cents*percent/100, nil
}
The function wraps a sentinel error with %w. Your test should assert on identity with errors.Is, never on the exact string. String matching breaks the moment someone adds context to the message. errors.Is walks the wrap chain and matches the underlying sentinel. If sentinels and %w are new to you, the error handling guide covers them in depth.
Table-driven cases and subtests with t.Run
Once you have more than two or three inputs, stop copy-pasting test functions. Put the cases in a slice of structs and loop. This is the dominant testing style in Go, including in the standard library itself. Combine it with t.Run so each case becomes a named subtest with its own pass/fail line.
// coupon_test.go
package coupon
import (
"errors"
"testing"
)
func TestDiscountedPrice(t *testing.T) {
tests := []struct {
name string
cents int
percent int
want int
wantErr error
}{
{name: "no discount", cents: 1000, percent: 0, want: 1000},
{name: "twenty percent", cents: 1000, percent: 20, want: 800},
{name: "full discount", cents: 1000, percent: 100, want: 0},
{name: "negative percent", cents: 1000, percent: -5, wantErr: ErrInvalidPercent},
{name: "over one hundred", cents: 1000, percent: 150, wantErr: ErrInvalidPercent},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := discountedPrice(tt.cents, tt.percent)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("discountedPrice(%d, %d) error = %v, want %v",
tt.cents, tt.percent, err, tt.wantErr)
}
if tt.wantErr == nil && got != tt.want {
t.Errorf("discountedPrice(%d, %d) = %d, want %d",
tt.cents, tt.percent, got, tt.want)
}
})
}
}
Running with -v shows every case by name:
=== RUN TestDiscountedPrice
=== RUN TestDiscountedPrice/no_discount
=== RUN TestDiscountedPrice/twenty_percent
=== RUN TestDiscountedPrice/full_discount
=== RUN TestDiscountedPrice/negative_percent
=== RUN TestDiscountedPrice/over_one_hundred
--- PASS: TestDiscountedPrice (0.00s)
--- PASS: TestDiscountedPrice/no_discount (0.00s)
--- PASS: TestDiscountedPrice/twenty_percent (0.00s)
--- PASS: TestDiscountedPrice/full_discount (0.00s)
--- PASS: TestDiscountedPrice/negative_percent (0.00s)
--- PASS: TestDiscountedPrice/over_one_hundred (0.00s)
PASS
ok example.com/shop/coupon 0.005s
The wantErr error field is the key trick for want-err: a happy-path case leaves it nil, and errors.Is(nil, nil) is true, so the same assertion handles both success and failure cases. When a subtest fails, the output names it (TestDiscountedPrice/over_one_hundred), and you can rerun just that one with go test -run TestDiscountedPrice/over_one_hundred. Table tests are the single highest-leverage habit in Go testing, and they get their own deep treatment in table-driven tests.
Isolating a unit by depending on an interface
Pure functions are easy. The hard part of unit testing is code that talks to the outside world: it sends email, writes to a queue, charges a card. You cannot unit-test that by actually sending email. The fix is to depend on an interface instead of a concrete client, then pass a fake in the test. This is the practical reason Go interfaces matter so much for testable code.
// signup.go
package signup
import "fmt"
// Mailer sends a message to an address. Production uses SMTP; tests use a fake.
type Mailer interface {
Send(to, body string) error
}
// RegisterUser validates an email and sends a welcome message.
func RegisterUser(m Mailer, email string) error {
if email == "" {
return fmt.Errorf("email is required")
}
return m.Send(email, "Welcome to the shop")
}
RegisterUser never names SMTP. It depends on the two-line Mailer interface. In the test, define a fake that records calls in memory:
// signup_test.go
package signup
import "testing"
// fakeMailer records calls instead of touching the network.
type fakeMailer struct {
sent []string
failWith error
}
func (f *fakeMailer) Send(to, body string) error {
if f.failWith != nil {
return f.failWith
}
f.sent = append(f.sent, to)
return nil
}
func TestRegisterUser_SendsWelcome(t *testing.T) {
mailer := &fakeMailer{}
err := RegisterUser(mailer, "[email protected]")
if err != nil {
t.Fatalf("RegisterUser returned error: %v", err)
}
if len(mailer.sent) != 1 || mailer.sent[0] != "[email protected]" {
t.Errorf("sent = %v, want one message to [email protected]", mailer.sent)
}
}
func TestRegisterUser_EmptyEmail(t *testing.T) {
mailer := &fakeMailer{}
err := RegisterUser(mailer, "")
if err == nil {
t.Fatal("expected error for empty email, got nil")
}
if len(mailer.sent) != 0 {
t.Errorf("no mail should be sent, got %v", mailer.sent)
}
}
=== RUN TestRegisterUser_SendsWelcome
--- PASS: TestRegisterUser_SendsWelcome (0.00s)
=== RUN TestRegisterUser_EmptyEmail
--- PASS: TestRegisterUser_EmptyEmail (0.00s)
PASS
ok example.com/shop/signup 0.002s
This hand-written fake is a few lines and needs no libraries. It also lets you assert on behavior: the second test confirms that an empty email sends no mail at all, which a return-value check alone would miss. For larger interfaces where hand-writing fakes gets tedious, generated mocks are the next step; see mocking in Go. Keep interfaces small so the fake stays small: a one- or two-method interface is trivial to fake, a ten-method one is a chore.
Testing time-dependent code by injecting a clock
Code that calls time.Now() directly is untestable, because the answer changes every run. Anything with an expiry, a timeout, or a “created less than an hour ago” check has this problem. The fix is the same shape as the mailer: stop reaching for the global, take the value as a parameter.
// token.go
package token
import "time"
// Session holds an expiry time.
type Session struct {
ExpiresAt time.Time
}
// IsExpired reports whether the session has expired as of now.
// now is injected so the check is testable without wall-clock time.
func (s Session) IsExpired(now time.Time) bool {
return now.After(s.ExpiresAt)
}
Because now is a parameter, the test picks a fixed instant and checks the boundaries exactly, including the tricky “exactly at expiry” case:
// token_test.go
package token
import (
"testing"
"time"
)
func TestSessionIsExpired(t *testing.T) {
expiry := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
s := Session{ExpiresAt: expiry}
tests := []struct {
name string
now time.Time
want bool
}{
{"before expiry", expiry.Add(-time.Minute), false},
{"exactly at expiry", expiry, false},
{"after expiry", expiry.Add(time.Second), true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := s.IsExpired(tt.now); got != tt.want {
t.Errorf("IsExpired(%v) = %v, want %v", tt.now, got, tt.want)
}
})
}
}
--- PASS: TestSessionIsExpired (0.00s)
--- PASS: TestSessionIsExpired/before_expiry (0.00s)
--- PASS: TestSessionIsExpired/exactly_at_expiry (0.00s)
--- PASS: TestSessionIsExpired/after_expiry (0.00s)
PASS
ok example.com/shop/token 0.003s
For anything more involved than a single check, define a Clock interface with a Now() time.Time method, pass a real clock in production and a fixed one in tests. Either way the rule is the same: a unit test never depends on what time it happens to be.
A fully tested component: order pricing with discounts
Layer everything together into something you would actually ship: a function that prices an order. It sums line items, applies an optional coupon, and adds shipping unless the subtotal clears a free-shipping threshold. It has real branches and real error paths.
// order.go
package order
import (
"errors"
"fmt"
)
var (
ErrEmptyOrder = errors.New("order has no line items")
ErrNegativeQty = errors.New("quantity cannot be negative")
ErrUnknownCoupon = errors.New("unknown coupon")
)
// LineItem is one product line in an order.
type LineItem struct {
Name string
UnitCents int
Quantity int
}
// coupons maps a code to a percent discount applied to the subtotal.
var coupons = map[string]int{
"WELCOME10": 10,
"HALFOFF": 50,
}
// OrderTotal computes the payable total in cents after an optional coupon.
// A subtotal of 5000 cents or more earns free shipping; otherwise add 500.
func OrderTotal(items []LineItem, coupon string) (int, error) {
if len(items) == 0 {
return 0, ErrEmptyOrder
}
subtotal := 0
for _, it := range items {
if it.Quantity < 0 {
return 0, fmt.Errorf("item %q: %w", it.Name, ErrNegativeQty)
}
subtotal += it.UnitCents * it.Quantity
}
if coupon != "" {
percent, ok := coupons[coupon]
if !ok {
return 0, fmt.Errorf("coupon %q: %w", coupon, ErrUnknownCoupon)
}
subtotal -= subtotal * percent / 100
}
shipping := 500
if subtotal >= 5000 {
shipping = 0
}
return subtotal + shipping, nil
}
The test table covers every branch: below and above the shipping threshold, both coupons, and all three error paths. Note the case where HALFOFF pushes the subtotal back under the threshold, so shipping reappears. That interaction between discount and shipping is exactly the kind of bug a single happy-path test never catches.
// order_test.go
package order
import (
"errors"
"testing"
)
func TestOrderTotal(t *testing.T) {
twoBooks := []LineItem{{Name: "Go book", UnitCents: 2000, Quantity: 2}}
tests := []struct {
name string
items []LineItem
coupon string
want int
wantErr error
}{
{
name: "under free-shipping threshold adds shipping",
items: []LineItem{{Name: "sticker", UnitCents: 300, Quantity: 1}},
want: 800, // 300 + 500 shipping
},
{
name: "over threshold ships free",
items: []LineItem{{Name: "keyboard", UnitCents: 6000, Quantity: 1}},
want: 6000,
},
{
name: "WELCOME10 takes ten percent",
items: twoBooks, // subtotal 4000
coupon: "WELCOME10",
want: 4100, // 3600 + 500 shipping
},
{
name: "HALFOFF drops below threshold so shipping returns",
items: twoBooks, // subtotal 4000 -> 2000
coupon: "HALFOFF",
want: 2500, // 2000 + 500
},
{
name: "empty order is rejected",
items: nil,
wantErr: ErrEmptyOrder,
},
{
name: "negative quantity is rejected",
items: []LineItem{{Name: "mug", UnitCents: 1200, Quantity: -1}},
wantErr: ErrNegativeQty,
},
{
name: "unknown coupon is rejected",
items: twoBooks,
coupon: "BOGUS",
wantErr: ErrUnknownCoupon,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := OrderTotal(tt.items, tt.coupon)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("OrderTotal() error = %v, want %v", err, tt.wantErr)
}
if tt.wantErr == nil && got != tt.want {
t.Errorf("OrderTotal() = %d, want %d", got, tt.want)
}
})
}
}
--- PASS: TestOrderTotal (0.00s)
--- PASS: TestOrderTotal/under_free-shipping_threshold_adds_shipping (0.00s)
--- PASS: TestOrderTotal/over_threshold_ships_free (0.00s)
--- PASS: TestOrderTotal/WELCOME10_takes_ten_percent (0.00s)
--- PASS: TestOrderTotal/HALFOFF_drops_below_threshold_so_shipping_returns (0.00s)
--- PASS: TestOrderTotal/empty_order_is_rejected (0.00s)
--- PASS: TestOrderTotal/negative_quantity_is_rejected (0.00s)
--- PASS: TestOrderTotal/unknown_coupon_is_rejected (0.00s)
PASS
ok example.com/shop/order 0.004s
Measuring coverage with go test -cover
Coverage tells you which lines your tests actually executed. Add the -cover flag:
ok example.com/shop/order coverage: 100.0% of statements
For a line-by-line view, write a profile and read it with the cover tool:
$ go test -coverprofile=cover.out ./...
$ go tool cover -func=cover.out
example.com/shop/order/order.go:29: OrderTotal 100.0%
total: (statements) 100.0%
Delete the negative-quantity and unknown-coupon cases and the number drops, which is the report doing its job:
example.com/shop/order/order.go:29: OrderTotal 87.5%
total: (statements) 87.5%
Run go tool cover -html=cover.out to open a browser view with untested lines highlighted in red. That red is where you go looking for missing cases. But read the number correctly: coverage measures which lines ran, not whether you asserted anything meaningful about them. You can hit 100 percent with tests that check nothing. Treat coverage as a way to find code you forgot to test, not as a score to maximize. Chasing 100 percent on trivial getters wastes time you could spend testing the branch that actually matters.
Keeping unit tests fast and deterministic
The value of a unit suite is that you run it constantly, so keep it fast and stable. Two rules cover most of it. No I/O: no network, no disk, no database, no sleeping. The examples above never touch any of that, which is why the whole suite finishes in a few milliseconds. And no shared mutable state between tests: each test builds its own inputs, like the fresh &fakeMailer{} in every case, so tests never leak into each other and can run with -race in parallel. When you do need a real database or HTTP round-trip, that is legitimate, but it goes in integration tests, kept separate so a slow, flaky external dependency never blocks the fast feedback loop.
Common mistakes
Hitting the network or a database in a unit test. The moment a test needs a live service, it stops being a unit test: it is slow, it fails when the service is down, and it fails in CI where the service does not exist. The fix is the interface-and-fake pattern from the signup example. Push the real client behind an interface and pass a fake.
Non-deterministic tests. A test that calls time.Now(), reads a random value, or depends on map iteration order will pass locally and fail at 2 a.m. in CI. Inject the clock (as in the session example), seed randomness explicitly, and sort before comparing. A test that fails intermittently is worse than no test, because the team learns to ignore red.
Testing the standard library instead of your own code. Writing a test that confirms strconv.Atoi("5") returns 5 verifies nothing about your program. Test your logic, the branch that decides shipping, the validation that rejects a bad coupon, not that Go’s own functions work.
Asserting on exact error strings. This one is common enough to show. Suppose a lookup wraps a sentinel:
// mistake.go
var ErrNotFound = errors.New("not found")
func lookup() error {
return fmt.Errorf("lookup user 42: %w", ErrNotFound)
}
A string comparison against "not found" fails the instant the message gains context, which it just did:
m_test.go:14: string compare missed it: "lookup user 42: not found"
errors.Is matches the wrapped sentinel regardless of the surrounding text and keeps passing:
if !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
Assert on error identity with errors.Is, or on error type with errors.As, never on the formatted string. The error handling guide explains why sentinels and wrapping exist in the first place.
What next
You now have a working unit-testing loop: structure, error checks, table cases, fakes, an injected clock, and coverage. Build outward from here:
- Table-driven tests: patterns for larger tables, shared setup, and parallel subtests.
- Mocking in Go: generating mocks when hand-written fakes get tedious.
- Integration tests: testing against real databases and HTTP servers, kept separate from your fast unit suite.
- The Go testing pillar: the full map of testing, benchmarks, and fuzzing.
For fundamentals underneath all of this, the Go tutorial and the interfaces guide are the two most useful references while you build testable code.
