expect
A Go testing library that provides snapshot testing helpers to make your tests more maintainable and readable.
What is Snapshot Testing?
Snapshot testing is a technique where the output of a function, API, SQL query, or any other operation is captured and saved as a snapshot. On subsequent test runs, the output is compared against the existing snapshot. If they differ, the test fails, signaling an unintended change.
This approach ensures that changes to your system are explicit and deliberate. If an update is intentional, you can regenerate the snapshots to reflect the new expected behavior.
Why Snapshot Testing?
Snapshot testing provides several key benefits:
- Easier to use than assertions: Instead of writing dozens of individual
assert.Equal() calls, you snapshot the entire output. This is much easier to maintain.
- Visual verification: Snapshots are stored as files you can read and review, making it easy to visually verify the output is correct.
- Catches all changes: Snapshots detect any difference in output, not just the specific fields you thought to assert on. This catches unexpected changes you might have missed.
However, because snapshots catch any diff, you need to control sources of nondeterminism (timestamps, UUIDs, random values, etc.) to prevent flaky tests. See the Handling Nondeterminism section below for guidance.
Features
- Snapshot Testing: Automatically create and compare test output snapshots
- HTTP Testing: Helpers for testing HTTP requests and responses
- SSE Stream Testing: Parse and test Server-Sent Events streams with structured data
- SQL Formatting: Format SQL with pgFormatter in Docker, then snapshot
- Mock Testing: Snapshot mock call history
- Log Testing: Snapshot structured log output
Installation
go get github.com/funnelstory/expect
Quick Start
import (
"testing"
"github.com/funnelstory/expect"
)
func TestMyFunction(t *testing.T) {
result := myFunction()
expect.Output(t, result)
}
Usage
Basic Output Snapshots
The most basic usage is to snapshot any output:
func TestOutput(t *testing.T) {
result := "Hello, World!"
expect.Output(t, result)
}
On the first run, this creates a snapshot file. On subsequent runs, it compares the output against the snapshot.
JSON Snapshots
Format and snapshot JSON data with proper indentation:
func TestJSON(t *testing.T) {
data := map[string]any{
"name": "John Doe",
"email": "john@example.com",
"age": 30,
}
expect.JSON(t, data)
}
HTTP Request Testing
Snapshot HTTP request details including headers and body:
func TestHTTPRequest(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/users",
strings.NewReader(`{"name":"John"}`))
req.Header.Set("Content-Type", "application/json")
expect.Request(t, req)
}
HTTP Response Testing
Snapshot HTTP responses with automatic cleanup of volatile headers:
func TestHTTPResponse(t *testing.T) {
recorder := httptest.NewRecorder()
recorder.Header().Set("Content-Type", "application/json")
recorder.WriteHeader(http.StatusOK)
recorder.Write([]byte(`{"status":"ok"}`))
resp := recorder.Result()
expect.Response(t, resp)
}
The Response function automatically removes common volatile headers:
Date
Vary
Access-Control-Allow-Credentials
Access-Control-Allow-Origin
- Any header ending with
-request-id (e.g., fs-request-id, x-request-id, custom-request-id)
Server-Sent Events (SSE) Testing
Test SSE streams with structured event parsing:
func TestEventStream(t *testing.T) {
recorder := httptest.NewRecorder()
// Create SSE stream
recorder.Header().Set("Content-Type", "text/event-stream")
recorder.WriteHeader(http.StatusOK)
recorder.Write([]byte(`event: message
data: {"text":"Hello"}
event: update
data: {"count":42}
id: evt-123
`))
resp := recorder.Result()
// Parse and snapshot the stream
events := expect.ResponseStream(t, resp)
// Make structured assertions on the events
require.Len(t, events, 2)
// First event
assert.Equal(t, "message", events[0].Event)
var data1 map[string]string
json.Unmarshal(events[0].Data, &data1)
assert.Equal(t, "Hello", data1["text"])
// Second event
assert.Equal(t, "update", events[1].Event)
assert.Equal(t, "evt-123", events[1].ID)
var data2 map[string]float64
json.Unmarshal(events[1].Data, &data2)
assert.Equal(t, 42.0, data2["count"])
}
The SSEEvent struct provides structured access to event data:
type SSEEvent struct {
Event string `json:"event,omitempty"` // Event type (from "event:" field)
Data []byte `json:"data,omitempty"` // Raw event data (from "data:" field)
ID string `json:"id,omitempty"` // Event ID (from "id:" field)
}
SSE comments (lines starting with :) are automatically filtered out during parsing.
SQL Query Snapshots
Format and snapshot SQL queries. Formatting runs pgFormatter inside Docker (ghcr.io/funnelstory/pgformatter). Snapshots are saved as .sql files under .snapshots/.
If Docker is unavailable or the container exits with an error, the unformatted SQL is snapshot instead. This is useful for first-time snapshot creation on machines without Docker, but subsequent runs will fail if the committed snapshot was formatted.
func TestSQLQuery(t *testing.T) {
query := `
SELECT u.id, u.name, o.total
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.active = true
ORDER BY o.created_at DESC
`
expect.SQL(t, query)
}
Requires a working docker CLI on the machine running tests when you want formatted snapshots. CI images should include Docker if you rely on formatting there.
Mock Call History
Snapshot mock call history for verification:
func TestMockCalls(t *testing.T) {
mockObj := new(MockService)
mockObj.On("DoSomething", "arg1", 123).Return(nil)
// Execute code that uses the mock
mockObj.DoSomething("arg1", 123)
// Snapshot the call history
expect.Calls(t, mockObj.Calls)
}
Structured Log Testing
Snapshot structured log output:
func TestLogs(t *testing.T) {
core, recorded := observer.New(zap.InfoLevel)
logger := zap.New(core)
// Generate some logs
logger.Info("user created", zap.String("user_id", "123"))
logger.Info("email sent", zap.String("to", "user@example.com"))
// Snapshot the logs
expect.Logs(t, recorded.All())
}
File Content Snapshots
Snapshot file contents:
func TestFileGeneration(t *testing.T) {
// Generate a file
generateConfigFile("/tmp/config.json")
// Snapshot its contents
expect.File(t, "/tmp/config.json")
}
Custom Extensions
Snapshot with custom file extensions:
func TestHTML(t *testing.T) {
html := "<html><body><h1>Test</h1></body></html>"
expect.HTML(t, html)
// Or use custom extension
expect.OutputWithExtension(t, ".xml", xmlContent)
}
Diff Output
Compare and snapshot differences between two outputs:
func TestDiff(t *testing.T) {
before := "version 1.0\nfeature: basic"
after := "version 2.0\nfeature: advanced"
expect.OutputDiff(t, before, after)
}
Real-World Examples
Testing an API Endpoint
func TestUserCreation(t *testing.T) {
server := setupTestServer(t)
// Create request
reqBody := `{"name":"Jane Doe","email":"jane@example.com"}`
req := httptest.NewRequest(http.MethodPost, "/api/users",
strings.NewReader(reqBody))
req.Header.Set("Content-Type", "application/json")
// Execute request
recorder := httptest.NewRecorder()
server.ServeHTTP(recorder, req)
// Snapshot the response
expect.Response(t, recorder.Result())
}
Testing Event Stream Responses
func TestFlowExecution(t *testing.T) {
server := setupTestServer(t)
// Execute flow that returns SSE stream
req := httptest.NewRequest(http.MethodPost, "/api/flows/run",
strings.NewReader(`{"flow_id":"test-flow"}`))
recorder := httptest.NewRecorder()
server.ServeHTTP(recorder, req)
// Parse and verify stream events
events := expect.ResponseStream(t, recorder.Result())
// Verify specific events
require.Len(t, events, 3)
assert.Equal(t, "started", events[0].Event)
assert.Equal(t, "progress", events[1].Event)
assert.Equal(t, "completed", events[2].Event)
}
Snapshot Management
Snapshots are stored in .snapshots/ directories next to your test files. The first time a test runs, it creates a snapshot. On subsequent runs, it compares against that snapshot.
To update snapshots when your code changes intentionally:
# Update all snapshots
UPDATE_SNAPSHOTS=true go test ./...
# Or use the cupaloy environment variable
CUPALOY_UPDATE=true go test ./...
Dependencies
Go modules:
- cupaloy for snapshot testing
- testify for assertions and mocks
- zap for log testing utilities
SQL formatting (expect.SQL) additionally expects Docker with access to ghcr.io/funnelstory/pgformatter at test time if you want pretty-printed SQL in snapshots.
Handling Nondeterminism
Snapshot testing forces you to remove sources of randomness. Here are common sources and how to handle them:
-
UUIDs: Generate them deterministically in your tests. Use a mock UUID generator or seed-based random number generator.
-
Time: Never call time.Now() directly in code you're snapshotting. Instead, control time in your tests using a time provider interface or mock clock to simulate time progression.
-
Map Iteration: Go's map iteration order is randomized. Ensure maps are iterated in a consistent order (e.g., sort keys before iteration) to prevent snapshot mismatches.
-
Floating-Point Precision: On different CPU architectures, floating-point calculations can produce slight variations. Round numbers in your algorithms before snapshotting, especially in ML or scientific computing contexts.
-
Request IDs: The Response function automatically removes headers ending with -request-id, but you may need to handle other request-specific identifiers in your application code.
Best Practices
- Use descriptive test names: Snapshot files are named after your test functions
- Keep snapshots small: Break large outputs into multiple test cases
- Review snapshot changes: Always review snapshot diffs in code review
- Don't snapshot timestamps: Use the built-in header cleanup for volatile data
- Combine with assertions: Use snapshots for overall structure, specific assertions for critical values
- Control randomness: Make your tests deterministic by controlling time, UUIDs, and other sources of randomness
License
MIT License - See LICENSE file for details