Approval-based snapshot testing library for Go (mirror)
1package shutter
2
3import (
4 "fmt"
5
6 "github.com/kortschak/utter"
7 "github.com/ptdewey/shutter/internal/review"
8 "github.com/ptdewey/shutter/internal/snapshots"
9 "github.com/ptdewey/shutter/internal/transform"
10)
11
12// snapshotFormatVersion indicates the snapshot format version used by this library.
13// This is automatically included in snapshot metadata for compatibility checking
14// when the snapshot format changes in future versions.
15const snapshotFormatVersion = "0.1.0"
16
17// utterConfig is a configured instance of utter for consistent formatting.
18// This avoids modifying global state and ensures snapshot formatting is isolated.
19var utterConfig = &utter.ConfigState{
20 Indent: " ",
21 ElideType: true,
22 SortKeys: true,
23}
24
25// Option is a marker interface for all snapshot options.
26// This allows compile-time type safety while supporting different option types.
27type Option interface {
28 isOption()
29}
30
31// Scrubber transforms content before snapshotting, typically to replace
32// dynamic or sensitive data with stable placeholders.
33//
34// Scrubbers are applied in the order they are provided. Later scrubbers
35// can transform the output of earlier scrubbers.
36//
37// Example:
38//
39// shutter.Snap(t, "user data", user,
40// shutter.ScrubUUID(), // First: UUIDs -> <UUID>
41// shutter.ScrubEmail(), // Second: emails -> <EMAIL>
42// )
43type Scrubber interface {
44 Option
45 Scrub(content string) string
46}
47
48// IgnorePattern determines whether a key-value pair should be excluded
49// from JSON snapshots. This is useful for removing fields that change
50// frequently or contain sensitive data.
51//
52// IgnorePatterns only work with SnapJSON. Using them with Snap or SnapString
53// will result in an error.
54type IgnorePattern interface {
55 Option
56 ShouldIgnore(key, value string) bool
57}
58
59// Snap takes a single value, formats it, and creates a snapshot with the given title.
60// Complex types are formatted using a pretty-printer for readability.
61//
62// Options can be provided to scrub sensitive or dynamic data before snapshotting.
63// Only Scrubber options are supported; IgnorePattern options will cause an error.
64//
65// Example:
66//
67// user := User{ID: "123", Email: "user@example.com"}
68// shutter.Snap(t, "user data", user,
69// shutter.ScrubUUID(),
70// shutter.ScrubEmail(),
71// )
72func Snap(t snapshots.T, title string, value any, opts ...Option) {
73 t.Helper()
74
75 scrubbers, ignores := separateOptions(opts)
76
77 if len(ignores) > 0 {
78 t.Error(fmt.Sprintf("snapshot %q: IgnorePattern options are not supported with Snap; use SnapJSON instead", title))
79 return
80 }
81
82 content := formatValue(value)
83 scrubbedContent := applyScrubbers(content, scrubbers)
84
85 snapshots.Snap(t, title, snapshotFormatVersion, scrubbedContent)
86}
87
88// SnapMany takes multiple values, formats them, and creates a snapshot with the given title.
89// This is useful when you want to snapshot multiple related values together.
90//
91// Options can be provided to scrub sensitive or dynamic data before snapshotting.
92// Only Scrubber options are supported; IgnorePattern options will cause an error.
93//
94// Example:
95//
96// shutter.SnapMany(t, "request and response",
97// []any{request, response},
98// shutter.ScrubUUID(),
99// shutter.ScrubTimestamp(),
100// )
101func SnapMany(t snapshots.T, title string, values []any, opts ...Option) {
102 t.Helper()
103
104 scrubbers, ignores := separateOptions(opts)
105
106 if len(ignores) > 0 {
107 t.Error(fmt.Sprintf("snapshot %q: IgnorePattern options are not supported with SnapMany; use SnapJSON instead", title))
108 return
109 }
110
111 content := formatValues(values...)
112 scrubbedContent := applyScrubbers(content, scrubbers)
113
114 snapshots.Snap(t, title, snapshotFormatVersion, scrubbedContent)
115}
116
117// SnapString takes a string value and creates a snapshot with the given title.
118// This is useful for snapshotting generated text, logs, or other string content.
119//
120// Options can be provided to scrub sensitive or dynamic data before snapshotting.
121// Only Scrubber options are supported; IgnorePattern options will cause an error.
122//
123// Example:
124//
125// output := generateReport()
126// shutter.SnapString(t, "report output", output,
127// shutter.ScrubTimestamp(),
128// )
129func SnapString(t snapshots.T, title string, content string, opts ...Option) {
130 t.Helper()
131
132 scrubbers, ignores := separateOptions(opts)
133
134 if len(ignores) > 0 {
135 t.Error(fmt.Sprintf("snapshot %q: IgnorePattern options are not supported with SnapString; use SnapJSON instead", title))
136 return
137 }
138
139 scrubbedContent := applyScrubbers(content, scrubbers)
140
141 snapshots.Snap(t, title, snapshotFormatVersion, scrubbedContent)
142}
143
144// SnapJSON takes a JSON string, validates it, and pretty-prints it with
145// consistent formatting before snapshotting. This preserves the raw JSON
146// format while ensuring valid JSON structure.
147//
148// Options can be provided to apply both Scrubbers and IgnorePatterns.
149// IgnorePatterns remove fields from the JSON structure before scrubbing.
150// Scrubbers then transform the remaining content.
151//
152// Example:
153//
154// jsonStr := `{"id": "550e8400-...", "email": "user@example.com", "password": "secret"}`
155// shutter.SnapJSON(t, "user response", jsonStr,
156// shutter.IgnoreKey("password"), // First: remove password field
157// shutter.ScrubUUID(), // Second: scrub remaining UUIDs
158// shutter.ScrubEmail(), // Third: scrub emails
159// )
160func SnapJSON(t snapshots.T, title string, jsonStr string, opts ...Option) {
161 t.Helper()
162
163 scrubbers, ignores := separateOptions(opts)
164
165 // Transform the JSON with ignore patterns and scrubbers
166 transformConfig := &transform.Config{
167 Scrubbers: toTransformScrubbers(scrubbers),
168 Ignore: toTransformIgnorePatterns(ignores),
169 }
170
171 transformedJSON, err := transform.TransformJSON(jsonStr, transformConfig)
172 if err != nil {
173 t.Error(fmt.Sprintf("snapshot %q: failed to transform JSON: %v", title, err))
174 return
175 }
176
177 snapshots.Snap(t, title, snapshotFormatVersion, transformedJSON)
178}
179
180// Review launches an interactive review session to accept or reject snapshot changes.
181func Review() error {
182 return review.Review()
183}
184
185// AcceptAll accepts all pending snapshot changes without review.
186func AcceptAll() error {
187 return review.AcceptAll()
188}
189
190// RejectAll rejects all pending snapshot changes without review.
191func RejectAll() error {
192 return review.RejectAll()
193}
194
195// formatValue formats a single value using the configured utter instance.
196func formatValue(v any) string {
197 return utterConfig.Sdump(v)
198}
199
200// formatValues formats multiple values using the configured utter instance.
201func formatValues(values ...any) string {
202 var result string
203 for _, v := range values {
204 result += formatValue(v)
205 }
206 return result
207}
208
209// separateOptions splits options into scrubbers and ignore patterns.
210func separateOptions(opts []Option) (scrubbers []Scrubber, ignores []IgnorePattern) {
211 for _, opt := range opts {
212 switch o := opt.(type) {
213 case IgnorePattern:
214 ignores = append(ignores, o)
215 case Scrubber:
216 scrubbers = append(scrubbers, o)
217 default:
218 // This shouldn't happen if Option interface is properly implemented
219 panic(fmt.Sprintf("unknown option type: %T", opt))
220 }
221 }
222 return scrubbers, ignores
223}
224
225// applyScrubbers applies all scrubbers to content in sequence.
226func applyScrubbers(content string, scrubbers []Scrubber) string {
227 for _, scrubber := range scrubbers {
228 content = scrubber.Scrub(content)
229 }
230 return content
231}
232
233// scrubberAdapter adapts a Scrubber to the transform.Scrubber interface.
234type scrubberAdapter struct {
235 scrubber Scrubber
236}
237
238func (s *scrubberAdapter) Scrub(content string) string {
239 return s.scrubber.Scrub(content)
240}
241
242func toTransformScrubbers(scrubbers []Scrubber) []transform.Scrubber {
243 result := make([]transform.Scrubber, len(scrubbers))
244 for i, scrubber := range scrubbers {
245 result[i] = &scrubberAdapter{scrubber: scrubber}
246 }
247 return result
248}
249
250// ignoreAdapter adapts an IgnorePattern to the transform.IgnorePattern interface.
251type ignoreAdapter struct {
252 ignore IgnorePattern
253}
254
255func (i *ignoreAdapter) ShouldIgnore(key, value string) bool {
256 return i.ignore.ShouldIgnore(key, value)
257}
258
259func toTransformIgnorePatterns(ignores []IgnorePattern) []transform.IgnorePattern {
260 result := make([]transform.IgnorePattern, len(ignores))
261 for i, ignore := range ignores {
262 result[i] = &ignoreAdapter{ignore: ignore}
263 }
264 return result
265}