Skip to content
JavaGo
Part VI · Building Services

Testing

The built-in test runner: table-driven tests, fakes instead of Mockito, httptest, the race detector, fuzzing, testing time with synctest, and benchmarks.

Section 26 of 31 9 min read

Go ships a test runner, coverage, benchmarks, fuzzing and a race detector in the toolchain itself. There is no JUnit, Surefire plugin or test framework to choose. Tests live next to the code in _test.go files and run with go test ./....

Writing and running tests

Java — JUnit 5
class MessageServiceTest {
    private MessageService svc;

    @BeforeEach
    void setUp() {
        svc = new MessageService(new InMemoryStore());
    }

    @Test
    void findById_returnsMessage() {
        assertEquals("hello", svc.findById(1L).getBody());
    }

    @Test
    void findById_throwsWhenMissing() {
        assertThrows(NotFoundException.class,
            () -> svc.findById(-1L));
    }
}
Go — the testing package
// message_test.go, in the same package
func TestFind(t *testing.T) {
    svc := NewService(newMemStore()) // setup is just code

    msg, err := svc.Find(t.Context(), "1") // t.Context: Go 1.24+
    if err != nil {
        t.Fatalf("Find: unexpected error: %v", err)
    }
    if msg.Body != "hello" {
        t.Errorf("Body = %q, want %q", msg.Body, "hello")
    }
}

func TestFindMissing(t *testing.T) {
    svc := NewService(newMemStore())
    _, err := svc.Find(t.Context(), "nope")
    if !errors.Is(err, ErrNotFound) {
        t.Errorf("err = %v, want ErrNotFound", err)
    }
}
  • t.Errorf records a failure and keeps going; t.Fatalf records it and stops this test. Prefer Errorf so one run shows every problem.
  • The message format Got = x, want y is the convention: say what was called, what came back, and what was expected.
  • go test ./... runs everything; go test -run TestFind ./internal/message runs one test; -v shows each test; -count=1 bypasses the test cache.

Table-driven tests

Java — @ParameterizedTest
@ParameterizedTest
@CsvSource({
    "positive, 1, 2, 3",
    "zeros,    0, 0, 0",
    "negative, -1, 1, 0",
})
void add(String name, int a, int b, int expected) {
    assertEquals(expected, Calculator.add(a, b));
}
Go — a slice of cases + t.Run
func TestAdd(t *testing.T) {
    tests := []struct {
        name    string
        a, b    int
        want    int
    }{
        {"positive", 1, 2, 3},
        {"zeros", 0, 0, 0},
        {"negative", -1, 1, 0},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            t.Parallel() // subtests may run concurrently
            if got := Add(tt.a, tt.b); got != tt.want {
                t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
            }
        })
    }
}
// go test -run 'TestAdd/zeros'

Each case becomes a named subtest

go test -run 'TestAdd/zeros'

Fig. 26.1A table-driven test is a slice of cases and a loop. Each row becomes a subtest named after its case, so failures point at the exact case, and -run can select one case by name; the others are not run at all.

Fakes instead of mocks

Java — Mockito
@Mock MessageStore store;
@InjectMocks MessageService svc;

@Test
void publishesMessage() {
    when(store.findById(1L)).thenReturn(new Message(1L, "hi"));

    svc.publish(1L);

    verify(store).markPublished(1L);
}
Go — a small interface and a fake
// The consumer's interface is small, so a fake is tiny
type fakeStore struct {
    messages  map[int64]Message
    published []int64
}

func (f *fakeStore) FindByID(_ context.Context, id int64) (Message, error) {
    m, ok := f.messages[id]
    if !ok {
        return Message{}, ErrNotFound
    }
    return m, nil
}

func (f *fakeStore) MarkPublished(_ context.Context, id int64) error {
    f.published = append(f.published, id)
    return nil
}

func TestPublish(t *testing.T) {
    store := &fakeStore{messages: map[int64]Message{1: {ID: 1}}}
    if err := NewService(store).Publish(t.Context(), 1); err != nil {
        t.Fatal(err)
    }
    if !slices.Equal(store.published, []int64{1}) {
        t.Errorf("published = %v, want [1]", store.published)
    }
}

Assertion libraries

