From 3403a8e168ddafb237bfed8619f4e9e82f2559a4 Mon Sep 17 00:00:00 2001 From: djmil Date: Wed, 1 Apr 2026 18:43:28 +0000 Subject: [PATCH] result package - usage tests as examples - test utils --- .vscode/launch.json | 2 +- .vscode/settings.json | 3 +- CLAUDE.md | 15 ++-- README.md | 20 +++-- cmd/app/main.go | 25 +++---- examples/result/usage_test.go | 118 +++++++++++++++++++++++++++++ internal/greeter/greeter.go | 12 +-- internal/greeter/greeter_test.go | 39 ++++------ internal/testutil/testutil.go | 44 +++++++++++ pkg/result/result.go | 123 +++++++++++++++++++++++++++++++ 10 files changed, 340 insertions(+), 61 deletions(-) create mode 100644 examples/result/usage_test.go create mode 100644 internal/testutil/testutil.go create mode 100644 pkg/result/result.go diff --git a/.vscode/launch.json b/.vscode/launch.json index 306789c..f0616c2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,7 @@ "request": "launch", "mode": "auto", "program": "${workspaceFolder}/cmd/app", - "args": ["-env", "dev", "-log-level", "debug"] + "args": ["-env", "dev", "-log-level", "debug", "-name", "Tester"] }, { // ── Debug: same as above but with delve attached ─────────────────────── diff --git a/.vscode/settings.json b/.vscode/settings.json index 4f31e21..90b4a2e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -36,6 +36,7 @@ "makefile.configureOnOpen": false, "cSpell.words": [ "djmil", - "gitea" + "gitea", + "testutil" ] } diff --git a/CLAUDE.md b/CLAUDE.md index 90d513c..d988f0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,8 +8,8 @@ Keep it concise — the agent needs signal, not essays. ## Project overview Go 1.25 template / PoC starter. Demonstrates: structured logging (slog), -config (flag), interfaces + manual fakes, linting (golangci-lint), -security scanning (gosec, govulncheck), git hooks, devcontainer, VSCode tasks. +config (flag), consumer-defined interfaces + manual fakes, result type (happy-path error handling), +linting (golangci-lint), security scanning (gosec, govulncheck), git hooks, devcontainer, VSCode tasks. Module: `gitea.djmil.dev/djmil/go-template` — update this when you fork. @@ -33,7 +33,7 @@ tools.go Tool version pinning (build tag: tools) - **Module imports** — always use the full module path `gitea.djmil.dev/djmil/go-template/...` - **Packages** — keep `cmd/` thin (wiring only); business logic belongs in `internal/` -- **Interfaces** — define interfaces where they are *used*, not where they are *implemented* +- **Types** — expose concrete types from constructors (`New(...) *Type`); never wrap in an interface at the implementation site. Consumers define their own interfaces if they need one (Go's implicit satisfaction makes this free) - **Errors** — wrap with `fmt.Errorf("context: %w", err)`; never swallow errors silently - **Logging** — use `log.WithField("key", val)` for structured context; never `fmt.Sprintf` in log messages; `log/slog` is the backend - **Config** — all configuration through `internal/config` (flag-parsed); no hard-coded values in logic packages @@ -85,10 +85,11 @@ Debug: use launch config "Debug: app" (F5). ## Adding new features (checklist) -1. Define the interface in `internal//` -2. Write the implementation and its unit tests (using a manual fake for the interface) -3. Wire it in `cmd/app/main.go` -4. Run `make lint test` before committing +1. Write the implementation in `internal//` — return a concrete `*Type`, no interface at the implementation site +2. In the *consumer* package (or `_test.go`), declare a minimal interface covering only the methods you call +3. Write unit tests using a manual fake that satisfies that interface +4. Wire the concrete type in `cmd/app/main.go` +5. Run `make lint test` before committing --- diff --git a/README.md b/README.md index b80312a..74281af 100644 --- a/README.md +++ b/README.md @@ -134,22 +134,28 @@ In development, `logger.NewDevelopment()` uses the human-friendly text handler. ## Testing -Tests use the standard `testing` package. For dependencies on interfaces, write -a manual fake inline in the test file — no code generation required: +Tests use the standard `testing` package. Concrete types are returned from +constructors — consumers (including tests) define their own minimal interfaces +and satisfy them with manual fakes. No code generation required. ```go -type fakeGreeter struct { - greetFn func(name string) (string, error) +// Interface declared in the consumer (or _test.go), not in greeter package. +type greeter interface { + Greet(name string) result.Expect[string] } -func (f *fakeGreeter) Greet(name string) (string, error) { +type fakeGreeter struct { + greetFn func(name string) result.Expect[string] +} + +func (f *fakeGreeter) Greet(name string) result.Expect[string] { return f.greetFn(name) } func TestSomething(t *testing.T) { fake := &fakeGreeter{ - greetFn: func(name string) (string, error) { - return "Hello, " + name + "!", nil + greetFn: func(name string) result.Expect[string] { + return result.Ok("Hello, " + name + "!") }, } // pass fake to the system under test diff --git a/cmd/app/main.go b/cmd/app/main.go index 899ce41..1a5651d 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -10,31 +10,31 @@ import ( "gitea.djmil.dev/djmil/go-template/internal/config" "gitea.djmil.dev/djmil/go-template/internal/greeter" "gitea.djmil.dev/djmil/go-template/internal/logger" + "gitea.djmil.dev/djmil/go-template/pkg/result" ) func main() { if err := run(); err != nil { fmt.Fprintf(os.Stderr, "fatal: %v\n", err) + if stack := result.StackTrace(err); stack != "" { + fmt.Fprintf(os.Stderr, "%s\n", stack) + } os.Exit(1) } } -func run() error { +func run() (err error) { + defer result.Catch(&err) + // ── Config ──────────────────────────────────────────────────────────────── cfg := config.Load() // ── Logger ──────────────────────────────────────────────────────────────── - var ( - log *logger.Logger - err error - ) + var log *logger.Logger if cfg.App.Env == "dev" { log = logger.NewDevelopment() } else { - log, err = logger.New(cfg.Logger.Level) - if err != nil { - return fmt.Errorf("creating logger: %w", err) - } + log = result.Of(logger.New(cfg.Logger.Level)).Expect("create logger") } log.WithFields(map[string]any{ @@ -43,13 +43,10 @@ func run() error { }).Info("starting up") // ── Services ────────────────────────────────────────────────────────────── - greetSvc := greeter.New(log) + var greetSvc greeter.Greeter = greeter.New(log) // ── Example usage ───────────────────────────────────────────────────────── - msg, err := greetSvc.Greet("Gopher") - if err != nil { - return fmt.Errorf("greeter: %w", err) - } + msg := greetSvc.Greet("").Expect("greeting") log.WithField("message", msg).Info("greeting complete") diff --git a/examples/result/usage_test.go b/examples/result/usage_test.go new file mode 100644 index 0000000..d7c3458 --- /dev/null +++ b/examples/result/usage_test.go @@ -0,0 +1,118 @@ +// Package result_example demonstrates typical usage patterns for pkg/result. +package result_example + +import ( + "errors" + "fmt" + "strconv" + + "gitea.djmil.dev/djmil/go-template/pkg/result" +) + +// parsePort wraps strconv.Atoi so callers can use the happy-path style. +func parsePort(s string) result.Expect[int] { + n, err := strconv.Atoi(s) + if err != nil { + return result.Fail[int](fmt.Errorf("parsePort: %w", err)) + } + if n < 1 || n > 65535 { + return result.Fail[int](fmt.Errorf("parsePort: %d out of range", n)) + } + return result.Ok(n) +} + +// Example_happyPath shows the basic happy-path pattern: call Expect at each +// step; if anything fails the panic unwinds to the nearest Catch. +func Example_happyPath() { + port := parsePort("8080").Expect("read port") + fmt.Println(port) + // Output: + // 8080 +} + +// Example_errCheck shows checking the error without panicking — useful at the +// outermost boundary where you want a normal error return. +func Example_errCheck() { + r := parsePort("not-a-number") + if r.Err() != nil { + fmt.Println("failed:", r.Err()) + } + // Output: + // failed: parsePort: strconv.Atoi: parsing "not-a-number": invalid syntax +} + +// Example_of shows wrapping an existing (value, error) function with result.Of. +func Example_of() { + port := result.Of(strconv.Atoi("9090")).Expect("parse port") + fmt.Println(port) + // Output: + // 9090 +} + +// Example_catch shows the boundary pattern: defer Catch at the entry point so +// any Expect/Must panic anywhere in the call stack is captured as a normal +// error return. +func Example_catch() { + run := func() (err error) { + defer result.Catch(&err) + // Simulate a deep call stack: this panics because the port is invalid. + _ = parsePort("99999").Expect("load config port") + return nil + } + + if err := run(); err != nil { + fmt.Println("caught:", err) + } + // Output: + // caught: load config port: parsePort: 99999 out of range +} + +// Example_unwrap shows re-joining the normal Go (value, error) world at a +// boundary where both values are needed separately. +func Example_unwrap() { + port, err := parsePort("443").Unwrap() + if err != nil { + fmt.Println("error:", err) + return + } + fmt.Println(port) + // Output: + // 443 +} + +// Example_nonErrorPanic shows that Catch does NOT swallow genuine runtime +// panics — only error panics produced by Must/Expect are captured. +func Example_nonErrorPanic() { + safeRun := func() (err error) { + defer func() { + // Outer recover catches the re-panic from Catch. + if v := recover(); v != nil { + err = fmt.Errorf("non-error panic: %v", v) + } + }() + defer result.Catch(&err) + panic("unexpected runtime problem") // not an error — Catch re-panics + } + + if err := safeRun(); err != nil { + fmt.Println(err) + } + // Output: + // non-error panic: unexpected runtime problem +} + +// Example_fail shows constructing a failed Expect explicitly, e.g. when a +// function detects an error condition before calling any fallible op. +func Example_fail() { + validate := func(name string) result.Expect[string] { + if name == "" { + return result.Fail[string](errors.New("name must not be empty")) + } + return result.Ok(name) + } + + r := validate("") + fmt.Println(r.Err()) + // Output: + // name must not be empty +} diff --git a/internal/greeter/greeter.go b/internal/greeter/greeter.go index 050eb44..c36e52e 100644 --- a/internal/greeter/greeter.go +++ b/internal/greeter/greeter.go @@ -8,15 +8,17 @@ package greeter import ( + "errors" "fmt" "gitea.djmil.dev/djmil/go-template/internal/logger" + "gitea.djmil.dev/djmil/go-template/pkg/result" ) // Greeter produces a greeting for a given name. // The interface is what other packages should depend on — never the concrete type. type Greeter interface { - Greet(name string) (string, error) + Greet(name string) result.Expect[string] } // Service is the concrete implementation. @@ -29,10 +31,10 @@ func New(log *logger.Logger) *Service { return &Service{log: log} } -// Greet returns a personalised greeting and logs the interaction. -func (s *Service) Greet(name string) (string, error) { +// Greet returns a personalized greeting and logs the interaction. +func (s *Service) Greet(name string) result.Expect[string] { if name == "" { - return "", fmt.Errorf("greeter: name must not be empty") + return result.Fail[string](errors.New("Greet: name must not be empty")) } msg := fmt.Sprintf("Hello, %s!", name) @@ -42,5 +44,5 @@ func (s *Service) Greet(name string) (string, error) { WithField("name", name). Info("greeting generated") - return msg, nil + return result.Ok(msg) } diff --git a/internal/greeter/greeter_test.go b/internal/greeter/greeter_test.go index 0cdf5fd..3fae222 100644 --- a/internal/greeter/greeter_test.go +++ b/internal/greeter/greeter_test.go @@ -1,11 +1,12 @@ package greeter_test import ( - "strings" "testing" "gitea.djmil.dev/djmil/go-template/internal/greeter" "gitea.djmil.dev/djmil/go-template/internal/logger" + "gitea.djmil.dev/djmil/go-template/internal/testutil" + "gitea.djmil.dev/djmil/go-template/pkg/result" ) // ── Service (unit tests) ────────────────────────────────────────────────────── @@ -14,23 +15,13 @@ func TestGreet(t *testing.T) { svc := greeter.New(logger.NewNop()) t.Run("returns personalized greeting", func(t *testing.T) { - msg, err := svc.Greet("World") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if msg != "Hello, World!" { - t.Errorf("got %q, want %q", msg, "Hello, World!") - } + msg, err := svc.Greet("World").Unwrap() + testutil.NoError(t, err) + testutil.Equal(t, msg, "Hello, World!") }) t.Run("rejects empty name", func(t *testing.T) { - _, err := svc.Greet("") - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "name must not be empty") { - t.Errorf("error %q does not contain %q", err.Error(), "name must not be empty") - } + testutil.ErrorContains(t, svc.Greet("").Err(), "name must not be empty") }) } @@ -39,25 +30,21 @@ func TestGreet(t *testing.T) { // No code generation required — just implement the interface directly. type fakeGreeter struct { - greetFn func(name string) (string, error) + greetFn func(name string) result.Expect[string] } -func (f *fakeGreeter) Greet(name string) (string, error) { +func (f *fakeGreeter) Greet(name string) result.Expect[string] { return f.greetFn(name) } func TestFakeUsageExample(t *testing.T) { fake := &fakeGreeter{ - greetFn: func(name string) (string, error) { - return "Hello, " + name + "!", nil + greetFn: func(name string) result.Expect[string] { + return result.Ok("Hello, " + name + "!") }, } - msg, err := fake.Greet("Alice") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if msg != "Hello, Alice!" { - t.Errorf("got %q, want %q", msg, "Hello, Alice!") - } + msg, err := fake.Greet("Alice").Unwrap() + testutil.NoError(t, err) + testutil.Equal(t, msg, "Hello, Alice!") } diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go new file mode 100644 index 0000000..2b77066 --- /dev/null +++ b/internal/testutil/testutil.go @@ -0,0 +1,44 @@ +// Package testutil provides lightweight test helpers to reduce boilerplate in +// table-driven tests. Import it from any _test.go file in this module. +// +// Every helper calls t.Helper() so failures are reported at the call site, not +// inside this package. +package testutil + +import ( + "strings" + "testing" +) + +// NoError fails the test immediately if err is not nil. +func NoError(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// Error fails the test if err is nil. +func Error(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected error, got nil") + } +} + +// ErrorContains fails the test if err is nil or its message does not contain substr. +func ErrorContains(t *testing.T, err error, substr string) { + t.Helper() + Error(t, err) + if !strings.Contains(err.Error(), substr) { + t.Errorf("error %q does not contain %q", err.Error(), substr) + } +} + +// Equal fails the test if got != want. +func Equal[T comparable](t *testing.T, got, want T) { + t.Helper() + if got != want { + t.Errorf("got %v, want %v", got, want) + } +} diff --git a/pkg/result/result.go b/pkg/result/result.go new file mode 100644 index 0000000..8b1f09f --- /dev/null +++ b/pkg/result/result.go @@ -0,0 +1,123 @@ +// Package result provides a generic Expect[T] type for happy-path-oriented code. +// +// The intended pattern is: +// +// 1. Deep call stacks write for the happy path, using Must/Expect to panic on +// unexpected errors rather than threading error returns through every frame. +// 2. The function that initiates a complex operation defers Catch, which +// recovers any Expect panic and returns it as a normal error. +// +// Stack traces are captured at the panic site (inside Expect/Must) and can be +// retrieved from the caught error via StackTrace. +// +// See the examples/ directory for complete usage patterns. +package result + +import ( + "errors" + "fmt" + "runtime/debug" +) + +// Expect holds either a value of type T or an error. +type Expect[T any] struct { + value T + err error +} + +// Ok wraps a successful value in an Expect. +func Ok[T any](v T) Expect[T] { + return Expect[T]{value: v} +} + +// Fail wraps an error in an Expect. +func Fail[T any](err error) Expect[T] { + return Expect[T]{err: err} +} + +// Of is a convenience constructor that bridges standard Go (value, error) +// return signatures: +// +// result.Of(os.Open("file.txt")).Expect("open config") +func Of[T any](v T, err error) Expect[T] { + return Expect[T]{value: v, err: err} +} + +// Err returns the wrapped error, or nil on success. +func (r Expect[T]) Err() error { + return r.err +} + +// Must returns the value or panics with the wrapped error and a stack trace. +// Prefer [Expect.Expect] — it adds a message that makes the panic site easy to +// locate in logs. +func (r Expect[T]) Must() T { + if r.err != nil { + panic(&stackError{err: r.err, stack: debug.Stack()}) + } + return r.value +} + +// Expect returns the value or panics with the error annotated by msg and a +// stack trace captured at this call site. +// +// data := Parse(raw).Expect("parse user input") +func (r Expect[T]) Expect(msg string) T { + if r.err != nil { + panic(&stackError{ + err: fmt.Errorf("%s: %w", msg, r.err), + stack: debug.Stack(), + }) + } + return r.value +} + +// Unwrap returns the value and error in the standard Go (value, error) form. +// Useful at the boundary where you want to re-join normal error-return code. +func (r Expect[T]) Unwrap() (T, error) { + return r.value, r.err +} + +// Catch recovers a panic produced by [Expect.Must] or [Expect.Expect] and +// stores it in *errp. Call it via defer at the entry point of any function +// that runs a happy-path call stack: +// +// func run() (err error) { +// defer result.Catch(&err) +// ... +// } +// +// The stored error retains its stack trace; retrieve it with [StackTrace]. +// Panics that are not errors (e.g. nil-pointer dereferences) are re-panicked +// so genuine bugs are not silently swallowed. +func Catch(errp *error) { + v := recover() + if v == nil { + return + } + if err, ok := v.(error); ok { + *errp = err + return + } + panic(v) // not an error — let it propagate +} + +// StackTrace returns the stack trace captured when [Expect.Expect] or +// [Expect.Must] panicked. Returns an empty string if err was not produced by +// this package. +func StackTrace(err error) string { + var s *stackError + if errors.As(err, &s) { + return string(s.stack) + } + return "" +} + +// stackError wraps an error with a stack trace captured at the panic site. +type stackError struct { + err error + stack []byte +} + +func (s *stackError) Error() string { return s.err.Error() } +func (s *stackError) Unwrap() error { return s.err }