testutil

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 13, 2026 License: MIT Imports: 12 Imported by: 0

Documentation

Overview

Package testutil provides helpers for testing Credo applications: building a hermetic test App, overriding dependencies with fakes, injecting config, and asserting on structured log output.

Building a test App

NewApp constructs a *credo.App that, unlike credo.New, never loads configuration from disk. It injects an empty config by default, registers a best-effort shutdown via tb.Cleanup, and leaves the container un-finalized so the test can add routes, providers, or overrides:

func TestPingHandler(t *testing.T) {
	app := testutil.NewApp(t)
	app.GET("/ping", pingHandler)

	rec := httptest.NewRecorder()
	app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/ping", nil))
	// ... assert on rec ...
}

Overriding dependencies

Use WithWiring to register the dependencies under test and WithOverride to swap any of them for a fake. Overrides run after wiring, so they win; WithOverride also adds a binding when none was wired. It is built on credo.App.Replace.

app := testutil.NewApp(t,
	testutil.WithWiring(func(app *credo.App) {
		app.MustProvide[*UserService](NewUserService)
		app.MustProvide[UserRepo](NewPostgresRepo)
	}),
	testutil.WithOverride[UserRepo](fakeRepo),
)
svc := app.MustResolve[*UserService]() // built with fakeRepo

Injecting config

WithConfig sets values at dotted key paths. Repeated calls merge into one document that is injected as the App's RawConfig:

app := testutil.NewApp(t,
	testutil.WithConfig("app.name", "checkout"),
	testutil.WithConfig("app.timeout", "5s"),
)

Asserting on logs

Wire a LogBuffer with WithLogBuffer to capture structured output, including the built-in request ID and access log records, then match records with LogBuffer.AssertHas:

buf := testutil.NewLogBuffer()
app := testutil.NewApp(t, testutil.WithLogBuffer(buf))
app.GET("/ping", pingHandler)

rec := httptest.NewRecorder()
app.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/ping", nil))

buf.AssertHas(t, testutil.LogEntry{
	Level:   "INFO",
	Message: "request completed",
	Attrs:   map[string]any{"method": "GET", "status": 200},
})

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func NewApp

func NewApp(tb testing.TB, opts ...Option) *credo.App

NewApp constructs a *credo.App for tests. Unlike credo.New, it never loads configuration from disk: by default it injects an empty RawConfig, so tests are hermetic. Provide values with WithConfig, wire dependencies with WithWiring, swap them with WithOverride, and capture logs with WithLogBuffer.

NewApp registers a best-effort graceful shutdown via tb.Cleanup. The App is not finalized, so tests may register additional routes, providers, or overrides, and may resolve services directly.

Types

type LogBuffer

type LogBuffer struct {
	// contains filtered or unexported fields
}

LogBuffer captures structured log output for assertions in tests. It implements io.Writer and feeds a slog JSON handler, so every attribute, group, and With-derived field (such as the request_id added by the built-in request middleware) is recorded exactly as slog renders it.

Wire a LogBuffer into a test App with WithLogBuffer:

buf := testutil.NewLogBuffer()
app := testutil.NewApp(t, testutil.WithLogBuffer(buf))
// ... exercise the app ...
buf.AssertHas(t, testutil.LogEntry{Level: "INFO", Message: "request completed"})
Example

ExampleLogBuffer captures structured log records and inspects them. A LogBuffer is usually wired into a test App with testutil.WithLogBuffer, but it works with any *slog.Logger. Records are inspected via Entries (here) or matched with AssertHas (which needs a *testing.T).

package main

import (
	"fmt"
	"log/slog"

	"github.com/credo-go/credo/testutil"
)

func main() {
	buf := testutil.NewLogBuffer()
	logger := slog.New(buf.Handler())

	logger.Info("user login", "user", "alice", "ok", true)
	logger.Warn("rate limited", "user", "bob")

	for _, e := range buf.Entries() {
		fmt.Printf("%s level=%s user=%s\n", e["msg"], e["level"], e["user"])
	}
}
Output:
user login level=INFO user=alice
rate limited level=WARN user=bob

