Skip to content

Commit

Permalink
Add a Clean option
Browse files Browse the repository at this point in the history
  • Loading branch information
FollowTheProcess committed Jan 7, 2025
1 parent 3409342 commit 3da2f13
Show file tree
Hide file tree
Showing 8 changed files with 105 additions and 66 deletions.
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Simple, intuitive snapshot testing with Go 📸
- [Why use `snapshot`?](#why-use-snapshot)
- [📝 Total Control over Serialisation](#-total-control-over-serialisation)
- [🔄 Automatic Updating](#-automatic-updating)
- [🗑️ Tidying Up](#️-tidying-up)
- [🤓 Follows Go Conventions](#-follows-go-conventions)
- [Serialisation Rules](#serialisation-rules)
- [Credits](#credits)
Expand Down Expand Up @@ -119,6 +120,30 @@ func TestSomething(t *testing.T) {
> [!WARNING]
> This will update *all* snapshots in one go, so make sure you run the tests normally first and check the diffs to make sure the changes are as expected
### 🗑️ Tidying Up

One criticism of snapshot testing is that if you restructure or rename your tests, you could end up with duplicated snapshots and/or messy unused ones cluttering up your repo. This is where the `Clean` option comes in:

```go
// something_test.go
import (
"testing"

"github.com/FollowTheProcess/snapshot"
)

var clean = flag.Bool("clean", false, "Clean up unused snapshots")

func TestSomething(t *testing.T) {
// Tell snapshot to prune the snapshots directory of unused snapshots
snap := snapshot.New(t, snapshot.Clean(*clean))

// .... rest of the test
}
```

This will erase all the snapshots currently managed by snapshot, and then run the tests as normal, creating the snapshots for all the new or renamed tests for the first time. The net result is a tidy snapshots directory with only what's needed

### 🤓 Follows Go Conventions

Snapshots are stored in a `snapshots` directory in the current package under `testdata` which is the canonical place to store test fixtures and other files of this kind, the go tool completely ignores `testdata` so you know these files will never impact your binary!
Expand Down
2 changes: 1 addition & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ tasks:
msg: requires gofumpt, run `go install mvdan.cc/gofumpt@latest`
cmds:
- gofumpt -w -extra .
- golines . --ignore-generated --write-output
- golines . --ignore-generated --write-output --max-len 120

test:
desc: Run the test suite
Expand Down
2 changes: 2 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ignore:
- internal/colour
34 changes: 31 additions & 3 deletions internal/colour/colour.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
// Package colour implements basic text colouring for showing text diffs.
//
// I'm really putting "a little copying is better than a little dependency" into action here
// this is from FollowTheProcess/test.
package colour

import (
"os"
"sync"
)

// ANSI codes for coloured output, they are all the same length so as not to throw off
// alignment of [text/tabwriter].
const (
Expand All @@ -13,6 +15,19 @@ const (
codeReset = "\x1b[000000m" // Reset all attributes
)

// getColourOnce is a [sync.OnceValues] function that returns the state of
// $NO_COLOR and $FORCE_COLOR, once and only once to avoid us calling
// os.Getenv on every call to a colour function.
var getColourOnce = sync.OnceValues(getColour)

// getColour returns whether $NO_COLOR and $FORCE_COLOR were set.
func getColour() (noColour, forceColour bool) {
no := os.Getenv("NO_COLOR") != ""
force := os.Getenv("FORCE_COLOR") != ""

return no, force
}

// Header returns a diff header styled string.
func Header(text string) string {
return sprint(codeHeader, text)
Expand All @@ -29,6 +44,19 @@ func Red(text string) string {
}

// sprint returns a string with a given colour and the reset code.
//
// It handles checking for NO_COLOR and FORCE_COLOR.
func sprint(code, text string) string {
noColor, forceColor := getColourOnce()

// $FORCE_COLOR overrides $NO_COLOR
if forceColor {
return code + text + codeReset
}

// $NO_COLOR is next
if noColor {
return text
}
return code + text + codeReset
}
39 changes: 0 additions & 39 deletions internal/colour/colour_test.go

This file was deleted.

29 changes: 22 additions & 7 deletions option.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
package snapshot

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

// Update is an [Option] that tells snapshot whether to automatically update the stored snapshots
// with the new value from each test. Typically you'll want the value of this option to be set
// from an environment variable or a 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(v bool) Option {
return func(s *Shotter) {
s.update = v
// with the new value from each test.
//
// Typically, you'll want the value of this option to be set from an environment variable or a
// 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) {
s.update = update
}
}

// Clean is an [Option] that tells snapshot to erase the snapshots directory for the given test
// before it runs. This is particularly useful if you've renamed or restructured your tests since
// the snapshots were last generated to remove all unused snapshots.
//
// Typically, you'll want the value of this option to be set from an environment variable or a
// 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) {
s.clean = clean

Check warning on line 27 in option.go

View check run for this annotation

Codecov / codecov/patch

option.go#L25-L27

Added lines #L25 - L27 were not covered by tests
}
}
34 changes: 21 additions & 13 deletions snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,21 @@ const (
defaultDirPermissions = 0o755 // Default permissions for creating directories, same as unix mkdir
)

// Shotter holds configuration and state and is responsible for performing
// 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 Shotter struct {
type SnapShotter struct {
tb testing.TB // The testing TB
update bool // Whether to update the snapshots automatically, defaults to false
update bool // Whether to update the snapshots automatically
clean bool // Erase snapshots prior to the run
}

// New builds and returns a new [Shotter], applying configuration
// New builds and returns a new [SnapShotter], applying configuration
// via functional options.
func New( //nolint: thelper // This actually isn't a helper
tb testing.TB,
options ...Option,
) *Shotter {
shotter := &Shotter{
func New(tb testing.TB, options ...Option) *SnapShotter { //nolint: thelper // This actually isn't a helper
shotter := &SnapShotter{
tb: tb,
}

Expand All @@ -58,17 +59,24 @@ func New( //nolint: thelper // This actually isn't a helper
//
// If the current snapshot does not match the existing one, the test will fail with a rich diff
// of the two snapshots for debugging.
func (s *Shotter) Snap(value any) {
func (s *SnapShotter) Snap(value any) {
s.tb.Helper()

// Join up the base with the generate filepath
path := s.Path()

// Because subtests insert a '/' i.e. TestSomething/subtest1, we need to make
// all directories along that path so find the last dir along the path
// and use that in the call to MkDirAll
dir := filepath.Dir(path)

// If clean is set, erase the snapshot directory and then carry on
if s.clean {
if err := os.RemoveAll(dir); err != nil {
s.tb.Fatalf("failed to delete %s: %v", dir, err)
return
}

Check warning on line 77 in snapshot.go

View check run for this annotation

Codecov / codecov/patch

snapshot.go#L74-L77

Added lines #L74 - L77 were not covered by tests
}

current := &bytes.Buffer{}

switch val := value.(type) {
Expand Down Expand Up @@ -96,7 +104,7 @@ func (s *Shotter) Snap(value any) {
current.Write(content)
case fmt.Stringer:
current.WriteString(val.String())
case string, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, uintptr, bool, float32, float64, complex64, complex128:
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:
Expand Down Expand Up @@ -143,7 +151,7 @@ func (s *Shotter) Snap(value any) {
}

// Path returns the path that a snapshot would be saved at for any given test.
func (s *Shotter) Path() string {
func (s *SnapShotter) Path() string {
// Base directory under testdata where all snapshots are kept
base := filepath.Join("testdata", "snapshots")

Expand Down
6 changes: 3 additions & 3 deletions snapshot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ func TestUpdate(t *testing.T) {
}
}

func makeSnapshot(t *testing.T, shotter *snapshot.Shotter, content string) {
func makeSnapshot(t *testing.T, shotter *snapshot.SnapShotter, content string) {
t.Helper()

path := shotter.Path()
Expand All @@ -278,11 +278,11 @@ func makeSnapshot(t *testing.T, shotter *snapshot.Shotter, content string) {
}
}

func deleteSnapshot(t *testing.T, shotter *snapshot.Shotter) {
func deleteSnapshot(t *testing.T, shotter *snapshot.SnapShotter) {
t.Helper()
path := shotter.Path()

if err := os.RemoveAll(path); err != nil {
t.Fatalf("could noot delete snapshot: %v", err)
t.Fatalf("could not delete snapshot: %v", err)
}
}

0 comments on commit 3da2f13

Please sign in to comment.