Skip to content

Testing Error Handling

Patterns for testing code that produces *ewrap.Error values.

Asserting identity with errors.Is

Always test by identity, not by string match. ewrap respects the stdlib chain via Unwrap(), so errors.Is works through Wrap:

sentinel := errors.New("not found")

err := layered() // returns ewrap.Wrap(sentinel, "...")

if !errors.Is(err, sentinel) {
    t.Fatalf("expected sentinel in chain, got %v", err)
}

Strings are fragile and break the moment somebody adds a layer.

Asserting type with errors.As

For typed errors:

var ec *ewrap.Error
if !errors.As(err, &ec) {
    t.Fatal("expected an ewrap.Error in the chain")
}

if ec.GetErrorContext().Type != ewrap.ErrorTypeValidation {
    t.Errorf("expected validation type, got %v", ec.GetErrorContext().Type)
}

Asserting attached state

HTTPStatus, IsRetryable, and the typed accessors are the right tools in tests:

if got := ewrap.HTTPStatus(err); got != http.StatusBadGateway {
    t.Errorf("HTTP status: got %d, want %d", got, http.StatusBadGateway)
}

if !ewrap.IsRetryable(err) {
    t.Error("expected retryable error")
}

if rs := ec.Recovery(); rs == nil || rs.Message == "" {
    t.Error("expected non-empty recovery suggestion")
}

Checking metadata

val, ok := ec.GetMetadata("user_id")
if !ok || val != "u-1" {
    t.Errorf("user_id metadata: got %v (ok=%v)", val, ok)
}

// Or with type checking
if id, ok := ewrap.GetMetadataValue[string](ec, "user_id"); !ok || id != "u-1" {
    t.Errorf("user_id: got %q (ok=%v)", id, ok)
}

Test loggers

Don't reach for a mocking framework — implement the three-method interface inline. The test suite in this repo uses this pattern:

type recordingLogger struct {
    mu     sync.Mutex
    logs   []entry
    calls  map[string]int
}

type entry struct {
    Level string
    Msg   string
    Args  []any
}

func (l *recordingLogger) Error(msg string, kv ...any) {
    l.mu.Lock()
    defer l.mu.Unlock()
    l.logs = append(l.logs, entry{"error", msg, kv})
    l.calls["error"]++
}
// Debug, Info similarly

func TestSomethingLogsErrors(t *testing.T) {
    l := &recordingLogger{calls: map[string]int{}}
    err := callee(ewrap.WithLogger(l))
    err.Log()

    if l.calls["error"] != 1 {
        t.Errorf("expected 1 error log, got %d", l.calls["error"])
    }
}

Test observers

The breaker subpackage uses an analogous pattern:

type recordingObserver struct {
    mu          sync.Mutex
    transitions []transition
}

func (o *recordingObserver) RecordTransition(name string, from, to breaker.State) {
    o.mu.Lock()
    defer o.mu.Unlock()
    o.transitions = append(o.transitions, transition{name, from, to})
}

Same shape works for ewrap.Observer.RecordError.

Concurrency tests

Run race-detected concurrent stress tests on anything that holds shared state. ewrap's own suite includes:

func TestConcurrentMetadata(t *testing.T) {
    t.Parallel()

    err := ewrap.New("test")

    var wg sync.WaitGroup
    for i := range 100 {
        wg.Go(func() { _ = err.WithMetadata(fmt.Sprintf("k%d", i), i) })
        wg.Go(func() { _, _ = err.GetMetadata(fmt.Sprintf("k%d", i)) })
    }
    wg.Wait()
}

Run with go test -race ./... to catch real races.

Deep-chain tests

Verify your code survives long wrap chains — easy to construct:

func TestDeepChain(t *testing.T) {
    var err error = errors.New("root")
    for i := range 200 {
        err = ewrap.Wrap(err, fmt.Sprintf("layer-%d", i))
    }

    if !errors.Is(err, errors.New("root")) {
        // would fail because errors.New gives unique identity each call
    }
}

(Use a sentinel var instead of inline errors.New for the assertion.)

Fuzz tests

(*Error).ToJSON and Newf are good fuzz targets:

func FuzzJSONRoundTrip(f *testing.F) {
    for _, seed := range []string{"", "boom", strings.Repeat("a", 1024)} {
        f.Add(seed)
    }
    f.Fuzz(func(t *testing.T, msg string) {
        err := ewrap.New(msg)
        s, jerr := err.ToJSON()
        if jerr != nil {
            t.Fatalf("ToJSON: %v", jerr)
        }
        var out ewrap.ErrorOutput
        if err := json.Unmarshal([]byte(s), &out); err != nil {
            t.Fatalf("invalid JSON: %v", err)
        }
        if out.Message != msg {
            t.Errorf("round-trip lost data: got %q, want %q", out.Message, msg)
        }
    })
}

t.Parallel() is safe

ewrap is goroutine-safe by design — every test in this repo runs with t.Parallel(). Add it to your tests too unless they mutate global state (e.g. runtime.MemProfileRate, env vars).

Testing the breaker

Pin the timeout to something tiny so you don't wait around:

func TestBreakerOpens(t *testing.T) {
    t.Parallel()

    cb := breaker.New("test", 1, 10*time.Millisecond)

    cb.RecordFailure()
    if cb.State() != breaker.Open {
        t.Errorf("State: got %v, want Open", cb.State())
    }

    time.Sleep(15 * time.Millisecond)

    if !cb.CanExecute() {
        t.Error("expected breaker to allow execution after timeout")
    }
}

The transition observer fires synchronously, so you don't need to sleep to wait for callbacks.

Test fixtures

For shared sentinels and constants across a test suite, define them in a *_test.go helper file:

// test_helpers_test.go
package mypkg

import "errors"

const (
    msgValidation = "invalid input"
    msgNotFound   = "user not found"
)

var (
    errSentinel = errors.New("sentinel")
    errOther    = errors.New("other")
)

This pattern silences goconst and err113 linters while keeping tests readable.