diff --git a/README.md b/README.md
index 01c2b87..a318cf8 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,7 @@
- [match.Any](#matchany)
- [match.Custom](#matchcustom)
- [match.Type\[ExpectedType\]](#matchtype)
+- [MatchStandaloneSnapshot](#matchstandalonesnapshot)
- [Configuration](#configuration)
- [Update Snapshots](#update-snapshots)
- [Clean obsolete Snapshots](#clean-obsolete-snapshots)
@@ -195,12 +196,36 @@ match.Type[string]("user.info").
You can see more [examples](./examples/matchJSON_test.go#L96).
+## MatchStandaloneSnapshot
+
+`MatchStandaloneSnapshot` will create snapshots on separate files as opposed to `MatchSnapshot` which adds multiple snapshots inside the same file.
+
+_Combined with `snaps.Ext` you can have proper syntax highlighting and better readability_
+
+```go
+// test_simple.go
+
+func TestSimple(t *testing.T) {
+ snaps.MatchStandaloneSnapshot(t, "Hello World")
+ // or create an html snapshot file
+ snaps.WithConfig(snaps.Ext(".html")).
+ MatchStandaloneSnapshot(t, "
Hello World
")
+}
+```
+
+`go-snaps` saves the snapshots in `__snapshots__` directory and the file
+name is the test file name with extension `.snap`.
+
+So for example if your test is called `test_simple.go` when you run your tests, a snapshot file
+will be created at `./__snapshots__/TestSimple_1.snaps`.
+
## Configuration
`go-snaps` allows passing configuration for overriding
- the directory where snapshots are stored, _relative or absolute path_
- the filename where snapshots are stored
+- the snapshot file's extension (_regardless the extension the filename will include the `.snaps` inside the filename_)
- programmatically control whether to update snapshots. _You can find an example usage at [examples](/examples/examples_test.go#13)_
```go
@@ -210,6 +235,7 @@ t.Run("snapshot tests", func(t *testing.T) {
s := snaps.WithConfig(
snaps.Dir("my_dir"),
snaps.Filename("json_file"),
+ snaps.Ext(".json")
snaps.Update(false)
)
diff --git a/examples/__snapshots__/TestMatchStandaloneSnapshot_should_create_html_snapshots_1.snap.html b/examples/__snapshots__/TestMatchStandaloneSnapshot_should_create_html_snapshots_1.snap.html
new file mode 100755
index 0000000..eb7386c
--- /dev/null
+++ b/examples/__snapshots__/TestMatchStandaloneSnapshot_should_create_html_snapshots_1.snap.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+My First Heading
+
+My first paragraph.
+
+
+
diff --git a/examples/__snapshots__/TestMatchStandaloneSnapshot_should_create_html_snapshots_2.snap.html b/examples/__snapshots__/TestMatchStandaloneSnapshot_should_create_html_snapshots_2.snap.html
new file mode 100755
index 0000000..34fa97d
--- /dev/null
+++ b/examples/__snapshots__/TestMatchStandaloneSnapshot_should_create_html_snapshots_2.snap.html
@@ -0,0 +1 @@
+Hello World
\ No newline at end of file
diff --git a/examples/__snapshots__/my_standalone_snap_1.snap b/examples/__snapshots__/my_standalone_snap_1.snap
new file mode 100755
index 0000000..8f2a542
--- /dev/null
+++ b/examples/__snapshots__/my_standalone_snap_1.snap
@@ -0,0 +1 @@
+hello world-0
\ No newline at end of file
diff --git a/examples/__snapshots__/my_standalone_snap_2.snap b/examples/__snapshots__/my_standalone_snap_2.snap
new file mode 100755
index 0000000..f842d2f
--- /dev/null
+++ b/examples/__snapshots__/my_standalone_snap_2.snap
@@ -0,0 +1 @@
+hello world-1
\ No newline at end of file
diff --git a/examples/__snapshots__/my_standalone_snap_3.snap b/examples/__snapshots__/my_standalone_snap_3.snap
new file mode 100755
index 0000000..22aacf2
--- /dev/null
+++ b/examples/__snapshots__/my_standalone_snap_3.snap
@@ -0,0 +1 @@
+hello world-2
\ No newline at end of file
diff --git a/examples/__snapshots__/my_standalone_snap_4.snap b/examples/__snapshots__/my_standalone_snap_4.snap
new file mode 100755
index 0000000..854f9e6
--- /dev/null
+++ b/examples/__snapshots__/my_standalone_snap_4.snap
@@ -0,0 +1 @@
+hello world-3
\ No newline at end of file
diff --git a/examples/matchStandaloneSnapshot_test.go b/examples/matchStandaloneSnapshot_test.go
new file mode 100644
index 0000000..bae6ef1
--- /dev/null
+++ b/examples/matchStandaloneSnapshot_test.go
@@ -0,0 +1,40 @@
+package examples
+
+import (
+ "testing"
+
+ "github.com/gkampitakis/go-snaps/snaps"
+)
+
+func TestMatchStandaloneSnapshot(t *testing.T) {
+ t.Run("should create html snapshots", func(t *testing.T) {
+ snaps := snaps.WithConfig(
+ snaps.Ext(".html"),
+ )
+
+ snaps.MatchStandaloneSnapshot(t, `
+
+
+
+
+My First Heading
+
+My first paragraph.
+
+
+
+`)
+ snaps.MatchStandaloneSnapshot(t, "Hello World
")
+ })
+
+ t.Run("should create standalone snapshots with specified filename", func(t *testing.T) {
+ snaps := snaps.WithConfig(
+ snaps.Filename("my_standalone_snap"),
+ )
+
+ snaps.MatchStandaloneSnapshot(t, "hello world-0")
+ snaps.MatchStandaloneSnapshot(t, "hello world-1")
+ snaps.MatchStandaloneSnapshot(t, "hello world-2")
+ snaps.MatchStandaloneSnapshot(t, "hello world-3")
+ })
+}
diff --git a/internal/test/test.go b/internal/test/test.go
index 1e4016d..263b7e1 100644
--- a/internal/test/test.go
+++ b/internal/test/test.go
@@ -126,6 +126,14 @@ func Contains(t *testing.T, s, substr string) {
}
}
+func HasSuffix(t *testing.T, s, suffix string) {
+ t.Helper()
+
+ if !strings.HasSuffix(s, suffix) {
+ t.Errorf("\n [expected] %s to have suffix %s", s, suffix)
+ }
+}
+
func CreateTempFile(t *testing.T, data string) string {
dir := t.TempDir()
path := filepath.Join(dir, "mock.file")
diff --git a/snaps/clean.go b/snaps/clean.go
index 1090667..73761d1 100644
--- a/snaps/clean.go
+++ b/snaps/clean.go
@@ -17,10 +17,7 @@ import (
"github.com/maruel/natural"
)
-// Matches [ Test... - number ] testIDs
-var (
- testEvents = newTestEvents()
-)
+var testEvents = newTestEvents()
const (
erred uint8 = iota
@@ -84,8 +81,18 @@ func Clean(m *testing.M, opts ...CleanOpts) {
_ = m
runOnly := flag.Lookup("test.run").Value.String()
count, _ := strconv.Atoi(flag.Lookup("test.count").Value.String())
+ registeredStandaloneTests := occurrences(
+ standaloneTestsRegistry.cleanup,
+ count,
+ standaloneOccurrenceFMT,
+ )
- obsoleteFiles, usedFiles := examineFiles(testsRegistry.cleanup, runOnly, shouldClean && !isCI)
+ obsoleteFiles, usedFiles := examineFiles(
+ testsRegistry.cleanup,
+ registeredStandaloneTests,
+ runOnly,
+ shouldClean && !isCI,
+ )
obsoleteTests, err := examineSnaps(
testsRegistry.cleanup,
usedFiles,
@@ -145,16 +152,13 @@ func isNumber(b []byte) bool {
return true
}
-/*
-Map containing the occurrences is checked against the filesystem.
-
-If a file exists but is not registered in the map we check if the file is
-skipped. (We do that by checking if the mod is imported and there is a call to
-`MatchSnapshot`). If not skipped and not registered means it's an obsolete snap file
-and we mark it as one.
-*/
+// examineFiles traverses all the directories where snap tests where executed and checks
+// if "orphan" snap files exist (files containing `.snap` in their name).
+//
+// If they do they are marked as obsolete and they are either deleted if `shouldUpdate=true` or printed on the console.
func examineFiles(
registry map[string]map[string]int,
+ registeredStandaloneTests set,
runOnly string,
shouldUpdate bool,
) (obsolete, used []string) {
@@ -164,13 +168,17 @@ func examineFiles(
uniqueDirs[filepath.Dir(snapPaths)] = struct{}{}
}
+ for snapPaths := range registeredStandaloneTests {
+ uniqueDirs[filepath.Dir(snapPaths)] = struct{}{}
+ }
+
for dir := range uniqueDirs {
dirContents, _ := os.ReadDir(dir)
for _, content := range dirContents {
// this is a sanity check shouldn't have dirs inside the snapshot dirs
// and only delete any `.snap` files
- if content.IsDir() || filepath.Ext(content.Name()) != snapsExt {
+ if content.IsDir() || !strings.Contains(content.Name(), snapsExt) {
continue
}
@@ -180,6 +188,13 @@ func examineFiles(
continue
}
+ // if it's a standalone snapshot we don't add it to used list
+ // as we don't need it for the next step, to examine individual snaps inside the file
+ // as it contains only one
+ if registeredStandaloneTests.Has(snapPath) {
+ continue
+ }
+
if isFileSkipped(dir, content.Name(), runOnly) {
continue
}
@@ -220,7 +235,7 @@ func examineSnaps(
var hasDiffs bool
- registeredTests := occurrences(registry[snapPath], count)
+ registeredTests := occurrences(registry[snapPath], count, snapshotOccurrenceFMT)
s := snapshotScanner(f)
for s.Scan() {
@@ -389,27 +404,16 @@ func printEvent(w io.Writer, color, symbol, verb string, events int) {
colors.Fprint(w, color, fmt.Sprintf("%s%v %s %s\n", symbol, events, subject, verb))
}
-/*
-Builds a Set with all snapshot ids registered inside a snap file
-Form: testname - number id
-
-tests have the form
-
- map[filepath]: map[testname]:
-
-e.g
-
- ./path/__snapshots__/add_test.snap map[TestAdd] 3
-
- will result to
+func standaloneOccurrenceFMT(s string, i int) string {
+ return fmt.Sprintf(s, i)
+}
- TestAdd - 1
- TestAdd - 2
- TestAdd - 3
+func snapshotOccurrenceFMT(s string, i int) string {
+ return fmt.Sprintf("%s - %d", s, i)
+}
-as it means there are 3 snapshots created inside TestAdd
-*/
-func occurrences(tests map[string]int, count int) set {
+// Builds a Set with all snapshot ids registered. It uses the provider formatter to build keys.
+func occurrences(tests map[string]int, count int, formatter func(string, int) string) set {
result := make(set, len(tests))
for testID, counter := range tests {
// divide a test's counter by count (how many times the go test suite ran)
@@ -417,10 +421,10 @@ func occurrences(tests map[string]int, count int) set {
counter = counter / count
if counter > 1 {
for i := 1; i <= counter; i++ {
- result[fmt.Sprintf("%s - %d", testID, i)] = struct{}{}
+ result[formatter(testID, i)] = struct{}{}
}
}
- result[fmt.Sprintf("%s - %d", testID, counter)] = struct{}{}
+ result[formatter(testID, counter)] = struct{}{}
}
return result
diff --git a/snaps/clean_test.go b/snaps/clean_test.go
index f9c6143..79da6ea 100644
--- a/snaps/clean_test.go
+++ b/snaps/clean_test.go
@@ -55,6 +55,30 @@ func setupTempExamineFiles(
name: filepath.FromSlash(dir2 + "/should_not_delete.txt"),
data: []byte{},
},
+ {
+ name: filepath.FromSlash(dir1 + "TestSomething_my_test_1.snap"),
+ data: []byte{},
+ },
+ {
+ name: filepath.FromSlash(dir1 + "TestSomething_my_test_2.snap"),
+ data: []byte{},
+ },
+ {
+ name: filepath.FromSlash(dir1 + "TestSomething_my_test_3.snap"),
+ data: []byte{},
+ },
+ {
+ name: filepath.FromSlash(dir2 + "TestAnotherThing_my_test_1.snap"),
+ data: []byte{},
+ },
+ {
+ name: filepath.FromSlash(dir2 + "TestAnotherThing_my_simple_test_1.snap"),
+ data: []byte{},
+ },
+ {
+ name: filepath.FromSlash(dir2 + "TestAnotherThing_my_simple_test_2.snap"),
+ data: []byte{},
+ },
}
for _, file := range files {
@@ -92,11 +116,18 @@ func TestExamineFiles(t *testing.T) {
loadMockSnap(t, "mock-snap-1"),
loadMockSnap(t, "mock-snap-2"),
)
- obsolete, used := examineFiles(tests, "", false)
+ obsolete, used := examineFiles(tests, set{
+ dir1 + "TestSomething_my_test_1.snap": struct{}{},
+ dir2 + "TestAnotherThing_my_simple_test_1.snap": struct{}{},
+ }, "", false)
obsoleteExpected := []string{
filepath.FromSlash(dir1 + "/obsolete1.snap"),
filepath.FromSlash(dir2 + "/obsolete2.snap"),
+ filepath.FromSlash(dir1 + "TestSomething_my_test_2.snap"),
+ filepath.FromSlash(dir1 + "TestSomething_my_test_3.snap"),
+ filepath.FromSlash(dir2 + "TestAnotherThing_my_test_1.snap"),
+ filepath.FromSlash(dir2 + "TestAnotherThing_my_simple_test_2.snap"),
}
usedExpected := []string{
filepath.FromSlash(dir1 + "/test1.snap"),
@@ -113,27 +144,32 @@ func TestExamineFiles(t *testing.T) {
test.Equal(t, usedExpected, used)
})
- t.Run("should remove outdate files", func(t *testing.T) {
+ t.Run("should remove outdated files", func(t *testing.T) {
shouldUpdate := true
tests, dir1, dir2 := setupTempExamineFiles(
t,
loadMockSnap(t, "mock-snap-1"),
loadMockSnap(t, "mock-snap-2"),
)
- examineFiles(tests, "", shouldUpdate)
-
- if _, err := os.Stat(filepath.FromSlash(dir1 + "/obsolete1.snap")); !errors.Is(
- err,
- os.ErrNotExist,
- ) {
- t.Error("obsolete obsolete1.snap not removed")
- }
-
- if _, err := os.Stat(filepath.FromSlash(dir2 + "/obsolete2.snap")); !errors.Is(
- err,
- os.ErrNotExist,
- ) {
- t.Error("obsolete obsolete2.snap not removed")
+ examineFiles(tests, set{
+ dir1 + "TestSomething_my_test_1.snap": struct{}{},
+ dir2 + "TestAnotherThing_my_simple_test_1.snap": struct{}{},
+ }, "", shouldUpdate)
+
+ for _, obsoleteFilename := range []string{
+ dir1 + "/obsolete1.snap",
+ dir2 + "/obsolete2.snap",
+ dir1 + "TestSomething_my_test_2.snap",
+ dir1 + "TestSomething_my_test_3.snap",
+ dir2 + "TestAnotherThing_my_test_1.snap",
+ dir2 + "TestAnotherThing_my_simple_test_2.snap",
+ } {
+ if _, err := os.Stat(filepath.FromSlash(obsoleteFilename)); !errors.Is(
+ err,
+ os.ErrNotExist,
+ ) {
+ t.Errorf("obsolete file %s not removed", obsoleteFilename)
+ }
}
})
}
@@ -315,42 +351,64 @@ string hello world 2 2 1
func TestOccurrences(t *testing.T) {
t.Run("when count 1", func(t *testing.T) {
tests := map[string]int{
- "add": 3,
- "subtract": 1,
- "divide": 2,
+ "add_%d": 3,
+ "subtract_%d": 1,
+ "divide_%d": 2,
}
expected := set{
- "add - 1": {},
- "add - 2": {},
- "add - 3": {},
- "subtract - 1": {},
- "divide - 1": {},
- "divide - 2": {},
+ "add_%d - 1": {},
+ "add_%d - 2": {},
+ "add_%d - 3": {},
+ "subtract_%d - 1": {},
+ "divide_%d - 1": {},
+ "divide_%d - 2": {},
+ }
+
+ expectedStandalone := set{
+ "add_1": {},
+ "add_2": {},
+ "add_3": {},
+ "subtract_1": {},
+ "divide_1": {},
+ "divide_2": {},
}
- test.Equal(t, expected, occurrences(tests, 1))
+ test.Equal(t, expected, occurrences(tests, 1, snapshotOccurrenceFMT))
+ test.Equal(t, expectedStandalone, occurrences(tests, 1, standaloneOccurrenceFMT))
})
t.Run("when count 3", func(t *testing.T) {
tests := map[string]int{
- "add": 12,
- "subtract": 3,
- "divide": 9,
+ "add_%d": 12,
+ "subtract_%d": 3,
+ "divide_%d": 9,
}
expected := set{
- "add - 1": {},
- "add - 2": {},
- "add - 3": {},
- "add - 4": {},
- "subtract - 1": {},
- "divide - 1": {},
- "divide - 2": {},
- "divide - 3": {},
+ "add_%d - 1": {},
+ "add_%d - 2": {},
+ "add_%d - 3": {},
+ "add_%d - 4": {},
+ "subtract_%d - 1": {},
+ "divide_%d - 1": {},
+ "divide_%d - 2": {},
+ "divide_%d - 3": {},
+ }
+
+ expectedStandalone := set{
+ "add_1": {},
+ "add_2": {},
+ "add_3": {},
+ "add_4": {},
+ "subtract_1": {},
+ "divide_1": {},
+ "divide_2": {},
+ "divide_3": {},
}
- test.Equal(t, expected, occurrences(tests, 3))
+ test.Equal(t, expected, occurrences(tests, 3, snapshotOccurrenceFMT))
+ test.Equal(t, expectedStandalone, occurrences(tests, 3, standaloneOccurrenceFMT))
})
}
diff --git a/snaps/matchJSON.go b/snaps/matchJSON.go
index 9fd5814..0eea31f 100644
--- a/snaps/matchJSON.go
+++ b/snaps/matchJSON.go
@@ -63,7 +63,7 @@ func MatchJSON(t testingT, input any, matchers ...match.JSONMatcher) {
func matchJSON(c *config, t testingT, input any, matchers ...match.JSONMatcher) {
t.Helper()
- snapPath, snapPathRel := snapshotPath(c)
+ snapPath, snapPathRel := snapshotPath(c, t.Name(), false)
testID := testsRegistry.getTestID(snapPath, t.Name())
t.Cleanup(func() {
testsRegistry.reset(snapPath, t.Name())
diff --git a/snaps/matchSnapshot.go b/snaps/matchSnapshot.go
index b381e68..5a66ce4 100644
--- a/snaps/matchSnapshot.go
+++ b/snaps/matchSnapshot.go
@@ -54,7 +54,7 @@ func matchSnapshot(c *config, t testingT, values ...any) {
return
}
- snapPath, snapPathRel := snapshotPath(c)
+ snapPath, snapPathRel := snapshotPath(c, t.Name(), false)
testID := testsRegistry.getTestID(snapPath, t.Name())
t.Cleanup(func() {
testsRegistry.reset(snapPath, t.Name())
@@ -109,11 +109,11 @@ func matchSnapshot(c *config, t testingT, values ...any) {
}
func takeSnapshot(objects []any) string {
- var snapshot string
+ snapshots := make([]string, len(objects))
- for i := 0; i < len(objects); i++ {
- snapshot += pretty.Sprint(objects[i]) + "\n"
+ for i, object := range objects {
+ snapshots[i] = pretty.Sprint(object)
}
- return strings.TrimSuffix(escapeEndChars(snapshot), "\n")
+ return escapeEndChars(strings.Join(snapshots, "\n"))
}
diff --git a/snaps/matchSnapshot_test.go b/snaps/matchSnapshot_test.go
index 87c3a9d..9e4605f 100644
--- a/snaps/matchSnapshot_test.go
+++ b/snaps/matchSnapshot_test.go
@@ -47,6 +47,7 @@ func setupSnapshot(t *testing.T, file string, ci bool, update ...bool) string {
t.Cleanup(func() {
os.Remove(snapPath)
testsRegistry = newRegistry()
+ standaloneTestsRegistry = newStandaloneRegistry()
testEvents = newTestEvents()
isCI = ciinfo.IsCI
updateVAR = updateVARPrev
@@ -78,7 +79,7 @@ func TestMatchSnapshot(t *testing.T) {
test.Equal(t, 1, testsRegistry.cleanup[snapPath]["mock-name"])
})
- t.Run("if it's running on ci should skip", func(t *testing.T) {
+ t.Run("if it's running on ci should skip creating snapshot", func(t *testing.T) {
setupSnapshot(t, fileName, true)
mockT := test.NewMockTestingT(t)
diff --git a/snaps/matchStandaloneSnapshot.go b/snaps/matchStandaloneSnapshot.go
new file mode 100644
index 0000000..02aab97
--- /dev/null
+++ b/snaps/matchStandaloneSnapshot.go
@@ -0,0 +1,96 @@
+package snaps
+
+import (
+ "errors"
+
+ "github.com/kr/pretty"
+)
+
+/*
+MatchStandaloneSnapshot verifies the value matches the most recent snap file
+
+ MatchStandaloneSnapshot(t, "Hello World")
+
+MatchStandaloneSnapshot creates one snapshot file per call.
+
+You can call MatchStandaloneSnapshot multiple times inside a test.
+It will create multiple snapshot files at `__snapshots__` folder by default.
+*/
+func (c *config) MatchStandaloneSnapshot(t testingT, value any) {
+ t.Helper()
+
+ matchStandaloneSnapshot(c, t, value)
+}
+
+/*
+MatchStandaloneSnapshot verifies the value matches the most recent snap file
+
+ MatchStandaloneSnapshot(t, "Hello World")
+
+MatchStandaloneSnapshot creates one snapshot file per call.
+
+You can call MatchStandaloneSnapshot multiple times inside a test.
+It will create multiple snapshot files at `__snapshots__` folder by default.
+*/
+func MatchStandaloneSnapshot(t testingT, value any) {
+ t.Helper()
+
+ matchStandaloneSnapshot(&defaultConfig, t, value)
+}
+
+func matchStandaloneSnapshot(c *config, t testingT, value any) {
+ t.Helper()
+
+ genericPathSnap, genericSnapPathRel := snapshotPath(c, t.Name(), true)
+ snapPath, snapPathRel := standaloneTestsRegistry.getTestID(genericPathSnap, genericSnapPathRel)
+ t.Cleanup(func() {
+ standaloneTestsRegistry.reset(genericPathSnap)
+ })
+
+ snapshot := pretty.Sprint(value)
+ prevSnapshot, err := getPrevStandaloneSnapshot(snapPath)
+ if errors.Is(err, errSnapNotFound) {
+ if isCI {
+ handleError(t, err)
+ return
+ }
+
+ err := upsertStandaloneSnapshot(snapshot, snapPath)
+ if err != nil {
+ handleError(t, err)
+ return
+ }
+
+ t.Log(addedMsg)
+ testEvents.register(added)
+ return
+ }
+ if err != nil {
+ handleError(t, err)
+ return
+ }
+
+ diff := prettyDiff(
+ prevSnapshot,
+ snapshot,
+ snapPathRel,
+ 1,
+ )
+ if diff == "" {
+ testEvents.register(passed)
+ return
+ }
+
+ if !shouldUpdate(c.update) {
+ handleError(t, diff)
+ return
+ }
+
+ if err = upsertStandaloneSnapshot(snapshot, snapPath); err != nil {
+ handleError(t, err)
+ return
+ }
+
+ t.Log(updatedMsg)
+ testEvents.register(updated)
+}
diff --git a/snaps/matchStandaloneSnapshot_test.go b/snaps/matchStandaloneSnapshot_test.go
new file mode 100644
index 0000000..5dd1c14
--- /dev/null
+++ b/snaps/matchStandaloneSnapshot_test.go
@@ -0,0 +1,175 @@
+package snaps
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/gkampitakis/go-snaps/internal/test"
+)
+
+const standaloneFilename = "mock-name_1.snap"
+
+func TestMatchStandaloneSnapshot(t *testing.T) {
+ t.Run("should create snapshot", func(t *testing.T) {
+ snapPath := setupSnapshot(t, standaloneFilename, false)
+ mockT := test.NewMockTestingT(t)
+ mockT.MockLog = func(args ...any) { test.Equal(t, addedMsg, args[0].(string)) }
+
+ MatchStandaloneSnapshot(mockT, "hello world")
+
+ test.Equal(t, "hello world", test.GetFileContent(t, snapPath))
+ test.Equal(t, 1, testEvents.items[added])
+ // clean up function called
+
+ registryKey := filepath.Join(
+ filepath.Dir(snapPath),
+ "mock-name_%d.snap",
+ )
+ test.Equal(t, 0, standaloneTestsRegistry.running[registryKey])
+ test.Equal(t, 1, standaloneTestsRegistry.cleanup[registryKey])
+ })
+
+ t.Run("should pass tests with no diff", func(t *testing.T) {
+ snapPath := setupSnapshot(t, standaloneFilename, false, false)
+
+ printerExpectedCalls := []func(received any){
+ func(received any) { test.Equal(t, addedMsg, received.(string)) },
+ func(received any) { t.Error("should not be called 3rd time") },
+ }
+ mockT := test.NewMockTestingT(t)
+ mockT.MockLog = func(args ...any) {
+ printerExpectedCalls[0](args[0])
+
+ // shift
+ printerExpectedCalls = printerExpectedCalls[1:]
+ }
+
+ s := WithConfig(Update(true))
+ // First call for creating the snapshot
+ s.MatchStandaloneSnapshot(mockT, "hello world")
+ test.Equal(t, 1, testEvents.items[added])
+
+ // Resetting registry to emulate the same MatchStandaloneSnapshot call
+ standaloneTestsRegistry = newStandaloneRegistry()
+
+ // Second call with same params
+ s.MatchStandaloneSnapshot(mockT, "hello world")
+
+ test.Equal(t, "hello world", test.GetFileContent(t, snapPath))
+ test.Equal(t, 1, testEvents.items[passed])
+ })
+
+ t.Run("if it's running on ci should skip creating snapshot", func(t *testing.T) {
+ setupSnapshot(t, standaloneFilename, true)
+
+ mockT := test.NewMockTestingT(t)
+ mockT.MockError = func(args ...any) {
+ test.Equal(t, errSnapNotFound, args[0].(error))
+ }
+
+ MatchStandaloneSnapshot(mockT, "hello world")
+
+ test.Equal(t, 1, testEvents.items[erred])
+ })
+
+ t.Run("should return error when diff is found", func(t *testing.T) {
+ setupSnapshot(t, standaloneFilename, false)
+
+ printerExpectedCalls := []func(received any){
+ func(received any) { test.Equal(t, addedMsg, received.(string)) },
+ func(received any) { t.Error("should not be called 2nd time") },
+ }
+ mockT := test.NewMockTestingT(t)
+ mockT.MockError = func(args ...any) {
+ expected := "\n\x1b[38;5;52m\x1b[48;5;225m- Snapshot - 1\x1b[0m\n\x1b[38;5;22m\x1b[48;5;159m" +
+ "+ Received + 1\x1b[0m\n\n\x1b[48;5;225m\x1b[38;5;52m- \x1b[0m\x1b[48;5;127m\x1b[38;5;255m" +
+ "hello\x1b[0m\x1b[48;5;225m\x1b[38;5;52m world\x1b[0m\n\x1b[48;5;159m\x1b[38;5;22m" +
+ "+ \x1b[0m\x1b[48;5;23m\x1b[38;5;255mbye\x1b[0m\x1b[48;5;159m\x1b[38;5;22m world\x1b[0m\n\n\x1b[2m" +
+ "at " + filepath.FromSlash(
+ "__snapshots__/mock-name_1.snap:1",
+ ) + "\n\x1b[0m"
+
+ test.Equal(t, expected, args[0].(string))
+ }
+ mockT.MockLog = func(args ...any) {
+ printerExpectedCalls[0](args[0])
+
+ // shift
+ printerExpectedCalls = printerExpectedCalls[1:]
+ }
+
+ // First call for creating the snapshot
+ MatchStandaloneSnapshot(mockT, "hello world")
+ test.Equal(t, 1, testEvents.items[added])
+
+ // Resetting registry to emulate the same MatchStandaloneSnapshot call
+ standaloneTestsRegistry = newStandaloneRegistry()
+
+ // Second call with different data
+ MatchStandaloneSnapshot(mockT, "bye world")
+ test.Equal(t, 1, testEvents.items[erred])
+ })
+
+ t.Run("should update snapshot", func(t *testing.T) {
+ t.Run("when 'updateVAR==true'", func(t *testing.T) {
+ snapPath := setupSnapshot(t, standaloneFilename, false, true)
+
+ printerExpectedCalls := []func(received any){
+ func(received any) { test.Equal(t, addedMsg, received.(string)) },
+ func(received any) { test.Equal(t, updatedMsg, received.(string)) },
+ func(received any) { t.Error("should not be called 3rd time") },
+ }
+ mockT := test.NewMockTestingT(t)
+ mockT.MockLog = func(args ...any) {
+ printerExpectedCalls[0](args[0])
+
+ // shift
+ printerExpectedCalls = printerExpectedCalls[1:]
+ }
+
+ // First call for creating the snapshot
+ MatchStandaloneSnapshot(mockT, "hello world")
+ test.Equal(t, 1, testEvents.items[added])
+
+ // Resetting registry to emulate the same MatchStandaloneSnapshot call
+ standaloneTestsRegistry = newStandaloneRegistry()
+
+ // Second call with different params
+ MatchStandaloneSnapshot(mockT, "bye world")
+
+ test.Equal(t, "bye world", test.GetFileContent(t, snapPath))
+ test.Equal(t, 1, testEvents.items[updated])
+ })
+
+ t.Run("when config update", func(t *testing.T) {
+ snapPath := setupSnapshot(t, standaloneFilename, false, false)
+
+ printerExpectedCalls := []func(received any){
+ func(received any) { test.Equal(t, addedMsg, received.(string)) },
+ func(received any) { test.Equal(t, updatedMsg, received.(string)) },
+ func(received any) { t.Error("should not be called 3rd time") },
+ }
+ mockT := test.NewMockTestingT(t)
+ mockT.MockLog = func(args ...any) {
+ printerExpectedCalls[0](args[0])
+
+ // shift
+ printerExpectedCalls = printerExpectedCalls[1:]
+ }
+
+ s := WithConfig(Update(true))
+ // First call for creating the snapshot
+ s.MatchStandaloneSnapshot(mockT, "hello world")
+ test.Equal(t, 1, testEvents.items[added])
+
+ // Resetting registry to emulate the same MatchStandaloneSnapshot call
+ standaloneTestsRegistry = newStandaloneRegistry()
+
+ // Second call with different params
+ s.MatchStandaloneSnapshot(mockT, "bye world")
+
+ test.Equal(t, "bye world", test.GetFileContent(t, snapPath))
+ test.Equal(t, 1, testEvents.items[updated])
+ })
+ })
+}
diff --git a/snaps/snapshot.go b/snaps/snapshot.go
index 05c588f..1feeebc 100644
--- a/snaps/snapshot.go
+++ b/snaps/snapshot.go
@@ -14,9 +14,10 @@ import (
)
var (
- testsRegistry = newRegistry()
- _m = sync.RWMutex{}
- endSequenceByteSlice = []byte(endSequence)
+ testsRegistry = newRegistry()
+ standaloneTestsRegistry = newStandaloneRegistry()
+ _m = sync.RWMutex{}
+ endSequenceByteSlice = []byte(endSequence)
)
var (
@@ -25,9 +26,10 @@ var (
)
type config struct {
- filename string
- snapsDir string
- update *bool
+ filename string
+ snapsDir string
+ extension string
+ update *bool
}
// Update determines whether to update snapshots or not
@@ -43,7 +45,7 @@ func Update(u bool) func(*config) {
//
// default: __snapshots__
//
-// this doesn't change the file extension
+// this doesn't change the file extension see `snap.Ext`
func Filename(name string) func(*config) {
return func(c *config) {
c.filename = name
@@ -61,6 +63,18 @@ func Dir(dir string) func(*config) {
}
}
+// Specify file name extension
+//
+// default: .snap
+//
+// Note: even if you specify a different extension the file still contain .snap
+// e.g. if you specify .txt the file will be .snap.txt
+func Ext(ext string) func(*config) {
+ return func(c *config) {
+ c.extension = ext
+ }
+}
+
// Create snaps with configuration
//
// e.g snaps.WithConfig(snaps.Filename("my_test")).MatchSnapshot(t, "hello world")
@@ -80,11 +94,9 @@ func handleError(t testingT, err any) {
testEvents.register(erred)
}
-/*
-We track occurrence as in the same test we can run multiple snapshots
-This also helps with keeping track with obsolete snaps
-map[snap path]: map[testname]:
-*/
+// We track occurrence as in the same test we can run multiple snapshots
+// This also helps with keeping track with obsolete snaps
+// map[snap path]: map[testname]:
type syncRegistry struct {
running map[string]map[string]int
cleanup map[string]map[string]int
@@ -124,6 +136,37 @@ func newRegistry() *syncRegistry {
}
}
+type syncStandaloneRegistry struct {
+ running map[string]int
+ cleanup map[string]int
+ sync.Mutex
+}
+
+func newStandaloneRegistry() *syncStandaloneRegistry {
+ return &syncStandaloneRegistry{
+ running: make(map[string]int),
+ cleanup: make(map[string]int),
+ Mutex: sync.Mutex{},
+ }
+}
+
+func (s *syncStandaloneRegistry) getTestID(snapPath, snapPathRel string) (string, string) {
+ s.Lock()
+
+ s.running[snapPath]++
+ s.cleanup[snapPath]++
+ c := s.running[snapPath]
+ s.Unlock()
+
+ return fmt.Sprintf(snapPath, c), fmt.Sprintf(snapPathRel, c)
+}
+
+func (s *syncStandaloneRegistry) reset(snapPath string) {
+ s.Lock()
+ s.running[snapPath] = 0
+ s.Unlock()
+}
+
// getPrevSnapshot scans file searching for a snapshot matching the given testID and returns
// the snapshot with the line where is located inside the file.
//
@@ -241,19 +284,36 @@ func removeSnapshot(s *bufio.Scanner) {
}
}
-/*
-Returns the path for snapshots
- - if no config provided returns the directory where tests are running
- - if snapsDir is relative path just gets appended to directory where tests are running
- - if snapsDir is absolute path then we are returning this path
+func upsertStandaloneSnapshot(snapshot, snapPath string) error {
+ if err := os.MkdirAll(filepath.Dir(snapPath), os.ModePerm); err != nil {
+ return err
+ }
+
+ return os.WriteFile(snapPath, []byte(snapshot), os.ModePerm)
+}
+
+func getPrevStandaloneSnapshot(snapPath string) (string, error) {
+ f, err := os.ReadFile(snapPath)
+ if err != nil {
+ return "", errSnapNotFound
+ }
-and for the filename
- - if no config provided we use the test file name with `.snap` extension
- - if filename provided we return the filename with `.snap` extension
+ return string(f), nil
+}
-Returns the relative path of the caller and the snapshot path.
-*/
-func snapshotPath(c *config) (string, string) {
+// Returns the path for snapshots:
+// - if no config provided returns the directory where tests are running
+// - if snapsDir is relative path just gets appended to directory where tests are running
+// - if snapsDir is absolute path then we are returning this path
+//
+// and for the filename:
+// - if no config provided we use the test file name with `.snap` extension
+// - if filename provided we return the filename with `.snap` extension
+// - if extension provided we return the filename with `.snap` and the provided extension
+// - if it's standalone snapshot we also append an integer (_%d) in the filename (even before `.snap`)
+//
+// Returns the relative path of the caller and the snapshot path.
+func snapshotPath(c *config, tName string, isStandalone bool) (string, string) {
// skips current func, the wrapper match* and the exported Match* func
callerFilename := baseCaller(3)
@@ -262,15 +322,29 @@ func snapshotPath(c *config) (string, string) {
dir = filepath.Join(filepath.Dir(callerFilename), c.snapsDir)
}
+ snapPath := filepath.Join(dir, constructFilename(c, callerFilename, tName, isStandalone))
+ snapPathRel, _ := filepath.Rel(filepath.Dir(callerFilename), snapPath)
+
+ return snapPath, snapPathRel
+}
+
+func constructFilename(c *config, callerFilename, tName string, isStandalone bool) string {
filename := c.filename
if filename == "" {
base := filepath.Base(callerFilename)
filename = strings.TrimSuffix(base, filepath.Ext(base))
+
+ if isStandalone {
+ filename = strings.ReplaceAll(tName, "/", "_")
+ }
}
- snapPath := filepath.Join(dir, filename+snapsExt)
- snapPathRel, _ := filepath.Rel(filepath.Dir(callerFilename), snapPath)
- return snapPath, snapPathRel
+ if isStandalone {
+ filename += "_%d"
+ }
+ filename += snapsExt + c.extension
+
+ return filename
}
func unescapeEndChars(s string) string {
diff --git a/snaps/snapshot_test.go b/snaps/snapshot_test.go
index eee23d3..9298ede 100644
--- a/snaps/snapshot_test.go
+++ b/snaps/snapshot_test.go
@@ -53,6 +53,70 @@ func TestSyncRegistry(t *testing.T) {
})
}
+func TestSyncStandaloneRegistry(t *testing.T) {
+ t.Run("should increment id on each call [concurrent safe]", func(t *testing.T) {
+ wg := sync.WaitGroup{}
+ registry := newStandaloneRegistry()
+
+ for i := 0; i < 5; i++ {
+ wg.Add(1)
+
+ go func() {
+ registry.getTestID("/file/my_file_%d.snap", "./__snapshots__/my_file_%d.snap")
+ wg.Done()
+ }()
+ }
+
+ wg.Wait()
+
+ snapPath, snapPathRel := registry.getTestID(
+ "/file/my_file_%d.snap",
+ "./__snapshots__/my_file_%d.snap",
+ )
+
+ test.Equal(t, "/file/my_file_6.snap", snapPath)
+ test.Equal(t, "./__snapshots__/my_file_6.snap", snapPathRel)
+
+ snapPath, snapPathRel = registry.getTestID(
+ "/file/my_other_file_%d.snap",
+ "./__snapshots__/my_other_file_%d.snap",
+ )
+
+ test.Equal(t, "/file/my_other_file_1.snap", snapPath)
+ test.Equal(t, "./__snapshots__/my_other_file_1.snap", snapPathRel)
+ test.Equal(t, registry.cleanup, registry.running)
+ })
+
+ t.Run("should reset running registry", func(t *testing.T) {
+ wg := sync.WaitGroup{}
+ registry := newStandaloneRegistry()
+
+ for i := 0; i < 100; i++ {
+ wg.Add(1)
+
+ go func() {
+ registry.getTestID("/file/my_file_%d.snap", "./__snapshots__/my_file_%d.snap")
+ wg.Done()
+ }()
+ }
+
+ wg.Wait()
+
+ registry.reset("/file/my_file_%d.snap")
+
+ snapPath, snapPathRel := registry.getTestID(
+ "/file/my_file_%d.snap",
+ "./__snapshots__/my_file_%d.snap",
+ )
+
+ // running registry start from 0 again
+ test.Equal(t, "/file/my_file_1.snap", snapPath)
+ test.Equal(t, "./__snapshots__/my_file_1.snap", snapPathRel)
+ // cleanup registry still has 101
+ test.Equal(t, 101, registry.cleanup["/file/my_file_%d.snap"])
+ })
+}
+
func TestGetPrevSnapshot(t *testing.T) {
t.Run("should return errSnapNotFound", func(t *testing.T) {
snap, line, err := getPrevSnapshot("", "")
@@ -139,67 +203,120 @@ func TestAddNewSnapshot(t *testing.T) {
test.Equal(t, "\n[mock-id]\nmy-snap\n---\n", test.GetFileContent(t, snapPath))
}
-func TestSnapPathAndFile(t *testing.T) {
- t.Run("should return default path and file", func(t *testing.T) {
- var (
- snapPath string
- snapPathRel string
- )
-
+func TestSnapshotPath(t *testing.T) {
+ snapshotPathWrapper := func(c *config, tName string, isStandalone bool) (snapPath, snapPathRel string) {
+ // This is for emulating being called from a func so we can find the correct file
+ // of the caller
func() {
- // This is for emulating being called from a func so we can find the correct file
- // of the caller
func() {
- snapPath, snapPathRel = snapshotPath(&defaultConfig)
+ snapPath, snapPathRel = snapshotPath(c, tName, isStandalone)
}()
}()
- test.Contains(t, snapPath, filepath.FromSlash("/snaps/__snapshots__"))
- test.Contains(t, snapPathRel, filepath.FromSlash("__snapshots__/snapshot_test.snap"))
+ return
+ }
+
+ t.Run("should return default path and file", func(t *testing.T) {
+ snapPath, snapPathRel := snapshotPathWrapper(&defaultConfig, "", false)
+
+ test.HasSuffix(t, snapPath, filepath.FromSlash("/snaps/__snapshots__/snapshot_test.snap"))
+ test.Equal(t, filepath.FromSlash("__snapshots__/snapshot_test.snap"), snapPathRel)
})
t.Run("should return path and file from config", func(t *testing.T) {
- var (
- snapPath string
- snapPathRel string
- )
-
- func() {
- // This is for emulating being called from a func so we can find the correct file
- // of the caller
- func() {
- snapPath, snapPathRel = snapshotPath(&config{
- filename: "my_file",
- snapsDir: "my_snapshot_dir",
- })
- }()
- }()
+ snapPath, snapPathRel := snapshotPathWrapper(&config{
+ filename: "my_file",
+ snapsDir: "my_snapshot_dir",
+ }, "", false)
// returns the current file's path /snaps/*
- test.Contains(t, snapPath, filepath.FromSlash("/snaps/my_snapshot_dir"))
- test.Contains(t, snapPathRel, filepath.FromSlash("my_snapshot_dir/my_file.snap"))
+ test.HasSuffix(t, snapPath, filepath.FromSlash("/snaps/my_snapshot_dir/my_file.snap"))
+ test.Equal(t, filepath.FromSlash("my_snapshot_dir/my_file.snap"), snapPathRel)
})
t.Run("should return absolute path", func(t *testing.T) {
- var (
- snapPath string
- snapPathRel string
+ snapPath, snapPathRel := snapshotPathWrapper(&config{
+ filename: "my_file",
+ snapsDir: "/path_to/my_snapshot_dir",
+ }, "", false)
+
+ test.HasSuffix(t, snapPath, filepath.FromSlash("/path_to/my_snapshot_dir/my_file.snap"))
+ // the depth depends on filesystem structure
+ test.HasSuffix(
+ t,
+ snapPathRel,
+ filepath.FromSlash("path_to/my_snapshot_dir/my_file.snap"),
)
+ })
- func() {
- // This is for emulating being called from a func so we can find the correct file
- // of the caller
- func() {
- snapPath, snapPathRel = snapshotPath(&config{
- filename: "my_file",
- snapsDir: "/path_to/my_snapshot_dir",
- })
- }()
- }()
+ t.Run("should add extension to filename", func(t *testing.T) {
+ snapPath, snapPathRel := snapshotPathWrapper(&config{
+ filename: "my_file",
+ snapsDir: "my_snapshot_dir",
+ extension: ".txt",
+ }, "", false)
+
+ test.HasSuffix(t, snapPath, filepath.FromSlash("/snaps/my_snapshot_dir/my_file.snap.txt"))
+ test.Equal(t, filepath.FromSlash("my_snapshot_dir/my_file.snap.txt"), snapPathRel)
+ })
+
+ t.Run("should return standalone snapPath", func(t *testing.T) {
+ snapPath, snapPathRel := snapshotPathWrapper(&defaultConfig, "my_test", true)
+
+ test.HasSuffix(
+ t,
+ snapPath,
+ filepath.FromSlash("/snaps/__snapshots__/my_test_%d.snap"),
+ )
+ test.Equal(
+ t,
+ filepath.FromSlash("__snapshots__/my_test_%d.snap"),
+ snapPathRel,
+ )
+ })
+
+ t.Run("should return standalone snapPath without '/'", func(t *testing.T) {
+ snapPath, snapPathRel := snapshotPathWrapper(&defaultConfig, "TestFunction/my_test", true)
- test.Contains(t, snapPath, filepath.FromSlash("/path_to/my_snapshot_dir"))
- test.Contains(t, snapPathRel, filepath.FromSlash("path_to/my_snapshot_dir/my_file.snap"))
+ test.HasSuffix(
+ t,
+ snapPath,
+ filepath.FromSlash("/snaps/__snapshots__/TestFunction_my_test_%d.snap"),
+ )
+ test.Equal(
+ t,
+ filepath.FromSlash("__snapshots__/TestFunction_my_test_%d.snap"),
+ snapPathRel,
+ )
})
+
+ t.Run("should return standalone snapPath with overridden filename", func(t *testing.T) {
+ snapPath, snapPathRel := snapshotPathWrapper(&config{
+ filename: "my_file",
+ snapsDir: "my_snapshot_dir",
+ }, "my_test", true)
+
+ test.HasSuffix(t, snapPath, filepath.FromSlash("/snaps/my_snapshot_dir/my_file_%d.snap"))
+ test.Equal(t, filepath.FromSlash("my_snapshot_dir/my_file_%d.snap"), snapPathRel)
+ })
+
+ t.Run(
+ "should return standalone snapPath with overridden filename and extension",
+ func(t *testing.T) {
+ snapPath, snapPathRel := snapshotPathWrapper(&config{
+ filename: "my_file",
+ snapsDir: "my_snapshot_dir",
+ extension: ".txt",
+ }, "my_test", true)
+
+ test.HasSuffix(
+ t,
+ snapPath,
+ filepath.FromSlash("/snaps/my_snapshot_dir/my_file_%d.snap.txt"),
+ )
+ test.Equal(t, filepath.FromSlash("my_snapshot_dir/my_file_%d.snap.txt"), snapPathRel)
+ },
+ )
}
func TestUpdateSnapshot(t *testing.T) {