-
Notifications
You must be signed in to change notification settings - Fork 20
/
Copy pathgoldie.go
479 lines (402 loc) · 12.7 KB
/
goldie.go
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
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
// Package goldie provides test assertions based on golden files. It's
// typically used for testing responses with larger data bodies.
//
// The concept is straight forward. Valid response data is stored in a "golden
// file". The actual response data will be byte compared with the golden file
// and the test will fail if there is a difference.
//
// Updating the golden file can be done by running `go test -update ./...`.
package goldie
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"testing"
"text/template"
"time"
"errors"
"github.com/pmezard/go-difflib/difflib"
"github.com/sergi/go-diff/diffmatchpatch"
)
const (
// defaultFixtureDir is the folder name for where the fixtures are stored.
// It's relative to the "go test" path.
defaultFixtureDir = "testdata"
// defaultFileNameSuffix is the suffix appended to the fixtures. Set to
// empty string to disable file name suffixes.
defaultFileNameSuffix = ".golden"
// defaultFilePerms is used to set the permissions on the golden fixture
// files.
defaultFilePerms os.FileMode = 0644
// defaultDirPerms is used to set the permissions on the golden fixture
// folder.
defaultDirPerms os.FileMode = 0755
// defaultDiffEngine sets which diff engine to use if not defined.
defaultDiffEngine = ClassicDiff
// defaultIgnoreTemplateErrors sets the default value for the
// WithIgnoreTemplateErrors option.
defaultIgnoreTemplateErrors = false
// defaultUseTestNameForDir sets the default value for the
// WithTestNameForDir option.
defaultUseTestNameForDir = false
// defaultUseSubTestNameForDir sets the default value for the
// WithSubTestNameForDir option.
defaultUseSubTestNameForDir = false
)
var (
// update determines if the actual received data should be written to the
// golden files or not. This should be true when you need to update the
// data, but false when actually running the tests.
update = flag.Bool("update", false, "Update golden test file fixture")
// clean determines if we should remove old golden test files in the output
// directory or not. This only takes effect if we are updating the golden
// test files.
clean = flag.Bool("clean", false, "Clean old golden test files before writing new olds")
// ts saves the timestamp of the test run, we use ts to mark the
// modification time of golden file dirs, for cleaning if required by
// `-clean` flag.
ts = time.Now()
)
type goldie struct {
fixtureDir string
fileNameSuffix string
filePerms os.FileMode
dirPerms os.FileMode
diffEngine DiffEngine
diffFn DiffFn
ignoreTemplateErrors bool
useTestNameForDir bool
useSubTestNameForDir bool
}
// === Create new testers ==================================
// New creates a new golden file tester. If there is an issue with applying any
// of the options, an error will be reported and t.FailNow() will be called.
func New(t *testing.T, options ...Option) *goldie {
g := goldie{
fixtureDir: defaultFixtureDir,
fileNameSuffix: defaultFileNameSuffix,
filePerms: defaultFilePerms,
dirPerms: defaultDirPerms,
diffEngine: defaultDiffEngine,
ignoreTemplateErrors: defaultIgnoreTemplateErrors,
useTestNameForDir: defaultUseTestNameForDir,
useSubTestNameForDir: defaultUseSubTestNameForDir,
}
var err error
for _, option := range options {
err = option(&g)
if err != nil {
t.Error(fmt.Errorf("Could not apply option: %w", err))
t.FailNow()
}
}
return &g
}
// Diff generates a string that shows the difference between the actual and the
// expected. This method could be called in your own DiffFn in case you want
// to leverage any of the engines defined.
func Diff(engine DiffEngine, actual string, expected string) string {
var diff string
switch engine {
case Simple:
diff = fmt.Sprintf("Expected: %s\nGot: %s", expected, actual)
case ClassicDiff:
diff, _ = difflib.GetUnifiedDiffString(difflib.UnifiedDiff{
A: difflib.SplitLines(expected),
B: difflib.SplitLines(actual),
FromFile: "Expected",
FromDate: "",
ToFile: "Actual",
ToDate: "",
Context: 1,
})
case ColoredDiff:
dmp := diffmatchpatch.New()
diffs := dmp.DiffMain(actual, expected, false)
diff = dmp.DiffPrettyText(diffs)
}
return diff
}
// === OptionProcessor ===============================
// WithFixtureDir sets the fixture directory.
//
// Defaults to `testdata`
func (g *goldie) WithFixtureDir(dir string) error {
g.fixtureDir = dir
return nil
}
// WithNameSuffix sets the file suffix to be used for the golden file.
//
// Defaults to `.golden`
func (g *goldie) WithNameSuffix(suffix string) error {
g.fileNameSuffix = suffix
return nil
}
// WithFilePerms sets the file permissions on the golden files that are
// created.
//
// Defaults to 0644.
func (g *goldie) WithFilePerms(mode os.FileMode) error {
g.filePerms = mode
return nil
}
// WithDirPerms sets the directory permissions for the directories in which the
// golden files are created.
//
// Defaults to 0755.
func (g *goldie) WithDirPerms(mode os.FileMode) error {
g.dirPerms = mode
return nil
}
// WithDiffEngine sets the `diff` engine that will be used to generate the
// `diff` text.
func (g *goldie) WithDiffEngine(engine DiffEngine) error {
g.diffEngine = engine
return nil
}
// WithDiffFn sets the `diff` engine to be a function that implements the
// DiffFn signature. This allows for any customized diff logic you would like
// to create.
func (g *goldie) WithDiffFn(fn DiffFn) error {
g.diffFn = fn
return nil
}
// WithIgnoreTemplateErrors allows template processing to ignore any variables
// in the template that do not have corresponding data values passed in.
//
// Default value is false.
func (g *goldie) WithIgnoreTemplateErrors(ignoreErrors bool) error {
g.ignoreTemplateErrors = ignoreErrors
return nil
}
// WithTestNameForDir will create a directory with the test's name in the
// fixture directory to store all the golden files.
//
// Default value is false.
func (g *goldie) WithTestNameForDir(use bool) error {
g.useTestNameForDir = use
return nil
}
// WithSubTestNameForDir will create a directory with the sub test's name to
// store all the golden files. If WithTestNameForDir is enabled, it will be in
// the test name's directory. Otherwise, it will be in the fixture directory.
//
// Default value is false.
func (g *goldie) WithSubTestNameForDir(use bool) error {
g.useSubTestNameForDir = use
return nil
}
// Assert compares the actual data received with the expected data in the
// golden files. If the update flag is set, it will also update the golden
// file.
//
// `name` refers to the name of the test and it should typically be unique
// within the package. Also it should be a valid file name (so keeping to
// `a-z0-9\-\_` is a good idea).
func (g *goldie) Assert(t *testing.T, name string, actualData []byte) {
t.Helper()
if *update {
err := g.Update(t, name, actualData)
if err != nil {
t.Error(err)
t.FailNow()
}
}
err := g.compare(t, name, actualData)
if err != nil {
{
var e *errFixtureNotFound
if errors.As(err, &e) {
t.Error(err)
t.FailNow()
return
}
}
{
var e *errFixtureMismatch
if errors.As(err, &e) {
t.Error(err)
return
}
}
t.Error(err)
}
}
// AssertJson compares the actual json data received with expected data in the
// golden files. If the update flag is set, it will also update the golden
// file.
//
// `name` refers to the name of the test and it should typically be unique
// within the package. Also it should be a valid file name (so keeping to
// `a-z0-9\-\_` is a good idea).
func (g *goldie) AssertJson(t *testing.T, name string, actualJsonData interface{}) {
t.Helper()
js, err := json.MarshalIndent(actualJsonData, "", " ")
if err != nil {
t.Error(err)
t.FailNow()
}
g.Assert(t, name, normalizeLF(js))
}
// normalizeLF normalizes line feed character set across os (es)
// \r\n (windows) & \r (mac) into \n (unix)
func normalizeLF(d []byte) []byte {
// if empty / nil return as is
if len(d) == 0 {
return d
}
// replace CR LF \r\n (windows) with LF \n (unix)
d = bytes.Replace(d, []byte{13, 10}, []byte{10}, -1)
// replace CF \r (mac) with LF \n (unix)
d = bytes.Replace(d, []byte{13}, []byte{10}, -1)
return d
}
// Assert compares the actual data received with the expected data in the
// golden files after executing it as a template with data parameter. If the
// update flag is set, it will also update the golden file. `name` refers to
// the name of the test and it should typically be unique within the package.
// Also it should be a valid file name (so keeping to `a-z0-9\-\_` is a good
// idea).
func (g *goldie) AssertWithTemplate(t *testing.T, name string, data interface{}, actualData []byte) {
t.Helper()
if *update {
err := g.Update(t, name, actualData)
if err != nil {
t.Error(err)
t.FailNow()
}
}
err := g.compareTemplate(t, name, data, actualData)
if err != nil {
{
var e *errFixtureNotFound
if errors.As(err, &e) {
t.Error(err)
t.FailNow()
return
}
}
{
var e *errFixtureMismatch
if errors.As(err, &e) {
t.Error(err)
return
}
}
t.Error(err)
}
}
// Update will update the golden fixtures with the received actual data.
//
// This method does not need to be called from code, but it's exposed so that
// it can be explicitly called if needed. The more common approach would be to
// update using `go test -update ./...`.
func (g *goldie) Update(t *testing.T, name string, actualData []byte) error {
goldenFile := g.GoldenFileName(t, name)
goldenFileDir := filepath.Dir(goldenFile)
if err := g.ensureDir(goldenFileDir); err != nil {
return err
}
if err := ioutil.WriteFile(goldenFile, actualData, g.filePerms); err != nil {
return err
}
if err := os.Chtimes(goldenFileDir, ts, ts); err != nil {
return err
}
return nil
}
// compare is reading the golden fixture file and compare the stored data with
// the actual data.
func (g *goldie) compare(t *testing.T, name string, actualData []byte) error {
expectedData, err := ioutil.ReadFile(g.GoldenFileName(t, name))
if err != nil {
if os.IsNotExist(err) {
return newErrFixtureNotFound()
}
return fmt.Errorf("Expected %s to be nil", err.Error())
}
if !bytes.Equal(actualData, expectedData) {
msg := "Result did not match the golden fixture. Diff is below:\n\n"
actual := string(actualData)
expected := string(expectedData)
if g.diffFn != nil {
msg += g.diffFn(actual, expected)
} else {
msg += Diff(g.diffEngine, actual, expected)
}
return newErrFixtureMismatch(msg)
}
return nil
}
// compareTemplate is reading the golden fixture file and compare the stored
// data with the actual data.
func (g *goldie) compareTemplate(t *testing.T, name string, data interface{}, actualData []byte) error {
expectedDataTmpl, err := ioutil.ReadFile(g.GoldenFileName(t, name))
if err != nil {
if os.IsNotExist(err) {
return newErrFixtureNotFound()
}
return fmt.Errorf("Expected %s to be nil", err.Error())
}
missingKey := "error"
if g.ignoreTemplateErrors {
missingKey = "default"
}
tmpl, err := template.New("test").Option("missingkey=" + missingKey).Parse(string(expectedDataTmpl))
if err != nil {
return fmt.Errorf("Expected %s to be nil", err.Error())
}
var expectedData bytes.Buffer
err = tmpl.Execute(&expectedData, data)
if err != nil {
return newErrMissingKey(fmt.Sprintf("Template error: %s", err.Error()))
}
if !bytes.Equal(actualData, expectedData.Bytes()) {
msg := "Result did not match the golden fixture. Diff is below:\n\n"
actual := string(actualData)
expected := expectedData.String()
if g.diffFn != nil {
msg += g.diffFn(actual, expected)
} else {
msg += Diff(g.diffEngine, actual, expected)
}
return newErrFixtureMismatch(msg)
}
return nil
}
// ensureDir will create the fixture folder if it does not already exist.
func (g *goldie) ensureDir(loc string) error {
s, err := os.Stat(loc)
switch {
case err != nil && os.IsNotExist(err):
// the location does not exist, so make directories to there
return os.MkdirAll(loc, g.dirPerms)
case err == nil && s.IsDir() && *clean && s.ModTime().UnixNano() != ts.UnixNano():
if err := os.RemoveAll(loc); err != nil {
return err
}
return os.MkdirAll(loc, g.dirPerms)
case err == nil && !s.IsDir():
return newErrFixtureDirectoryIsFile(loc)
}
return err
}
// GoldenFileName simply returns the file name of the golden file fixture.
func (g *goldie) GoldenFileName(t *testing.T, name string) string {
dir := g.fixtureDir
if g.useTestNameForDir {
dir = filepath.Join(dir, strings.Split(t.Name(), "/")[0])
}
if g.useSubTestNameForDir {
n := strings.Split(t.Name(), "/")
if len(n) > 1 {
dir = filepath.Join(dir, n[1])
}
}
return filepath.Join(dir, fmt.Sprintf("%s%s", name, g.fileNameSuffix))
}