This document outlines the testing patterns and frameworks used in the task-tools repository, focusing on unit test patterns, the hydronica/trial framework, and conversion strategies for testify-based tests.
The repository uses a consistent testing approach with the following key components:
- hydronica/trial - Primary testing framework for table-driven tests
- Go's built-in testing - Standard Go testing patterns
- testify/assert - Legacy testing assertions (to be converted)
- Example functions - Documentation-style tests
The hydronica/trial framework is the primary testing tool used throughout the repository. It provides a clean, type-safe way to write table-driven tests.
func TestFunctionName(t *testing.T) {
fn := func(input InputType) (OutputType, error) {
// Test logic here
return result, err
}
cases := trial.Cases[InputType, OutputType]{
"test case name": {
Input: inputValue,
Expected: expectedValue,
},
"error case": {
Input: errorInput,
ShouldErr: true,
},
}
trial.New(fn, cases).Test(t)
}Comparers: Custom comparison logic for complex types
trial.New(fn, cases).Comparer(trial.Contains).Test(t)
trial.New(fn, cases).Comparer(trial.EqualOpt(trial.IgnoreAllUnexported)).Test(t)SubTests: For complex test scenarios
trial.New(fn, cases).SubTest(t)Timeouts: For tests that might hang
trial.New(fn, cases).Timeout(time.Second).SubTest(t)The repository uses trial's time utilities instead of literal time.Date() calls:
// Preferred (using trial utilities)
trial.TimeDay("2023-01-01")
trial.TimeHour("2023-01-01T12")
trial.Time(time.RFC3339, "2023-01-01T00:00:00Z")
// Avoid (literal time.Date calls)
time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)For simple tests that don't require table-driven patterns:
func TestSimpleFunction(t *testing.T) {
result := functionUnderTest()
if result != expected {
t.Errorf("got %v, expected %v", result, expected)
}
}Used for documentation and demonstrating API usage:
func ExampleFunctionName() {
// Example code
fmt.Println("output")
// Output:
// output
}Example functions are found in files like file/writebyhour_test.go and serve as both tests and documentation.
For setup and teardown across multiple tests:
func TestMain(m *testing.M) {
// Setup code
code := m.Run()
// Cleanup code
os.Exit(code)
}The following files use the trial framework:
apps/flowlord/taskmaster_test.goapps/flowlord/handler_test.goapps/flowlord/files_test.goapps/flowlord/batch_test.goapps/flowlord/cache/cache_test.gofile/file_test.gofile/util/util_test.gofile/nop/nop_test.gofile/minio/client_test.gofile/minio/read_test.gofile/minio/write_test.gofile/local/read_test.gofile/local/write_test.gofile/local/local_test.gofile/buf/buf_test.gofile/stat/stat_test.gofile/scanner_test.goworkflow/workflow_test.gotmpl/tmpl_test.godb/prep_test.godb/batch/batch_test.godb/batch/stat_test.goconsumer/discover_test.gobootstrap/bootstrap_test.goapps/workers/*/worker_test.go(multiple worker test files)apps/tm-archive/*/app_test.go(multiple archive test files)apps/utils/*/logger_test.go,stats_test.go,recap_test.go,filewatcher_test.go
These files need conversion to trial or standard Go testing:
apps/tm-archive/http/http_test.goapps/utils/filewatcher/watcher_test.go
Files with extensive example functions:
file/writebyhour_test.go(13 example functions)
Current testify pattern:
func TestFunction(t *testing.T) {
result := functionUnderTest()
assert.Equal(t, expected, result)
assert.NotNil(t, err)
}Convert to trial pattern:
func TestFunction(t *testing.T) {
fn := func(input InputType) (OutputType, error) {
return functionUnderTest(input)
}
cases := trial.Cases[InputType, OutputType]{
"success case": {
Input: testInput,
Expected: expectedOutput,
},
"error case": {
Input: errorInput,
ShouldErr: true,
},
}
trial.New(fn, cases).Test(t)
}For simple cases that don't benefit from table-driven tests:
func TestFunction(t *testing.T) {
result := functionUnderTest()
if result != expected {
t.Errorf("got %v, expected %v", result, expected)
}
}- Use descriptive test case names
- Group related test cases logically
- Use table-driven tests for multiple scenarios
- Keep test functions focused and single-purpose
cases := trial.Cases[InputType, OutputType]{
"error case": {
Input: errorInput,
ShouldErr: true,
},
}Always use trial time utilities:
// Good
trial.TimeDay("2023-01-01")
trial.TimeHour("2023-01-01T12")
// Avoid
time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC)Use appropriate comparers for complex types:
trial.New(fn, cases).Comparer(trial.EqualOpt(trial.IgnoreAllUnexported)).Test(t)- Place tests in
*_test.gofiles - Use
TestMainfor setup/teardown when needed - Use example functions for API documentation
- Keep test data in separate files when appropriate
-
apps/tm-archive/http/http_test.go
- Convert
assert.Equalcalls to trial cases - Convert
assert.Containsto appropriate trial comparers
- Convert
-
apps/utils/filewatcher/watcher_test.go
- Convert
assert.Equalandassert.NotNilcalls - Create table-driven test cases
- Convert
- Ensure all new tests use trial framework
- Convert any remaining standard Go tests to trial when beneficial
- Maintain example functions for documentation
- Update this document as patterns evolve
- Provide examples for common testing scenarios
- Establish coding standards for test writing
func TestWithDependencies(t *testing.T) {
fn := func(input InputType) (OutputType, error) {
// Setup mocks or test doubles
mockDep := &MockDependency{}
service := NewService(mockDep)
return service.Process(input)
}
cases := trial.Cases[InputType, OutputType]{
"success": {
Input: validInput,
Expected: expectedOutput,
},
}
trial.New(fn, cases).Test(t)
}func TestAsyncOperation(t *testing.T) {
fn := func(input InputType) (OutputType, error) {
result := make(chan OutputType, 1)
err := make(chan error, 1)
go func() {
output, e := asyncOperation(input)
result <- output
err <- e
}()
return <-result, <-err
}
cases := trial.Cases[InputType, OutputType]{
"async success": {
Input: testInput,
Expected: expectedOutput,
},
}
trial.New(fn, cases).Timeout(5 * time.Second).Test(t)
}This testing framework provides a consistent, maintainable approach to testing across the entire task-tools repository.