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 ¶
- func NewApp(tb testing.TB, opts ...Option) *credo.App
- type LogBuffer
- func (b *LogBuffer) AssertEmpty(tb testing.TB)
- func (b *LogBuffer) AssertHas(tb testing.TB, want LogEntry)
- func (b *LogBuffer) AssertNotHas(tb testing.TB, want LogEntry)
- func (b *LogBuffer) Entries() []map[string]any
- func (b *LogBuffer) Handler() slog.Handler
- func (b *LogBuffer) Reset()
- func (b *LogBuffer) Write(p []byte) (int, error)
- type LogEntry
- type Option
Examples ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func NewApp ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
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 ¶
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 ¶
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 ¶
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 ¶
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.