Java — JUnit / AssertJ
assertEquals("hello", msg.getBody());
assertNotNull(msg);
assertTrue(msg.isActive());
assertThat(tags).containsExactly("a", "b");
Go — testify
import (
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

require.NoError(t, err)        // require: stops the test (Fatal)
assert.Equal(t, "hello", msg.Body) // assert: records, continues (Error)
assert.True(t, msg.Active)
assert.Equal(t, []string{"a", "b"}, tags)

Testing HTTP handlers

Java — MockMvc
@WebMvcTest(MessageController.class)
class MessageControllerTest {
    @Autowired MockMvc mvc;
    @MockBean MessageService service;

    @Test
    void getById() throws Exception {
        when(service.find("1"))
            .thenReturn(new Message("1", "hello"));
        mvc.perform(get("/api/messages/1"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.body").value("hello"));
    }
}
Go — httptest
func TestGetMessage(t *testing.T) {
    h := &Handler{messages: fakeFinder{"1": {ID: "1", Body: "hello"}}}

    // Route through a real mux so {id} is populated
    mux := http.NewServeMux()
    mux.HandleFunc("GET /api/messages/{id}", h.Get)

    req := httptest.NewRequest(http.MethodGet, "/api/messages/1", nil)
    rec := httptest.NewRecorder()
    mux.ServeHTTP(rec, req) // no server, no network

    if rec.Code != http.StatusOK {
        t.Fatalf("status = %d, want 200", rec.Code)
    }
    var got Message
    if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
        t.Fatal(err)
    }
    if got.Body != "hello" {
        t.Errorf("body = %q, want %q", got.Body, "hello")
    }
}

The race detector

go test -race: data races found at test time
go test -race ./...

# The compiler instruments every memory access. When two goroutines
# touch the same memory without synchronisation, and at least one of
# them writes, the test fails with both goroutines' stack traces.
#
# Costs roughly 2-20x CPU and 5-10x memory, so it is for tests and
# staging, not production. Run it in CI on every change.

Testing code that uses time (Go 1.25+)

testing/synctest: a fake clock for concurrent code
func TestDebounce(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        // Inside the bubble, time is fake: it only advances when
        // every goroutine in the bubble is blocked. Sleeps are instant.
        var calls atomic.Int32
        d := NewDebouncer(time.Second, func() { calls.Add(1) })

        d.Trigger()
        time.Sleep(500 * time.Millisecond)
        d.Trigger() // restarts the 1s quiet period

        time.Sleep(999 * time.Millisecond)
        synctest.Wait() // let timers and goroutines settle
        if n := calls.Load(); n != 0 {
            t.Fatalf("calls = %d before quiet period, want 0", n)
        }

        time.Sleep(time.Millisecond)
        synctest.Wait()
        if n := calls.Load(); n != 1 {
            t.Fatalf("calls = %d, want 1", n)
        }
    })
}

Fuzzing

built-in fuzzing (Go 1.18+)
func FuzzSlugify(f *testing.F) {
    f.Add("Hello, World") // seed corpus
    f.Add("")
    f.Fuzz(func(t *testing.T, s string) {
        got := Slugify(s)
        if strings.HasPrefix(got, "-") || strings.HasSuffix(got, "-") {
            t.Errorf("Slugify(%q) = %q: leading or trailing dash", s, got)
        }
    })
}

// go test runs the seeds as normal tests.
// go test -fuzz=FuzzSlugify generates new inputs until it finds a
// failure, then saves it under testdata/fuzz as a regression test.

Benchmarks

b.Loop (Go 1.24+) replaces the b.N loop
func BenchmarkSlugify(b *testing.B) {
    input := strings.Repeat("Hello, World ", 10) // setup: not timed
    for b.Loop() {
        Slugify(input)
    }
}

// go test -run '^$' -bench . -benchmem ./...
//
// BenchmarkSlugify-10   15649554   71.00 ns/op   24 B/op   1 allocs/op
//                    ^ GOMAXPROCS   ^ iterations  ^ time    ^ allocations
JavaGo
@Testfunc TestXxx(t *testing.T)
@ParameterizedTestTable-driven test + t.Run
@BeforeEach / @AfterEachSetup code in the test; t.Cleanup(func() { … })
@BeforeAll / @AfterAllfunc TestMain(m *testing.M)
@Disabled / assumeTruet.Skip("reason")
@Timeoutgo test -timeout 30s
@TempDirt.TempDir(), removed automatically
MockitoHand-written fakes behind small interfaces
MockMvc / WebTestClienthttptest.NewRecorder / httptest.NewServer
JaCoCogo test -cover, -coverprofile, go tool cover -html
JMHfunc BenchmarkXxx(b *testing.B)
jqwik / property-based testsfunc FuzzXxx(f *testing.F)
(nothing built in)go test -race: the race detector
↑ ↓ to navigateEnter to openEsc to close