Search and navigate

Pages

Start HereRoadmapTutorialsCheatsheetInterview QuestionsResources

Published tutorials

Building a REST API with Go: The Complete TutorialGeneric Functions in Go: Writing Reusable CodeGo Benchmarking: Measuring Performance with testing.BGo Channels: Communication Between GoroutinesGo Constraints: comparable, Ordered and Custom SetsGo Context: Cancellation, Deadlines, and Request ValuesGo HTTP Client: Timeouts, JSON, and Reliable RequestsGo HTTP Middleware: Build a Production ChainGo Interview Questions: 25 Answers That Show UnderstandingGo Mutex: Protecting Shared State from Race ConditionsGo net/http: Build an HTTP Server from ScratchGo select Statement: Multiplexing Channels ExplainedGo Tools You Need for a Reliable Development LoopGo Tutorial: From First Program to Backend FoundationsGo Type Parameters: Syntax and Constraints ExplainedGo Worker Pool Pattern: Bounded Concurrency at ScaleGo's Type System Deep Dive: Static Typing Done RightGolang Compilation and Execution ExplainedGolang File Structure for Modules and ApplicationsGolang Hello World and Your First Go ProgramGolang IDEs: Choose an Editor for Your WorkflowGolang Interfaces: What They Are and How to Use ThemGoroutine Leaks in Go: How to Detect and Prevent ThemGoroutines in Go: Concurrency Without LeaksHow to Download and Install Golang SafelyHow to Learn Golang with a Focused Study PlanJSON in Go: Encoding, Decoding, and ValidationLearn Golang with a Practical Beginner Coursesync.WaitGroup in Go: Coordinating Multiple GoroutinesTable-Driven Tests in Go: The Idiomatic WayTesting in Go: Table Tests, HTTP, Fakes, and CoverageWhat Is Go and How Does a Go Program Work?Why Learn Golang and What Is Go Used For?Writing Unit Tests in Go: A Practical Guide
Open full search and filters →

Writing Unit Tests in Go: A Practical Guide

Write golang unit tests that catch bugs: test structure, error checks, table cases, fakes, injected clocks, and coverage, with real go test output.

Standard-library lessonGo requirement: Go 1.21+How tutorials are checked
Testing · Lesson 2Saved in this browser. No account required.
A focused software component passing through repeatable unit-test inputs and producing validated outputs
A unit test isolates one component and checks deterministic behavior across controlled inputs. Image: Golang Tutorial

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:

For fundamentals underneath all of this, the Go tutorial and the interfaces guide are the two most useful references while you build testable code.