Skip to content

Commit

Permalink
feat: introduce property matchers for matchJSON (#52)
Browse files Browse the repository at this point in the history
  • Loading branch information
gkampitakis authored Nov 6, 2022
1 parent 33b8ac0 commit 6f3bcb8
Show file tree
Hide file tree
Showing 16 changed files with 778 additions and 32 deletions.
78 changes: 77 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,16 @@
</p>


> matchJSON and matchers are still under development that means their API can change. Use with caution and please share feedback for improvements.
## Highlights

- [Installation](#installation)
- [MatchSnapshot](#matchsnapshot)
- [MatchJSON](#matchjson)
- [Matchers](#matchers)
- [match.Any](#matchany)
- [match.Custom](#matchcustom)
- [Update Snapshots](#update-snapshots)
- [Clean obsolete Snapshots](#clean-obsolete-snapshots)
- [Skipping Tests](#skipping-tests)
Expand Down Expand Up @@ -108,7 +113,78 @@ func TestJSON(t *testing.T) {
}
```

JSON will be saved in snapshot in pretty format for more readability.
JSON will be saved in snapshot in pretty format for more readability and deterministic diffs.

### Matchers

`MatchJSON`'s third argument can accept a list of matchers. Matchers are functions that can act
as property matchers and test values.

You can pass a path of the property you want to match and test.

The path syntax is a series of keys separated by a dot. The dot and colon can be escaped with `\`.

Currently `go-snaps` has two build in matchers

- `match.Any`
- `match.Custom`

#### match.Any

Any matcher acts as a placeholder for any value. It replaces any targeted path with a
placeholder string.

```go
Any("user.name")
// or with multiple paths
Any("user.name", "user.email")
```

Any matcher provides some methods for setting options

```go
match.Any("user.name").
Placeholder(value). // allows to define a different placeholder value from the default "<Any Value>"
ErrOnMissingPath(bool) // determines whether the matcher will err in case of a missing, default true
```

#### match.Custom

Custom matcher allows you to bring your own validation and placeholder value

```go
match.Custom("user.age", func(val interface{}) (interface{}, error) {
age, ok := val.(float64)
if !ok {
return nil, fmt.Errorf("expected number but got %T", val)
}

return "some number", nil
})
```

The callback parameter value for JSON can be on of these types:

```go
bool // for JSON booleans
float64 // for JSON numbers
string // for JSON string literals
nil // for JSON null
map[string]interface{} // for JSON objects
[]interface{} // for JSON arrays
```

If Custom matcher returns an error the snapshot test will fail with that error.

Custom matcher provides a method for setting an option

```go
match.Custom("path",myFunc).
Placeholder(value). // allows to define a different placeholder value from the default "<Any Value>"
ErrOnMissingPath(bool) // determines whether the matcher will err in case of a missing path, default true
```

You can see more [examples](./examples/matchJSON_test.go#L93).

## Update Snapshots

Expand Down
49 changes: 49 additions & 0 deletions examples/__snapshots__/matchJSON_test.snap
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,52 @@
"name": "mock-name"
}
---

[TestMatchers/Custom_matcher/struct_marshalling - 1]
{
"email": "mock-user@email.com",
"keys": [
1,
2,
3,
4,
5
],
"name": "mock-user"
}
---

[TestMatchers/JSON_string_validation - 1]
{
"age": "<less than 5 age>",
"email": "mock@email.com",
"user": "mock-user"
}
---

[TestMatchers/Any_matcher/should_ignore_fields - 1]
{
"age": 10,
"nested": {
"now": [
"<Any value>"
]
},
"user": "mock-user"
}
---

[TestMatchers/my_matcher/should_allow_using_your_matcher - 1]
{
"value": "blue"
}
---

[TestMatchers/http_response - 1]
{
"data": {
"createdAt": "<Any value>",
"message": "hello world"
}
}
---
22 changes: 0 additions & 22 deletions examples/__snapshots__/matchSnapshot_test.snap
Original file line number Diff line number Diff line change
Expand Up @@ -39,32 +39,10 @@ another snapshot
}
---

[TestMatchSnapshotTable/string - 1]
input
---

[TestMatchSnapshotTable/integer - 1]
int(10)
---

[TestMatchSnapshotTable/map - 1]
map[string]interface {}{
"test": func() {...},
}
---

[TestMatchSnapshot/nest/more/one_more_nested_test - 1]
it's okay
---

[TestMatchSnapshotTable/buffer - 1]
&bytes.Buffer{
buf: {0x42, 0x75, 0x66, 0x66, 0x65, 0x72, 0x20, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67},
off: 0,
lastRead: 0,
}
---

[TestMatchSnapshot/.* - 1]
ignore regex patterns on names
---
Expand Down
139 changes: 139 additions & 0 deletions examples/matchJSON_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,63 @@
package examples

import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"

"github.com/gkampitakis/go-snaps/match"
"github.com/gkampitakis/go-snaps/snaps"
)

type myMatcher struct {
age int
}

func Matcher() *myMatcher {
return &myMatcher{}
}

func (m *myMatcher) AgeGreater(a int) *myMatcher {
m.age = a
return m
}

func (m *myMatcher) JSON(s []byte) ([]byte, []match.MatcherError) {
var v struct {
User string
Age int
Email string
}

err := json.Unmarshal(s, &v)
if err != nil {
return nil, []match.MatcherError{
{
Reason: err,
Matcher: "my matcher",
Path: "",
},
}
}

if v.Age < m.age {
return nil, []match.MatcherError{
{
Reason: fmt.Errorf("%d is >= from %d", m.age, v.Age),
Matcher: "my matcher",
Path: "age",
},
}
}

// the second string is the formatted error message
return []byte(`{"value":"blue"}`), nil
}

func TestMatchJSON(t *testing.T) {
t.Run("should make a json object snapshot", func(t *testing.T) {
m := map[string]interface{}{
Expand Down Expand Up @@ -40,3 +92,90 @@ func TestMatchJSON(t *testing.T) {
snaps.MatchJSON(t, u)
})
}

func TestMatchers(t *testing.T) {
t.Run("Custom matcher", func(t *testing.T) {
t.Run("struct marshalling", func(t *testing.T) {
type User struct {
Name string `json:"name"`
Email string `json:"email"`
Keys []int `json:"keys"`
}

u := User{
Name: "mock-user",
Email: "mock-user@email.com",
Keys: []int{1, 2, 3, 4, 5},
}

snaps.MatchJSON(t, u, match.Custom("keys", func(val interface{}) (interface{}, error) {
keys, ok := val.([]interface{})
if !ok {
return nil, fmt.Errorf("expected []interface{} but got %T", val)
}

if len(keys) > 5 {
return nil, fmt.Errorf("expected less than 5 keys")
}

return val, nil
}))
})
})

t.Run("JSON string validation", func(t *testing.T) {
value := `{"user":"mock-user","age":2,"email":"mock@email.com"}`

snaps.MatchJSON(t, value, match.Custom("age", func(val interface{}) (interface{}, error) {
if valInt, ok := val.(float64); !ok || valInt >= 5 {
return nil, fmt.Errorf("expecting number less than 5")
}

return "<less than 5 age>", nil
}))
})

t.Run("Any matcher", func(t *testing.T) {
t.Run("should ignore fields", func(t *testing.T) {
value := fmt.Sprintf(
`{"user":"mock-user","age":10,"nested":{"now":["%s"]}}`,
time.Now(),
)
snaps.MatchJSON(t, value, match.Any("nested.now.0"))
})
})

t.Run("my matcher", func(t *testing.T) {
t.Run("should allow using your matcher", func(t *testing.T) {
value := `{"user":"mock-user","age":10,"email":"mock@email.com"}`

snaps.MatchJSON(t, value, Matcher().AgeGreater(5))
})
})

t.Run("http response", func(t *testing.T) {
// mock server returning a json object
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
payload := fmt.Sprintf(
`{"data":{"message":"hello world","createdAt":"%s"}}`,
time.Now().UTC(),
)
w.Write([]byte(payload))
}))

res, err := http.Get(s.URL)
if err != nil {
t.Errorf("unexpected error %s", err)
return
}
defer res.Body.Close()

body, err := io.ReadAll(res.Body)
if err != nil {
t.Errorf("unexpected error %s", err)
return
}

snaps.MatchJSON(t, body, match.Any("data.createdAt"))
})
}
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ require (
github.com/rogpeppe/go-internal v1.9.0 // indirect
github.com/tidwall/gjson v1.14.3
github.com/tidwall/pretty v1.2.0
github.com/tidwall/sjson v1.2.5
)
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,14 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsK
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.14.3 h1:9jvXn7olKEHU1S9vwoMGliaT8jq1vJ7IH/n9zD9Dnlw=
github.com/tidwall/gjson v1.14.3/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
4 changes: 4 additions & 0 deletions golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ linters-settings:
line-length: 130
gofumpt:
extra-rules: true
revive:
rules:
- name: exported
disabled: true

run:
skip-files:
Expand Down
Loading

0 comments on commit 6f3bcb8

Please sign in to comment.