Level 10 /

Testing

Before the line can open, the rail authority requires a full safety inspection. Every piece of track, every car, every switch, every signal. Gopher puts on the inspector uniform, picks up the clipboard, and walks the completed rail line checking every single piece.

Gopher says

Before any train leaves the station, it passes a safety inspection. In Go, that’s go test. No framework to install, no config files - just naming conventions and the testing package.

In baton-junction, every client method and builder function is testable in isolation - the Validate method, the display name logic, URL construction - all verifiable with a single go test command.

Test File Conventions

Test files live right next to the code they test - same directory, same package:

source filestest files
pkg/connector/
    connector.go
    connector_test.go
    users.go
    users_test.go

Two rules make this work:

  • Files ending in _test.go are only compiled by go test
  • Test functions start with Test and take *testing.T
keywordTest prefix (required)name of what you're testingtesting parameter
func TestMapUserStatus(t *testing.T) {
    ...
}

Table-Driven Tests

When you have many variations of the same behavior (different inputs, different expected outputs), repeating test functions gets tedious fast. Go’s table-driven tests solve this: instead of one function per scenario, define a table:

func TestMapUserStatus(t *testing.T) {
    tests := []struct {
        input string
        want  v2.UserTrait_Status_Status
    }{
        {"active", v2.UserTrait_Status_STATUS_ENABLED},
        {"disabled", v2.UserTrait_Status_STATUS_DISABLED},
        {"suspended", v2.UserTrait_Status_STATUS_DISABLED},
        {"deleted", v2.UserTrait_Status_STATUS_DELETED},
        {"unknown", v2.UserTrait_Status_STATUS_UNSPECIFIED},
        {"", v2.UserTrait_Status_STATUS_UNSPECIFIED},
    }

    for _, tt := range tests {
        t.Run(tt.input, func(t *testing.T) {
            got := mapUserStatus(tt.input)
            if got != tt.want {
                t.Errorf("mapUserStatus(%q) = %v, want %v",
                    tt.input, got, tt.want)
            }
        })
    }
}

Adding a test case is one line. Missing cases are visually obvious. t.Run creates named subtests so failures show TestMapUserStatus/active instead of just TestMapUserStatus.

t.Errorf vs t.Fatalf

When a test fails, you get to choose: collect all failures in one run, or stop at the first one. The choice matters for debugging. Here’s the distinction:

  • t.Errorf - marks failure, keeps running (collect all failures)
  • t.Fatalf - marks failure, stops immediately (prevents cascading panics)
r, err := resource.NewUserResource(...)
if err != nil {
    t.Fatalf("unexpected error: %v", err)  // r is nil, can't continue
}
if r.DisplayName != tt.wantDisplay {
    t.Errorf("DisplayName = %q, want %q", r.DisplayName, tt.wantDisplay)
}

Use Fatalf when setup fails. Use Errorf when checking individual fields.

Mock HTTP Servers

Your connector talks to external APIs, and you can’t hit production during tests. Mock HTTP servers let you simulate real API responses without leaving your machine - no network calls, no rate limits, no flakiness. The httptest package spins up real HTTP servers in your test process:

func TestGetGroups(t *testing.T) {
    mux := http.NewServeMux()
    mux.HandleFunc("/api/groups", func(w http.ResponseWriter, r *http.Request) {
        writeJSON(t, w, ListResponse[Group]{
            Data: []Group{{ID: "g1", Name: "Regional Pass"}},
        })
    })

    c := newTestClient(t, mux)
    groups, _, err := c.GetGroups(context.Background(), "")
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
    if groups[0].Name != "Regional Pass" {
        t.Errorf("expected Regional Pass, got %s", groups[0].Name)
    }
}

The test server catches bugs: JSON serialization errors, wrong URL paths, incorrect HTTP methods.

Running Tests

Run all tests in the project recursively with verbose output:

Run only the TestMapUserStatus/active subtest in the connector package:

Add at least 3 test cases to the tests table that cover the displayName fallback chain: full name, first name only, email fallback, and optionally the ID fallback.

Gopher says

Go testing is refreshingly simple: naming conventions replace configuration, tables replace duplication, and httptest catches HTTP bugs.

In baton-junction, we test the display name fallback chain, URL construction, error classification, and full HTTP round-trips - all with zero test framework dependencies.

Next: reading real connector code!

Explore the related connector code files

Next Lesson

Reading Connector Code

Continue