Skip to content
Testing

Testing

The testing packages provide facilities for exercising HTTP handlers, whole applications, and JSON responses.

handlertest

handlertest.New(ctx, t, handler) wraps any http.Handler in a fluent request builder. Requests run against httptest with a fresh DI context, so injected dependencies can be set up before each call and shared state is isolated per test:

handlertest.New(ctx, t, handler).
	Get("/users").
	AssertStatusOK()

The builder supports Get, Post, Put, Patch, and Delete, each with a JSON variant that sets Accept/Content-Type headers and encodes the body from a Go value. WithHeader(key, value) adds arbitrary headers.

HttpResult is the fluent assertion result. Status helpers cover exact matches and ranges; body helpers compare decoded JSON, including dotted-path lookups:

result methodeffect
AssertStatus(status)exact status code
AssertStatusOK()any 2xx-3xx status
AssertStatus2XX()/3XX/4XX/5XXstatus within the range
AssertStatusRange(min, max)status within bounds
AssertJSON(expected)whole body equals decoded value; expected is generic, so T is inferred from the argument
AssertJSONString(s)whole body equals a JSON string
AssertJSONContains(path, matcher)value at a.b.c satisfies a match matcher
Body()raw response bytes
handlertest.New(ctx, t, h).
	PostJSON("/users", createUserRequest{Name: "Ada"}).
	AssertStatus(http.StatusCreated).
	AssertJSONContains("user.email", match.Equal("[email protected]"))

kerneltest

kerneltest.TestKernel[T] boots a real kernel.Kernel with a salusaconfig config and routes requests through k.RootHandler(). Build it once with NewTestKernelFactory(kernel, config) and reuse per test; each call gets a fresh DI context and a bootstrapped kernel:

var newKernel = kerneltest.NewTestKernelFactory(buildKernel(), &config.Config{})

func TestUserGet(t *testing.T) {
	newKernel(t).
		GetJSON("/api/users/1").
		AssertStatus(http.StatusOK).
		AssertJSONContains("name", match.Equal("Ada"))
}

The same fluent verbs as the builder are forwarded: Get, GetJSON, Post, PostJSON, Put, PutJSON, Patch, PatchJSON, Delete, DeleteJSON. Because each call bootstraps the kernel with Bootstrap(ctx), providers and services register fresh for every test.

match

The match package defines the Matcher interface used for deferred assertions. A matcher reports whether a value matches and, on failure, a description of the mismatch, as a Result:

type Matcher interface {
	Matches(actual any) Result
}

type Result struct {
	Matches     bool
	Description string
}

Matchers compose freely:

matchereffect
Equal(expected)value is deeply equal to expected
Same(expected)value is the same pointer as expected
Len(length)value has the given length
Greater(value)value is greater than value
GreaterOrEqual(value)value is greater than or equal to value
Less(value)value is less than value
LessOrEqual(value)value is less than or equal to value
All(matchers...)satisfies every matcher

The comparison matchers are generic over any cmp.Ordered type — the numeric types and string — so the expected value’s type is inferred from the argument and the actual value is type-asserted to the same type. That makes them a natural fit for fields that are easier to bound than to pin exactly:

handlertest.New(ctx, t, h).
	Get("/users").
	AssertStatusOK().
	AssertJSONContains("count", match.Greater(float64(0))).
	AssertJSONContains("user.created_at", match.Less(float64(time.Now().Unix())))

cmp.Ordered covers only the basic ordered types, so a time.Time value has to be reduced to one of them — .Unix(), .UnixMilli(), or a string from Format — before it can be compared. A value that fails the type assertion simply does not match, so the expected value’s type has to match the decoded value’s exactly. JSON bodies are decoded without json.UseNumber, which means every JSON number surfaces as float64: comparing a JSON field against an int or int64 literal fails the assertion even when the numbers compare correctly.

To write a matcher that isn’t in the table, implement the Matcher interface or convert a plain function with MatcherFunc, and return a Result describing the mismatch when it does not hold:

func HasPrefix(prefix string) match.Matcher {
	return match.MatcherFunc(func(actual any) match.Result {
		s, ok := actual.(string)
		if !ok || !strings.HasPrefix(s, prefix) {
			return match.Result{
				Matches:     false,
				Description: fmt.Sprintf("%v is not prefixed with %q", actual, prefix),
			}
		}
		return match.Result{Matches: true}
	})
}

Assertion helpers such as AssertJSONContains take a Matcher instead of a raw value:

handlertest.New(ctx, t, h).
	Get("/users").
	AssertStatusOK().
	AssertJSONContains("users", match.Len(2)).
	AssertJSONContains("user.name", match.All(match.Len(3), match.Equal("Ada")))