better tests

This commit is contained in:
djmil 2026-05-04 22:11:53 +00:00
parent c737e7d6be
commit ea38cf5a5a
3 changed files with 163 additions and 103 deletions

View File

@ -8,25 +8,24 @@ import (
"gitea.djmil.dev/go/template/pkg/result" "gitea.djmil.dev/go/template/pkg/result"
) )
// parsePort wraps strconv.Atoi so callers can use the happy-path style. // parseHost is an example of a simple utility function, that validates a hostname.
func parsePort(s string) result.Expect[int] { func parseHost(s string) result.Expect[string] {
n, err := strconv.Atoi(s) if s == "" {
if err != nil { return result.Fail[string](errors.New("host must not be empty"))
return result.Fail[int](fmt.Errorf("parsePort: %w", err))
} }
if n < 1 || n > 65535 { return result.Ok(s)
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 // parsePort wraps strconv.Atoi so callers can use the happy-path style.
// step; if anything fails the panic unwinds to the nearest Catch. func parsePort(s string) result.Expect[int] {
func Example_happyPath() { port := result.Of(strconv.Atoi(s))
port := parsePort("8080").Expect("read port") if port.Err() != nil {
fmt.Println(port) return port
// Output: }
// 8080 if port.Value() < 1 || port.Value() > 65535 {
return result.Fail[int](fmt.Errorf("%d out of range", port.Value()))
}
return port
} }
// Example_errCheck shows checking the error without panicking — useful at the // Example_errCheck shows checking the error without panicking — useful at the
@ -34,59 +33,102 @@ func Example_happyPath() {
func Example_errCheck() { func Example_errCheck() {
r := parsePort("not-a-number") r := parsePort("not-a-number")
if r.Err() != nil { if r.Err() != nil {
fmt.Println("failed:", r.Err()) fmt.Println("parsePort failed:", r.Err())
} }
// Output: // Output:
// failed: parsePort: strconv.Atoi: parsing "not-a-number": invalid syntax // parsePort failed: strconv.Atoi: parsing "not-a-number": invalid syntax
} }
// Example_of shows wrapping an existing (value, error) function with result.Of. // Example_happyPath shows the basic happy-path pattern: call Expect at each
func Example_of() { // step; if anything fails the panic unwinds to the nearest Catch.
port := result.Of(strconv.Atoi("9090")).Expect("parse port") func Example_happyPath() {
fmt.Println(port) host := parseHost("localhost").Expect("read host")
fmt.Println(host)
// Output: // Output:
// 9090 // localhost
} }
// Example_go shows result.Go running a closure in a goroutine and returning // Example_catch shows the failure path: when Expect exits the goroutine,
// a typed Expect[T] value — the happy-path result or a collected error. // defer Catch at the entry point converts it into a normal error return.
func Example_go() { func Example_catch() {
port := result.Go(func() int { loadHost := func() (err error) {
return parsePort("8080").Expect("parse port") defer result.Catch(&err)
host := parseHost("").Expect("read host")
fmt.Println(host)
return
}
if err := loadHost(); err != nil {
fmt.Println("caught:", err)
}
// Output:
// caught: read host: host must not be empty
}
// getURL is an example of business logic implementation with emphasis on happy-path.
func getURL(host, port string) string {
mHost := parseHost(host).Expect("parse host")
mPort := parsePort(port).Expect("parse port")
return fmt.Sprintf("http://%s:%d", mHost, mPort)
}
// Example_run shows result.Run as a lightweight synchronous boundary — a concise
// alternative to defer Catch when no named return is needed. Expect calls may be
// chained freely inside.
func Example_run() {
err := result.Run(func() {
url := getURL("localhost", "8080")
fmt.Println(url)
}) })
fmt.Println(port.Expect("main")) if err != nil {
// Output:
// 8080
}
// Example_goError shows result.Go collecting an error from a failed Expect
// call inside the goroutine.
func Example_goError() {
port := result.Go(func() int {
return parsePort("99999").Expect("parse port")
})
if err := port.Err(); err != nil {
fmt.Println("failed:", err) fmt.Println("failed:", err)
} }
// Output: // Output:
// failed: parse port: parsePort: 99999 out of range // http://localhost:8080
} }
// Example_catch shows the boundary pattern: result.Run collects any // Example_runtimeError shows that Expectf annotates the error message with the
// Expect/Expectf failure anywhere in the call stack as a normal error. // formatted context, just like Expect does.
func Example_catch() { //
// NOTE: Genuine runtime panics (nil-deref, index out of bounds) are NOT collected,
// they propagate and crash the program, as they should.
func Example_runtimeError() {
err := result.Run(func() { err := result.Run(func() {
// Simulate a deep call stack: this exits the goroutine because the port is invalid. _ = parsePort("99999").Expectf("arg %d port value", 2)
_ = parsePort("99999").Expect("load config port")
}) })
if err != nil { if err != nil {
fmt.Println("caught:", err) fmt.Println("caught:", err)
} }
// Output: // Output:
// caught: load config port: parsePort: 99999 out of range // caught: arg 2 port value: 99999 out of range
}
// Example_go is like result.Run but returns a typed Expect[T] so the computed
// value can be retrieved. Any failure is collected and surfaced on that Expect.
func Example_go() {
url := result.Go(func() string {
return getURL("localhost", "8080")
})
fmt.Println(url.Expect("get url"))
// Output:
// http://localhost:8080
}
// Example_goError shows result.Go collecting an error when one of the chained
// Expect calls inside the goroutine fails.
func Example_goError() {
url := result.Go(func() string {
return getURL("localhost", "99999")
})
if err := url.Err(); err != nil {
fmt.Println("failed:", err)
}
// Output:
// failed: parse port: 99999 out of range
} }
// Example_unwrap shows re-joining the normal Go (value, error) world at a // Example_unwrap shows re-joining the normal Go (value, error) world at a
@ -101,58 +143,3 @@ func Example_unwrap() {
// Output: // Output:
// 443 // 443
} }
// Example_mustCollected shows that Must() panics (produced by this package)
// are collected by Go/Run as normal errors, just like Expect failures.
// Genuine runtime panics (nil-deref, index out of bounds) are NOT collected —
// they propagate and crash the program, as they should.
func Example_mustCollected() {
err := result.Run(func() {
result.Fail[int](errors.New("unrecoverable")).Must()
})
if err != nil {
fmt.Println("collected:", err)
}
// Output:
// collected: unrecoverable
}
// Example_expectf shows Expectf for context messages that include runtime
// values — equivalent to Expect(fmt.Sprintf(...)) but more concise.
func Example_expectf() {
port := parsePort("3000").Expectf("read port from arg %d", 1)
fmt.Println(port)
// Output:
// 3000
}
// Example_expectfError shows that Expectf annotates the error message with the
// formatted context, just like Expect does.
func Example_expectfError() {
err := result.Run(func() {
_ = parsePort("99999").Expectf("arg %d port value", 2)
})
if err != nil {
fmt.Println("caught:", err)
}
// Output:
// caught: arg 2 port value: parsePort: 99999 out of range
}
// 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
}

67
pkg/result/panic_test.go Normal file
View File

@ -0,0 +1,67 @@
package result_test
import (
"errors"
"os"
"os/exec"
"strings"
"testing"
"gitea.djmil.dev/go/template/pkg/result"
)
// TestMustCollected verifies that Must() failures are collected by Run as
// normal errors, not left as unhandled panics. By contrast, genuine runtime
// panics (nil-deref, index out of bounds, etc.) are NOT collected — they
// propagate and crash the program. That behavior cannot be unit-tested
// without a subprocess, so it is only documented here.
func TestMustCollected(t *testing.T) {
err := result.Run(func() {
result.Fail[int](errors.New("unrecoverable")).Must()
})
if err == nil {
t.Fatal("expected error, got nil")
}
if err.Error() != "unrecoverable" {
t.Fatalf("unexpected error: %v", err)
}
}
// TestGenuineRuntimePanicPropagates verifies that genuine runtime panics
// (nil-deref, index out of bounds, etc.) are NOT collected by Go/Run — they
// crash the program. Tested via subprocess so the crash doesn't kill the suite.
func TestGenuineRuntimePanicPropagates(t *testing.T) {
if os.Getenv("RESULT_TEST_CRASH") == "1" {
_ = result.Run(func() {
panic("genuine runtime panic") // not a *stackError, so Run must not collect it
})
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestGenuineRuntimePanicPropagates")
cmd.Env = append(os.Environ(), "RESULT_TEST_CRASH=1")
if cmd.Run() == nil {
t.Fatal("expected subprocess to crash, but it exited cleanly")
}
}
// TestStackTrace verifies that StackTrace returns a non-empty string containing
// the call site of the failing Expect call.
func TestStackTrace(t *testing.T) {
err := result.Run(func() {
parsePort("not-a-number").Expect("parse port")
})
if err == nil {
t.Fatal("expected error, got nil")
}
trace := result.StackTrace(err)
if trace == "" {
t.Fatal("expected non-empty stack trace")
}
if !strings.Contains(trace, "panic_test.go") {
t.Fatalf("expected trace to reference panic_test.go, got:\n%s", trace)
}
}

View File

@ -61,6 +61,12 @@ func (r Expect[T]) Err() error {
return r.err return r.err
} }
// Value returns the underlying value, or the zero value of T if the Expect
// holds an error. Always check [Expect.Err] first.
func (r Expect[T]) Value() T {
return r.value
}
// Must returns the value or panics with the wrapped error and a stack trace. // Must returns the value or panics with the wrapped error and a stack trace.
// Use for genuine unrecoverable conditions where an immediate crash is correct. // Use for genuine unrecoverable conditions where an immediate crash is correct.
// For normal error propagation inside [Go] or [Run], use [Expect.Expect]. // For normal error propagation inside [Go] or [Run], use [Expect.Expect].