result package

- usage tests as examples
- test utils
This commit is contained in:
djmil 2026-04-01 18:43:28 +00:00
parent 0f75b279c3
commit 3403a8e168
10 changed files with 340 additions and 61 deletions

2
.vscode/launch.json vendored
View File

@ -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

View File

@ -36,6 +36,7 @@
"makefile.configureOnOpen": false,
"cSpell.words": [
"djmil",
"gitea"
"gitea",
"testutil"
]
}

View File

@ -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
---

View File

@ -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

View File

@ -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")

View 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
}

View File

@ -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)
}

View File

@ -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!")
}

View 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
View 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 }