-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsnap.go
More file actions
370 lines (325 loc) · 8.48 KB
/
snap.go
File metadata and controls
370 lines (325 loc) · 8.48 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
// Package snap supports source-based snapshot testing. Package snap maintains
// expected or "golden" values in _test.go files and can update the expected
// value by rewriting the source.
//
// To make a snapshot test, create a snapshot with [Source] and test the
// snapshot with a Check function such as [CheckString]. Then, call [Run] in a
// TestMain function and run tests with "go test -update-snapshots" to update
// snapshots.
//
// As an example, this is a complete test file with [Source], [CheckString],
// and [Run]. The snapshot is out of date: It should mention "complicated
// value" but instead it is "old".
//
// package example_test
//
// import (
// "testing"
// "github.com/jellevandenhooff/snap"
// )
//
// func TestSnapshot(t *testing.T) {
// magic := "complicated value"
// snap.CheckString(t, magic, snap.Source("old"))
// }
//
// func TestMain(m *testing.M) {
// snap.Run(m)
// }
//
// Running "go test" will fail because the snapshot is out of date.
// Running "go test -update-snapshots" will update the snapshot in the file to
// "snap.Source(`complicated value`)" and afterwards tests will pass.
package snap
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"go/scanner"
"go/token"
"io"
"log"
"os"
"runtime"
"strconv"
"strings"
"sync"
"testing"
"unicode/utf8"
)
var updateSnapshots bool
// Run should be called from TestMain (see [testing.M]) to run tests and
// optionally update snapshots with the "go test -update-snapshots" flag.
//
// Any snapshots in skipped tests will be kept as-is.
func Run(m *testing.M) {
flag.BoolVar(&updateSnapshots, "update-snapshots", false, "update snapshots")
flag.Parse()
code := m.Run()
if updateSnapshots {
if err := globalShots.update(); err != nil {
log.Fatalf("updating snapshots: %s", err)
}
}
os.Exit(code)
}
type location struct {
file string
line int
}
func (l *location) String() string {
return fmt.Sprintf("%s:%d", l.file, l.line)
}
type shots struct {
mu sync.Mutex
byLocation map[location]*Snapshot
}
var globalShots = &shots{
byLocation: make(map[location]*Snapshot),
}
func format(s string, indent int) string {
replaced := strings.ReplaceAll(s, "\n", "")
if strconv.CanBackquote(replaced) {
if indent > 0 {
var builder strings.Builder
builder.WriteString("`")
prefix := strings.Repeat("\t", indent)
lines := strings.Split(s, "\n")
for i, line := range lines {
if i > 0 {
builder.WriteString("\n")
builder.WriteString(prefix)
}
builder.WriteString(line)
}
builder.WriteString("`")
return builder.String()
}
return "`" + s + "`"
}
return strconv.Quote(s)
}
type tok struct {
token token.Token
literal string
line int
offset int
}
func (s *shots) updateFile(file string) error {
// update a file to include new snapshot values. We scan the file using the
// built-in go/scanner and looks for tokens indicating a call to
// "snap.Source(". Between calls to snap.Source( we copy all bytes
// verbatim to not mess up any existing comments.
// read the file and a create a scanner
source, err := os.ReadFile(file)
if err != nil {
return fmt.Errorf("could not read file %s: %s", file, err)
}
fs := token.NewFileSet()
f := fs.AddFile(file, -1, len(source))
var sc scanner.Scanner
var parseError error
sc.Init(f, source, func(pos token.Position, msg string) {
parseError = fmt.Errorf("failed to parse %s: %s", pos, msg)
}, scanner.ScanComments)
nextToken := func() (tok, error) {
pos, kind, lit := sc.Scan()
if parseError != nil {
return tok{}, parseError
}
return tok{
token: kind,
literal: lit,
line: f.Line(pos),
offset: f.Offset(pos),
}, nil
}
// processing state:
// modified source
var out bytes.Buffer
// last offset copied
prevOffset := 0
// tokens we are looking for
match := [4]tok{
{token: token.IDENT, literal: "snap"},
{token: token.PERIOD},
{token: token.IDENT, literal: "Source"},
{token: token.LPAREN},
}
// recently read tokens
var recent [len(match)]tok
for {
next, err := nextToken()
if err != nil {
return err
}
if next.token == token.EOF {
break
}
copy(recent[0:], recent[1:])
recent[len(recent)-1] = tok{token: next.token, literal: next.literal} // drop source info
if recent != match {
continue
}
// found snapshot; only update if we have a single new value from a test
line := next.line
shot, ok := s.byLocation[location{file: file, line: line}]
if !ok || !shot.hasActual || shot.calledMultiple {
continue
}
// mark the shot as updated
shot.updated = true
// maybe figure out indentation to add
indent := 0
if shot.indentOk {
// scan backwards until we find a newline; then count tabs
offset := next.offset
for offset > 0 {
r, n := utf8.DecodeLastRune(source[:offset])
if r == '\n' {
break
}
offset -= n
}
if offset+indent < len(source) && source[offset+indent] == '\t' {
indent++
}
}
// format value for source
formatted := format(shot.actual, indent)
// copy all non-snapshot code verbatim
out.Write(source[prevOffset:next.offset])
// write the new value
out.WriteString("(")
out.WriteString(formatted)
out.WriteString(")")
// consume everything inside the parenthesis
depth := 1
for depth != 0 {
next, err = nextToken()
if err != nil {
return err
}
if next.token == token.EOF {
return io.ErrUnexpectedEOF
}
switch next.token {
case token.LPAREN:
depth++
case token.RPAREN:
depth--
}
}
prevOffset = next.offset + 1
}
// copy all non-snapshot code verbatim
out.Write(source[prevOffset:])
// update file if necessary
if !bytes.Equal(out.Bytes(), source) {
if err := os.WriteFile(file, out.Bytes(), 0644); err != nil {
return fmt.Errorf("error writing file %s: %s", file, err)
}
}
return nil
}
func (s *shots) update() error {
files := make(map[string]struct{})
for _, shot := range s.byLocation {
files[shot.location.file] = struct{}{}
}
// update snapshots file-by-file
for file := range files {
if err := s.updateFile(file); err != nil {
return err
}
}
for _, shot := range s.byLocation {
if shot.hasActual && !shot.updated {
return fmt.Errorf("failed to update snapshot at location %s; somehow failed to find it", shot.location.String())
}
}
return nil
}
func (s *shots) register(shot *Snapshot) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.byLocation[shot.location]; ok {
fmt.Printf("bad test: two calls to snap.Source on the same line %s; only one call per line is supported\n", shot.location.String())
os.Exit(1)
}
s.byLocation[shot.location] = shot
}
// A Snapshot is an expected value in a test created by calling [Source].
// See [Source] on how to use Snapshots.
type Snapshot struct {
location location
expected string
hasActual bool
actual string
indentOk bool
updated bool
calledMultiple bool
}
func trimLines(a string) string {
lines := strings.Split(a, "\n")
for i, line := range lines {
lines[i] = strings.TrimSpace(line)
}
return strings.Join(lines, "")
}
// CheckString compares a string with a snapshot.
func CheckString(t *testing.T, actual string, expected *Snapshot) {
t.Helper()
if !updateSnapshots {
var ok bool
if !expected.indentOk {
ok = expected.expected == actual
} else {
ok = trimLines(expected.expected) == trimLines(actual)
}
if !ok {
t.Errorf("snapshot different; expected %q, got %q", expected.expected, actual)
}
}
if expected.hasActual {
t.Errorf("snapshot compare invoked more than once")
expected.calledMultiple = true
}
expected.hasActual = true
expected.actual = actual
}
// CompareString compares a JSON-marshaled with a snapshot.
func CheckJSON(t *testing.T, actual any, expected *Snapshot) {
t.Helper()
b, err := json.MarshalIndent(actual, "", "\t")
if err != nil {
t.Errorf("could not marshal json: %s", err)
}
expected.indentOk = true
CheckString(t, string(b), expected)
}
// Source creates a automatically-updated [Snapshot].
//
// A [Snapshot] must be passed to a Check function like [CheckJSON] to be
// tested. Each [Snapshot] can be used only once.
//
// The argument to [Source] can be automatically updated against actual values
// using the "go test -update-snapshots" flag; see [Run].
//
// Only one call to [Source] per line is supported.
func Source(input string) *Snapshot {
_, file, line, ok := runtime.Caller(1)
if !ok {
panic("could not find caller")
}
shot := &Snapshot{
location: location{
file: file,
line: line,
},
expected: input,
}
globalShots.register(shot)
return shot
}