gotestify

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 6, 2026 License: MIT Imports: 16 Imported by: 0

README

go-testify

An extension of stretchr/testify for asserting on JSON documents - and, to a lesser extent, tables.

go get github.com/iv-one/go-testify
import "github.com/iv-one/go-testify" // package gotestify

Requires Go 1.27 (uses the stdlib encoding/json/v2 and uuid packages). The only dependency is testify itself, and only for the test suite.

The helpers accept the same TestingT that testify's assert package does, take arguments in the same (t, expected, actual) order, and return a bool the same way, so they drop in next to assert and require without ceremony.

The problem

You have a service method that returns a struct, and you want to assert on the whole thing:

func (s *UserService) GetUser(ctx context.Context, id string) (*User, error)

Asserting field by field is verbose and, worse, it silently ignores the fields you forgot to list - a new field with a wrong value slips through:

u, err := svc.GetUser(ctx, id)
require.NoError(t, err)
assert.Equal(t, "alice@example.com", u.Email)
assert.Equal(t, "Alice", u.Name)
assert.NotZero(t, u.CreatedAt)  // and so on, forever

assert.Equal against a full literal struct is exhaustive, but then you have to construct - and keep constructing - the server-generated values: ids, timestamps, tenant references. Those change on every run.

The fix

Write the expected value as the JSON you actually expect, and use a placeholder wherever the value is generated rather than fixed:

func TestGetUser(t *testing.T) {
	u, err := svc.GetUser(ctx, id)
	require.NoError(t, err)

	gotestify.JSONEqual(t, `{
		"id":         "{{uuid}}",
		"email":      "alice@example.com",
		"name":       "Alice",
		"team_id":    "{{uuid}}",
		"created_at": "{{timestamp}}",
		"updated_at": "{{timestamp}}"
	}`, u)
}

The actual value can be a struct, a pointer, a map, or a JSON string - anything that is not a string is marshaled with encoding/json/v2 first, so the assertion is written against the same JSON your API actually serves.

The comparison is exhaustive: add a field to User and this test fails until you acknowledge it. On failure you get one colorized diff of the whole document rather than a list of unrelated assertion errors.

Runnable examples for every helper are in example_test.go and on pkg.go.dev.

Matchers

Placeholders go on the expected side.

Placeholder Matches
{{any}} any non-null value
{{timestamp}} a string parseable as RFC 3339
{{uuid}} a string parseable as a UUID
{{name}} anything - and binds the actual value to the variable name

Add your own with RegisterMatcher, typically from an init or TestMain:

gotestify.RegisterMatcher("email", func(v any) bool {
	s, ok := v.(string)
	return ok && strings.Contains(s, "@")
})

gotestify.JSONEqual(t, `{"email": "{{email}}"}`, resp)

Numbers reach a matcher as gotestify.Number, the literal text of the JSON number.

Capturing and reusing values

A placeholder that is not a registered matcher is a capture variable. It binds to whatever the actual side holds, and every later use of that name must match the same value - which is how you assert that two ids in a response refer to each other, without knowing either:

gotestify.JSONEqual(t, `{
	"user":  {"id": "{{uid}}", "name": "Alice"},
	"owner": {"id": "{{uid}}"},
	"self":  "/users/{{uid}}"
}`, resp)

That passes when user.id, owner.id and the self link agree, and fails when they don't. "{{x}}:{{y}}" and similar are rendered with text/template against the captured variables, so you can assert on composed strings.

To pull a generated id out of one response and feed it into the next request, use CollectVars:

vars, err := gotestify.CollectVars(expected, actual, gotestify.JSONDiffOptions())
require.NoError(t, err)
id := vars["uid"].(string)

Partial matching

JSONSubset accepts extra fields on the actual side - useful when you only care about part of a large payload:

gotestify.JSONSubset(t, `{"id": "{{uuid}}", "name": "Alice"}`, resp)

For the raw result, Compare returns a Difference (FullMatch, SubsetMatch, SupersetMatch, NoMatch, ...) plus the rendered diff, which lets you write your own assertion or inspect the comparison without failing a test.

Custom encoding

JSONEqual, JSONSubset, PrintTable and TableEqual take trailing ...json.Options (v2), so the codecs your service uses apply to the value under test. Say your API renders a Timestamp type as RFC 3339 rather than its default encoding:

rfc3339 := json.WithMarshalers(json.MarshalToFunc(
	func(enc *jsontext.Encoder, ts Timestamp) error {
		return enc.WriteToken(jsontext.String(ts.Time().UTC().Format(time.RFC3339)))
	}))

gotestify.JSONEqual(t, expected, resp, rfc3339)

Options shape how the arguments are marshaled before comparison. The comparison itself works on the resulting JSON text and is not affected by them.

Compared to jsonassert

kinbiko/jsonassert solves the same core problem and is the more mature, more focused library. The differences that matter when choosing:

  • Placeholders. jsonassert has <<PRESENCE>> - the value exists, ignore it. This package adds typed matchers ({{uuid}}, {{timestamp}}, your own via RegisterMatcher), so a malformed id or a timestamp serialized in the wrong format fails instead of passing as "present".
  • Cross-field assertions. Capture variables have no jsonassert equivalent. Asserting that two generated ids in a payload are the same id is the main reason to reach for this package.
  • Arrays. jsonassert has <<UNORDERED>>; this package compares arrays strictly by index. If your payloads have non-deterministic array order, prefer jsonassert.
  • Formatting. jsonassert builds the expected document with Assertf and fmt.Sprintf verbs. Here the expected document is a plain string and substitution happens through placeholders, which keeps % literals and %d-shaped content out of the picture.

Gotchas

  • {{any}} does not match null. Write null explicitly when you expect it.
  • Raw []byte is marshaled as a base64 string, not treated as a JSON document. Convert a response body with string(b) first.
  • Numbers are compared by their literal text, so 1 does not equal 1.0 and large integers keep full precision. Set DiffOptions.CompareNumbers to relax that.
  • Arrays are compared by index; there is no unordered mode.
  • A variable used as an object key must also be bound from a value position elsewhere in the document - a key-only variable resolves to nothing.
  • A malformed {{...}} expression yields ExpressionError with the template error in the diff; it never panics.
  • Rendered diffs are meant to be read, not parsed. They are not valid JSON.
  • DiffOptions.SkipMatches collapses the matching parts of a diff, which helps on large payloads.

Tables

TableEqual renders a slice as a text table and diffs it cell by cell - handy for asserting on report or listing output. Columns are JSON field names:

rows := []Row{{Name: "Alice", Age: 25}, {Name: "Bob", Age: 30}}

gotestify.TableEqual(t, `
	Alice |25 |
	Bob   |30 |
`, rows, []string{"name", "age"})

Leading and trailing whitespace on the expected side is trimmed, so the literal can be indented to match the surrounding code. PrintTable returns the rendered table if you want to assert on it yourself.

Capturing logs

CaptureSlog redirects the default slog logger into a buffer for the duration of a test and restores it on cleanup. It swaps a process-global, so it is not safe under t.Parallel:

buf := gotestify.CaptureSlog(t)
svc.DoWork(ctx)
assert.Contains(t, buf.String(), "work completed")

Credits

JSON diffing is based on nsf/jsondiff, extended with the placeholder and capture-variable machinery described above.

License

MIT - see LICENSE.

Documentation

Overview

Package gotestify extends github.com/stretchr/testify with assertions on JSON documents and tabular output. Its helpers take the same TestingT that testify's assert package accepts, follow the same (t, expected, actual) argument order, and return a bool the same way, so they sit naturally next to assert and require in a test.

JSONEqual and JSONSubset compare two JSON documents and fail the test with a colorized diff. The expected side may contain {{...}} placeholders that match a value by shape ({{uuid}}, {{timestamp}}, {{any}}, or anything added with RegisterMatcher), capture a value for reuse, or interpolate previously captured values; see Compare for the details.

PrintTable and TableEqual render a slice of values as a text table keyed by JSON field name and diff it cell by cell.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func CaptureSlog

func CaptureSlog(t CleanupT) *bytes.Buffer

CaptureSlog routes the default slog logger into the returned buffer for the rest of the test, restoring the previous logger at cleanup. Not safe under t.Parallel: the default logger is process-global.

Example
var t reporter
buf := gotestify.CaptureSlog(&t)

slog.Info("work completed", "items", 3)

fmt.Println(contains(buf.String(), "work completed"), contains(buf.String(), "items=3"))
for _, f := range t.cleanups {
	f()
}
Output:
true true

func CollectVars

func CollectVars(a, b string, opts *DiffOptions) (map[string]any, error)

CollectVars collects the variables from two JSON documents using given options.

Example
vars, err := gotestify.CollectVars(
	`{"id": "{{uid}}", "team": {"id": "{{tid}}"}}`,
	`{"id": "u_1", "team": {"id": "t_9"}}`,
	gotestify.JSONDiffOptions(),
)

fmt.Println(err, vars["uid"], vars["tid"])
Output:
<nil> u_1 t_9

func CollectVarsStream

func CollectVarsStream(a, b io.Reader, opts *DiffOptions) (map[string]any, error)

CollectVarsStream collects the variables from two JSON documents streamed by the specified readers using given options.

func JSONEqual

func JSONEqual(t TestingT, expected, actual any, jsonOpts ...json.Options) bool

JSONEqual reports whether expected and actual are the same JSON document, failing t with a colorized diff when they are not. Either argument may be a JSON string or any value marshalable with encoding/json/v2 and jsonOpts. The expected side may use {{...}} placeholders: see Compare.

Example
var t reporter
user := getUser()

ok := gotestify.JSONEqual(&t, `{
		"id":         "{{uuid}}",
		"email":      "alice@example.com",
		"team_id":    "{{uuid}}",
		"created_at": "{{timestamp}}"
	}`, user)

fmt.Println(ok)
Output:
true
Example (CaptureVariables)
var t reporter
resp := `{
		"user":  {"id": "u_1", "name": "Alice"},
		"owner": {"id": "u_1"},
		"self":  "/users/u_1"
	}`

// {{uid}} binds to the first value it meets and must match everywhere it
// recurs; "/users/{{uid}}" is rendered from the captured value.
ok := gotestify.JSONEqual(&t, `{
		"user":  {"id": "{{uid}}", "name": "Alice"},
		"owner": {"id": "{{uid}}"},
		"self":  "/users/{{uid}}"
	}`, resp)

fmt.Println(ok)
Output:
true

func JSONSubset

func JSONSubset(t TestingT, expected, actual any, jsonOpts ...json.Options) bool

JSONSubset is JSONEqual that also accepts extra properties on the actual side, for asserting on part of a larger payload.

Example
var t reporter
resp := `{"id": "0193f2a1-4c3b-7a91-b8e2-1f5d9c7a3e40", "name": "Alice", "internal": true}`

// Extra properties on the actual side are fine.
ok := gotestify.JSONSubset(&t, `{"id": "{{uuid}}", "name": "Alice"}`, resp)

fmt.Println(ok)
Output:
true

func PrettyJSON

func PrettyJSON(val any, opts ...json.Options) string

PrettyJSON formats the given value as an indented JSON string. On failure it returns the error text, so it stays usable inside a failure message.

func PrintTable

func PrintTable(data any, columns []string, opts ...json.Options) (string, error)

PrintTable renders data as a text table with the given columns. Columns are JSON field names: see createTable.

func RegisterMatcher

func RegisterMatcher(name string, fn Matcher)

RegisterMatcher makes {{name}} usable as a placeholder on the expected side of a comparison, matching any value for which fn returns true. Registering a name again replaces the previous matcher. It panics if name is empty or fn is nil.

Example
var t reporter
gotestify.RegisterMatcher("email", func(v any) bool {
	s, ok := v.(string)
	return ok && len(s) > 3 && s[0] != '@' && s[len(s)-1] != '@' && contains(s, "@")
})

ok := gotestify.JSONEqual(&t, `{"email": "{{email}}"}`, `{"email": "alice@example.com"}`)

fmt.Println(ok)
Output:
true

func TableEqual

func TableEqual(t TestingT, expected string, data any, columns []string, jsonOpts ...json.Options) bool

TableEqual renders data as a table (see PrintTable) and reports whether it matches expected line by line and cell by cell, failing t with a colorized diff when it does not. Leading and trailing whitespace on each expected line is ignored, as are blank lines, so the literal can be indented to match the surrounding code.

Example
var t reporter
type Row struct {
	Name string `json:"name"`
	Age  int    `json:"age"`
}
rows := []Row{{"Alice", 25}, {"Bob", 30}}

// Columns are JSON field names; whitespace around each expected line is
// ignored so the literal can be indented.
ok := gotestify.TableEqual(&t, `
		Alice |25 |
		Bob   |30 |
	`, rows, []string{"name", "age"})

fmt.Println(ok)
Output:
true

Types

type CleanupT

type CleanupT interface {
	Cleanup(func())
}

CleanupT is the subset of *testing.T the capture needs.

type DiffOptions

type DiffOptions struct {
	Normal  Tag
	Added   Tag
	Removed Tag
	Changed Tag
	Skipped Tag

	Prefix           string
	Indent           string
	PrintTypes       bool
	ChangedSeparator string
	// When provided, this function will be used to compare two numbers. By default numbers are compared using their
	// literal representation byte by byte.
	CompareNumbers func(a, b Number) bool
	// When true, only differences will be printed. By default, it will print the full json.
	SkipMatches bool
	// contains filtered or unexported fields
}