func NewLogBuffer

func NewLogBuffer() *LogBuffer

NewLogBuffer returns an empty LogBuffer ready to be wired with WithLogBuffer.

func (*LogBuffer) AssertEmpty

func (b *LogBuffer) AssertEmpty(tb testing.TB)

AssertEmpty fails the test (via tb.Errorf) when any log records were captured at all. Useful after LogBuffer.Reset or for code paths that must stay silent.

func (*LogBuffer) AssertHas

func (b *LogBuffer) AssertHas(tb testing.TB, want LogEntry)

AssertHas fails the test (via tb.Errorf) unless at least one captured log record matches want. The matching rules are described on LogEntry. On failure it reports the wanted matcher and every captured record.

func (*LogBuffer) AssertNotHas

func (b *LogBuffer) AssertNotHas(tb testing.TB, want LogEntry)

AssertNotHas fails the test (via tb.Errorf) when at least one captured log record matches want — the negative counterpart of [AssertHas], for asserting that something was NOT logged (a skipped access log, a masked error detail). The matching rules are described on LogEntry.

func (*LogBuffer) Entries

func (b *LogBuffer) Entries() []map[string]any

Entries parses and returns every captured log record as a decoded JSON object, in the order written. Lines that fail to parse as JSON are skipped.

func (*LogBuffer) Handler

func (b *LogBuffer) Handler() slog.Handler

Handler returns a slog.Handler that writes JSON log records to the buffer at debug level, so all records are captured. It delegates to slog.NewJSONHandler, which keeps attribute, group, and WithAttrs semantics correct without a hand-written handler.

func (*LogBuffer) Reset

func (b *LogBuffer) Reset()

Reset discards all captured log records.

func (*LogBuffer) Write

func (b *LogBuffer) Write(p []byte) (int, error)

Write implements io.Writer. It is safe for concurrent use: slog handlers may be invoked from multiple goroutines serving requests.

type LogEntry

type LogEntry struct {
	// Level matches the slog "level" field case-insensitively (e.g. "INFO").
	Level string
	// Message matches the slog "msg" field exactly.
	Message string
	// Attrs matches as a subset; each key must be present with an equal value.
	Attrs map[string]any
}

LogEntry is a partial matcher for LogBuffer.AssertHas. Only the non-zero fields participate: an empty Level or Message is ignored, and Attrs matches as a subset (extra attributes on the actual record are allowed). Attribute values are compared after JSON normalization, so LogEntry{Attrs: {"status": 200}} matches a record whose status decoded as float64(200).

type Option

type Option func(*options)

Option configures a test App built by NewApp.

func WithConfig

func WithConfig(key string, val any) Option

WithConfig sets a single configuration value at a dotted key path (for example "server.port"). Repeated calls merge into one nested document that is injected as the App's RawConfig. Using WithConfig switches NewApp from its hermetic empty config to the real config loader.

func WithLogBuffer

func WithLogBuffer(buf *LogBuffer) Option

WithLogBuffer routes the App's logger to buf so tests can assert on structured log output, including the built-in request ID and access log records. Without this option the test App uses a silent logger.

func WithOverride

func WithOverride[T any](v T) Option

WithOverride replaces the binding for type T with value v via credo.App.Replace. Overrides run after WithWiring, making them the right tool for swapping a real dependency for a stub or fake. Because Replace adds the binding when it is absent, WithOverride works whether or not T was previously wired.

testutil.WithOverride[UserRepo](fakeRepo)

func WithWiring

func WithWiring(fns ...func(*credo.App)) Option

WithWiring registers functions that wire dependencies into the container (typically credo.App.Provide / credo.App.MustProvideValue calls). They run after the App is constructed but before any WithOverride, so an override can replace a binding established here.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL