result package
- usage tests as examples - test utils
This commit is contained in:
parent
0f75b279c3
commit
3403a8e168
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@ -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 ───────────────────────
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -36,6 +36,7 @@
|
||||
"makefile.configureOnOpen": false,
|
||||
"cSpell.words": [
|
||||
"djmil",
|
||||
"gitea"
|
||||
"gitea",
|
||||
"testutil"
|
||||
]
|
||||
}
|
||||
|
||||
15
CLAUDE.md
15
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/<domain>/`
|
||||
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/<domain>/` — 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
|
||||
|
||||
---
|
||||
|
||||
|
||||
20
README.md
20
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
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
118
examples/result/usage_test.go
Normal file
118
examples/result/usage_test.go
Normal file
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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!")
|
||||
}
|
||||
|
||||
44
internal/testutil/testutil.go
Normal file
44
internal/testutil/testutil.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
123
pkg/result/result.go
Normal file
123
pkg/result/result.go
Normal file
@ -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 }
|
||||
Loading…
Reference in New Issue
Block a user