Options controls how Compare renders a diff. It is unrelated to json.Options.

func ConsoleDiffOptions

func ConsoleDiffOptions() *DiffOptions

ConsoleDiffOptions provides a set of options that are well suited for console output. Options use ANSI foreground color escape sequences to highlight changes. It returns the default console options.

func JSONDiffOptions

func JSONDiffOptions() *DiffOptions

JSONDiffOptions provides a set of options in JSON format that are fully parseable. It returns the default JSON options.

type Difference

type Difference int

Difference is the difference type.

const (
	// FullMatch means provided arguments are deeply equal.
	FullMatch Difference = iota
	// SupersetMatch means first argument is a superset of a second argument.
	SupersetMatch
	// SubsetMatch means first argument is a subset of a second argument.
	SubsetMatch
	// NoMatch means there is no match.
	NoMatch
	// FirstArgIsInvalidJSON means the first argument is invalid JSON.
	FirstArgIsInvalidJSON
	// SecondArgIsInvalidJSON means the second argument is invalid JSON.
	SecondArgIsInvalidJSON
	// BothArgsAreInvalidJSON means both arguments are invalid JSON.
	BothArgsAreInvalidJSON
	// ExpressionError means a {{...}} expression in the first argument could
	// not be rendered, for example because it references an unbound variable
	// with invalid template syntax.
	ExpressionError
)

func Compare

func Compare(a, b []byte, opts *DiffOptions) (Difference, string)

Compare compares two JSON documents using given options. Returns difference type and a string describing differences.

*FullMatch* means provided arguments are deeply equal.

*SupersetMatch* means first argument is a superset of a second argument. In this context being a superset means that for each object or array in the hierarchy which don't match exactly, it must be a superset of another one. For example:

{"a": 123, "b": 456, "c": [7, 8, 9]}

Is a superset of:

{"a": 123, "c": [7, 8]}

*SubsetMatch* means first argument is a subset of a second argument. In this context being a subset means that for each object or array in the hierarchy which don't match exactly, it must be a subset of another one. For example:

{"a": 123, "c": [7, 8]}

Is a subset of:

{"a": 123, "b": 456, "c": [7, 8, 9]}

*NoMatch* means there is no match.

The rest of the difference types mean that one of or both JSON documents are invalid JSON.

Returned string uses a format similar to pretty printed JSON to show the human-readable difference between provided JSON documents. It is important to understand that returned format is not a valid JSON and is not meant to be machine readable.

Strings on the a side may be placeholders: {{any}}, {{timestamp}} and {{uuid}} match a value by shape; any other {{name}} captures the b value and must match it wherever the name recurs; "{{x}}:{{y}}" and similar are rendered with text/template against the captured values.

Both documents are decoded with encoding/json (v1) so that numbers keep their literal text; json.Options passed to JSONEqual and friends affect only how the arguments are marshaled, not this comparison.

Example
diff, rendered := gotestify.Compare(
	[]byte(`{"a": 1, "b": "{{any}}", "c": [1, 2]}`),
	[]byte(`{"a": 2, "b": null, "c": [1, 2, 3]}`),
	gotestify.JSONDiffOptions(),
)

fmt.Println(diff)
fmt.Println(rendered)
Output:
NoMatch
{
    "a": {"changed":[1, 2]},
    "b": {"changed":["{{any}}", null]},
    "c": [
        1,
        2,
        "prop-added":{3}
    ]
}

func CompareStr

func CompareStr(a, b string, opts *DiffOptions) (Difference, string)

CompareStr is Compare for string documents.

func CompareStreams

func CompareStreams(a, b io.Reader, opts *DiffOptions) (Difference, string)

CompareStreams compares two JSON documents streamed by the specified readers. See the documentation for `Compare` for a description of the input options and return values.

func (Difference) String

func (d Difference) String() string

String returns the string representation of the difference type.

type Matcher

type Matcher func(actual any) bool

Matcher reports whether an actual value satisfies a placeholder such as {{uuid}}.

type Number

type Number string

Number is a JSON number kept as its literal text, so that 1 and 1.0 stay distinguishable and large integers keep their precision. It is what Compare passes to DiffOptions.CompareNumbers.

type Tag

type Tag struct {
	Begin string
	End   string
}

Tag wraps a span of diff output, e.g. with ANSI color codes.

type TestingT

type TestingT interface {
	Errorf(format string, args ...any)
}

TestingT is the subset of *testing.T the assertion helpers need.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL