Snapshot Testing in Go: Cupaloy and go-snaps Compared

Snapshot Testing in Go: Cupaloy and go-snaps Compared

Go's testing package has no built-in snapshot testing, but two libraries fill the gap: cupaloy (simpler, widely used) and go-snaps (more features, testify integration). Both capture test output to files, compare on future runs, and update via an environment variable flag. This guide covers both with practical examples.

Key Takeaways

Cupaloy is simpler; go-snaps has more features. Cupaloy is a thin wrapper: c.SnapshotT(t, value). go-snaps adds MatchSnapshot(), testify integration, and custom serializers. Start with cupaloy; move to go-snaps if you need more.

Update snapshots with an environment variable, not a flag. Both libraries use UPDATE_SNAPS=true go test ./... (go-snaps) or UPDATE_SNAPSHOTS=true (cupaloy) instead of a CLI flag.

Snapshot files are plain text — commit them. Both libraries create text files in __snapshots__/ directories. They're human-readable and belong in source control.

Serialize to a deterministic format before snapshotting. json.Marshal in Go produces stable JSON only if your struct doesn't contain maps (maps are unordered). Use encoding/json with sorted keys or json.MarshalIndent.

Use table-driven tests with snapshots for complex inputs. Go's idiomatic table-driven tests combine naturally with snapshot testing — each test case gets its own snapshot entry.

Why Snapshot Testing in Go?

Go's testing philosophy favors explicit assertions: if got != want { t.Errorf(...) }. That works great for simple values. It breaks down for complex outputs — JSON API responses, formatted reports, code generator output — where writing out the expected value manually is tedious and brittle.

Snapshot testing solves this: capture the output once, store it, fail if it changes.

Cupaloy Setup

go get github.com/bradleyjkemp/cupaloy/v2

Cupaloy stores snapshots in .snapshots/ by default. Configure via cupaloy.New() or environment variables.

Basic Usage

// orders_test.go
package orders_test

import (
    "encoding/json"
    "testing"

    "github.com/bradleyjkemp/cupaloy/v2"
    "myapp/orders"
)

var snapshotter = cupaloy.New(
    cupaloy.SnapshotFileExtension(".txt"),
    cupaloy.SnapshotSubdirectory("testdata/snapshots"),
)

func TestFormatOrderSummary(t *testing.T) {
    order := orders.Order{
        ID:       "ORD-001",
        Customer: "Alice",
        Items: []orders.Item{
            {Product: "Widget", Qty: 2, Price: 14.99},
        },
        Total: 29.98,
    }

    summary := orders.FormatSummary(order)

    snapshotter.SnapshotT(t, summary)
}

First run creates testdata/snapshots/TestFormatOrderSummary.txt and fails — you must review the file and approve by running:

UPDATE_SNAPSHOTS=true go <span class="hljs-built_in">test ./...

Wait — that creates AND approves. For cupaloy, the workflow is:

  1. Run test — it fails with "snapshot not found"
  2. The received output is printed in the test failure
  3. Copy the output to the snapshot file manually, or use UPDATE_SNAPSHOTS=true
  4. Re-run — test passes

Cupaloy with JSON Output

For structured data, marshal to JSON before snapshotting:

func TestOrderAsJSON(t *testing.T) {
    order := buildTestOrder()

    b, err := json.MarshalIndent(order, "", "  ")
    if err != nil {
        t.Fatal(err)
    }

    snapshotter.SnapshotT(t, string(b))
}

Cupaloy Table-Driven Tests

func TestPricingRules(t *testing.T) {
    tests := []struct {
        name     string
        customer string
        qty      int
    }{
        {"standard-small", "standard", 1},
        {"standard-bulk", "standard", 100},
        {"premium-small", "premium", 1},
        {"premium-bulk", "premium", 100},
    }

    for _, tc := range tests {
        t.Run(tc.name, func(t *testing.T) {
            result := CalculatePrice(tc.customer, tc.qty)

            b, _ := json.MarshalIndent(result, "", "  ")
            snapshotter.SnapshotT(t, string(b))
            // Creates: testdata/snapshots/TestPricingRules/standard-small.txt
        })
    }
}

Each sub-test gets its own snapshot file in a subdirectory.

go-snaps Setup

go get github.com/gkampitakis/go-snaps

go-snaps has a richer API and integrates with testify's assert and require:

import snaps "github.com/gkampitakis/go-snaps/snaps"

Basic Usage

func TestFormatReport(t *testing.T) {
    report := GenerateMonthlyReport("2024-01")

    b, _ := json.MarshalIndent(report, "", "  ")
    snaps.MatchSnapshot(t, string(b))
}

