Course outline

Go / Intermediate: Packages and Concurrent Work / Lesson 8

Testing and Fuzzing Contracts

Derive meaningful checks from domain promises and use Go’s testing tools without confusing coverage with proof.

About 5 min readContent reviewed
On this page

Lesson goals

  • Design table-driven boundary and failure tests.
  • Use independent expected values and injected failures.
  • Explain fuzzing, examples, and race-detector limits.

Begin with the public promise

A useful test checks a contract, not merely that an implementation line executes. Derive cases from accepted domains, boundaries, failure behavior, and ownership. For a parser of counts zero through one hundred, include zero, one hundred, a negative number, one hundred one, empty text, and a valid prefix with an invalid suffix.

An independent oracle is essential. Repeating the implementation’s expression in every expected result can duplicate the same error. Use simple known cases, mathematical properties with justified domains, or a separate trusted strategy. Keep test names descriptive enough that a failing case identifies the violated promise.

A table makes boundary policy visible

The complete program below acts as a small self-checking runner. It checks explicit expected values for a clamp operation and prints success only when all cases agree. In a normal package, these cases belong in a file ending _test.go under a TestXxx function accepting *testing.T; t.Errorf reports a mismatch with context.

Table-driven structure avoids repetitive scaffolding but should not erase meaningful differences between cases. Include a clear name when cases become more complex. A passing suite samples behavior; it does not prove every input is correct or every goroutine interaction race-free.

go A deterministic table of boundary expectations
package main

import "fmt"

func clamp(n int) int {
	if n < 0 {
		return 0
	}
	if n > 100 {
		return 100
	}
	return n
}

func main() {
	cases := []struct{ input, want int }{{-1, 0}, {0, 0}, {100, 100}, {101, 100}}
	for _, test := range cases {
		if got := clamp(test.input); got != test.want {
			panic("clamp contract failed")
		}
	}
	fmt.Println("4 checks passed")
}
Expected output
4 checks passed

Four explicit input/result pairs establish this bounded demonstration’s oracle.

Exercise the failure path deliberately

Interfaces make resource failures testable without filling disks or breaking services. The second example supplies a writer that always returns a sentinel error. The helper returns that error, and errors.Is verifies the failure category. No filesystem damage or external service is required.

For partial I/O, also test the API’s allowed combinations of count and error. Verify the result state after failure, including unchanged-on-failure promises and cleanup. A test that merely accepts any non-nil error can miss the wrong cause or an error returned only after unintended state mutation.

go Inject a controlled writer failure
package main

import (
	"errors"
	"fmt"
	"io"
)

var errWrite = errors.New("synthetic write failure")

type failingWriter struct{}

func (failingWriter) Write(p []byte) (int, error) { return 0, errWrite }
func emit(w io.Writer) error                      { _, err := io.WriteString(w, "sample"); return err }

func main() {
	fmt.Println("expected failure", errors.Is(emit(failingWriter{}), errWrite))
}
Expected output
expected failure true

The returned sentinel is checked directly instead of causing a real resource failure.

Fuzz a bounded property

Go fuzzing explores many inputs from seed cases, retaining failures for reproduction. A fuzz target needs a meaningful property and limits on memory, time, and output size. A parser should not panic on arbitrary bounded bytes, but that alone does not prove it interprets valid records correctly. Add round-trip or reference checks only when their preconditions really hold.

Fuzzing support arrived before this course’s Go 1.22 baseline. Run a specific target with a deliberate fuzz duration in an isolated development environment, and retain the minimized regression case. Do not use fuzz inputs to contact real services or write uncontrolled paths. Deterministic unit tests should still cover known domain boundaries.

Interpret reports with their scope

go test runs tests, go vet performs selected static checks, and -race instruments supported executions for data races. Coverage reports execution reach, not semantic correctness. A high coverage percentage can coexist with weak assertions, missing failure cases, or a race on an unexecuted path. Use each report to identify an investigation rather than as a universal quality score.

Avoid timing-based concurrency tests that sleep and hope. Coordinate goroutines through channels or explicit barriers, and wait for completion. Use test cleanup for acquired resources, keep parallel tests from sharing mutable fixtures, and record compatibility requirements when adopting newer testing helpers unavailable in Go 1.22.

Common errors and fixes

A test passes while the wrong error is returned.

Why it happens
It only checks err != nil.
How to fix it
Assert the documented category and relevant state effects.
Prevent it next time
Use sentinel or typed error checks where promised.

A concurrent test fails only occasionally.

Why it happens
It relies on sleeping instead of a completion protocol.
How to fix it
Use channels or synchronization to establish the intended event ordering.
Prevent it next time
Make scheduling assumptions explicit and removable.

A fuzz target exhausts memory.

Why it happens
Input growth drives unbounded allocation or work.
How to fix it
Bound input size and the property’s resource requirements.
Prevent it next time
Treat fuzzing as hostile-input testing in an isolated scope.

Check your understanding

1. Choose tests for an append operation promising that a full bounded buffer remains unchanged.

Show worked solution

Test empty-with-space, one-before-full, exactly-full, and invalid input. Verify returned status plus logical length and meaningful content. For the full case, save the old content and compare afterward. This independently checks the unchanged-on-failure promise. If storage is shared, also check the documented effect on aliases rather than assuming the return value tells the whole story.

2. A parser has complete statement coverage but no test for 12x. Explain the gap and the regression to add.

Show worked solution

The test suite may execute the parsing branch using only fully valid text. Add a case requiring rejection of a numeric prefix followed by garbage, then assert the documented error and preserved state. Coverage cannot distinguish a complete-record parser from one that accepts a prefix unless the oracle checks that requirement. Retain the input as a deterministic regression.

Key takeaways

  • Tests need independent expectations tied to contracts.
  • Inject failures instead of damaging real resources.
  • Coverage, fuzzing, vet, and race reports provide complementary evidence with explicit limits.

Official references