testutil

testutil provides test-only helpers for forcing I/O failures, capturing process output, bootstrapping HTTP handlers, and normalizing time-variant values.

Part of nurago, a collection of independent Go packages for backend services.

import "github.com/tecnickcom/nurago/pkg/testutil"

Test-only helpers: readers and closers that always fail, capture of stdout, stderr and the default logger, a pre-routed HTTP handler, and substitution of time-variant values in expected output.

Failing I/O on demand

reader := testutil.NewErrorReader("read failed")
closer := testutil.NewErrorCloser("close failed")

ErrorReader.Read always returns the error. ErrorCloser.Close always returns the error, on an io.ReadCloser whose read side works normally.

These exist because the error branches around I/O are the ones coverage reports flag and nobody tests. Provoking a genuine read failure needs a closed file descriptor, a broken pipe, or a full disk, none of which a unit test can arrange reliably. Provoking a Close failure is harder still: on a buffered writer it happens when the final flush fails, which is exactly the case where a handler that ignores the error loses data.

An always-failing implementation covers the branch deterministically, with no timing dependence and nothing to clean up.

NewErrorCloser is the one that pairs with errutil’s JoinFnError: it is how you assert that a deferred close failure actually reaches the returned error rather than being swallowed.

Capturing output

output := testutil.CaptureOutput(t, func() {
    fmt.Println("hello")
})

Redirects stdout, stderr, and the default log logger for the duration of the call, then restores them.

Redirecting all three matters. Code under test writes to os.Stdout directly, or through fmt.Print, or through the standard log package, and the standard logger holds its own io.Writer reference that swapping os.Stdout does not affect. Capturing only one of the three misses output from the other two.

It takes *testing.T, so restoration is tied to the test rather than left to the caller’s defer.

Normalising timestamps in assertions

got = testutil.ReplaceDateTime(got, "<DATETIME>")
got = testutil.ReplaceUnixTimestamp(got, "<TS>")

A JSON response carrying a generation timestamp cannot be compared against a fixed expected string, because the timestamp differs every run. The usual workarounds are to unmarshal and compare field by field, which is verbose, or to inject a clock everywhere, which changes production code for the benefit of tests.

These two substitute the time-variant fragments with a placeholder so the whole response body can be compared as one string. Both are regular-expression replacements over the text, so they work on a JSON body without parsing it.

Know what each pattern actually matches before relying on it:

  • ReplaceDateTime matches RFC3339-shaped values, YYYY-MM-DDThh:mm:ss with optional fractional seconds and an optional Z or numeric offset.
  • ReplaceUnixTimestamp matches a run of exactly 19 digits on word boundaries, which is a nanosecond epoch value. A 10-digit seconds timestamp does not match, and neither does a 13-digit millisecond one.

Nineteen digits is what jsendx puts in its envelope, so the pairing is deliberate. For a seconds-resolution timestamp, substitute it yourself.

An HTTP handler with a route already on it

h := testutil.RouterWithHandler(http.MethodGet, "/health", handler)

Returns an http.Handler backed by julienschmidt/httprouter with one route registered, for tests that need a real router rather than calling the handler function directly.

That distinction matters when the handler reads path parameters: those come from the router’s context, so a handler invoked directly sees none of them.

Keep it out of the production build

Everything here is exported from a normal package, so nothing stops it being imported by non-test code. Importing it in production pulls julienschmidt/httprouter into the build and puts always-failing I/O types where someone can reach them.

The package is named and documented as test-only. A lint rule restricting the import to _test.go files is worth adding if your linter supports it.

Example

output := testutil.CaptureOutput(t, func() {
    fmt.Println("hello")
})

fmt.Println(output)

Full source is in example_testutil_test.go. More runnable examples are on pkg.go.dev.

Dependencies

Reaches github.com/julienschmidt/httprouter for RouterWithHandler.