go-snaps creates __snapshots__/TestFormatReport.snap:

[TestFormatReport - 1]
{
  "month": "2024-01",
  "totalOrders": 142,
  "totalRevenue": 15984.32
}
---

Updating go-snaps Snapshots

UPDATE_SNAPS=true go <span class="hljs-built_in">test ./...

go-snaps with testify

import (
    "testing"
    "github.com/stretchr/testify/assert"
    snaps "github.com/gkampitakis/go-snaps/snaps"
)

func TestAPIResponse(t *testing.T) {
    resp := callAPI("/api/orders")

    // Use testify for status code — simple assertion
    assert.Equal(t, 200, resp.StatusCode)

    // Use snapshot for body — complex output
    snaps.MatchSnapshot(t, resp.Body)
}

go-snaps Custom Matchers

go-snaps supports scrubbing non-deterministic values:

func TestOrderWithTimestamp(t *testing.T) {
    order := CreateOrder()

    // Scrub timestamp before snapshot
    snaps.MatchSnapshot(t, scrubOrder(order))
}

func scrubOrder(o Order) Order {
    o.ID = "<ID>"
    o.CreatedAt = time.Time{}  // Zero value
    return o
}

Handling Non-Deterministic Data

Both libraries require deterministic input. Common sources of non-determinism in Go:

Map Iteration Order

Go maps have undefined iteration order. Don't snapshot maps directly:

// BAD: map order is non-deterministic
data := map[string]int{"a": 1, "b": 2, "c": 3}
snaps.MatchSnapshot(t, fmt.Sprint(data))  // May produce different output each run

// GOOD: use a struct or sort the keys
type DataResult struct {
    A int `json:"a"`
    B int `json:"b"`
    C int `json:"c"`
}
result := DataResult{A: 1, B: 2, C: 3}
b, _ := json.MarshalIndent(result, "", "  ")
snaps.MatchSnapshot(t, string(b))

Timestamps

// Use a fixed time in tests
func TestWithFixedTime(t *testing.T) {
    fixedTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)

    order := createOrderAt(fixedTime)
    b, _ := json.MarshalIndent(order, "", "  ")
    snaps.MatchSnapshot(t, string(b))
}

Pointer Addresses

// BAD: printing a struct with %v includes pointer addresses
fmt.Sprintf("%v", &MyStruct{})  // &{0xc0001234}

// GOOD: serialize to JSON or use a stable string representation

Cupaloy vs. go-snaps Side by Side

Feature cupaloy go-snaps
Update flag UPDATE_SNAPSHOTS=true UPDATE_SNAPS=true
File format Custom (configurable) .snap format
Snapshot location .snapshots/ (configurable) __snapshots__/
First run behavior Fails — output printed Creates snapshot, passes
Testify integration None snaps.MatchSnapshot works with testify
Custom serializers Via string conversion Via MatchJSON, custom marshalers
Stars (GitHub) ~900 ~600

Choose cupaloy if you want simplicity and don't need testify integration. Choose go-snaps if you use testify and want richer snapshot management.

CI Configuration

In CI, never pass the update flag. If snapshots don't match, the test fails:

# .github/workflows/test.yml
- name: Run tests with snapshots
  run: go test ./... -v
  # No UPDATE_SNAPS or UPDATE_SNAPSHOTS env var
  # Snapshot mismatches fail the build

Make sure snapshot files are committed:

git add **/__snapshots__/*.snap
git add **/.snapshots/*.txt

Add to .gitignore (if you have a received/temp pattern):

# cupaloy received files (if any)
*.received.txt

When to Use Snapshot Tests in Go

Good fit Poor fit
Complex struct serialization Simple values (use assert.Equal)
Template/report output Random or time-dependent output
Code generator output Very large outputs (hard to review)
HTTP response bodies Tests that need to express why output is correct
CLI output formatting Outputs with uncontrollable non-determinism

Summary

Go has two good snapshot testing libraries:

  • cupaloy — minimal API, SnapshotT(t, value), good for simple cases
  • go-snaps — richer API, testify integration, .snap format with labeled entries

Both require:

  1. Deterministic input (no maps, timestamps, or random values)
  2. Snapshot files committed to source control
  3. UPDATE_SNAPSHOTS / UPDATE_SNAPS env var (not a flag) to regenerate

Use snapshot tests for complex output where hand-written assertions would span 20+ lines. Keep using assert.Equal for simple values — snapshot testing is a tool for specific cases, not a replacement for all assertions.

Read more