better tests
This commit is contained in:
parent
c737e7d6be
commit
ea38cf5a5a
@ -8,25 +8,24 @@ import (
|
||||
"gitea.djmil.dev/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))
|
||||
// parseHost is an example of a simple utility function, that validates a hostname.
|
||||
func parseHost(s string) result.Expect[string] {
|
||||
if s == "" {
|
||||
return result.Fail[string](errors.New("host must not be empty"))
|
||||
}
|
||||
if n < 1 || n > 65535 {
|
||||
return result.Fail[int](fmt.Errorf("parsePort: %d out of range", n))
|
||||
}
|
||||
return result.Ok(n)
|
||||
return result.Ok(s)
|
||||
}
|
||||
|
||||
// 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
|
||||
// parsePort wraps strconv.Atoi so callers can use the happy-path style.
|
||||
func parsePort(s string) result.Expect[int] {
|
||||
port := result.Of(strconv.Atoi(s))
|
||||
if port.Err() != nil {
|
||||
return port
|
||||
}
|
||||
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
|
||||
@ -34,59 +33,102 @@ func Example_happyPath() {
|
||||
func Example_errCheck() {
|
||||
r := parsePort("not-a-number")
|
||||
if r.Err() != nil {
|
||||
fmt.Println("failed:", r.Err())
|
||||
fmt.Println("parsePort failed:", r.Err())
|
||||
}
|
||||
// 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.
|
||||
func Example_of() {
|
||||
port := result.Of(strconv.Atoi("9090")).Expect("parse port")
|
||||
fmt.Println(port)
|
||||
// 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() {
|
||||
host := parseHost("localhost").Expect("read host")
|
||||
fmt.Println(host)
|
||||
// Output:
|
||||
// 9090
|
||||
// localhost
|
||||
}
|
||||
|
||||
// Example_go shows result.Go running a closure in a goroutine and returning
|
||||
// a typed Expect[T] value — the happy-path result or a collected error.
|
||||
func Example_go() {
|
||||
port := result.Go(func() int {
|
||||
return parsePort("8080").Expect("parse port")
|
||||
// Example_catch shows the failure path: when Expect exits the goroutine,
|
||||
// defer Catch at the entry point converts it into a normal error return.
|
||||
func Example_catch() {
|
||||
loadHost := func() (err error) {
|
||||
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"))
|
||||
// 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 {
|
||||
if err != nil {
|
||||
fmt.Println("failed:", err)
|
||||
}
|
||||
// Output:
|
||||
// failed: parse port: parsePort: 99999 out of range
|
||||
// http://localhost:8080
|
||||
}
|
||||
|
||||
// Example_catch shows the boundary pattern: result.Run collects any
|
||||
// Expect/Expectf failure anywhere in the call stack as a normal error.
|
||||
func Example_catch() {
|
||||
// Example_runtimeError shows that Expectf annotates the error message with the
|
||||
// formatted context, just like Expect does.
|
||||
//
|
||||
// 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() {
|
||||
// Simulate a deep call stack: this exits the goroutine because the port is invalid.
|
||||
_ = parsePort("99999").Expect("load config port")
|
||||
_ = parsePort("99999").Expectf("arg %d port value", 2)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("caught:", err)
|
||||
}
|
||||
// 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
|
||||
@ -101,58 +143,3 @@ func Example_unwrap() {
|
||||
// Output:
|
||||
// 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
67
pkg/result/panic_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -61,6 +61,12 @@ func (r Expect[T]) Err() error {
|
||||
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.
|
||||
// Use for genuine unrecoverable conditions where an immediate crash is correct.
|
||||
// For normal error propagation inside [Go] or [Run], use [Expect.Expect].
|
||||
|
||||
Loading…
Reference in New Issue
Block a user