better tests
This commit is contained in:
parent
c737e7d6be
commit
ea38cf5a5a
@ -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
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
|
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].
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user