Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Flesh out the serialisation rules #5

Merged
merged 1 commit into from
Nov 19, 2024
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
92 changes: 92 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ Simple, intuitive snapshot testing with Go 📸

![caution](./img/caution.png)

- [Snapshot](#snapshot)
- [Project Description](#project-description)
- [Installation](#installation)
- [Quickstart](#quickstart)
- [Why use `snapshot`?](#why-use-snapshot)
- [📝 Total Control over Serialisation](#-total-control-over-serialisation)
- [🔄 Automatic Updating](#-automatic-updating)
- [🤓 Follows Go Conventions](#-follows-go-conventions)
- [Serialisation Rules](#serialisation-rules)
- [Credits](#credits)

## Project Description

Snapshot testing is where you assert the result of your code is identical to a specific reference value... which is basically *all* testing. If you've ever written:
Expand All @@ -32,6 +43,8 @@ The next jump up is what's typically called "golden files".

These are files (typically manually created) that contain the expected output, any difference in what your code produces to what's in the file is an error.

**Enter snapshot testing 📸**

Think of snapshot testing as an automated, configurable, and simple way of managing golden files. All you need to do is call `Snap` and everything is handled for you!

## Installation
Expand Down Expand Up @@ -59,9 +72,88 @@ func TestSnapshot(t *testing.T) {
}
```

## Why use `snapshot`?

### 📝 Total Control over Serialisation

A few other libraries are out there for snapshot testing in Go, but they typically control the serialisation for you, using a generic object dumping library. This means you get what you get and there's not much option to change it.

Not very helpful if you want your snapshots to be as readable as possible!

With `snapshot`, you have full control over how your type is serialised to the snapshot file (if you need it). You can either:

- Let `snapshot` take a best guess at how to serialise your type
- Implement one of `snapshot.Snapper`, [encoding.TextMarshaler], or [fmt.Stringer] to override how it's serialised

See [Serialisation Rules](#serialisation-rules) 👇🏻 for more info on how `snapshot` decides how to snap your value

### 🔄 Automatic Updating

Let's say you've got a bunch of snapshots saved already, and you change your implementation. *All* those snapshots will now likely need to change (after you've carefully reviewed the changes and decided they are okay!)

`snapshot` lets you do this with one line of configuration, which you can set with a test flag or environment variable, or however you like:

```go
import (
"testing"

"github.com/FollowTheProcess/snapshot"
)

// something_test.go
var update = flag.Bool("update", false, "Update golden files")

func TestSomething(t *testing.T) {
// Tell snapshot to update everything if the -update flag was used
snap := snapshot.New(t, snapshot.Update(*update))

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

> [!TIP]
> If you declare top level flags in a test file, you can pass them to `go test`. So in this case, `go test -update` would store `true` in the update var. You can also use environments variables and test them with `os.Getenv` e.g. `UPDATE_SNAPSHOTS=true go test`. Whatever works for you.

> [!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

### 🤓 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!

See `go help test`...

```plaintext
The go tool will ignore a directory named "testdata", making it available
to hold ancillary data needed by the tests.
```

The files will be named automatically after the test:

- Single tests will be given the name of the test e.g. `func TestMyThing(t *testing.T)` will produce a snapshot file of `testdata/snapshots/TestMyThing.snap.txt`
- Sub tests (including table driven tests) will use the sub test name e.g. `testdata/snapshots/TestAdd/positive_numbers.snap.txt`

## Serialisation Rules

snapshot deals with plain text files as snapshots, this keeps them easy to read/write for both computers and humans. But crucially, easy to diff in pull request reviews!

Because of this, it needs to know how to serialise your value (which could be basically any valid construct in Go) to plain text, so we follow a few basic rules in priority order:

- **`snapshot.Snapper`:** If your type implements the `Snapper` interface, this is preferred over all other potential serialisation, this allows you to have total control over how your type is snapshotted, do whatever you like in the `Snap` method, just return a `[]byte` that you'd like to look at in the snapshot and thats it!
- **[encoding.TextMarshaler]:** If your type implements [encoding.TextMarshaler], this will be used to render your value to the snapshot
- **[fmt.Stringer]:** If your type implements the [fmt.Stringer] interface, this is then used instead
- **Primitive Types:** Any primitive type in Go (`bool`, `int`, `string` etc.) is serialised according to the `%v` verb in the [fmt] package
- **Fallback:** If your type hasn't been caught by any of the above rules, we will snap it using the `GoString` mechanism (the `%#v` print verb)

> [!TIP]
> snapshot effectively goes through this list top to bottom to discover how to serialise your type, so mechanisms at the top are preferentially chosen over mechanisms lower down. If your snapshot doesn't look quite right, consider implementing a method higher up the list to get the behaviour you need

### Credits

This package was created with [copier] and the [FollowTheProcess/go_copier] project template.

[copier]: https://copier.readthedocs.io/en/stable/
[FollowTheProcess/go_copier]: https://github.com/FollowTheProcess/go_copier
[fmt]: https://pkg.go.dev/fmt
[encoding.TextMarshaler]: https://pkg.go.dev/encoding#TextMarshaler
[fmt.Stringer]: https://pkg.go.dev/fmt#Stringer
19 changes: 14 additions & 5 deletions snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package snapshot

import (
"bytes"
"encoding"
"errors"
"fmt"
"io/fs"
Expand Down Expand Up @@ -70,18 +71,26 @@ func (s *Shotter) Snap(value any) {
case Snapper:
content, err := val.Snap()
if err != nil {
s.tb.Fatalf("Snap() returned an error: %v", err)
s.tb.Fatalf("%T implements Snapper but Snap() 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, 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:
// TODO(@FollowTheProcess): Every other type, maybe fall back to
// some sort of generic printing thing?
s.tb.Fatalf("Snap: unhandled type %[1]T, consider implementing snapshot.Snapper for %[1]T", val)
return
// Fallback, use %#v as a best effort at generic printing
s.tb.Logf("Snap: falling back to GoString for %[1]T, consider implementing snapshot.Snapper, encoding.TextMarshaler or fmt.Stringer for %[1]T", val)
fmt.Fprintf(current, "%#v", val)
}

// Check if one exists already
Expand Down
50 changes: 50 additions & 0 deletions snapshot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ func (t *TB) Name() string {
return t.name
}

func (t *TB) Logf(format string, args ...any) {
fmt.Fprintf(t.out, format, args...)
}

func (t *TB) Fatal(args ...any) {
t.failed = true
fmt.Fprint(t.out, args...)
Expand Down Expand Up @@ -59,6 +63,30 @@ func (e explosion) Snap() ([]byte, error) {
return nil, errors.New("bang")
}

// nosnap has no Snap implementation.
type nosnap struct{}

// textMarshaler is a struct that implements encoding.TextMarshaller.
type textMarshaler struct{}

func (t textMarshaler) MarshalText() (text []byte, err error) {
return []byte("MarshalText() called\n"), nil
}

// errMarshaler is a struct that implements encoding.TextMarshaller, but always returns an error.
type errMarshaler struct{}

func (t errMarshaler) MarshalText() (text []byte, err error) {
return nil, errors.New("MarshalText error")
}

// stringer is a struct that implements fmt.Stringer.
type stringer struct{}

func (s stringer) String() string {
return "String() called\n"
}

func TestSnap(t *testing.T) {
tests := []struct {
value any // Value to snap
Expand Down Expand Up @@ -114,6 +142,28 @@ func TestSnap(t *testing.T) {
wantFail: false,
existingSnap: "3.14159",
},
{
name: "no snap",
value: nosnap{},
wantFail: false,
},
{
name: "text marshaler",
value: textMarshaler{},
wantFail: false,
existingSnap: "MarshalText() called\n",
},
{
name: "text marshaler error",
value: errMarshaler{},
wantFail: true,
},
{
name: "stringer",
value: stringer{},
wantFail: false,
existingSnap: "String() called\n",
},
}

for _, tt := range tests {
Expand Down
1 change: 1 addition & 0 deletions testdata/snapshots/TestSnap/no_snap.snap.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
snapshot_test.nosnap{}
1 change: 1 addition & 0 deletions testdata/snapshots/TestSnap/stringer.snap.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
String() called
1 change: 1 addition & 0 deletions testdata/snapshots/TestSnap/text_marshaler.snap.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
MarshalText() called
Loading