diff --git a/README.md b/README.md index a318cf8..588e0a3 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Go Reference](https://pkg.go.dev/badge/github.com/gkampitakis/go-snaps.svg)](https://pkg.go.dev/github.com/gkampitakis/go-snaps)

-Jest-like snapshot testing in Golang +Jest-like snapshot testing in Go


@@ -18,12 +18,13 @@ - [Installation](#installation) - [MatchSnapshot](#matchsnapshot) +- [MatchStandaloneSnapshot](#matchstandalonesnapshot) `New` - [MatchJSON](#matchjson) - - [Matchers](#matchers) - - [match.Any](#matchany) - - [match.Custom](#matchcustom) - - [match.Type\[ExpectedType\]](#matchtype) -- [MatchStandaloneSnapshot](#matchstandalonesnapshot) +- [MatchYAML](#matchyaml) `New` +- [Matchers](#matchers) + - [match.Any](#matchany) + - [match.Custom](#matchcustom) + - [match.Type\[ExpectedType\]](#matchtype) - [Configuration](#configuration) - [Update Snapshots](#update-snapshots) - [Clean obsolete Snapshots](#clean-obsolete-snapshots) @@ -85,6 +86,29 @@ 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__/test_simple.snaps`. +## 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`. + ## MatchJSON `MatchJSON` can be used to capture data that can represent a valid json. @@ -107,21 +131,51 @@ func TestJSON(t *testing.T) { JSON will be saved in snapshot in pretty format for more readability and deterministic diffs. +## MatchYAML + +`MatchYAML` can be used to capture data that can represent a valid yaml. + +You can pass a valid json in form of `string` or `[]byte` or whatever value can be passed +successfully on `yaml.Marshal`. + +```go +func TestYAML(t *testing.T) { + type User struct { + Age int + Email string + } + + snaps.MatchYAML(t, "user: \"mock-user\"\nage: 10\nemail: mock@email.com") + snaps.MatchYAML(t, []byte("user: \"mock-user\"\nage: 10\nemail: mock@email.com")) + snaps.MatchYAML(t, User{10, "mock-email"}) +} +``` + ### Matchers -`MatchJSON`'s third argument can accept a list of matchers. Matchers are functions that can act +`MatchJSON`'s and `MatchYAML`'s third argument can accept a list of matchers. Matchers are functions that can act as property matchers and test values. You can pass the path of the property you want to match and test. -_More information about the supported path syntax from [gjson](https://github.com/tidwall/gjson/blob/v1.17.0/SYNTAX.md)._ - Currently `go-snaps` has three build in matchers - `match.Any` - `match.Custom` - `match.Type[ExpectedType]` +_Open to feedback for building more matchers or you can build your own [example](./examples/matchJSON_test.go#L16)._ + +#### Path Syntax + +For JSON go-snaps utilises gjson. + +_More information about the supported path syntax from [gjson](https://github.com/tidwall/gjson/blob/v1.17.0/SYNTAX.md)._ + +As for YAML go-snaps utilises [github.com/goccy/go-yaml#5-use-yamlpath](https://github.com/goccy/go-yaml#5-use-yamlpath). + +_More information about the supported syntax [PathString](https://github.com/goccy/go-yaml/blob/9cbf5d4217830fd4ad1504e9ed117c183ade0994/path.go#L17-L26)._ + #### match.Any Any matcher acts as a placeholder for any value. It replaces any targeted path with a @@ -196,29 +250,6 @@ 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 diff --git a/examples/__snapshots__/matchYAML_test.snap b/examples/__snapshots__/matchYAML_test.snap new file mode 100755 index 0000000..4bb82a2 --- /dev/null +++ b/examples/__snapshots__/matchYAML_test.snap @@ -0,0 +1,29 @@ + +[TestMatchYaml/should_match_struct_yaml - 1] +name: John Doe +age: 30 +email: john.doe@example.com +address: mock-address +time: mock-time + +--- + +[TestMatchYaml/custom_matching_logic - 1] +name: mock-user +email: mock-user@email.com +keys: + - 1 + - 2 + - 3 + - 4 + - 5 + +--- + +[TestMatchYaml/type_matcher - 1] +data: +--- + +[TestMatchYaml/type_matcher - 2] +metadata: +--- diff --git a/examples/matchYAML_test.go b/examples/matchYAML_test.go new file mode 100644 index 0000000..2dfa76f --- /dev/null +++ b/examples/matchYAML_test.go @@ -0,0 +1,67 @@ +package examples + +import ( + "fmt" + "testing" + "time" + + "github.com/gkampitakis/go-snaps/match" + "github.com/gkampitakis/go-snaps/snaps" +) + +func TestMatchYaml(t *testing.T) { + t.Run("should match struct yaml", func(t *testing.T) { + type User struct { + Name string `yaml:"name"` + Age int `yaml:"age"` + Email string `yaml:"email"` + Address string `yaml:"address"` + Time time.Time `yaml:"time"` + } + + snaps.MatchYAML(t, User{ + Name: "John Doe", + Age: 30, + Email: "john.doe@example.com", + Address: "123 Main St", + Time: time.Now(), + }, match.Any("$.time").Placeholder("mock-time"), match.Any("$.address").Placeholder("mock-address")) + }) + + t.Run("custom matching logic", 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.MatchYAML(t, u, match.Custom("$.keys", func(val any) (any, error) { + keys, ok := val.([]any) + if !ok { + return nil, fmt.Errorf("expected []any but got %T", val) + } + + if len(keys) > 5 { + return nil, fmt.Errorf("expected less than 5 keys") + } + + return val, nil + })) + }) + + t.Run("type matcher", func(t *testing.T) { + snaps.MatchYAML(t, "data: 10", match.Type[uint64]("$.data")) + + snaps.MatchYAML( + t, + "metadata:\n timestamp: 1687108093142", + match.Type[map[string]any]("$.metadata"), + ) + }) +} diff --git a/go.mod b/go.mod index 96bcb0b..6373a3a 100644 --- a/go.mod +++ b/go.mod @@ -1,19 +1,20 @@ module github.com/gkampitakis/go-snaps -go 1.21 +go 1.22 require ( - github.com/gkampitakis/ciinfo v0.3.0 + github.com/gkampitakis/ciinfo v0.3.1 github.com/gkampitakis/go-diff v1.3.2 + github.com/goccy/go-yaml v1.15.13 github.com/kr/pretty v0.3.1 github.com/maruel/natural v1.1.1 - github.com/tidwall/gjson v1.17.0 + github.com/tidwall/gjson v1.18.0 github.com/tidwall/pretty v1.2.1 github.com/tidwall/sjson v1.2.5 ) require ( github.com/kr/text v0.2.0 // indirect - github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/tidwall/match v1.1.1 // indirect ) diff --git a/go.sum b/go.sum index 80004fe..6600622 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,10 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/gkampitakis/ciinfo v0.3.0 h1:gWZlOC2+RYYttL0hBqcoQhM7h1qNkVqvRCV1fOvpAv8= -github.com/gkampitakis/ciinfo v0.3.0/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/ciinfo v0.3.1 h1:lzjbemlGI4Q+XimPg64ss89x8Mf3xihJqy/0Mgagapo= +github.com/gkampitakis/ciinfo v0.3.1/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/goccy/go-yaml v1.15.13 h1:Xd87Yddmr2rC1SLLTm2MNDcTjeO/GYo0JGiww6gSTDg= +github.com/goccy/go-yaml v1.15.13/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -11,11 +13,11 @@ github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= -github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= -github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM= -github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/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/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= diff --git a/match/any.go b/match/any.go index 361a759..8947ed6 100644 --- a/match/any.go +++ b/match/any.go @@ -1,8 +1,10 @@ package match import ( - "errors" + "bytes" + "github.com/gkampitakis/go-snaps/match/internal/yaml" + "github.com/goccy/go-yaml/parser" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -14,14 +16,22 @@ type anyMatcher struct { name string } +func (a *anyMatcher) matcherError(err error, path string) MatcherError { + return MatcherError{ + Reason: err, + Matcher: a.name, + Path: path, + } +} + /* Any matcher acts as a placeholder for any value It replaces any targeted path with a placeholder string - Any("user.name") - // or with multiple paths Any("user.name", "user.email") + // or for yaml + Any("$.user.name", "$.user.email") */ func Any(paths ...string) *anyMatcher { return &anyMatcher{ @@ -38,41 +48,65 @@ func (a *anyMatcher) Placeholder(p any) *anyMatcher { return a } -// ErrOnMissingPath determines if matcher will fail in case of trying to access a json path +// ErrOnMissingPath determines if matcher will fail in case of trying to access a path // that doesn't exist func (a *anyMatcher) ErrOnMissingPath(e bool) *anyMatcher { a.errOnMissingPath = e return a } +// YAML is intended to be called internally on snaps.MatchYAML for applying Any matchers +func (a anyMatcher) YAML(b []byte) ([]byte, []MatcherError) { + var errs []MatcherError + + f, err := parser.ParseBytes(b, parser.ParseComments) + if err != nil { + return b, []MatcherError{a.matcherError(err, "*")} + } + + for _, p := range a.paths { + path, _, exists, err := yaml.Get(f, p) + if err != nil { + errs = append(errs, a.matcherError(err, p)) + + continue + } + if !exists { + if a.errOnMissingPath { + errs = append(errs, a.matcherError(errPathNotFound, p)) + } + + continue + } + + if err := yaml.Update(f, path, a.placeholder); err != nil { + errs = append(errs, a.matcherError(err, p)) + + continue + } + } + + return yaml.MarshalFile(f, bytes.HasSuffix(b, []byte("\n"))), errs +} + // JSON is intended to be called internally on snaps.MatchJSON for applying Any matchers -func (a anyMatcher) JSON(s []byte) ([]byte, []MatcherError) { +func (a anyMatcher) JSON(b []byte) ([]byte, []MatcherError) { var errs []MatcherError - json := s + json := b for _, path := range a.paths { r := gjson.GetBytes(json, path) if !r.Exists() { if a.errOnMissingPath { - errs = append(errs, MatcherError{ - Reason: errors.New("path does not exist"), - Matcher: a.name, - Path: path, - }) + errs = append(errs, a.matcherError(errPathNotFound, path)) } + continue } - j, err := sjson.SetBytesOptions(json, path, a.placeholder, &sjson.Options{ - Optimistic: true, - ReplaceInPlace: true, - }) + j, err := sjson.SetBytesOptions(json, path, a.placeholder, setJSONOptions) if err != nil { - errs = append(errs, MatcherError{ - Reason: err, - Matcher: a.name, - Path: path, - }) + errs = append(errs, a.matcherError(err, path)) continue } diff --git a/match/any_test.go b/match/any_test.go index 902b0c3..a49b7dc 100644 --- a/match/any_test.go +++ b/match/any_test.go @@ -99,4 +99,68 @@ func TestAnyMatcher(t *testing.T) { }, ) }) + + t.Run("YAML", func(t *testing.T) { + y := []byte(`user: + name: mock-user + email: mock-email +date: 16/10/2022 +`) + + t.Run("should return error in case of missing path", func(t *testing.T) { + a := Any("$.user.missing") + res, errs := a.YAML(y) + + test.Equal(t, y, res) + test.Equal(t, 1, len(errs)) + + err := errs[0] + + test.Equal(t, "path does not exist", err.Reason.Error()) + test.Equal(t, "Any", err.Matcher) + test.Equal(t, "$.user.missing", err.Path) + }) + + t.Run("should aggregate errors", func(t *testing.T) { + a := Any("$.user.missing.key", "$.user.missing.key1") + res, errs := a.YAML(y) + + test.Equal(t, y, res) + test.Equal(t, 2, len(errs)) + }) + + t.Run("should replace value and return new yaml", func(t *testing.T) { + a := Any("$.user.email", "$.date", "$.missing.key").ErrOnMissingPath(false) + res, errs := a.YAML(y) + expected := `user: + name: mock-user + email: +date: +` + + test.Equal(t, 0, len(errs)) + test.Equal(t, expected, string(res)) + }) + + t.Run( + "should replace value and return new yaml with different placeholder", + func(t *testing.T) { + a := Any( + "$.user.email", + "$.date", + "$.missing.key", + ).ErrOnMissingPath(false). + Placeholder(10) + res, errs := a.YAML(y) + expected := `user: + name: mock-user + email: 10 +date: 10 +` + + test.Equal(t, 0, len(errs)) + test.Equal(t, expected, string(res)) + }, + ) + }) } diff --git a/match/custom.go b/match/custom.go index a5fa41f..bef2a6c 100644 --- a/match/custom.go +++ b/match/custom.go @@ -1,8 +1,10 @@ package match import ( - "errors" + "bytes" + "github.com/gkampitakis/go-snaps/match/internal/yaml" + "github.com/goccy/go-yaml/parser" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -14,6 +16,14 @@ type customMatcher struct { path string } +func (c *customMatcher) matcherError(err error) []MatcherError { + return []MatcherError{{ + Reason: err, + Matcher: c.name, + Path: c.path, + }} +} + type CustomCallback func(val any) (any, error) /* @@ -28,13 +38,22 @@ Custom matcher allows you to bring your own validation and placeholder value. return "some number", nil }) - The callback func value for JSON can be on of these types: + The callback func value for JSON can be one of these types: bool // for JSON booleans float64 // for JSON numbers string // for JSON string literals nil // for JSON null map[string]any // for JSON objects []any // for JSON arrays + + The callback func value for YAML can be one of these types: + bool // for YAML booleans + float64 // for YAML float numbers + uint64 // for YAML integer numbers + string // for YAML string literals + nil // for YAML null + map[string]any // for YAML objects + []any // for YAML arrays */ func Custom(path string, callback CustomCallback) *customMatcher { return &customMatcher{ @@ -52,41 +71,62 @@ func (c *customMatcher) ErrOnMissingPath(e bool) *customMatcher { return c } +// YAML is intended to be called internally on snaps.MatchYAML for applying Custom matcher +func (c *customMatcher) YAML(b []byte) ([]byte, []MatcherError) { + f, err := parser.ParseBytes(b, parser.ParseComments) + if err != nil { + return nil, c.matcherError(err) + } + + path, node, exists, err := yaml.Get(f, c.path) + if err != nil { + return nil, c.matcherError(err) + } + if !exists { + if c.errOnMissingPath { + return nil, c.matcherError(errPathNotFound) + } + + return b, nil + } + + value, err := yaml.GetValue(node) + if err != nil { + return nil, c.matcherError(err) + } + + result, err := c.callback(value) + if err != nil { + return nil, c.matcherError(err) + } + + if err := yaml.Update(f, path, result); err != nil { + return nil, c.matcherError(err) + } + + return yaml.MarshalFile(f, bytes.HasSuffix(b, []byte("\n"))), nil +} + // JSON is intended to be called internally on snaps.MatchJSON for applying Custom matcher -func (c *customMatcher) JSON(s []byte) ([]byte, []MatcherError) { - r := gjson.GetBytes(s, c.path) +func (c *customMatcher) JSON(b []byte) ([]byte, []MatcherError) { + r := gjson.GetBytes(b, c.path) if !r.Exists() { if c.errOnMissingPath { - return nil, []MatcherError{{ - Reason: errors.New("path does not exist"), - Matcher: c.name, - Path: c.path, - }} + return nil, c.matcherError(errPathNotFound) } - return s, nil + return b, nil } value, err := c.callback(r.Value()) if err != nil { - return nil, []MatcherError{{ - Reason: err, - Matcher: c.name, - Path: c.path, - }} + return nil, c.matcherError(err) } - s, err = sjson.SetBytesOptions(s, c.path, value, &sjson.Options{ - Optimistic: true, - ReplaceInPlace: true, - }) + b, err = sjson.SetBytesOptions(b, c.path, value, setJSONOptions) if err != nil { - return nil, []MatcherError{{ - Reason: err, - Matcher: c.name, - Path: c.path, - }} + return nil, c.matcherError(err) } - return s, nil + return b, nil } diff --git a/match/custom_test.go b/match/custom_test.go index 7212724..fb77a9c 100644 --- a/match/custom_test.go +++ b/match/custom_test.go @@ -18,7 +18,7 @@ func TestCustomMatcher(t *testing.T) { test.Equal(t, c.name, "Custom") }) - t.Run("should allow overrding values", func(t *testing.T) { + t.Run("should allow overriding values", func(t *testing.T) { c := Custom("path", func(val any) (any, error) { return nil, nil }).ErrOnMissingPath(false) @@ -100,4 +100,74 @@ func TestCustomMatcher(t *testing.T) { test.Nil(t, errs) }) }) + + t.Run("YAML", func(t *testing.T) { + y := []byte(` +user: + name: mock-user + email: mock-email +date: 16/10/2022 +`) + + t.Run("should return error in case of missing path", func(t *testing.T) { + c := Custom("$.missing.key", func(val any) (any, error) { + return nil, nil + }) + + res, errs := c.YAML(y) + + test.Nil(t, res) + test.Equal(t, 1, len(errs)) + + err := errs[0] + + test.Equal(t, "path does not exist", err.Reason.Error()) + test.Equal(t, "Custom", err.Matcher) + test.Equal(t, "$.missing.key", err.Path) + }) + + t.Run("should ignore error in case of missing path", func(t *testing.T) { + c := Custom("$.missing.key", func(val any) (any, error) { + return nil, nil + }).ErrOnMissingPath(false) + + res, errs := c.YAML(y) + test.Equal(t, y, res) + test.Nil(t, errs) + }) + + t.Run("should return error from custom callback", func(t *testing.T) { + c := Custom("$.user.email", func(val any) (any, error) { + return nil, errors.New("custom error") + }) + + res, errs := c.YAML(y) + + test.Nil(t, res) + test.Equal(t, 1, len(errs)) + + err := errs[0] + + test.Equal(t, "custom error", err.Reason.Error()) + test.Equal(t, "Custom", err.Matcher) + test.Equal(t, "$.user.email", err.Path) + }) + + t.Run("should apply value from custom callback to yaml", func(t *testing.T) { + c := Custom("$.user.email", func(val any) (any, error) { + return "replaced email", nil + }) + + res, errs := c.YAML(y) + + expected := `user: + name: mock-user + email: replaced email +date: 16/10/2022 +` + + test.Equal(t, expected, string(res)) + test.Nil(t, errs) + }) + }) } diff --git a/match/internal/yaml/yaml.go b/match/internal/yaml/yaml.go new file mode 100644 index 0000000..6b22881 --- /dev/null +++ b/match/internal/yaml/yaml.go @@ -0,0 +1,70 @@ +package yaml + +import ( + "bytes" + "errors" + "strings" + + "github.com/goccy/go-yaml" + "github.com/goccy/go-yaml/ast" +) + +// GetValue returns the value of the node. +func GetValue(node ast.Node) (interface{}, error) { + data, err := node.MarshalYAML() + if err != nil { + return nil, err + } + + var value interface{} + if err := yaml.Unmarshal(data, &value); err != nil { + return nil, err + } + + return value, nil +} + +// Get takes an ast.File and a string representing a path +// and returns the yaml.Path, the node and a bool indicating if the node exists. +func Get(f *ast.File, p string) (*yaml.Path, ast.Node, bool, error) { + path, err := yaml.PathString(p) + if err != nil { + return nil, nil, false, err + } + + node, err := path.FilterFile(f) + if err != nil { + if errors.Is(err, yaml.ErrNotFoundNode) { + return path, nil, false, nil + } + + return path, nil, false, err + } + + return path, node, true, nil +} + +// Update marshals the value and replaces the file at the path provided with the new value. +func Update(f *ast.File, path *yaml.Path, value interface{}) error { + b, err := yaml.Marshal(value) + if err != nil { + return err + } + + return path.ReplaceWithReader(f, bytes.NewReader(b)) +} + +// MarshalFile returns the representation of the ast.File to a byte slice. +func MarshalFile(f *ast.File, addNewLine bool) []byte { + docs := make([]string, 0, len(f.Docs)) + + for _, doc := range f.Docs { + docs = append(docs, doc.String()) + } + + if addNewLine { + docs = append(docs, "") + } + + return []byte(strings.Join(docs, "\n")) +} diff --git a/match/type.go b/match/type.go index 37d55a2..5f532e7 100644 --- a/match/type.go +++ b/match/type.go @@ -1,9 +1,11 @@ package match import ( - "errors" + "bytes" "fmt" + "github.com/gkampitakis/go-snaps/match/internal/yaml" + "github.com/goccy/go-yaml/parser" "github.com/tidwall/gjson" "github.com/tidwall/sjson" ) @@ -15,14 +17,22 @@ type typeMatcher[ExpectedType any] struct { expectedType any } +func (t *typeMatcher[ExpectedType]) matcherError(err error, path string) MatcherError { + return MatcherError{ + Reason: err, + Matcher: t.name, + Path: path, + } +} + /* Type matcher evaluates types that are passed in a snapshot It replaces any targeted path with placeholder in the form of `` - match.Type[string]("user.info") - // or with multiple paths - match.Type[float64]("user.age", "data.items") + match.Type[string]("user.info", "user.age") + // or for yaml + match.Type[string]("$.user.info", "$.user.age") */ func Type[ExpectedType any](paths ...string) *typeMatcher[ExpectedType] { return &typeMatcher[ExpectedType]{ @@ -33,36 +43,77 @@ func Type[ExpectedType any](paths ...string) *typeMatcher[ExpectedType] { } } -// ErrOnMissingPath determines if matcher will fail in case of trying to access a json path +// ErrOnMissingPath determines if matcher will fail in case of trying to access a path // that doesn't exist func (t *typeMatcher[T]) ErrOnMissingPath(e bool) *typeMatcher[T] { t.errOnMissingPath = e return t } -func (t typeMatcher[ExpectedType]) JSON(s []byte) ([]byte, []MatcherError) { +// YAML is intended to be called internally on snaps.MatchJSON for applying Type matchers +func (t typeMatcher[ExpectedType]) YAML(b []byte) ([]byte, []MatcherError) { + var errs []MatcherError + + f, err := parser.ParseBytes(b, parser.ParseComments) + if err != nil { + return b, []MatcherError{t.matcherError(err, "*")} + } + + for _, p := range t.paths { + path, node, exists, err := yaml.Get(f, p) + if err != nil { + errs = append(errs, t.matcherError(err, p)) + + continue + } + if !exists { + if t.errOnMissingPath { + errs = append(errs, t.matcherError(errPathNotFound, p)) + } + + continue + } + + value, err := yaml.GetValue(node) + if err != nil { + errs = append(errs, t.matcherError(err, p)) + + continue + } + + if err := typeCheck[ExpectedType](value); err != nil { + errs = append(errs, t.matcherError(err, p)) + + continue + } + + if err := yaml.Update(f, path, typePlaceholder(value)); err != nil { + errs = append(errs, t.matcherError(err, p)) + + continue + } + } + + return yaml.MarshalFile(f, bytes.HasSuffix(b, []byte("\n"))), errs +} + +// JSON is intended to be called internally on snaps.MatchJSON for applying Type matchers +func (t typeMatcher[ExpectedType]) JSON(b []byte) ([]byte, []MatcherError) { var errs []MatcherError - json := s + json := b for _, path := range t.paths { r := gjson.GetBytes(json, path) if !r.Exists() { if t.errOnMissingPath { - errs = append(errs, MatcherError{ - Reason: errors.New("path does not exist"), - Matcher: t.name, - Path: path, - }) + errs = append(errs, t.matcherError(errPathNotFound, path)) } + continue } - if _, ok := r.Value().(ExpectedType); !ok { - errs = append(errs, MatcherError{ - Reason: fmt.Errorf("expected type %T, received %T", *new(ExpectedType), r.Value()), - Matcher: t.name, - Path: path, - }) + if err := typeCheck[ExpectedType](r.Value()); err != nil { + errs = append(errs, t.matcherError(err, path)) continue } @@ -70,18 +121,11 @@ func (t typeMatcher[ExpectedType]) JSON(s []byte) ([]byte, []MatcherError) { j, err := sjson.SetBytesOptions( json, path, - fmt.Sprintf("", r.Value()), - &sjson.Options{ - Optimistic: true, - ReplaceInPlace: true, - }, + typePlaceholder(r.Value()), + setJSONOptions, ) if err != nil { - errs = append(errs, MatcherError{ - Reason: err, - Matcher: t.name, - Path: path, - }) + errs = append(errs, t.matcherError(err, path)) continue } @@ -91,3 +135,15 @@ func (t typeMatcher[ExpectedType]) JSON(s []byte) ([]byte, []MatcherError) { return json, errs } + +func typeCheck[ExpectedType any](value interface{}) error { + if _, ok := value.(ExpectedType); !ok { + return fmt.Errorf("expected type %T, received %T", *new(ExpectedType), value) + } + + return nil +} + +func typePlaceholder(value interface{}) string { + return fmt.Sprintf("", value) +} diff --git a/match/type_test.go b/match/type_test.go index 371a127..a0e423f 100644 --- a/match/type_test.go +++ b/match/type_test.go @@ -88,4 +88,50 @@ func TestTypeMatcher(t *testing.T) { test.Equal(t, "expected type int, received float64", errs[1].Reason.Error()) }) }) + + t.Run("YAML", func(t *testing.T) { + y := []byte(`user: + name: mock-user + email: mock-email + age: 29 +date: 16/10/2022 +`) + + t.Run("should return error in case of missing path", func(t *testing.T) { + tm := Type[string]("$.user.missing") + res, errs := tm.YAML(y) + + test.Equal(t, string(y), string(res)) + test.Equal(t, 1, len(errs)) + + err := errs[0] + + test.Equal(t, "path does not exist", err.Reason.Error()) + test.Equal(t, "Type", err.Matcher) + test.Equal(t, "$.user.missing", err.Path) + }) + + t.Run("should aggregate errors", func(t *testing.T) { + tm := Type[string]("$.user.missing", "$.user.missing_key") + res, errs := tm.YAML(y) + + test.Equal(t, y, res) + test.Equal(t, 2, len(errs)) + }) + + t.Run("should evaluate passed type and replace yaml", func(t *testing.T) { + tm := Type[string]("$.user.name", "$.date") + res, errs := tm.YAML(y) + + expected := `user: + name: + email: mock-email + age: 29 +date: +` + + test.Nil(t, errs) + test.Equal(t, expected, string(res)) + }) + }) } diff --git a/match/utils.go b/match/utils.go index 96f0d8e..a1cabb2 100644 --- a/match/utils.go +++ b/match/utils.go @@ -1,9 +1,27 @@ package match +import ( + "errors" + + "github.com/tidwall/sjson" +) + +var ( + errPathNotFound = errors.New("path does not exist") + setJSONOptions = &sjson.Options{ + Optimistic: true, + ReplaceInPlace: true, + } +) + type JSONMatcher interface { JSON([]byte) ([]byte, []MatcherError) } +type YAMLMatcher interface { + YAML([]byte) ([]byte, []MatcherError) +} + // internal Error struct returned from Matchers type MatcherError struct { Reason error diff --git a/snaps/matchJSON.go b/snaps/matchJSON.go index 10d78a7..9907a66 100644 --- a/snaps/matchJSON.go +++ b/snaps/matchJSON.go @@ -25,14 +25,14 @@ MatchJSON verifies the input matches the most recent snap file. Input can be a valid json string or []byte or whatever value can be passed successfully on `json.Marshal`. - MatchJSON(t, `{"user":"mock-user","age":10,"email":"mock@email.com"}`) - MatchJSON(t, []byte(`{"user":"mock-user","age":10,"email":"mock@email.com"}`)) - MatchJSON(t, User{10, "mock-email"}) + snaps.MatchJSON(t, `{"user":"mock-user","age":10,"email":"mock@email.com"}`) + snaps.MatchJSON(t, []byte(`{"user":"mock-user","age":10,"email":"mock@email.com"}`)) + snaps.MatchJSON(t, User{10, "mock-email"}) MatchJSON also supports passing matchers as a third argument. Those matchers can act either as validators or placeholders for data that might change on each invocation e.g. dates. - MatchJSON(t, User{created: time.Now(), email: "mock-email"}, match.Any("created")) + snaps.MatchJSON(t, User{Created: time.Now(), Email: "mock-email"}, match.Any("created")) */ func (c *Config) MatchJSON(t testingT, input any, matchers ...match.JSONMatcher) { t.Helper() @@ -45,14 +45,14 @@ MatchJSON verifies the input matches the most recent snap file. Input can be a valid json string or []byte or whatever value can be passed successfully on `json.Marshal`. - MatchJSON(t, `{"user":"mock-user","age":10,"email":"mock@email.com"}`) - MatchJSON(t, []byte(`{"user":"mock-user","age":10,"email":"mock@email.com"}`)) - MatchJSON(t, User{10, "mock-email"}) + snaps.MatchJSON(t, `{"user":"mock-user","age":10,"email":"mock@email.com"}`) + snaps.MatchJSON(t, []byte(`{"user":"mock-user","age":10,"email":"mock@email.com"}`)) + snaps.MatchJSON(t, User{10, "mock-email"}) MatchJSON also supports passing matchers as a third argument. Those matchers can act either as validators or placeholders for data that might change on each invocation e.g. dates. - MatchJSON(t, User{created: time.Now(), email: "mock-email"}, match.Any("created")) + snaps.MatchJSON(t, User{Created: time.Now(), Email: "mock-email"}, match.Any("created")) */ func MatchJSON(t testingT, input any, matchers ...match.JSONMatcher) { t.Helper() diff --git a/snaps/matchJSON_test.go b/snaps/matchJSON_test.go index 6f30d93..4d113cf 100644 --- a/snaps/matchJSON_test.go +++ b/snaps/matchJSON_test.go @@ -31,7 +31,7 @@ func TestMatchJSON(t *testing.T) { input: `{"user":"mock-name","items":[5,1,3,4]}`, }, { - name: "string", + name: "byte", input: []byte(`{"user":"mock-name","items":[5,1,3,4]}`), }, { diff --git a/snaps/matchYAML.go b/snaps/matchYAML.go new file mode 100644 index 0000000..79f8937 --- /dev/null +++ b/snaps/matchYAML.go @@ -0,0 +1,189 @@ +package snaps + +import ( + "errors" + "fmt" + "strings" + + "github.com/gkampitakis/go-snaps/internal/colors" + "github.com/gkampitakis/go-snaps/match" + "github.com/goccy/go-yaml" +) + +var yamlEncodeOptions = []yaml.EncodeOption{ + yaml.Indent(2), + yaml.IndentSequence(true), +} + +/* +MatchYAML verifies the input matches the most recent snap file. +Input can be a valid json string or []byte or whatever value can be passed +successfully on `yaml.Marshal`. + + snaps.MatchYAML(t, "user: \"mock-user\"\nage: 10\nemail: mock@email.com") + snaps.MatchYAML(t, []byte("user: \"mock-user\"\nage: 10\nemail: mock@email.com")) + snaps.MatchYAML(t, User{10, "mock-email"}) + +MatchYAML also supports passing matchers as a third argument. Those matchers can act either as +validators or placeholders for data that might change on each invocation e.g. dates. + + snaps.MatchYAML(t, User{Created: time.Now(), Email: "mock-email"}, match.Any("$.created")) +*/ +func (c *Config) MatchYAML(t testingT, input any, matchers ...match.YAMLMatcher) { + t.Helper() + + matchYAML(c, t, input, matchers...) +} + +/* +MatchYAML verifies the input matches the most recent snap file. +Input can be a valid json string or []byte or whatever value can be passed +successfully on `yaml.Marshal`. + + snaps.MatchYAML(t, "user: \"mock-user\"\nage: 10\nemail: mock@email.com") + snaps.MatchYAML(t, []byte("user: \"mock-user\"\nage: 10\nemail: mock@email.com")) + snaps.MatchYAML(t, User{10, "mock-email"}) + +MatchYAML also supports passing matchers as a third argument. Those matchers can act either as +validators or placeholders for data that might change on each invocation e.g. dates. + + snaps.MatchYAML(t, User{Created: time.Now(), Email: "mock-email"}, match.Any("$.created")) +*/ +func MatchYAML(t testingT, input any, matchers ...match.YAMLMatcher) { + t.Helper() + + matchYAML(&defaultConfig, t, input, matchers...) +} + +func matchYAML(c *Config, t testingT, input any, matchers ...match.YAMLMatcher) { + t.Helper() + + snapPath, snapPathRel := snapshotPath(c, t.Name(), false) + testID := testsRegistry.getTestID(snapPath, t.Name()) + t.Cleanup(func() { + testsRegistry.reset(snapPath, t.Name()) + }) + + y, err := validateYAML(input) + if err != nil { + handleError(t, err) + return + } + + y, matchersErrors := applyYAMLMatchers(y, matchers...) + if len(matchersErrors) > 0 { + s := strings.Builder{} + + for _, err := range matchersErrors { + colors.Fprint( + &s, + colors.Red, + fmt.Sprintf( + "\n%smatch.%s(\"%s\") - %s", + errorSymbol, + err.Matcher, + err.Path, + err.Reason, + ), + ) + } + + handleError(t, s.String()) + return + } + + snapshot := takeYAMLSnapshot(y) + prevSnapshot, line, err := getPrevSnapshot(testID, snapPath) + if errors.Is(err, errSnapNotFound) { + if isCI { + handleError(t, err) + return + } + + err := addNewSnapshot(testID, 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( + unescapeEndChars(prevSnapshot), + unescapeEndChars(snapshot), + snapPathRel, + line, + ) + if diff == "" { + testEvents.register(passed) + return + } + + if !shouldUpdate(c.update) { + handleError(t, diff) + return + } + + if err = updateSnapshot(testID, snapshot, snapPath); err != nil { + handleError(t, err) + return + } + + t.Log(updatedMsg) + testEvents.register(updated) +} + +func validateYAML(input any) ([]byte, error) { + var out interface{} + + switch y := input.(type) { + case string: + err := yaml.Unmarshal([]byte(y), &out) + if err != nil { + return nil, fmt.Errorf("invalid yaml: %w", err) + } + + return []byte(y), nil + case []byte: + err := yaml.Unmarshal(y, &out) + if err != nil { + return nil, fmt.Errorf("invalid yaml: %w", err) + } + + return y, nil + default: + data, err := yaml.MarshalWithOptions(input, yamlEncodeOptions...) + if err != nil { + return nil, fmt.Errorf("invalid yaml: %w", err) + } + + return data, nil + } +} + +func applyYAMLMatchers(b []byte, matchers ...match.YAMLMatcher) ([]byte, []match.MatcherError) { + errors := []match.MatcherError{} + + for _, m := range matchers { + y, errs := m.YAML(b) + if len(errs) > 0 { + errors = append(errors, errs...) + continue + } + + b = y + } + + return b, errors +} + +func takeYAMLSnapshot(b []byte) string { + return escapeEndChars(string(b)) +} diff --git a/snaps/matchYAML_test.go b/snaps/matchYAML_test.go new file mode 100644 index 0000000..39592ff --- /dev/null +++ b/snaps/matchYAML_test.go @@ -0,0 +1,207 @@ +package snaps + +import ( + "errors" + "fmt" + "testing" + + "github.com/gkampitakis/go-snaps/internal/test" + "github.com/gkampitakis/go-snaps/match" +) + +const yamlFilename = "matchYAML_test.snap" + +func TestMatchYAML(t *testing.T) { + t.Run("should create yaml snapshot", func(t *testing.T) { + expected := `user: mock-name +items: + - 5 + - 1 + - 3 + - 4 +` + + for _, tc := range []struct { + name string + input any + }{ + { + name: "string", + input: "user: mock-name\nitems:\n - 5\n - 1\n - 3\n - 4\n", + }, + { + name: "byte", + input: []byte("user: mock-name\nitems:\n - 5\n - 1\n - 3\n - 4\n"), + }, + { + name: "marshal object", + input: struct { + User string `yaml:"user"` + Items []int `yaml:"items"` + }{ + User: "mock-name", + Items: []int{5, 1, 3, 4}, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + snapPath := setupSnapshot(t, yamlFilename, false) + + mockT := test.NewMockTestingT(t) + mockT.MockLog = func(args ...any) { test.Equal(t, addedMsg, args[0].(string)) } + + MatchYAML(mockT, tc.input) + + snap, line, err := getPrevSnapshot("[mock-name - 1]", snapPath) + + test.NoError(t, err) + test.Equal(t, 2, line) + test.Equal(t, expected, snap) + test.Equal(t, 1, testEvents.items[added]) + // clean up function called + test.Equal(t, 0, testsRegistry.running[snapPath]["mock-name"]) + test.Equal(t, 1, testsRegistry.cleanup[snapPath]["mock-name"]) + }) + } + + t.Run("should validate yaml", func(t *testing.T) { + for _, tc := range []struct { + name string + input any + err string + }{ + { + name: "string", + input: "key1: \"value1\nkey2: \"value2\"", + err: `invalid yaml: [2:8] value is not allowed in this context. map key-value is pre-defined + 1 | key1: "value1 +> 2 | key2: "value2" + ^ +`, + }, + { + name: "byte", + input: []byte("key1: \"value1\nkey2: \"value2\""), + err: `invalid yaml: [2:8] value is not allowed in this context. map key-value is pre-defined + 1 | key1: "value1 +> 2 | key2: "value2" + ^ +`, + }, + { + name: "struct", + input: make(chan struct{}), + err: "invalid yaml: unknown value type chan struct {}", + }, + } { + t.Run(tc.name, func(t *testing.T) { + setupSnapshot(t, yamlFilename, false) + + mockT := test.NewMockTestingT(t) + mockT.MockError = func(args ...any) { + test.Equal(t, tc.err, (args[0].(error)).Error()) + } + + MatchYAML(mockT, tc.input) + }) + } + }) + + t.Run("matchers", func(t *testing.T) { + t.Run("should apply matches in order", func(t *testing.T) { + setupSnapshot(t, yamlFilename, false) + + mockT := test.NewMockTestingT(t) + mockT.MockLog = func(args ...any) { test.Equal(t, addedMsg, args[0].(string)) } + mockT.MockError = func(a ...any) { + fmt.Println(a) + } + + c1 := func(val any) (any, error) { + return map[string]any{"key2": nil}, nil + } + c2 := func(val any) (any, error) { + return map[string]any{"key3": nil}, nil + } + c3 := func(val any) (any, error) { + return map[string]any{"key4": nil}, nil + } + + MatchYAML(mockT, "key1: \"\"", + match.Custom("$.key1", c1), + match.Custom("$.key1.key2", c2), + match.Custom("$.key1.key2.key3", c3), + ) + }) + + t.Run("should aggregate errors from matchers", func(t *testing.T) { + setupSnapshot(t, yamlFilename, false) + + mockT := test.NewMockTestingT(t) + mockT.MockError = func(args ...any) { + test.Equal(t, + "\x1b[31;1m\n✕ match.Custom(\"$.age\") - mock error"+ + "\x1b[0m\x1b[31;1m\n✕ match.Any(\"$.missing.key.1\") - path does not exist"+ + "\x1b[0m\x1b[31;1m\n✕ match.Any(\"$.missing.key.2\") - path does not exist\x1b[0m", + args[0], + ) + } + + c := func(val any) (any, error) { + return nil, errors.New("mock error") + } + MatchYAML( + mockT, + `age: 10`, + match.Custom("$.age", c), + match.Any("$.missing.key.1", "$.missing.key.2"), + ) + }) + }) + + t.Run("if it's running on ci should skip creating snapshot", func(t *testing.T) { + setupSnapshot(t, yamlFilename, true) + + mockT := test.NewMockTestingT(t) + mockT.MockError = func(args ...any) { + test.Equal(t, errSnapNotFound, args[0].(error)) + } + + MatchYAML(mockT, "") + + test.Equal(t, 1, testEvents.items[erred]) + }) + + t.Run("should update snapshot when 'shouldUpdate'", func(t *testing.T) { + snapPath := setupSnapshot(t, yamlFilename, false, true) + printerExpectedCalls := []func(received any){ + func(received any) { test.Equal(t, addedMsg, received.(string)) }, + func(received any) { test.Equal(t, updatedMsg, received.(string)) }, + } + mockT := test.NewMockTestingT(t) + mockT.MockLog = func(args ...any) { + printerExpectedCalls[0](args[0]) + + // shift + printerExpectedCalls = printerExpectedCalls[1:] + } + + // First call for creating the snapshot + MatchYAML(mockT, "value: hello world") + test.Equal(t, 1, testEvents.items[added]) + + // Resetting registry to emulate the same MatchSnapshot call + testsRegistry = newRegistry() + + // Second call with different params + MatchYAML(mockT, "value: bye world") + + test.Equal( + t, + "\n[mock-name - 1]\nvalue: bye world\n---\n", + test.GetFileContent(t, snapPath), + ) + test.Equal(t, 1, testEvents.items[updated]) + }) + }) +} diff --git a/snaps/snapshot.go b/snaps/snapshot.go index 6466d5a..c2dc810 100644 --- a/snaps/snapshot.go +++ b/snaps/snapshot.go @@ -41,9 +41,9 @@ func Update(u bool) func(*Config) { } } -// Specify folder name where snapshots are stored +// Specify snapshot file name // -// default: __snapshots__ +// default: test's filename // // this doesn't change the file extension see `snap.Ext` func Filename(name string) func(*Config) {