Approval-based snapshot testing library for Go (mirror)
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: wip core implementation

+412 -9
+154
files.go
··· 1 + package freeze 2 + 3 + import ( 4 + "fmt" 5 + "os" 6 + "path/filepath" 7 + "regexp" 8 + "strings" 9 + ) 10 + 11 + func findProjectRoot() (string, error) { 12 + cwd, err := os.Getwd() 13 + if err != nil { 14 + return "", err 15 + } 16 + 17 + current := cwd 18 + for { 19 + if _, err := os.Stat(filepath.Join(current, "go.mod")); err == nil { 20 + return current, nil 21 + } 22 + 23 + parent := filepath.Dir(current) 24 + if parent == current { 25 + return "", fmt.Errorf("go.mod not found") 26 + } 27 + current = parent 28 + } 29 + } 30 + 31 + func getSnapshotDir() (string, error) { 32 + root, err := findProjectRoot() 33 + if err != nil { 34 + return "", err 35 + } 36 + 37 + // TODO: pull this from config. 38 + // config should allow having snapshot dir at project root (with or w/o subdirs) 39 + // or in a __snapshots__ dir inside of each package dir 40 + snapshotDir := filepath.Join(root, "__snapshots__") 41 + if err := os.MkdirAll(snapshotDir, 0755); err != nil { 42 + return "", err 43 + } 44 + 45 + return snapshotDir, nil 46 + } 47 + 48 + func SnapshotFileName(testName string) string { 49 + var result strings.Builder 50 + for i, r := range testName { 51 + if i > 0 && r >= 'A' && r <= 'Z' { 52 + result.WriteRune('_') 53 + } 54 + result.WriteRune(r) 55 + } 56 + s := result.String() 57 + s = strings.ToLower(s) 58 + s = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(s, "_") 59 + s = strings.Trim(s, "_") 60 + return s 61 + } 62 + 63 + func SaveSnapshot(snap *Snapshot, state string) error { 64 + snapshotDir, err := getSnapshotDir() 65 + if err != nil { 66 + return err 67 + } 68 + 69 + fileName := SnapshotFileName(snap.TestName) + "." + state 70 + filePath := filepath.Join(snapshotDir, fileName) 71 + 72 + return os.WriteFile(filePath, []byte(snap.Serialize()), 0644) 73 + } 74 + 75 + func ReadSnapshot(testName string, state string) (*Snapshot, error) { 76 + snapshotDir, err := getSnapshotDir() 77 + if err != nil { 78 + return nil, err 79 + } 80 + 81 + fileName := SnapshotFileName(testName) + "." + state 82 + filePath := filepath.Join(snapshotDir, fileName) 83 + 84 + data, err := os.ReadFile(filePath) 85 + if err != nil { 86 + return nil, err 87 + } 88 + 89 + return Deserialize(string(data)) 90 + } 91 + 92 + func readAccepted(testName string) (*Snapshot, error) { 93 + return ReadSnapshot(testName, "accepted") 94 + } 95 + 96 + func readNew(testName string) (*Snapshot, error) { 97 + return ReadSnapshot(testName, "new") 98 + } 99 + 100 + func ListNewSnapshots() ([]string, error) { 101 + snapshotDir, err := getSnapshotDir() 102 + if err != nil { 103 + return nil, err 104 + } 105 + 106 + entries, err := os.ReadDir(snapshotDir) 107 + if err != nil { 108 + return nil, err 109 + } 110 + 111 + var newSnapshots []string 112 + for _, entry := range entries { 113 + if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".new") { 114 + name := strings.TrimSuffix(entry.Name(), ".new") 115 + newSnapshots = append(newSnapshots, name) 116 + } 117 + } 118 + 119 + return newSnapshots, nil 120 + } 121 + 122 + func AcceptSnapshot(testName string) error { 123 + snapshotDir, err := getSnapshotDir() 124 + if err != nil { 125 + return err 126 + } 127 + 128 + fileName := SnapshotFileName(testName) 129 + newPath := filepath.Join(snapshotDir, fileName+".new") 130 + acceptedPath := filepath.Join(snapshotDir, fileName+".accepted") 131 + 132 + data, err := os.ReadFile(newPath) 133 + if err != nil { 134 + return err 135 + } 136 + 137 + if err := os.WriteFile(acceptedPath, data, 0644); err != nil { 138 + return err 139 + } 140 + 141 + return os.Remove(newPath) 142 + } 143 + 144 + func RejectSnapshot(testName string) error { 145 + snapshotDir, err := getSnapshotDir() 146 + if err != nil { 147 + return err 148 + } 149 + 150 + fileName := SnapshotFileName(testName) + ".new" 151 + filePath := filepath.Join(snapshotDir, fileName) 152 + 153 + return os.Remove(filePath) 154 + }
+88 -9
freeze.go
··· 1 1 package freeze 2 2 3 - type Snapshot struct { 4 - Version string 5 - TestName string 6 - Content string 3 + import ( 4 + "fmt" 5 + "reflect" 6 + ) 7 + 8 + const version = "0.1.0" 9 + 10 + func SnapString(t testingT, content string) { 11 + t.Helper() 12 + snap(t, content) 13 + } 14 + 15 + func Snap(t testingT, values ...any) { 16 + t.Helper() 17 + content := formatValues(values...) 18 + snap(t, content) 7 19 } 8 20 9 - type Config struct { 10 - snapshotDir string 11 - extension string 21 + func SnapWithTitle(t testingT, title string, values ...any) { 22 + t.Helper() 23 + content := formatValues(values...) 24 + snapWithTitle(t, title, content) 12 25 } 13 26 14 - func Frame(t testingT, vals ...any) { 27 + func snap(t testingT, content string) { 15 28 t.Helper() 29 + testName := t.Name() 30 + snapWithTitle(t, testName, content) 16 31 } 17 32 18 - func newSnapshot(name, content string, cfg Config) { 33 + func snapWithTitle(t testingT, title string, content string) { 34 + t.Helper() 35 + 36 + snapshot := &Snapshot{ 37 + Version: version, 38 + TestName: title, 39 + Content: content, 40 + } 41 + 42 + accepted, err := readAccepted(title) 43 + if err == nil { 44 + if accepted.Content == content { 45 + return 46 + } 47 + } 48 + 49 + if err := SaveSnapshot(snapshot, "new"); err != nil { 50 + t.Error("failed to save snapshot:", err) 51 + return 52 + } 53 + 54 + t.Error("snapshot mismatch - run 'freeze review' to update") 55 + } 19 56 57 + func formatValues(values ...any) string { 58 + if len(values) == 0 { 59 + return "" 60 + } 61 + 62 + if len(values) == 1 { 63 + return formatValue(values[0]) 64 + } 65 + 66 + var result string 67 + for i, v := range values { 68 + if i > 0 { 69 + result += "\n" 70 + } 71 + result += formatValue(v) 72 + } 73 + return result 74 + } 75 + 76 + func formatValue(v any) string { 77 + if v == nil { 78 + return "<nil>" 79 + } 80 + 81 + if formattable, ok := v.(interface{ Format() string }); ok { 82 + return formattable.Format() 83 + } 84 + 85 + if stringer, ok := v.(interface{ String() string }); ok { 86 + return stringer.String() 87 + } 88 + 89 + val := reflect.ValueOf(v) 90 + switch val.Kind() { 91 + case reflect.String: 92 + return v.(string) 93 + case reflect.Struct, reflect.Slice, reflect.Array, reflect.Map: 94 + // TODO: make this better probably 95 + return fmt.Sprintf("%#v", v) 96 + default: 97 + return fmt.Sprint(v) 98 + } 20 99 }
+115
freeze_test.go
··· 1 + package freeze_test 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + 8 + "github.com/ptdewey/freeze" 9 + ) 10 + 11 + func TestSnapString(t *testing.T) { 12 + freeze.SnapString(t, "hello world") 13 + } 14 + 15 + func TestSnapMultiple(t *testing.T) { 16 + freeze.Snap(t, "value1", "value2", 42) 17 + } 18 + 19 + type CustomStruct struct { 20 + Name string 21 + Age int 22 + } 23 + 24 + func (c CustomStruct) Format() string { 25 + return "CustomStruct{Name: " + c.Name + ", Age: " + string(rune(c.Age)) + "}" 26 + } 27 + 28 + func TestSnapCustomType(t *testing.T) { 29 + cs := CustomStruct{Name: "Alice", Age: 30} 30 + freeze.Snap(t, cs) 31 + } 32 + 33 + func TestSerializeDeserialize(t *testing.T) { 34 + snap := &freeze.Snapshot{ 35 + Version: "1.0.0", 36 + TestName: "TestExample", 37 + Content: "test content\nmultiline", 38 + } 39 + 40 + serialized := snap.Serialize() 41 + expected := "---\nversion: 1.0.0\ntest_name: TestExample\n---\ntest content\nmultiline" 42 + if serialized != expected { 43 + t.Errorf("expected:\n%s\ngot:\n%s", expected, serialized) 44 + } 45 + 46 + deserialized, err := freeze.Deserialize(serialized) 47 + if err != nil { 48 + t.Fatalf("failed to deserialize: %v", err) 49 + } 50 + 51 + if deserialized.Version != snap.Version { 52 + t.Errorf("version mismatch: %s != %s", deserialized.Version, snap.Version) 53 + } 54 + if deserialized.TestName != snap.TestName { 55 + t.Errorf("test name mismatch: %s != %s", deserialized.TestName, snap.TestName) 56 + } 57 + if deserialized.Content != snap.Content { 58 + t.Errorf("content mismatch: %s != %s", deserialized.Content, snap.Content) 59 + } 60 + } 61 + 62 + func TestFileOperations(t *testing.T) { 63 + snap := &freeze.Snapshot{ 64 + Version: "0.1.0", 65 + TestName: "TestFileOps", 66 + Content: "file test content", 67 + } 68 + 69 + if err := freeze.SaveSnapshot(snap, "test"); err != nil { 70 + t.Fatalf("failed to save snapshot: %v", err) 71 + } 72 + 73 + read, err := freeze.ReadSnapshot("TestFileOps", "test") 74 + if err != nil { 75 + t.Fatalf("failed to read snapshot: %v", err) 76 + } 77 + 78 + if read.Content != snap.Content { 79 + t.Errorf("content mismatch: %s != %s", read.Content, snap.Content) 80 + } 81 + 82 + cleanupTestSnapshots(t) 83 + } 84 + 85 + func TestSnapshotFileName(t *testing.T) { 86 + tests := []struct { 87 + input string 88 + expected string 89 + }{ 90 + {"TestMyFunction", "test_my_function"}, 91 + {"test_another_one", "test_another_one"}, 92 + {"TestCamelCase", "test_camel_case"}, 93 + {"TestWithNumbers123", "test_with_numbers123"}, 94 + } 95 + 96 + for _, tt := range tests { 97 + result := freeze.SnapshotFileName(tt.input) 98 + if result != tt.expected { 99 + t.Errorf("SnapshotFileName(%s) = %s, want %s", tt.input, result, tt.expected) 100 + } 101 + } 102 + } 103 + 104 + func cleanupTestSnapshots(t *testing.T) { 105 + t.Helper() 106 + 107 + cwd, err := os.Getwd() 108 + if err != nil { 109 + t.Logf("failed to get cwd: %v", err) 110 + return 111 + } 112 + 113 + snapshotDir := filepath.Join(cwd, "__snapshots__") 114 + _ = os.RemoveAll(snapshotDir) 115 + }
+2
justfile
··· 1 + test: 2 + @go test ./... -cover -coverprofile=cover.out
+53
snapshot.go
··· 1 + package freeze 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + ) 7 + 8 + type Snapshot struct { 9 + Version string 10 + TestName string 11 + Content string 12 + } 13 + 14 + func (s *Snapshot) Serialize() string { 15 + header := fmt.Sprintf("---\nversion: %s\ntest_name: %s\n---\n", s.Version, s.TestName) 16 + return header + s.Content 17 + } 18 + 19 + func Deserialize(raw string) (*Snapshot, error) { 20 + parts := strings.SplitN(raw, "---\n", 3) 21 + if len(parts) < 3 { 22 + return nil, fmt.Errorf("invalid snapshot format") 23 + } 24 + 25 + header := parts[1] 26 + content := parts[2] 27 + 28 + snap := &Snapshot{ 29 + Content: content, 30 + } 31 + 32 + for _, line := range strings.Split(header, "\n") { 33 + line = strings.TrimSpace(line) 34 + if line == "" { 35 + continue 36 + } 37 + 38 + kv := strings.SplitN(line, ": ", 2) 39 + if len(kv) != 2 { 40 + continue 41 + } 42 + 43 + key, value := kv[0], kv[1] 44 + switch key { 45 + case "version": 46 + snap.Version = value 47 + case "test_name": 48 + snap.TestName = value 49 + } 50 + } 51 + 52 + return snap, nil 53 + }