Something went wrong. Try again.
A lightweight test helper package
Something went wrong. Try again.
test test.go
12 kB · 454 lines
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455// Package test provides a lightweight, but useful extension to the std lib's testing package with// a friendlier and more intuitive API.//// Simple tests become trivial and test provides mechanisms for adding useful context to test failures.package test
import ( "bytes" "errors" "fmt" "io" "math" "os" "strings" "sync" "testing"
"github.com/FollowTheProcess/test/internal/colour" "github.com/FollowTheProcess/test/internal/diff")
// Equal fails if got != want.//// test.Equal(t, "apples", "apples") // Passes// test.Equal(t, "apples", "oranges") // Failsfunc Equal[T comparable](tb testing.TB, got, want T, options ...Option) { tb.Helper() cfg := defaultConfig() cfg.title = "Not Equal"
for _, option := range options { if err := option.apply(&cfg); err != nil { tb.Fatalf("Equal: could not apply options: %v", err) return } }
if got != want { fail := failure[T]{ got: got, want: want, cfg: cfg, } tb.Fatal(fail.String()) }}
// NotEqual is the opposite of [Equal], it fails if got == want.//// test.NotEqual(t, 10, 42) // Passes// test.NotEqual(t, 42, 42) // Failsfunc NotEqual[T comparable](tb testing.TB, got, want T, options ...Option) { tb.Helper() cfg := defaultConfig() cfg.title = "Equal"
for _, option := range options { if err := option.apply(&cfg); err != nil { tb.Fatalf("NotEqual: could not apply options: %v", err) return } }
if got == want { fail := failure[T]{ got: got, want: want, cfg: cfg, } tb.Fatal(fail.String()) }}
// EqualFunc is like [Equal] but accepts a custom comparator function, useful// when the items to be compared do not implement the comparable generic constraint.//// The signature of the comparator is such that standard library functions such as// [slices.Equal] or [maps.Equal] can be used.//// The comparator should return true if the two items should be considered equal.//// test.EqualFunc(t, []int{1, 2, 3}, []int{1, 2, 3}, slices.Equal) // Passes// test.EqualFunc(t, []int{1, 2, 3}, []int{4, 5, 6}, slices.Equal) // Failsfunc EqualFunc[T any](tb testing.TB, got, want T, equal func(a, b T) bool, options ...Option) { tb.Helper() cfg := defaultConfig() cfg.title = "Not Equal"
for _, option := range options { if err := option.apply(&cfg); err != nil { tb.Fatalf("EqualFunc: could not apply options: %v", err) return } }
if !equal(got, want) { cfg.reason = "equal(got, want) returned false" fail := failure[T]{ got: got, want: want, cfg: cfg, } tb.Fatal(fail.String()) }}
// NotEqualFunc is like [Equal] but accepts a custom comparator function, useful// when the items to be compared do not implement the comparable generic constraint.//// The signature of the comparator is such that standard library functions such as// [slices.Equal] or [maps.Equal] can be used.//// The comparator should return true if the two items should be considered equal.//// test.EqualFunc(t, []int{1, 2, 3}, []int{1, 2, 3}, slices.Equal) // Fails// test.EqualFunc(t, []int{1, 2, 3}, []int{4, 5, 6}, slices.Equal) // Passesfunc NotEqualFunc[T any](tb testing.TB, got, want T, equal func(a, b T) bool, options ...Option) { tb.Helper() cfg := defaultConfig() cfg.title = "Equal"
for _, option := range options { if err := option.apply(&cfg); err != nil { tb.Fatalf("NotEqualFunc: could not apply options: %v", err) return } }
if equal(got, want) { cfg.reason = "equal(got, want) returned true" fail := failure[T]{ got: got, want: want, cfg: cfg, } tb.Fatal(fail.String()) }}
// NearlyEqual is like [Equal] but for floating point numbers where absolute equality often fails.//// If the difference between got and want is sufficiently small, they are considered equal. This threshold// defaults to 1e-8 but can be configured with the [FloatEqualityThreshold] option.//// test.NearlyEqual(t, 3.0000000001, 3.0) // Passes, close enough to be considered equal// test.NearlyEqual(t, 3.0000001, 3.0) // Fails, too differentfunc NearlyEqual[T ~float32 | ~float64](tb testing.TB, got, want T, options ...Option) { tb.Helper() cfg := defaultConfig() cfg.title = "Not NearlyEqual"
for _, option := range options { if err := option.apply(&cfg); err != nil { tb.Fatalf("NearlyEqual: could not apply options: %v", err) return } }
diff := math.Abs(float64(got - want)) if diff > cfg.floatEqualityThreshold { cfg.reason = fmt.Sprintf( "Difference %v - %v = %v exceeds maximum tolerance of %v", got, want, diff, cfg.floatEqualityThreshold, ) fail := failure[T]{ got: got, want: want, cfg: cfg, } tb.Fatal(fail.String()) }}
// Ok fails if err != nil.//// err := doSomething()// test.Ok(t, err)func Ok(tb testing.TB, err error, options ...Option) { tb.Helper() cfg := defaultConfig() cfg.title = "Not Ok"
for _, option := range options { if optionErr := option.apply(&cfg); optionErr != nil { tb.Fatalf("Ok: could not apply options: %v", optionErr) return } }
if err != nil { fail := failure[error]{ got: err, want: nil, cfg: cfg, } tb.Fatal(fail.String()) }}
// Err fails if err == nil.//// err := shouldFail()// test.Err(t, err)func Err(tb testing.TB, err error, options ...Option) { tb.Helper() cfg := defaultConfig() cfg.title = "Not Err"
for _, option := range options { if optionErr := option.apply(&cfg); optionErr != nil { tb.Fatalf("Err: could not apply options: %v", optionErr) return } }
if err == nil { fail := failure[error]{ got: nil, want: errors.New("error"), cfg: cfg, } tb.Fatal(fail.String()) }}
// WantErr fails if you got an error and didn't want it, or if you didn't// get an error but wanted one.//// It greatly simplifies checking for errors in table driven tests where an error// may or may not be nil on any given test case.//// test.WantErr(t, errors.New("uh oh"), true) // Passes, got error when we wanted one// test.WantErr(t, errors.New("uh oh"), false) // Fails, got error but didn't want one// test.WantErr(t, nil, true) // Fails, wanted an error but didn't get one// test.WantErr(t, nil, false) // Passes, didn't want an error and didn't get onefunc WantErr(tb testing.TB, err error, want bool, options ...Option) { tb.Helper() cfg := defaultConfig() cfg.title = "WantErr"
for _, option := range options { if optionErr := option.apply(&cfg); optionErr != nil { tb.Fatalf("WantErr: could not apply options: %v", optionErr) return } }
if (err != nil) != want { var ( reason string wanted error ) if want { reason = fmt.Sprintf("Wanted an error but got %v", err) wanted = errors.New("error") } else { reason = fmt.Sprintf("Got an unexpected error: %v", err) wanted = nil } cfg.reason = reason fail := failure[error]{ got: err, want: wanted, cfg: cfg, } tb.Fatal(fail.String()) }}
// True fails if got is false.//// test.True(t, true) // Passes// test.True(t, false) // Failsfunc True(tb testing.TB, got bool, options ...Option) { tb.Helper() cfg := defaultConfig() cfg.title = "Not True"
for _, option := range options { if err := option.apply(&cfg); err != nil { tb.Fatalf("True: could not apply options: %v", err) return } }
if !got { fail := failure[bool]{ got: got, want: true, cfg: cfg, } tb.Fatal(fail.String()) }}
// False fails if got is true.//// test.False(t, false) // Passes// test.False(t, true) // Failsfunc False(tb testing.TB, got bool, options ...Option) { tb.Helper() cfg := defaultConfig() cfg.title = "Not False"
for _, option := range options { if err := option.apply(&cfg); err != nil { tb.Fatalf("False: could not apply options: %v", err) return } }
if got { fail := failure[bool]{ got: got, want: false, cfg: cfg, } tb.Fatal(fail.String()) }}
// Diff fails if the two strings got and want are not equal and provides a rich// unified diff of the two for easy comparison.func Diff(tb testing.TB, got, want string) { tb.Helper()
if diff := diff.Diff("want", []byte(want), "got", []byte(got)); diff != nil { tb.Fatalf("\nDiff\n----\n%s\n", prettyDiff(string(diff))) }}
// DiffBytes fails if the two []byte got and want are not equal and provides a rich// unified diff of the two for easy comparison.func DiffBytes(tb testing.TB, got, want []byte) { tb.Helper()
if diff := diff.Diff("want", want, "got", got); diff != nil { tb.Fatalf("\nDiff\n----\n%s\n", prettyDiff(string(diff))) }}
// CaptureOutput captures and returns data printed to [os.Stdout] and [os.Stderr] by the provided function fn, allowing// you to test functions that write to those streams and do not have an option to pass in an [io.Writer].//// If the provided function returns a non nil error, the test is failed with the error logged as the reason.//// If any error occurs capturing stdout or stderr, the test will also be failed with a descriptive log.//// fn := func() error {// fmt.Println("hello stdout")// return nil// }//// stdout, stderr := test.CaptureOutput(t, fn)// fmt.Print(stdout) // "hello stdout\n"// fmt.Print(stderr) // ""func CaptureOutput(tb testing.TB, fn func() error) (stdout, stderr string) { tb.Helper()
// Take copies of the original streams oldStdout := os.Stdout oldStderr := os.Stderr
defer func() { // Restore everything back to normal os.Stdout = oldStdout os.Stderr = oldStderr }()
stdoutReader, stdoutWriter, err := os.Pipe() if err != nil { tb.Fatalf("CaptureOutput: could not construct an os.Pipe(): %v", err) }
stderrReader, stderrWriter, err := os.Pipe() if err != nil { tb.Fatalf("CaptureOutput: could not construct an os.Pipe(): %v", err) }
// Set stdout and stderr streams to the pipe writers os.Stdout = stdoutWriter os.Stderr = stderrWriter
stdoutCapture := make(chan string) stderrCapture := make(chan string)
var wg sync.WaitGroup wg.Add(2) //nolint: mnd
// Copy in goroutines to avoid blocking go func(wg *sync.WaitGroup) { defer func() { close(stdoutCapture) wg.Done() }() buf := &bytes.Buffer{} if _, err := io.Copy(buf, stdoutReader); err != nil { tb.Fatalf("CaptureOutput: failed to copy from stdout reader: %v", err) } stdoutCapture <- buf.String() }(&wg)
go func(wg *sync.WaitGroup) { defer func() { close(stderrCapture) wg.Done() }() buf := &bytes.Buffer{} if _, err := io.Copy(buf, stderrReader); err != nil { tb.Fatalf("CaptureOutput: failed to copy from stderr reader: %v", err) } stderrCapture <- buf.String() }(&wg)
// Call the test function that produces the output if err := fn(); err != nil { tb.Fatalf("CaptureOutput: user function returned an error: %v", err) }
// Close the writers stdoutWriter.Close() stderrWriter.Close()
capturedStdout := <-stdoutCapture capturedStderr := <-stderrCapture
wg.Wait()
return capturedStdout, capturedStderr}
// prettyDiff takes a string diff in unified diff format and colourises it for easier viewing.func prettyDiff(diff string) string { lines := strings.Split(diff, "\n") for i := 0; i < len(lines); i++ { trimmed := strings.TrimSpace(lines[i]) if strings.HasPrefix(trimmed, "---") || strings.HasPrefix(trimmed, "- ") { lines[i] = colour.Red(lines[i]) }
if strings.HasPrefix(trimmed, "@@") { lines[i] = colour.Header(lines[i]) }
if strings.HasPrefix(trimmed, "+++") || strings.HasPrefix(trimmed, "+ ") { lines[i] = colour.Green(lines[i]) } }
return strings.Join(lines, "\n")}