···55 "fmt"
66 "math"
77 "strings"
88+ "unicode/utf8"
89)
9101011const (
···3738// String implements [fmt.Stringer] for failure, allowing it to print itself in the test log.
3839func (f failure[T]) String() string {
3940 s := &strings.Builder{}
4040- s.WriteByte('\n')
4141-4242- s.WriteString(f.cfg.title)
4343- s.WriteByte('\n')
4444- s.WriteString(strings.Repeat("-", len(f.cfg.title)))
4545- s.WriteString("\n\n")
4141+ f.cfg.writeHeader(s)
46424743 fmt.Fprintf(s, "Got:\t%+v\n", f.got)
4844 fmt.Fprintf(s, "Wanted:\t%+v\n", f.want)
49455050- if f.cfg.context != "" {
5151- fmt.Fprintf(s, "\n(%s)\n", f.cfg.context)
5252- }
5353-5454- if f.cfg.reason != "" {
5555- fmt.Fprintf(s, "\nBecause: %s\n", f.cfg.reason)
5656- }
4646+ f.cfg.writeFooter(s)
57475848 return s.String()
4949+}
5050+5151+// writeHeader writes the title block (leading blank line, title, underline, blank line)
5252+// to s. The underline is sized by rune count so multi-byte titles align correctly.
5353+func (c config) writeHeader(s *strings.Builder) {
5454+ s.WriteByte('\n')
5555+ s.WriteString(c.title)
5656+ s.WriteByte('\n')
5757+ s.WriteString(strings.Repeat("-", utf8.RuneCountInString(c.title)))
5858+ s.WriteString("\n\n")
5959+}
6060+6161+// writeFooter writes any optional context and reason lines to s.
6262+func (c config) writeFooter(s *strings.Builder) {
6363+ if c.context != "" {
6464+ fmt.Fprintf(s, "\n(%s)\n", c.context)
6565+ }
6666+6767+ if c.reason != "" {
6868+ fmt.Fprintf(s, "\nBecause: %s\n", c.reason)
6969+ }
5970}
60716172// Option is a configuration option for a test.
···7990// two floating point numbers before they are considered equal. This setting is only
8091// used in [NearlyEqual] and [NotNearlyEqual].
8192//
8282-// Setting threshold to ±math.Inf is an error and will fail the test.
9393+// Setting threshold to ±[math.Inf] is an error and will fail the test.
8394//
8495// The default is 1e-8, a sensible default for most cases.
8596func FloatEqualityThreshold(threshold float64) Option {
···137148// test.Ok(t, err, test.Context("something complicated failed"))
138149func Context(format string, args ...any) Option {
139150 f := func(cfg *config) error {
140140- if format == "" {
151151+ context := strings.TrimSpace(fmt.Sprintf(format, args...))
152152+ if context == "" {
141153 return errors.New("cannot set context to an empty string")
142154 }
143155144144- context := fmt.Sprintf(format, args...)
145145- cfg.context = strings.TrimSpace(context)
156156+ cfg.context = context
146157147158 return nil
148159 }
+136-69
test.go
···1111 "io"
1212 "math"
1313 "os"
1414- "sync"
1414+ "strings"
1515 "testing"
16161717 "go.followtheprocess.codes/diff"
1818 "go.followtheprocess.codes/diff/render"
1919 "go.followtheprocess.codes/hue"
2020)
2121+2222+// errAny is a sentinel rendered in failure output when the caller expected
2323+// an error but got nil — it reads more naturally than the previous
2424+// placeholder errors.New("error") (which printed "Wanted: error").
2525+var errAny = errors.New("<any error>")
21262227// ColorEnabled sets whether the output from this package is colourised.
2328//
···125130 }
126131}
127132128128-// NotEqualFunc is like [Equal] but accepts a custom comparator function, useful
133133+// NotEqualFunc is like [NotEqual] but accepts a custom comparator function, useful
129134// when the items to be compared do not implement the comparable generic constraint.
130135//
131136// The signature of the comparator is such that standard library functions such as
···133138//
134139// The comparator should return true if the two items should be considered equal.
135140//
136136-// test.EqualFunc(t, []int{1, 2, 3}, []int{1, 2, 3}, slices.Equal) // Fails
137137-// test.EqualFunc(t, []int{1, 2, 3}, []int{4, 5, 6}, slices.Equal) // Passes
141141+// test.NotEqualFunc(t, []int{1, 2, 3}, []int{1, 2, 3}, slices.Equal) // Fails
142142+// test.NotEqualFunc(t, []int{1, 2, 3}, []int{4, 5, 6}, slices.Equal) // Passes
138143func NotEqualFunc[T any](tb testing.TB, got, want T, equal func(a, b T) bool, options ...Option) {
139144 tb.Helper()
140145···181186 }
182187 }
183188184184- diff := math.Abs(float64(got - want))
185185- if diff > cfg.floatEqualityThreshold {
189189+ delta := math.Abs(float64(got - want))
190190+ if delta > cfg.floatEqualityThreshold {
186191 cfg.reason = fmt.Sprintf(
187192 "Difference %v - %v = %v exceeds maximum tolerance of %v",
188193 got,
189194 want,
190190- diff,
195195+ delta,
196196+ cfg.floatEqualityThreshold,
197197+ )
198198+ fail := failure[T]{
199199+ got: got,
200200+ want: want,
201201+ cfg: cfg,
202202+ }
203203+ tb.Fatal(fail.String())
204204+ }
205205+}
206206+207207+// NotNearlyEqual is the opposite of [NearlyEqual]. It fails when got and want
208208+// are within the float equality threshold of each other.
209209+//
210210+// The threshold defaults to 1e-8 and can be configured with the
211211+// [FloatEqualityThreshold] option.
212212+//
213213+// test.NotNearlyEqual(t, 3.0000001, 3.0) // Passes, different enough
214214+// test.NotNearlyEqual(t, 3.0000000001, 3.0) // Fails, too close to be considered different
215215+func NotNearlyEqual[T ~float32 | ~float64](tb testing.TB, got, want T, options ...Option) {
216216+ tb.Helper()
217217+218218+ cfg := defaultConfig()
219219+ cfg.title = "NearlyEqual"
220220+221221+ for _, option := range options {
222222+ if err := option.apply(&cfg); err != nil {
223223+ tb.Fatalf("NotNearlyEqual: could not apply options: %v", err)
224224+225225+ return
226226+ }
227227+ }
228228+229229+ delta := math.Abs(float64(got - want))
230230+ if delta <= cfg.floatEqualityThreshold {
231231+ cfg.reason = fmt.Sprintf(
232232+ "Difference %v - %v = %v is within tolerance of %v",
233233+ got,
234234+ want,
235235+ delta,
191236 cfg.floatEqualityThreshold,
192237 )
193238 fail := failure[T]{
···248293 if err == nil {
249294 fail := failure[error]{
250295 got: nil,
251251- want: errors.New("error"),
296296+ want: errAny,
252297 cfg: cfg,
253298 }
254299 tb.Fatal(fail.String())
···287332288333 if want {
289334 reason = fmt.Sprintf("Wanted an error but got %v", err)
290290- wanted = errors.New("error")
335335+ wanted = errAny
291336 } else {
292337 reason = fmt.Sprintf("Got an unexpected error: %v", err)
293338 wanted = nil
···364409//
365410// If either got or want do not end in a newline, one is added to avoid a
366411// "No newline at end of file" warning in the diff which is visually distracting.
367367-func Diff(tb testing.TB, got, want string) {
412412+func Diff(tb testing.TB, got, want string, options ...Option) {
368413 tb.Helper()
369369- DiffBytes(tb, []byte(got), []byte(want))
414414+ DiffBytes(tb, []byte(got), []byte(want), options...)
370415}
371416372417// DiffBytes fails if the two []byte got and want are not equal and provides a rich
···374419//
375420// If either got or want do not end in a newline, one is added to avoid a
376421// "No newline at end of file" warning in the diff which is visually distracting.
377377-func DiffBytes(tb testing.TB, got, want []byte) {
422422+func DiffBytes(tb testing.TB, got, want []byte, options ...Option) {
378423 tb.Helper()
424424+425425+ cfg := defaultConfig()
426426+ cfg.title = "Diff"
427427+428428+ for _, option := range options {
429429+ if err := option.apply(&cfg); err != nil {
430430+ tb.Fatalf("DiffBytes: could not apply options: %v", err)
431431+432432+ return
433433+ }
434434+ }
379435380436 got = fixNL(got)
381437 want = fixNL(want)
···383439 d := diff.New("want", want, "got", got)
384440385441 if !d.Equal() {
386386- tb.Fatalf("\nDiff\n----\n%s\n", render.Render(d))
442442+ s := &strings.Builder{}
443443+ cfg.writeHeader(s)
444444+ s.Write(render.Render(d))
445445+ cfg.writeFooter(s)
446446+ tb.Fatal(s.String())
387447 }
388448}
389449···392452//
393453// If either got or want do not end in a newline, one is added to avoid
394454// a "No newline at end of file" warning in the diff which is visually distracting.
395395-func DiffReader(tb testing.TB, got, want io.Reader) {
455455+func DiffReader(tb testing.TB, got, want io.Reader, options ...Option) {
396456 tb.Helper()
397457398458 gotData, err := io.ReadAll(got)
399459 if err != nil {
400400- tb.Fatalf("DiffReader: could not read from got: %v\n", err)
460460+ tb.Fatalf("DiffReader: could not read from got: %v", err)
401461 }
402462403463 wantData, err := io.ReadAll(want)
404464 if err != nil {
405405- tb.Fatalf("DiffReader: could not read from want: %v\n", err)
465465+ tb.Fatalf("DiffReader: could not read from want: %v", err)
406466 }
407467408408- DiffBytes(tb, gotData, wantData)
468468+ DiffBytes(tb, gotData, wantData, options...)
409469}
410470411471// CaptureOutput captures and returns data printed to [os.Stdout] and [os.Stderr] by the provided function fn, allowing
···414474// If the provided function returns a non nil error, the test is failed with the error logged as the reason.
415475//
416476// If any error occurs capturing stdout or stderr, the test will also be failed with a descriptive log.
477477+//
478478+// CaptureOutput replaces the process-wide [os.Stdout] and [os.Stderr] for the duration of the call,
479479+// so it is NOT safe to use from tests marked with [testing.T.Parallel].
417480//
418481// fn := func() error {
419482// fmt.Println("hello stdout")
···426489func CaptureOutput(tb testing.TB, fn func() error) (stdout, stderr string) {
427490 tb.Helper()
428491429429- // Take copies of the original streams
430492 oldStdout := os.Stdout
431493 oldStderr := os.Stderr
432432-433433- defer func() {
434434- // Restore everything back to normal
435435- os.Stdout = oldStdout
436436- os.Stderr = oldStderr
437437- }()
438494439495 stdoutReader, stdoutWriter, err := os.Pipe()
440496 if err != nil {
441497 tb.Fatalf("CaptureOutput: could not construct an os.Pipe(): %v", err)
498498+499499+ return "", ""
442500 }
443501444502 stderrReader, stderrWriter, err := os.Pipe()
445503 if err != nil {
504504+ stdoutReader.Close()
505505+ stdoutWriter.Close()
446506 tb.Fatalf("CaptureOutput: could not construct an os.Pipe(): %v", err)
507507+508508+ return "", ""
447509 }
448510449449- // Set stdout and stderr streams to the pipe writers
450511 os.Stdout = stdoutWriter
451512 os.Stderr = stderrWriter
452513453453- stdoutCapture := make(chan string)
454454- stderrCapture := make(chan string)
514514+ // Buffered so the copy goroutines can always deliver their result, even if
515515+ // the main goroutine exits early via Fatalf / runtime.Goexit / panic.
516516+ stdoutCapture := make(chan string, 1)
517517+ stderrCapture := make(chan string, 1)
455518456456- var wg sync.WaitGroup
519519+ // Goroutines use Errorf rather than Fatalf — the testing contract says
520520+ // FailNow/Fatal* must only be called from the main test goroutine.
521521+ go copyInto(tb, "stdout", stdoutReader, stdoutCapture)
522522+ go copyInto(tb, "stderr", stderrReader, stderrCapture)
457523458458- // Copy in goroutines to avoid blocking
459459- wg.Go(func() {
460460- defer func() {
461461- close(stdoutCapture)
462462- }()
463463-464464- buf := &bytes.Buffer{}
465465- if _, err := io.Copy(buf, stdoutReader); err != nil {
466466- tb.Fatalf("CaptureOutput: failed to copy from stdout reader: %v", err)
524524+ // Ensure the real streams are restored and the pipe writers are closed on
525525+ // every exit path (including panic / Goexit). Closing the writers lets the
526526+ // copy goroutines see EOF and deliver their buffers to the channels.
527527+ writersClosed := false
528528+ closeWriters := func() {
529529+ if writersClosed {
530530+ return
467531 }
468532469469- stdoutCapture <- buf.String()
470470- })
533533+ writersClosed = true
471534472472- wg.Go(func() {
473473- defer func() {
474474- close(stderrCapture)
475475- }()
476476-477477- buf := &bytes.Buffer{}
478478- if _, err := io.Copy(buf, stderrReader); err != nil {
479479- tb.Fatalf("CaptureOutput: failed to copy from stderr reader: %v", err)
480480- }
481481-482482- stderrCapture <- buf.String()
483483- })
484484-485485- // Call the test function that produces the output
486486- if err := fn(); err != nil {
487487- tb.Fatalf("CaptureOutput: user function returned an error: %v", err)
535535+ stdoutWriter.Close()
536536+ stderrWriter.Close()
488537 }
489538490490- // Close the writers
491491- stdoutCloseErr := stdoutWriter.Close()
492492- if stdoutCloseErr != nil {
493493- tb.Fatalf("CaptureOutput: could not close stdout pipe: %v", stdoutCloseErr)
539539+ defer func() {
540540+ closeWriters()
541541+542542+ os.Stdout = oldStdout
543543+ os.Stderr = oldStderr
544544+ }()
545545+546546+ if fnErr := fn(); fnErr != nil {
547547+ tb.Fatalf("CaptureOutput: user function returned an error: %v", fnErr)
548548+549549+ return "", ""
494550 }
495551496496- stderrCloseErr := stderrWriter.Close()
497497- if stderrCloseErr != nil {
498498- tb.Fatalf("CaptureOutput: could not close stderr pipe: %v", stderrCloseErr)
552552+ // Happy path: close writers now so we can receive the captured data before
553553+ // the defer runs (the defer's close is then a no-op).
554554+ closeWriters()
555555+556556+ return <-stdoutCapture, <-stderrCapture
557557+}
558558+559559+// copyInto reads from r into a buffer and sends the result on out. Any copy
560560+// error is reported against tb via Errorf — Fatal* is unsafe from non-main
561561+// goroutines. The send uses a buffered channel so it never blocks.
562562+func copyInto(tb testing.TB, name string, r io.Reader, out chan<- string) {
563563+ tb.Helper()
564564+565565+ buf := &bytes.Buffer{}
566566+567567+ defer func() {
568568+ out <- buf.String()
569569+ }()
570570+571571+ if _, err := io.Copy(buf, r); err != nil {
572572+ tb.Errorf("CaptureOutput: failed to copy from %s reader: %v", name, err)
499573 }
500500-501501- capturedStdout := <-stdoutCapture
502502- capturedStderr := <-stderrCapture
503503-504504- wg.Wait()
505505-506506- return capturedStdout, capturedStderr
507574}
508575509576// If data is empty or ends in \n, fixNL returns data.
···11source: test_test.go
22expression: buf.String()
33---
44-|+
44+|
5566 Diff
77 ----
88+89 diff want got
910 --- want
1011 +++ got
···1516 - this line is different
1617 + lines as well wow
1718 some more stuff
1818-
···11source: test_test.go
22expression: buf.String()
33---
44-|+
44+|
5566 Diff
77 ----
88+89 diff want got
910 --- want
1011 +++ got
···1516 - this line is different
1617 + lines as well wow
1718 some more stuff
1818-
···11+source: test_test.go
22+expression: buf.String()
33+---
44+|
55+66+ Diff
77+ ----
88+99+ diff want got
1010+ --- want
1111+ +++ got
1212+ @@ -1,4 +1,4 @@
1313+ Some
1414+ - different stuff here in this file
1515+ + stuff here in this file
1616+ - this line is different
1717+ + lines as well wow
1818+ some more stuff
1919+2020+ (config file drifted from checked-in copy)
···11+source: test_test.go
22+expression: buf.String()
33+---
44+|
55+66+ File drift
77+ ----------
88+99+ diff want got
1010+ --- want
1111+ +++ got
1212+ @@ -1,4 +1,4 @@
1313+ Some
1414+ - different stuff here in this file
1515+ + stuff here in this file
1616+ - this line is different
1717+ + lines as well wow
1818+ some more stuff
+2-2
testdata/snapshots/TestTest/DiffBytes/fail.snap
···11source: test_test.go
22expression: buf.String()
33---
44-|+
44+|
5566 Diff
77 ----
88+89 diff want got
910 --- want
1011 +++ got
···1516 - this line is different
1617 + lines as well wow
1718 some more stuff
1818-
+2-2
testdata/snapshots/TestTest/DiffReader/fail.snap
···11source: test_test.go
22expression: buf.String()
33---
44-|+
44+|
5566 Diff
77 ----
88+89 diff want got
910 --- want
1011 +++ got
···1516 - this line is different
1617 + lines as well wow
1718 some more stuff
1818-
···11+source: test_test.go
22+expression: buf.String()
33+---
44+'NearlyEqual: could not apply options: cannot set floating point equality threshold to ±infinity'
···11+source: test_test.go
22+expression: buf.String()
33+---
44+'NearlyEqual: could not apply options: cannot set floating point equality threshold to ±infinity'