Approval-based snapshot testing library for Go (mirror)
at main 265 lines 8.3 kB view raw
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}