Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 54 additions & 9 deletions option.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
package snapshot

import "github.com/FollowTheProcess/hue"
import (
"fmt"
"regexp"

"github.com/FollowTheProcess/hue"
)

// Option is a functional option for configuring snapshot tests.
type Option func(*SnapShotter)
type Option func(*SnapShotter) error

// Update is an [Option] that tells snapshot whether to automatically update the stored snapshots
// with the new value from each test.
Expand All @@ -12,8 +17,9 @@
// test flag so that you can inspect the diffs prior to deciding that the changes are
// expected, and therefore the snapshots should be updated.
func Update(update bool) Option {
return func(s *SnapShotter) {
return func(s *SnapShotter) error {
s.update = update
return nil
}
}

Expand All @@ -25,20 +31,59 @@
// test flag so that it only happens when explicitly requested, as like [Update], fresh snapshots
// will always pass the tests.
func Clean(clean bool) Option {
return func(s *SnapShotter) {
return func(s *SnapShotter) error {

Check warning on line 34 in option.go

View check run for this annotation

Codecov / codecov/patch

option.go#L34

Added line #L34 was not covered by tests
s.clean = clean
return nil

Check warning on line 36 in option.go

View check run for this annotation

Codecov / codecov/patch

option.go#L36

Added line #L36 was not covered by tests
}
}

// Color is an [Option] that tells snapshot whether or not it can use color to render the diffs.
//
// By default diffs are colorised as one would expect, with removals in red and additions in green.
func Color(v bool) Option {
return func(_ *SnapShotter) {
// If color is explicitly set to false we want to honour it, otherwise
// rely on hue's autodetection, which also respects $NO_COLOR
if !v {
hue.Enabled(v)
return func(s *SnapShotter) error {
hue.Enabled(v)
return nil
}
}

// Filter is an [Option] that configures a filter that is applied to a snapshot prior
// to saving to disc.
//
// Filters can be used to ensure deterministic snapshots given non-deterministic data.
//
// A motivating example would be if your snapshot contained a UUID that was generated
// each time, your snapshot would always fail. You could of course implement the [Snapper]
// interface on your type but this is not always convenient.
//
// Instead you might add a filter:
//
// snapshot.New(t, snapshot.Filter("(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", "[UUID]"))
//
// Now any match of this regex anywhere in your snapshot will be replaced by the literal string "[UUID]".
//
// Inside replacement, '$' may be used to refer to capture groups. For example:
//
// snapshot.Filter(`\\([\w\d]|\.)`, "/$1")
//
// Replaces windows style paths with a unix style path with the same information, e.g.
//
// some\windows\path.txt
//
// Becomes:
//
// some/windows/path.txt
//
// Filters use [regexp.ReplaceAll] underneath so in general the behaviour is as documented there,
// see also [regexp.Expand] for documentation on how '$' may be used.
func Filter(pattern, replacement string) Option {
return func(s *SnapShotter) error {
re, err := regexp.Compile(pattern)
if err != nil {
return fmt.Errorf("could not compile filter regex: %w", err)
}

s.filters = append(s.filters, filter{pattern: re, replacement: replacement})
return nil
}
}
127 changes: 78 additions & 49 deletions snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"testing"

Expand All @@ -32,15 +33,13 @@ const (
green = hue.Green
)

// TODO(@FollowTheProcess): A storage backend interface, one for files (real) and one for in memory (testing), also opens up
// for others to be implemented

// SnapShotter holds configuration and state and is responsible for performing
// the tests and managing the snapshots.
type SnapShotter struct {
tb testing.TB // The testing TB
update bool // Whether to update the snapshots automatically
clean bool // Erase snapshots prior to the run
tb testing.TB
filters []filter
update bool
clean bool
}

// New builds and returns a new [SnapShotter], applying configuration
Expand All @@ -51,7 +50,9 @@ func New(tb testing.TB, options ...Option) *SnapShotter { //nolint: thelper // T
}

for _, option := range options {
option(shotter)
if err := option(shotter); err != nil {
tb.Fatalf("snapshot.New(): %v", err)
}
}

return shotter
Expand Down Expand Up @@ -85,52 +86,14 @@ func (s *SnapShotter) Snap(value any) {
}
}

current := &bytes.Buffer{}

switch val := value.(type) {
case Snapper:
content, err := val.Snap()
if err != nil {
s.tb.Fatalf("%T implements Snapper but Snap() returned an error: %v", val, err)
return
}

current.Write(content)
case json.Marshaler:
// Use MarshalIndent for better readability
content, err := json.MarshalIndent(val, "", " ")
if err != nil {
s.tb.Fatalf("%T implements json.Marshaler but MarshalJSON() returned an error: %v", val, err)
return
}

current.Write(content)
case encoding.TextMarshaler:
content, err := val.MarshalText()
if err != nil {
s.tb.Fatalf("%T implements encoding.TextMarshaler but MarshalText() returned an error: %v", val, err)
return
}

current.Write(content)
case fmt.Stringer:
current.WriteString(val.String())
case string, []byte, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, uintptr, bool, float32, float64, complex64, complex128:
// For any primitive type just use %v
fmt.Fprintf(current, "%v", val)
default:
// Fallback, use %#v as a best effort at generic printing
s.tb.Logf("Snap: falling back to GoString for %T, consider creating a new type and implementing snapshot.Snapper, encoding.TextMarshaler or fmt.Stringer", val)
fmt.Fprintf(current, "%#v", val)
}

// Check if one exists already
exists, err := fileExists(path)
if err != nil {
s.tb.Fatalf("Snap: %v", err)
}

currentBytes := bytes.ReplaceAll(current.Bytes(), []byte("\r\n"), []byte("\n"))
// Do the actual snapshotting
content := s.do(value)

if !exists || s.update {
// No previous snapshot, save the current one, potentially creating the
Expand All @@ -143,7 +106,7 @@ func (s *SnapShotter) Snap(value any) {
s.tb.Logf("Snap: updating snapshot %s", path)
}

if err = os.WriteFile(path, currentBytes, defaultFilePermissions); err != nil {
if err = os.WriteFile(path, content, defaultFilePermissions); err != nil {
s.tb.Fatalf("Snap: could not write snapshot: %v", err)
}
// We're done
Expand All @@ -159,11 +122,62 @@ func (s *SnapShotter) Snap(value any) {
// Normalise CRLF to LF everywhere
previous = bytes.ReplaceAll(previous, []byte("\r\n"), []byte("\n"))

if diff := diff.Diff("previous", previous, "current", currentBytes); diff != nil {
if diff := diff.Diff("previous", previous, "current", content); diff != nil {
s.tb.Fatalf("\nMismatch\n--------\n%s\n", prettyDiff(string(diff)))
}
}

// do actually does the snapshotting, returning the raw bytes of what was captured.
func (s *SnapShotter) do(value any) []byte {
buf := &bytes.Buffer{}

switch val := value.(type) {
case Snapper:
content, err := val.Snap()
if err != nil {
s.tb.Fatalf("%T implements Snapper but Snap() returned an error: %v", val, err)
return nil
}

buf.Write(content)
case json.Marshaler:
// Use MarshalIndent for better readability
content, err := json.MarshalIndent(val, "", " ")
if err != nil {
s.tb.Fatalf("%T implements json.Marshaler but MarshalJSON() returned an error: %v", val, err)
return nil
}

buf.Write(content)
case encoding.TextMarshaler:
content, err := val.MarshalText()
if err != nil {
s.tb.Fatalf("%T implements encoding.TextMarshaler but MarshalText() returned an error: %v", val, err)
return nil
}

buf.Write(content)
case fmt.Stringer:
buf.WriteString(val.String())
case string, []byte, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, uintptr, bool, float32, float64, complex64, complex128:
// For any primitive type just use %v
fmt.Fprintf(buf, "%v", val)
default:
// Fallback, use %#v as a best effort at generic printing
s.tb.Logf("Snap: falling back to GoString for %T, consider creating a new type and implementing snapshot.Snapper, encoding.TextMarshaler or fmt.Stringer", val)
fmt.Fprintf(buf, "%#v", val)
}

// Normalise line endings and apply any installed filters
content := bytes.ReplaceAll(buf.Bytes(), []byte("\r\n"), []byte("\n"))

for _, filter := range s.filters {
content = filter.pattern.ReplaceAll(content, []byte(filter.replacement))
}

return content
}

// Path returns the path that a snapshot would be saved at for any given test.
func (s *SnapShotter) Path() string {
// Base directory under testdata where all snapshots are kept
Expand Down Expand Up @@ -217,3 +231,18 @@ func prettyDiff(diff string) string {

return strings.Join(lines, "\n")
}

// A filter is a mechanism for normalising non-deterministic snapshot contents such
// as windows/unix filepaths, uuids, timestamps etc.
//
// It contains a pattern which must be a valid regex, and a replacement string to substitute
// in the snapshot.
type filter struct {
// pattern is the regex to search for in the snapshot
// e.g. (?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12} for a UUID v4
pattern *regexp.Regexp

// replacement is the deterministic replacement string to substitute any instance of pattern with.
// e.g. [UUID]
replacement string
}
72 changes: 71 additions & 1 deletion snapshot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ func TestSnap(t *testing.T) {
t.Fatalf("%s initial failed state should be false", t.Name())
}

shotter := snapshot.New(tb)
shotter := snapshot.New(tb, snapshot.Color(true))

if tt.clean {
deleteSnapshot(t, shotter)
Expand Down Expand Up @@ -233,6 +233,76 @@ func TestSnap(t *testing.T) {
}
}

func TestFilters(t *testing.T) {
tests := []struct {
name string // Name of the test case
value string // Thing to snap
pattern string // Regex to replace
replacement string // Replacement
wantFail bool // Whether we want the test to fail
}{
{
name: "no filters",
value: `{"id": "5c62efe3-36e4-41f7-aa3b-c871f659ea31", "name": "Bob"}`,
pattern: "",
replacement: "",
wantFail: false,
},
{
name: "uuid filter",
value: `{"id": "c2160f4a-9bf4-400a-829f-d42c060ebbb8", "name": "John"}`,
pattern: "(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
replacement: "[UUID]",
wantFail: false,
},
{
name: "windows path",
value: `some\windows\path.txt`,
pattern: `\\([\w\d]|\.)`,
replacement: "/$1",
wantFail: false,
},
{
name: "macos temp",
value: `/var/folders/y_/1g9jx9bd5fg9_5134n1dtq1c0000gn/T/tmp.Y2CkGLik3Q`,
pattern: `/var/folders/\S+?/T/\S+`,
replacement: "[TEMP_FILE]",
wantFail: false,
},
{
name: "bad pattern",
value: "doesn't matter",
pattern: `(?[\p{Thai}&\p{Digit}])`,
replacement: "",
wantFail: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
buf := &bytes.Buffer{}
tb := &TB{out: buf, name: t.Name()}

if tb.failed {
t.Fatalf("%s initial failed state should be false", t.Name())
}

snap := snapshot.New(tb, snapshot.Filter(tt.pattern, tt.replacement))

snap.Snap(tt.value)

if tb.failed != tt.wantFail {
t.Fatalf(
"tb.failed =\t%v\ntt.wantFail =\t%v\noutput =\t%s\n",
tb.failed,
tt.wantFail,
buf.String(),
)
}
})
}
}

func TestUpdate(t *testing.T) {
value := []string{"hello", "this", "is", "a", "snapshot"}
snap := snapshot.New(t, snapshot.Update(true))
Expand Down
1 change: 1 addition & 0 deletions testdata/snapshots/TestFilters/bad_pattern.snap.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
doesn't matter
1 change: 1 addition & 0 deletions testdata/snapshots/TestFilters/macos_temp.snap.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
[TEMP_FILE]
1 change: 1 addition & 0 deletions testdata/snapshots/TestFilters/no_filters.snap.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"id": "5c62efe3-36e4-41f7-aa3b-c871f659ea31", "name": "Bob"}
1 change: 1 addition & 0 deletions testdata/snapshots/TestFilters/uuid_filter.snap.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"id": "[UUID]", "name": "John"}
1 change: 1 addition & 0 deletions testdata/snapshots/TestFilters/windows_path.snap.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
some/windows/path.txt
Empty file.
Empty file.
Empty file.
Loading