From eaba9082a1640898e0701ec973e2e45ca9c4c32b Mon Sep 17 00:00:00 2001 From: Joshua Smith Date: Fri, 23 Jan 2026 14:50:47 -0700 Subject: [PATCH 1/2] improve docs for AI context windows --- README.md | 136 +++++++++-------- docs/api.md | 112 ++++++++++++++ docs/comparers.md | 355 ++++++++++++++++++++++++++++++++++++++++++++ docs/examples.md | 364 ++++++++++++++++++++++++++++++++++++++++++++++ docs/helpers.md | 184 +++++++++++++++++++++++ go.mod | 6 +- go.sum | 4 +- test_case.md | 158 -------------------- 8 files changed, 1086 insertions(+), 233 deletions(-) create mode 100644 docs/api.md create mode 100644 docs/comparers.md create mode 100644 docs/examples.md create mode 100644 docs/helpers.md delete mode 100644 test_case.md diff --git a/README.md b/README.md index 2cc0550..24c340e 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,16 @@ # Trial - Prove the Innocence of your code -[![GoDoc](https://img.shields.io/badge/go-documentation-blue.svg?style=flat-square)](https://godoc.org/github.com/hydronica/trial) +[![GoDoc](https://img.shields.io/badge/go-documentation-blue.svg?style=flat-square)](https://pkg.go.dev/github.com/hydronica/trial) ![Build Status](https://github.com/hydronica/trial/actions/workflows/test.yml/badge.svg) -[![Go Report Card](https://goreportcard.com/badge/github.com/jbsmith7741/trial)](https://goreportcard.com/report/github.com/hydronica/trial) -[![codecov](https://codecov.io/gh/jbsmith7741/trial/branch/master/graph/badge.svg)](https://codecov.io/gh/hydronica/trial) +[![Go Report Card](https://goreportcard.com/badge/github.com/hydronica/trial)](https://goreportcard.com/report/github.com/hydronica/trial) +[![codecov](https://codecov.io/gh/hydronica/trial/branch/master/graph/badge.svg)](https://codecov.io/gh/hydronica/trial) Go testing framework to make tests easier to create, maintain and debug. +## Documentation -See [wiki](https://github.com/hydronica/trial/wiki) for tips and guides - -| [Examples](https://github.com/hydronica/trial/wiki) | [Compare Functions](https://github.com/hydronica/trial/wiki/Comparers) | [Helper Functions](https://github.com/hydronica/trial/wiki/Helpers) | -|-|-|-| +| [Examples & Patterns](docs/examples.md) | [API Reference](docs/api.md) | [Comparers](docs/comparers.md) | [Helpers](docs/helpers.md) | +|-|-|-|-| ## Philosophy @@ -29,7 +28,7 @@ See [wiki](https://github.com/hydronica/trial/wiki) for tips and guides - Test for observable behavior ## Features - - function verification. + - Function verification - Check results for exact matches including private values (default behavior) - Check results for values contained in others (see Contains function) - Allows for custom compare functions @@ -38,50 +37,50 @@ See [wiki](https://github.com/hydronica/trial/wiki) for tips and guides - check for expected panic cases with `ShouldPanic` - Test error cases - Check that a function returns an error: `ShouldErr` - - Check that an error strings contains expected string: `ExpectedErr` + - Check that an error string contains expected string: `ExpectedErr` - Check that an error is of expected type: `ExpectedErr: ErrType(err)` - Fail tests that take too long to complete - `trial.New(fn,cases).Timeout(time.Second)` +## Getting Started - -## Getting Starting - -``` go +```go go get github.com/hydronica/trial ``` Each test has 3 parts to it. A **function** to test against, a set of test **cases** to validate with and **trial** that sets up the table driven tests -### **Test Function** -``` go +### Test Function + +```go testFunc[In any, Out any] func(in In) (result Out, err error) ``` -a generic function that has a single input and returns a result and an error. Wrap your function to test more complex functions or methods. -*Example* -``` go - fn := func(i int) (string, error) { - return strconv.ItoA(i), nil - } +A generic function that has a single input and returns a result and an error. Wrap your function to test more complex functions or methods. +*Example* +```go +fn := func(i int) (string, error) { + return strconv.Itoa(i), nil +} ``` +### Cases -### **Cases** -a collection (map) of test cases that have a unique title and defined *input* to be passed to the test function. The expected behavior is described by providing an *output*, setting an *ExpectedErr* or saying it *ShouldErr*. *ShouldPanic* can be used to test when function panic condition. -``` go -type Case[In any, Out any] struct { - Input In - Expected Out +A collection (map) of test cases that have a unique title and defined *input* to be passed to the test function. The expected behavior is described by providing an *output*, setting an *ExpectedErr* or saying it *ShouldErr*. *ShouldPanic* can be used to test when function panic condition. +```go +type Case[In any, Out any] struct { + Input In + Expected Out - ShouldErr bool // is an error expected - ExpectedErr error // the error that was expected (nil is no error expected) - ShouldPanic bool // is a panic expected + ShouldErr bool // is an error expected + ExpectedErr error // the error that was expected (nil is no error expected) + ShouldPanic bool // is a panic expected } ``` -Each + +Each case field is described below: - **Input** *generic* - a convenience structure to handle input more dynamically - **Expected** *generic* - the expected output of the method being tested. @@ -93,26 +92,26 @@ Each - use *ErrType* to test that the error is the same type as expected. - **ShouldPanic** *bool* - indicates the method should panic - ### Trial Setup Run the test cases either within a single test function or as subtests. The *input* and *output* values must match between the test function and cases. -``` go +```go trial.New(fn,cases).Test(t) // or trial.New(fn,cases).SubTest(t) ``` -By default trial uses a strict matching values and uses cmp.Equal to compare values. *Compare* Functions can be customized to ignore certain fields or are contained withing maps, slices or strings. See **Compare Functions** for more details. A timeout can be added onto the trial builder with `.Timeout(time.Second)` +By default trial uses strict matching values and uses cmp.Equal to compare values. *Compare* functions can be customized to ignore certain fields or are contained within maps, slices or strings. See [Comparers](docs/comparers.md) for more details. A timeout can be added onto the trial builder with `.Timeout(time.Second)` ### Getting Started Template -``` go + +```go fn := func(in any) (any, error) { // TODO: setup test case, call routine and return result return nil, nil } -cases := trail.Cases{ +cases := trial.Cases[any, any]{ "default": { Input: 123, Expected: 1, @@ -121,62 +120,57 @@ cases := trail.Cases{ trial.New(fn,cases).Test(t) ``` -For more examples see the [wiki](https://github.com/hydronica/trial/wiki) +## Compare Functions -# Compare Functions -used to compare two values to determine equality and displayed a detailed string describing any differences. +Used to compare two values to determine equality and display a detailed string describing any differences. -``` go +```go func(actual, expected any) (equal bool, differences string) ``` -override the default +Override the default: -``` go +```go trial.New(fn, cases).Comparer(myComparer).Test(t) ``` -## Equal -The default comparer used, it is a wrapping for cmp.Equal with the AllowUnexported option set for all structs. This causes all fields (public and private) in a struct to be compared. (see https://github.com/google/go-cmp) +### Equal + +The default comparer used, it is a wrapper for cmp.Equal with the AllowUnexported option set for all structs. This causes all fields (public and private) in a struct to be compared. (see https://github.com/google/go-cmp) ### EqualOpt Customize the use of cmp.Equal with the following supported options: - `AllowAllUnexported` - **[default: Equal]** compare all unexported (private) variables within a struct. This is useful when testing a struct inside its own package. - `IgnoreAllUnexported` - ignore all unexported (private) variables within a struct. This is useful when dealing with a struct outside the project. - - `IgnoreFields(fields ...string)` - define a list of variables to exclude for the comparer, the field name are case sensitize and can be dot-delimited ("Field", "Parent.child") + - `IgnoreFields(fields ...string)` - define a list of variables to exclude for the comparer, the field names are case sensitive and can be dot-delimited ("Field", "Parent.child") - `EquateEmpty`- **[default: Equal]** a nil map or slice is equal to an empty one (len is zero) - `IgnoreTypes(values ...interface{})` - ignore all types of the values passed in. Ex: IgnoreTypes(int64(0), float32(0.0)) ignore int64 and float32 - - `ApproxTime(d time.Duration)` - approximates time values to to the nearest duration. + - `ApproxTime(d time.Duration)` - approximates time values to the nearest duration. -``` go +```go // example struct that is compared to expected type Example struct { - Field1 int - Field2 int - ignoreMe string // ignored unexported - Timestamp time.Time // ignored - LastUpdate time.Time // ignored + Field1 int + Field2 int + ignoreMe string // ignored unexported + Timestamp time.Time // ignored + LastUpdate time.Time // ignored } - trial.New(fn, cases).Comparer( - trial.EqualOpt( - trial.IgnoreAllUnexported, - trial.IgnoreFields("Timestamp", "LastUpdate")), - ).SubTest(t) +trial.New(fn, cases).Comparer( + trial.EqualOpt( + trial.IgnoreAllUnexported, + trial.IgnoreFields("Timestamp", "LastUpdate")), +).SubTest(t) ``` -## Contains ⊇ - -Checks if the expected value is *contained* in the actual value. The symbol ⊇ is used to donate a subset. ∈ is used to show that a value exists in a slice. Contains checks the following relationships - -- **string ⊇ string** - - is the expected string contained in the actual string (strings.Contains) -- **string ⊇ []string** - - are the expected substrings contained in the actual string -- **[]interface{} ⊇ interface{}** - - is the expected value found in the slice or array -- **[]interface{} ⊇ []interface{}** - - is the expected slice a subset of the actual slice. all values in expected exist and are contained in actual. -- **map[key]interface{} ⊇ map[key]interface{}** - - is the expected map a subset of the actual map. all keys in expected are in actual and all values under that key are contained in actual +### Contains ⊇ + +Checks if the expected value is *contained* in the actual value. The symbol ⊇ is used to denote a subset. ∈ is used to show that a value exists in a slice. Contains checks the following relationships: + +- **string ⊇ string** - is the expected string contained in the actual string (strings.Contains) +- **string ⊇ []string** - are the expected substrings contained in the actual string +- **[]interface{} ⊇ interface{}** - is the expected value found in the slice or array +- **[]interface{} ⊇ []interface{}** - is the expected slice a subset of the actual slice. all values in expected exist and are contained in actual. +- **map[key]interface{} ⊇ map[key]interface{}** - is the expected map a subset of the actual map. all keys in expected are in actual and all values under that key are contained in actual diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..76a29f5 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,112 @@ +# Trial API Reference + +Quick reference for the `hydronica/trial` testing framework. See linked docs for detailed explanations and examples. + +## Core Types + +```go +// Main test runner +type Trial[In any, Out any] struct { /* ... */ } + +// Map of named test cases +type Cases[In any, Out any] map[string]Case[In, Out] + +// Single test case +type Case[In any, Out any] struct { + Input In + Expected Out + ShouldErr bool // expect any error + ExpectedErr error // expect error containing this string (or use ErrType for type check) + ShouldPanic bool // expect panic +} + +// Dynamic input wrapper (use with Args) +type Input struct { /* ... */ } + +// Custom comparer signature +type CompareFunc func(actual, expected interface{}) (equal bool, differences string) +``` + +## Core Functions + +| Function | Description | +|----------|-------------| +| `New(fn, cases)` | Create Trial instance | +| `ErrType(err)` | Wrap error for type-based comparison | + +## Trial Methods + +| Method | Description | +|--------|-------------| +| `.Test(t)` | Run all cases in single test | +| `.SubTest(t)` | Run each case as subtest (recommended) | +| `.Comparer(fn)` | Set custom comparison function | +| `.Timeout(d)` | Set max duration per case | + +## Case Fields + +| Field | Type | Description | +|-------|------|-------------| +| `Input` | `In` | Value passed to test function | +| `Expected` | `Out` | Expected return value | +| `ShouldErr` | `bool` | Expect any error | +| `ExpectedErr` | `error` | Expect error containing string (uses `strings.Contains`) | +| `ShouldPanic` | `bool` | Expect panic | + +**Notes:** +- `ExpectedErr` implies `ShouldErr`, no need to set both +- Use `ErrType(MyError{})` with `ExpectedErr` to check error types + +## Input Methods + +For use with `trial.Args()`. See [helpers.md](helpers.md#input-helpers) for details. + +| Method | Return | Description | +|--------|--------|-------------| +| `String()` | `string` | Get as string | +| `Int()` | `int` | Get as int (parses strings) | +| `Uint()` | `uint` | Get as uint | +| `Float64()` | `float64` | Get as float64 | +| `Bool()` | `bool` | Get as bool | +| `Slice(i)` | `Input` | Get element at index | +| `Map(key)` | `Input` | Get value for key | +| `Interface()` | `interface{}` | Get raw value | + +## Comparers + +See [comparers.md](comparers.md) for detailed documentation. + +| Comparer | Description | +|----------|-------------| +| `Equal` | Default. Strict equality via `cmp.Equal` | +| `Contains` | Subset/substring matching | +| `EqualOpt(opts...)` | Customizable equality | +| `CmpFuncs` | Compare function pointers | + +### EqualOpt Options + +| Option | Description | +|--------|-------------| +| `AllowAllUnexported` | Compare private fields (default in `Equal`) | +| `IgnoreAllUnexported` | Skip private fields | +| `IgnoreFields(names...)` | Skip specific fields | +| `IgnoreTypes(values...)` | Skip specific types | +| `ApproxTime(d)` | Fuzzy time comparison | +| `EquateEmpty` | nil == empty slice/map (default in `Equal`) | + +## Helpers + +See [helpers.md](helpers.md) for detailed documentation. + +| Function | Description | +|----------|-------------| +| `Args(values...)` | Create Input from multiple args | +| `Pointer[T](v)` | Create pointer to primitive | +| `Time(layout, value)` | Parse time (panics on error) | +| `Day(value)` | Parse `2006-01-02` | +| `Hour(value)` | Parse `2006-01-02T15` | +| `Times(layout, values...)` | Parse multiple times | +| `TimeP(layout, value)` | Parse time, return pointer | +| `CaptureLog()` | Capture log output | +| `CaptureStdOut()` | Capture stdout | +| `CaptureStdErr()` | Capture stderr | diff --git a/docs/comparers.md b/docs/comparers.md new file mode 100644 index 0000000..c44d1a5 --- /dev/null +++ b/docs/comparers.md @@ -0,0 +1,355 @@ +# Comparers + +Comparers determine how trial checks equality between actual and expected values. This guide covers all available comparers and their options. + +## Table of Contents + +- [Overview](#overview) +- [Equal (Default)](#equal-default) +- [EqualOpt](#equalopt) +- [Contains](#contains) +- [CmpFuncs](#cmpfuncs) +- [Custom Comparers](#custom-comparers) + +--- + +## Overview + +A comparer is a function with this signature: + +```go +type CompareFunc func(actual, expected interface{}) (equal bool, differences string) +``` + +- `equal` - Returns `true` if values match +- `differences` - Human-readable string describing differences (used in failure messages) + +Set a custom comparer using the `Comparer()` method: + +```go +trial.New(fn, cases).Comparer(myComparer).Test(t) +``` + +--- + +## Equal (Default) + +The default comparer. Wraps `cmp.Equal` from [google/go-cmp](https://github.com/google/go-cmp) with these defaults: + +- **AllowAllUnexported** - Compares all fields, including private (unexported) ones +- **EquateEmpty** - Treats `nil` and empty slices/maps as equal + +```go +func Equal(actual, expected interface{}) (bool, string) +``` + +**When to use:** Most cases. Strict equality checking. + +**Example:** +```go +// Default behavior - no need to specify +trial.New(fn, cases).Test(t) + +// Or explicitly: +trial.New(fn, cases).Comparer(trial.Equal).Test(t) +``` + +--- + +## EqualOpt + +Customizable equality comparer. Build your own comparison logic by combining options. + +```go +func EqualOpt(optFns ...func(i interface{}) cmp.Option) func(actual, expected interface{}) (bool, string) +``` + +### Available Options + +#### AllowAllUnexported + +Compare all unexported (private) fields in structs. This is the default behavior in `Equal`. + +```go +trial.EqualOpt(trial.AllowAllUnexported) +``` + +**Use case:** Testing structs within the same package where you have access to private fields. + +#### IgnoreAllUnexported + +Ignore all unexported (private) fields in structs. + +```go +trial.EqualOpt(trial.IgnoreAllUnexported) +``` + +**Use case:** Testing against structs from external packages where private fields may change. + +#### IgnoreFields + +Exclude specific fields from comparison by name. + +```go +func IgnoreFields(f ...string) func(interface{}) cmp.Option +``` + +- Field names are **case-sensitive** +- Supports dot notation for nested fields: `"Parent.child"` + +```go +trial.EqualOpt( + trial.IgnoreFields("ID", "CreatedAt", "UpdatedAt"), +) +``` + +**Use case:** Ignoring auto-generated fields like timestamps or IDs. + +#### IgnoreTypes + +Ignore all values of specified types. + +```go +func IgnoreTypes(types ...interface{}) func(interface{}) cmp.Option +``` + +```go +trial.EqualOpt( + trial.IgnoreTypes(time.Time{}, uuid.UUID{}), +) +``` + +**Use case:** Ignoring all time fields without listing each one. + +#### ApproxTime + +Consider time values equal if they differ by less than the specified duration. + +```go +func ApproxTime(d time.Duration) func(interface{}) cmp.Option +``` + +```go +trial.EqualOpt( + trial.ApproxTime(time.Second), +) +``` + +**Use case:** Comparing times that may differ by small amounts due to execution time. + +#### EquateEmpty + +Treat `nil` and empty slices/maps as equal. This is the default behavior in `Equal`. + +```go +trial.EqualOpt(trial.EquateEmpty) +``` + +**Use case:** When `nil` vs empty slice distinction doesn't matter. + +### Combining Options + +Options can be combined: + +```go +type User struct { + ID int + Name string + email string // private + CreatedAt time.Time + UpdatedAt time.Time +} + +trial.New(fn, cases).Comparer( + trial.EqualOpt( + trial.IgnoreAllUnexported, + trial.IgnoreFields("ID", "CreatedAt", "UpdatedAt"), + ), +).SubTest(t) +``` + +--- + +## Contains + +Subset matching comparer. Checks if the expected value is **contained in** the actual value. + +```go +func Contains(x, y interface{}) (bool, string) +``` + +### Symbols + +- `⊇` - Superset (actual contains expected) +- `∈` - Element of (value exists in slice) + +### Supported Comparisons + +#### String Contains String + +Checks if expected string is a substring of actual. + +```go +actual := "Hello, World!" +expected := "World" +// Passes: "Hello, World!" contains "World" +``` + +#### String Contains []String + +Checks if all expected substrings are in the actual string. + +```go +actual := "The quick brown fox" +expected := []string{"quick", "fox"} +// Passes: actual contains both "quick" and "fox" +``` + +#### Slice Contains Value + +Checks if expected value exists in the actual slice. + +```go +actual := []int{1, 2, 3, 4, 5} +expected := 3 +// Passes: 3 is in the slice +``` + +#### Slice Contains Slice + +Checks if all expected values exist in the actual slice (subset). + +```go +actual := []string{"a", "b", "c", "d"} +expected := []string{"b", "d"} +// Passes: both "b" and "d" are in actual +``` + +#### Map Contains Map + +Checks if all expected keys exist in actual and their values match. + +```go +actual := map[string]int{"a": 1, "b": 2, "c": 3} +expected := map[string]int{"a": 1, "c": 3} +// Passes: keys "a" and "c" exist with matching values +``` + +### Example + +```go +func TestContains(t *testing.T) { + fn := func(in string) (string, error) { + return fmt.Sprintf("User %s logged in at %s", in, time.Now()), nil + } + cases := trial.Cases[string, string]{ + "message contains username": { + Input: "alice", + Expected: "alice", + }, + } + trial.New(fn, cases).Comparer(trial.Contains).Test(t) +} +``` + +--- + +## CmpFuncs + +Compares two function values to determine if they point to the same function. + +```go +func CmpFuncs(x, y interface{}) (b bool, s string) +``` + +**Note:** Due to Go's function comparison limitations, this compares function pointers, not function behavior. + +```go +func TestFunctionEquality(t *testing.T) { + myHandler := func(w http.ResponseWriter, r *http.Request) {} + + fn := func(in string) (func(http.ResponseWriter, *http.Request), error) { + return myHandler, nil + } + cases := trial.Cases[string, func(http.ResponseWriter, *http.Request)]{ + "returns correct handler": { + Input: "test", + Expected: myHandler, + }, + } + trial.New(fn, cases).Comparer(trial.CmpFuncs).Test(t) +} +``` + +--- + +## Custom Comparers + +Create your own comparer for specialized comparison logic. + +### Signature + +```go +func MyComparer(actual, expected interface{}) (bool, string) +``` + +### Example: Fuzzy String Matching + +```go +func FuzzyMatch(actual, expected interface{}) (bool, string) { + a, ok1 := actual.(string) + e, ok2 := expected.(string) + if !ok1 || !ok2 { + return false, "both values must be strings" + } + + // Normalize: lowercase and trim + a = strings.TrimSpace(strings.ToLower(a)) + e = strings.TrimSpace(strings.ToLower(e)) + + if a == e { + return true, "" + } + return false, fmt.Sprintf("'%s' != '%s'", a, e) +} + +// Usage +trial.New(fn, cases).Comparer(FuzzyMatch).Test(t) +``` + +### Example: Struct with Tolerance + +```go +func WithinTolerance(tolerance float64) trial.CompareFunc { + return func(actual, expected interface{}) (bool, string) { + a, ok1 := actual.(float64) + e, ok2 := expected.(float64) + if !ok1 || !ok2 { + return trial.Equal(actual, expected) + } + + diff := math.Abs(a - e) + if diff <= tolerance { + return true, "" + } + return false, fmt.Sprintf("%f differs from %f by %f (tolerance: %f)", a, e, diff, tolerance) + } +} + +// Usage +trial.New(fn, cases).Comparer(WithinTolerance(0.001)).Test(t) +``` + +--- + +## Choosing a Comparer + +| Scenario | Recommended Comparer | +|----------|---------------------| +| Exact equality (default) | `Equal` | +| Ignore specific fields | `EqualOpt(IgnoreFields(...))` | +| Ignore private fields | `EqualOpt(IgnoreAllUnexported)` | +| Ignore timestamps | `EqualOpt(IgnoreFields(...))` or `ApproxTime(...)` | +| Substring/subset matching | `Contains` | +| Function comparison | `CmpFuncs` | +| Custom logic | Write your own `CompareFunc` | diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..8df340e --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,364 @@ +# Trial Examples and Patterns + +Practical examples for using the `hydronica/trial` testing framework. + +## Table of Contents + +- [Principles](#principles) +- [Basic Template](#basic-template) +- [Testing with Input Structs](#testing-with-input-structs) +- [Testing Functions with Multiple Arguments](#testing-functions-with-multiple-arguments) +- [Error Handling](#error-handling) +- [Panic Testing](#panic-testing) +- [Test() vs SubTest()](#test-vs-subtest) +- [Using Helpers](#using-helpers) +- [Custom Comparers](#custom-comparers) +- [Timeout](#timeout) +- [Complete Example](#complete-example) + +--- + +## Principles + +1. **Keep it simple** - Use primitives when possible for input and output +2. **Return errors** - Test functions must return `(result, error)` +3. **Self-contained cases** - Each case should not depend on others +4. **Limit scope** - Define custom structs within the test function + +--- + +## Basic Template + +```go +func TestBasic(t *testing.T) { + fn := func(in string) (int, error) { + return len(in), nil + } + cases := trial.Cases[string, int]{ + "empty string": { + Input: "", + Expected: 0, + }, + "hello": { + Input: "hello", + Expected: 5, + }, + } + trial.New(fn, cases).Test(t) +} +``` + +--- + +## Testing with Input Structs + +When testing functions with multiple parameters, define an input struct: + +```go +func Add(i1, i2 int) int { + return i1 + i2 +} + +func TestAdd(t *testing.T) { + type input struct { + i1 int + i2 int + } + fn := func(in input) (int, error) { + return Add(in.i1, in.i2), nil + } + cases := trial.Cases[input, int]{ + "add two positive numbers": { + Input: input{i1: 1, i2: 2}, + Expected: 3, + }, + "add negative numbers": { + Input: input{i1: -5, i2: -3}, + Expected: -8, + }, + } + trial.New(fn, cases).Test(t) +} +``` + +--- + +## Testing Functions with Multiple Arguments + +Alternative: use `trial.Args()` with `trial.Input` for dynamic argument handling: + +```go +func TestAddWithArgs(t *testing.T) { + fn := func(in trial.Input) (int, error) { + return Add(in.Slice(0).Int(), in.Slice(1).Int()), nil + } + cases := trial.Cases[trial.Input, int]{ + "add two numbers": { + Input: trial.Args(1, 2), + Expected: 3, + }, + } + trial.New(fn, cases).Test(t) +} +``` + +--- + +## Error Handling + +### ShouldErr - Expect Any Error + +```go +cases := trial.Cases[[]int, int]{ + "divide by zero": { + Input: []int{1, 0}, + ShouldErr: true, + }, +} +``` + +### ExpectedErr - Expect Specific Error Message + +Uses `strings.Contains` to check the error message: + +```go +cases := trial.Cases[string, output]{ + "invalid character": { + Input: `{"Value", "abc"}`, + ExpectedErr: errors.New("invalid character"), + }, +} +``` + +### ErrType - Expect Specific Error Type + +```go +type ValidationError struct { + Field string +} + +func (e ValidationError) Error() string { + return "validation failed: " + e.Field +} + +cases := trial.Cases[string, string]{ + "validation error": { + Input: "", + ExpectedErr: trial.ErrType(ValidationError{}), + }, +} +``` + +--- + +## Panic Testing + +Use `ShouldPanic: true` to test functions that should panic. Each case is isolated, so a panic won't stop other cases. + +```go +func TestDivideWithPanic(t *testing.T) { + fn := func(in []int) (int, error) { + return in[0] / in[1], nil // panics if in[1] is 0 + } + cases := trial.Cases[[]int, int]{ + "normal division": { + Input: []int{10, 2}, + Expected: 5, + }, + "divide by zero panics": { + Input: []int{1, 0}, + ShouldPanic: true, + }, + } + trial.New(fn, cases).Test(t) +} +``` + +--- + +## Test() vs SubTest() + +### Test() + +Runs all cases in a single test function: + +```go +trial.New(fn, cases).Test(t) +// PASS: "case 1" +// PASS: "case 2" +// FAIL: "case 3" +``` + +### SubTest() + +Runs each case as a separate Go subtest using `t.Run()`: + +```go +trial.New(fn, cases).SubTest(t) +// === RUN TestFunction/case_1 +// --- PASS: TestFunction/case_1 +// === RUN TestFunction/case_2 +// --- PASS: TestFunction/case_2 +``` + +**Use `SubTest()` when you need:** +- CI/CD systems that track individual test results +- Run specific cases: `go test -run "TestName/case_name"` +- Better test isolation and reporting + +--- + +## Using Helpers + +See [helpers.md](helpers.md) for complete documentation. + +### Time Helpers + +```go +cases := trial.Cases[time.Time, string]{ + "parse day": { + Input: trial.Day("2024-01-15"), + Expected: "January 15, 2024", + }, + "parse hour": { + Input: trial.Hour("2024-01-15T14"), + Expected: "January 15, 2024", + }, + "custom format": { + Input: trial.Time(time.RFC3339, "2024-01-15T14:30:00Z"), + Expected: "January 15, 2024", + }, +} +``` + +### Pointer Helpers + +```go +type Config struct { + Name *string + Count *int + Enabled *bool +} + +cases := trial.Cases[Config, string]{ + "with all fields": { + Input: Config{ + Name: trial.Pointer("app"), + Count: trial.Pointer(42), + Enabled: trial.Pointer(true), + }, + Expected: "app", + }, +} +``` + +--- + +## Custom Comparers + +See [comparers.md](comparers.md) for complete documentation. + +### Ignoring Fields + +```go +type Example struct { + Field1 int + Field2 int + Timestamp time.Time + LastUpdate time.Time +} + +trial.New(fn, cases).Comparer( + trial.EqualOpt( + trial.IgnoreAllUnexported, + trial.IgnoreFields("Timestamp", "LastUpdate"), + ), +).SubTest(t) +``` + +### Using Contains + +For substring/subset matching: + +```go +fn := func(in string) (string, error) { + return "Hello, " + in + "! Welcome.", nil +} +cases := trial.Cases[string, string]{ + "greeting contains name": { + Input: "World", + Expected: "World", // actual contains "World" + }, +} +trial.New(fn, cases).Comparer(trial.Contains).Test(t) +``` + +### Approximate Time Comparison + +```go +trial.New(fn, cases).Comparer( + trial.EqualOpt(trial.ApproxTime(time.Second)), +).Test(t) +``` + +--- + +## Timeout + +Fail cases that take too long: + +```go +trial.New(fn, cases).Timeout(time.Second).Test(t) +``` + +--- + +## Complete Example + +Copy-paste-ready test file: + +```go +package mypackage + +import ( + "errors" + "testing" + "github.com/hydronica/trial" +) + +func Divide(a, b int) (int, error) { + if b == 0 { + return 0, errors.New("cannot divide by zero") + } + return a / b, nil +} + +func TestDivide(t *testing.T) { + type input struct { + a int + b int + } + fn := func(in input) (int, error) { + return Divide(in.a, in.b) + } + cases := trial.Cases[input, int]{ + "10 / 2 = 5": { + Input: input{a: 10, b: 2}, + Expected: 5, + }, + "9 / 3 = 3": { + Input: input{a: 9, b: 3}, + Expected: 3, + }, + "divide by zero": { + Input: input{a: 1, b: 0}, + ExpectedErr: errors.New("cannot divide by zero"), + }, + "negative division": { + Input: input{a: -10, b: 2}, + Expected: -5, + }, + } + trial.New(fn, cases).SubTest(t) +} +``` diff --git a/docs/helpers.md b/docs/helpers.md new file mode 100644 index 0000000..e6fff67 --- /dev/null +++ b/docs/helpers.md @@ -0,0 +1,184 @@ +# Helper Functions + +Trial provides helper functions to simplify test setup. + +## Table of Contents + +- [Input Helpers](#input-helpers) +- [Pointer Helpers](#pointer-helpers) +- [Time Helpers](#time-helpers) +- [Error Helpers](#error-helpers) +- [Capture Utilities](#capture-utilities) + +--- + +## Input Helpers + +### Args + +Converts multiple arguments into an `Input` value. + +```go +func Args(args ...interface{}) Input +``` + +**Example:** +```go +fn := func(in trial.Input) (int, error) { + a := in.Slice(0).Int() + b := in.Slice(1).Int() + return a + b, nil +} +cases := trial.Cases[trial.Input, int]{ + "add": { + Input: trial.Args(5, 3), + Expected: 8, + }, +} +``` + +### Input Methods + +| Method | Return | Description | +|--------|--------|-------------| +| `String()` | `string` | Get as string (panics on struct/ptr/slice/map/array/chan) | +| `Int()` | `int` | Get as int (parses strings) | +| `Uint()` | `uint` | Get as uint (parses strings) | +| `Float64()` | `float64` | Get as float64 (parses strings) | +| `Bool()` | `bool` | Get as bool (parses strings) | +| `Slice(i int)` | `Input` | Get element at index | +| `Map(key interface{})` | `Input` | Get value for key | +| `Interface()` | `interface{}` | Get raw value | + +--- + +## Pointer Helpers + +### Pointer[T] + +Creates a pointer to a primitive value. + +```go +func Pointer[T primitives](v T) *T +``` + +**Supported types:** `int`, `int8`, `int16`, `int32`, `int64`, `uint`, `uint8`, `uint16`, `uint32`, `uint64`, `float32`, `float64`, `string`, `bool` + +**Example:** +```go +type Config struct { + Host *string + Port *int + Debug *bool +} + +cases := trial.Cases[Config, bool]{ + "debug enabled": { + Input: Config{ + Host: trial.Pointer("localhost"), + Port: trial.Pointer(8080), + Debug: trial.Pointer(true), + }, + Expected: true, + }, +} +``` + +### Deprecated Pointer Helpers + +Use `Pointer[T]()` instead of: `IntP`, `Int8P`, `Int16P`, `Int32P`, `Int64P`, `UintP`, `Uint8P`, `Uint16P`, `Uint32P`, `Uint64P`, `Float32P`, `Float64P`, `BoolP`, `StringP` + +--- + +## Time Helpers + +Time helpers parse time strings and **panic on error** (fail fast in test setup). + +| Function | Format | Example | +|----------|--------|---------| +| `Day(value)` | `2006-01-02` | `Day("2024-01-15")` | +| `Hour(value)` | `2006-01-02T15` | `Hour("2024-01-15T14")` | +| `Time(layout, value)` | Custom | `Time(time.RFC3339, "2024-01-15T14:30:00Z")` | +| `Times(layout, values...)` | Custom (slice) | `Times(time.RFC3339, "...", "...")` | +| `TimeP(layout, value)` | Custom (pointer) | `TimeP(time.RFC3339, "...")` | + +**Example:** +```go +cases := trial.Cases[input, int]{ + "one day difference": { + Input: input{ + start: trial.Day("2024-01-15"), + end: trial.Day("2024-01-16"), + }, + Expected: 24, // hours + }, +} +``` + +### Deprecated Time Helpers + +Use `Hour()` instead of `TimeHour()`, use `Day()` instead of `TimeDay()`. + +--- + +## Error Helpers + +### ErrType + +Wraps an error for **type-based comparison**. When used with `ExpectedErr`, trial checks that the returned error is the same type (not message). + +```go +func ErrType(err error) error +``` + +**Example:** +```go +type ValidationError struct{ Field string } +func (e ValidationError) Error() string { return "invalid " + e.Field } + +cases := trial.Cases[string, string]{ + "returns validation error": { + Input: "", + ExpectedErr: trial.ErrType(ValidationError{}), + }, +} +``` + +--- + +## Capture Utilities + +Capture log output, stdout, or stderr during tests. + +### Functions + +| Function | Captures | +|----------|----------| +| `CaptureLog()` | Standard `log` package output | +| `CaptureStdOut()` | `os.Stdout` | +| `CaptureStdErr()` | `os.Stderr` | + +### Methods + +| Method | Return | Description | +|--------|--------|-------------| +| `ReadAll()` | `string` | Stop capturing, return all output | +| `ReadLines()` | `[]string` | Stop capturing, return lines | + +**Example:** +```go +func TestLogging(t *testing.T) { + c := trial.CaptureLog() + + log.Println("Hello, World!") + + output := c.ReadAll() + if !strings.Contains(output, "Hello, World!") { + t.Error("expected log message not found") + } +} +``` + +**Important:** +- Always call `ReadAll()` or `ReadLines()` to restore normal output +- Only one capture at a time diff --git a/go.mod b/go.mod index 19709d6..e179407 100644 --- a/go.mod +++ b/go.mod @@ -1,5 +1,7 @@ module github.com/hydronica/trial -go 1.18 +go 1.21 -require github.com/google/go-cmp v0.6.0 +toolchain go1.24.3 + +require github.com/google/go-cmp v0.7.0 diff --git a/go.sum b/go.sum index 5a8d551..40e761a 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= diff --git a/test_case.md b/test_case.md deleted file mode 100644 index d59eb81..0000000 --- a/test_case.md +++ /dev/null @@ -1,158 +0,0 @@ -# Table-Driven Testing with hydronica/trial - -The `hydronica/trial` package makes table-driven testing in Go simple and expressive. Below are practical examples and patterns for using this library, including time helper functions. For more, see the [trial wiki](https://github.com/hydronica/trial/wiki). - ---- - -## Principles -1. Keep it simple - 1. Use primitives when possible for input and output. - 2. Functions with multiple args can be passed in by creating an input struct with each arg -2. Return errors in the test function. Use nil if needed -3. Test cases should be self-contained -4. limit scope of custom structs and helper function by embedding them without the Test Function - -## - -```go -import ( - "testing" - "github.com/hydronica/trial" -) - -func Add(i1, i2 int) int { - return i1 + i2 -} - -func TestAdd(t *testing.T) { - type input struct { - i1 int - i2 int - } - testFn := func(in input) (int, error) { - return Add(in.i1, in.i2), nil - } - cases := trial.Cases[input, int]{ - "Add two numbers": { - Input: input{i1:1, i2:2}, - Expected: 3, - }, - } - trial.New(testFn, cases).Test(t) -} -``` - ---- - -## Testing with error and using output struct - -```go -import ( - "encoding/json" - "errors" - "testing" - "github.com/hydronica/trial" -) - -type output struct { - Name string - Count int - Value float64 -} - -func TestMarshal(t *testing.T) { - fn := func(s string) (output, error) { - v := output{} - err := json.Unmarshal([]byte(s), &v) - return v, err - } - cases := trial.Cases[string, output]{ - "valid": { - Input: `{"Name":"abc", "Count":10, "Value": 12.34}`, - Expected: output{Name: "abc", Count: 10, Value: 12.34}, - }, - "invalid type": { - Input: `{"Value", "abc"}`, - ExpectedErr: errors.New("invalid character"), - }, - "invalid json": { - Input: `{`, - ShouldErr: true, - }, - } - trial.New(fn, cases).SubTest(t) -} -``` - ---- - -## Using Time Helper Functions - -The package provides helpers for time parsing in tests: -- `TimeHour(s string)` — format: `"2006-01-02T15"` -- `TimeDay(s string)` — format: `"2006-01-02"` -- `Time(layout, value string)` -- `Times(layout string, values ...string)` -- `TimeP(layout, s string) *time.Time` - -## Using Pointer Helper Functions - -The `Pointer[T]` helper function makes it easy to create pointers to primitive values in your test cases. This is particularly useful when testing functions that expect pointer parameters. - -Supported primitive types: -- Integers: `int`, `int8`, `int16`, `int32`, `int64` -- Unsigned integers: `uint`, `uint8`, `uint16`, `uint32`, `uint64` -- Floats: `float32`, `float64` -- `string` -- `bool` - - ---- - -## Using Custom Comparers - -You can customize how trial compares expected and actual values using comparers. For example, you may want to ignore unexported fields or certain struct fields when comparing results. - -```go -import ( - "testing" - "time" - "github.com/hydronica/trial" -) - -type Example struct { - Field1 int - Field2 int - ignoreMe string // unexported, will be ignored - Timestamp time.Time // will be ignored - LastUpdate time.Time // will be ignored -} - -func TestWithComparer(t *testing.T) { - fn := func(i int) (Example, error) { - return Example{ - Field1: i, - Field2: i * 2, - ignoreMe: "should be ignored", - Timestamp: time.Now(), - LastUpdate: time.Now(), - }, nil - } - cases := trial.Cases[int, Example]{ - "basic": { - Input: 2, - Expected: Example{Field1: 2, Field2: 4}, - }, - } - trial.New(fn, cases).Comparer( - trial.EqualOpt( - trial.IgnoreAllUnexported, - trial.IgnoreFields("Timestamp", "LastUpdate"), - ), - ).SubTest(t) -} -``` - -This will compare only the exported fields `Field1` and `Field2`, ignoring the unexported and specified fields. - ---- From 96de0fd88ffb74c6d4bf035836cbac7613217de8 Mon Sep 17 00:00:00 2001 From: Joshua Smith Date: Fri, 23 Jan 2026 14:55:51 -0700 Subject: [PATCH 2/2] misc cleanup --- .github/workflows/test.yml | 30 +++++++++++++++--------------- docs/comparers.md | 32 ++++++++++++++++++++++++++++++++ functions.go | 21 ++++++++++++++++----- functions_test.go | 6 +++--- go.mod | 10 ++++++---- go.sum | 4 ++-- trial_test.go | 10 +++++----- 7 files changed, 79 insertions(+), 34 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 978a366..3db114a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,30 +7,30 @@ jobs: test: strategy: matrix: - go-version: [1.18.x, 1.19.x] + go-version: [1.18.x, 1.19.x, 1.20.x, 1.21.x, 1.22.x, 1.23.x, 1.24.x] os: [ubuntu-latest] runs-on: ${{ matrix.os }} steps: - name: Install Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Test - run: | + run: | go test -v -race -covermode=atomic coverage: runs-on: ubuntu-latest - steps: - - name: Install Go - uses: actions/setup-go@v2 - with: - go-version: 1.19.x - - name: Checkout code - uses: actions/checkout@v2 - - name: Test - run: | + steps: + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: stable + - name: Checkout code + uses: actions/checkout@v4 + - name: Test + run: | go test -v -race -coverprofile=coverage.txt -covermode=atomic - - name: Coverage - uses: codecov/codecov-action@v2 \ No newline at end of file + - name: Coverage + uses: codecov/codecov-action@v4 \ No newline at end of file diff --git a/docs/comparers.md b/docs/comparers.md index c44d1a5..b520f8b 100644 --- a/docs/comparers.md +++ b/docs/comparers.md @@ -105,6 +105,37 @@ trial.EqualOpt( **Use case:** Ignoring auto-generated fields like timestamps or IDs. +#### IgnoreFieldsOf (Embedded Structs) + +Ignore specific fields on a given struct type. Use this when ignoring fields in embedded structs where `IgnoreFields` cannot infer the correct type. + +```go +func IgnoreFieldsOf(structType interface{}, fields ...string) func(interface{}) cmp.Option +``` + +```go +type Metadata struct { + CreatedAt time.Time + UpdatedAt time.Time + Version int +} + +type User struct { + ID int + Name string + Metadata // embedded struct +} + +// To ignore fields in the embedded Metadata struct: +trial.New(fn, cases).Comparer( + trial.EqualOpt( + trial.IgnoreFieldsOf(Metadata{}, "CreatedAt", "UpdatedAt"), + ), +).SubTest(t) +``` + +**Use case:** Ignoring fields in embedded structs where `trial.IgnoreFields` doesn't reach the nested type. + #### IgnoreTypes Ignore all values of specified types. @@ -348,6 +379,7 @@ trial.New(fn, cases).Comparer(WithinTolerance(0.001)).Test(t) |----------|---------------------| | Exact equality (default) | `Equal` | | Ignore specific fields | `EqualOpt(IgnoreFields(...))` | +| Ignore embedded struct fields | `EqualOpt(IgnoreFieldsOf(EmbeddedType{}, ...))` | | Ignore private fields | `EqualOpt(IgnoreAllUnexported)` | | Ignore timestamps | `EqualOpt(IgnoreFields(...))` or `ApproxTime(...)` | | Substring/subset matching | `Contains` | diff --git a/functions.go b/functions.go index 0dbcad7..41396e6 100644 --- a/functions.go +++ b/functions.go @@ -27,11 +27,11 @@ func Contains(x, y interface{}) (bool, string) { return false, r.String() } -const ( - SubStrings = iota - SubSlices - SubMaps -) +// const ( +// SubStrings = iota +// SubSlices +// SubMaps +// ) // ContainsOpt allow configurable options to the contains method // 1. Check for sub-strings ("abc" -> "abcdefg") @@ -191,6 +191,17 @@ func IgnoreFields(f ...string) func(interface{}) cmp.Option { } } +// IgnoreFieldsOf ignores specific fields on a given struct type. +// Use this when ignoring fields in embedded structs where IgnoreFields +// cannot infer the correct type. +// +// trial.EqualOpt(trial.IgnoreFieldsOf(Metadata{}, "CreatedAt", "UpdatedAt")) +func IgnoreFieldsOf(structType interface{}, fields ...string) func(interface{}) cmp.Option { + return func(_ interface{}) cmp.Option { + return cmpopts.IgnoreFields(structType, fields...) + } +} + // IgnoreTypes is a wrapper around the cmpopts.IgnoreTypes // it allows ignore the type of the values passed in // int32(0), int(0), string(0), time.Duration(0), etc diff --git a/functions_test.go b/functions_test.go index ce26678..00e7361 100644 --- a/functions_test.go +++ b/functions_test.go @@ -78,8 +78,8 @@ func TestEqualFn(t *testing.T) { }, "private key in map": { Input: Args( - map[test]string{test{Public: 1, private: "a"}: "apple"}, - map[test]string{test{Public: 1, private: "a"}: "apple"}, + map[test]string{{Public: 1, private: "a"}: "apple"}, + map[test]string{{Public: 1, private: "a"}: "apple"}, ), Expected: true, }, @@ -90,7 +90,7 @@ func TestEqualFn(t *testing.T) { ), Expected: true, }, - "embeded map": { + "embedded map": { Input: Args( &maper{myMap: map[string]test{"a": {private: "pritate", Public: 10}}}, &maper{myMap: map[string]test{"a": {private: "pritate", Public: 10}}}, diff --git a/go.mod b/go.mod index e179407..b7d0a86 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,9 @@ module github.com/hydronica/trial -go 1.21 +go 1.18 -toolchain go1.24.3 - -require github.com/google/go-cmp v0.7.0 +// Note: go-cmp v0.7.0 is available but requires Go 1.21+. +// We use v0.6.0 to support Go 1.18+ (minimum for generics). +// v0.7.0 only adds compare function support for SortSlices/SortMaps, +// which is not needed by this library. +require github.com/google/go-cmp v0.6.0 diff --git a/go.sum b/go.sum index 40e761a..5a8d551 100644 --- a/go.sum +++ b/go.sum @@ -1,2 +1,2 @@ -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= diff --git a/trial_test.go b/trial_test.go index 85e95dc..6917e5d 100644 --- a/trial_test.go +++ b/trial_test.go @@ -187,23 +187,23 @@ func TestInput(t *testing.T) { expected interface{} } cases := map[string]tester{ - "string": tester{ + "string": { fn: func() interface{} { return newInput("hello world").String() }, expected: "hello world", }, - "string (int)": tester{ + "string (int)": { fn: func() interface{} { return newInput(123).String() }, expected: "123", }, - "string (float)": tester{ + "string (float)": { fn: func() interface{} { return newInput(12.8).String() }, expected: "12.8", }, - "string (bool)": tester{ + "string (bool)": { fn: func() interface{} { return newInput(true).String() }, expected: "true", }, - "string panic": tester{ + "string panic": { fn: func() interface{} { return newInput(struct{}{}).String() }, shouldPanic: true, },