diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad6a567..53e7dff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v2 with: - version: v1.42 + version: v1.43 env: VERBOSE: "true" diff --git a/Makefile b/Makefile index 15b676b..e6248a7 100644 --- a/Makefile +++ b/Makefile @@ -49,7 +49,7 @@ endef $(eval $(call tool,godoc,golang.org/x/tools/cmd/godoc)) $(eval $(call tool,gofumpt,mvdan.cc/gofumpt)) $(eval $(call tool,goimports,golang.org/x/tools/cmd/goimports)) -$(eval $(call tool,golangci-lint,github.com/golangci/golangci-lint/cmd/golangci-lint@v1.42)) +$(eval $(call tool,golangci-lint,github.com/golangci/golangci-lint/cmd/golangci-lint@v1.43)) $(eval $(call tool,gomod,github.com/Helcaraxan/gomod)) .PHONY: tools diff --git a/README.md b/README.md index 9f68dcd..e44fa8a 100644 --- a/README.md +++ b/README.md @@ -70,4 +70,4 @@ for documentation and examples. ## License -[MIT](https://github.com/jimeh/go-golden/blob/master/LICENSE) +[MIT](https://github.com/jimeh/go-golden/blob/main/LICENSE) diff --git a/assert.go b/assert.go index b18033d..f2ca44d 100644 --- a/assert.go +++ b/assert.go @@ -1,26 +1,16 @@ package golden -import ( - "bytes" - "encoding/json" - "encoding/xml" - "io" - "testing" - - "gopkg.in/yaml.v3" -) - -var globalAssert = NewAssert() +var defaultAsserter = NewAsserter() // AssertJSONMarshaling asserts that the given "v" value JSON marshals to an // expected value fetched from a golden file on disk, and then verifies that the // marshaled result produces a value that is equal to "v" when unmarshaled. // // Used for objects that do NOT change when they are marshaled and unmarshaled. -func AssertJSONMarshaling(t *testing.T, v interface{}) { +func AssertJSONMarshaling(t TestingT, v interface{}) { t.Helper() - globalAssert.JSONMarshaling(t, v) + defaultAsserter.JSONMarshaling(t, v) } // AssertJSONMarshalingP asserts that the given "v" value JSON marshals to an @@ -28,10 +18,10 @@ func AssertJSONMarshaling(t *testing.T, v interface{}) { // marshaled result produces a value that is equal to "want" when unmarshaled. // // Used for objects that change when they are marshaled and unmarshaled. -func AssertJSONMarshalingP(t *testing.T, v, want interface{}) { +func AssertJSONMarshalingP(t TestingT, v, want interface{}) { t.Helper() - globalAssert.JSONMarshalingP(t, v, want) + defaultAsserter.JSONMarshalingP(t, v, want) } // AssertXMLMarshaling asserts that the given "v" value XML marshals to an @@ -39,10 +29,10 @@ func AssertJSONMarshalingP(t *testing.T, v, want interface{}) { // marshaled result produces a value that is equal to "v" when unmarshaled. // // Used for objects that do NOT change when they are marshaled and unmarshaled. -func AssertXMLMarshaling(t *testing.T, v interface{}) { +func AssertXMLMarshaling(t TestingT, v interface{}) { t.Helper() - globalAssert.XMLMarshaling(t, v) + defaultAsserter.XMLMarshaling(t, v) } // AssertXMLMarshalingP asserts that the given "v" value XML marshals to an @@ -50,10 +40,10 @@ func AssertXMLMarshaling(t *testing.T, v interface{}) { // marshaled result produces a value that is equal to "want" when unmarshaled. // // Used for objects that change when they are marshaled and unmarshaled. -func AssertXMLMarshalingP(t *testing.T, v, want interface{}) { +func AssertXMLMarshalingP(t TestingT, v, want interface{}) { t.Helper() - globalAssert.XMLMarshalingP(t, v, want) + defaultAsserter.XMLMarshalingP(t, v, want) } // AssertYAMLMarshaling asserts that the given "v" value YAML marshals to an @@ -61,10 +51,10 @@ func AssertXMLMarshalingP(t *testing.T, v, want interface{}) { // marshaled result produces a value that is equal to "v" when unmarshaled. // // Used for objects that do NOT change when they are marshaled and unmarshaled. -func AssertYAMLMarshaling(t *testing.T, v interface{}) { +func AssertYAMLMarshaling(t TestingT, v interface{}) { t.Helper() - globalAssert.YAMLMarshaling(t, v) + defaultAsserter.YAMLMarshaling(t, v) } // AssertYAMLMarshalingP asserts that the given "v" value YAML marshals to an @@ -72,236 +62,8 @@ func AssertYAMLMarshaling(t *testing.T, v interface{}) { // marshaled result produces a value that is equal to "want" when unmarshaled. // // Used for objects that change when they are marshaled and unmarshaled. -func AssertYAMLMarshalingP(t *testing.T, v, want interface{}) { +func AssertYAMLMarshalingP(t TestingT, v, want interface{}) { t.Helper() - globalAssert.YAMLMarshalingP(t, v, want) -} - -// Assert exposes a series of JSON, YAML, and XML marshaling assertion helpers. -type Assert interface { - // JSONMarshaling asserts that the given "v" value JSON marshals to an - // expected value fetched from a golden file on disk, and then verifies that - // the marshaled result produces a value that is equal to "v" when - // unmarshaled. - // - // Used for objects that do NOT change when they are marshaled and - // unmarshaled. - JSONMarshaling(t *testing.T, v interface{}) - - // JSONMarshalingP asserts that the given "v" value JSON marshals to an - // expected value fetched from a golden file on disk, and then verifies that - // the marshaled result produces a value that is equal to "want" when - // unmarshaled. - // - // Used for objects that change when they are marshaled and unmarshaled. - JSONMarshalingP(t *testing.T, v interface{}, want interface{}) - - // XMLMarshaling asserts that the given "v" value XML marshals to an - // expected value fetched from a golden file on disk, and then verifies that - // the marshaled result produces a value that is equal to "v" when - // unmarshaled. - // - // Used for objects that do NOT change when they are marshaled and - // unmarshaled. - XMLMarshaling(t *testing.T, v interface{}) - - // XMLMarshalingP asserts that the given "v" value XML marshals to an - // expected value fetched from a golden file on disk, and then verifies that - // the marshaled result produces a value that is equal to "want" when - // unmarshaled. - // - // Used for objects that change when they are marshaled and unmarshaled. - XMLMarshalingP(t *testing.T, v interface{}, want interface{}) - - // YAMLMarshaling asserts that the given "v" value YAML marshals to an - // expected value fetched from a golden file on disk, and then verifies that - // the marshaled result produces a value that is equal to "v" when - // unmarshaled. - // - // Used for objects that do NOT change when they are marshaled and - // unmarshaled. - YAMLMarshaling(t *testing.T, v interface{}) - - // YAMLMarshalingP asserts that the given "v" value YAML marshals to an - // expected value fetched from a golden file on disk, and then verifies that - // the marshaled result produces a value that is equal to "want" when - // unmarshaled. - // - // Used for objects that change when they are marshaled and unmarshaled. - YAMLMarshalingP(t *testing.T, v interface{}, want interface{}) -} - -type AssertOption interface { - apply(*asserter) -} - -type assertOptionFunc func(*asserter) - -func (fn assertOptionFunc) apply(c *asserter) { - fn(c) -} - -// WithGolden allows setting a custom *Golden instance when calling NewAssert(). -func WithGolden(golden *Golden) AssertOption { - return assertOptionFunc(func(a *asserter) { - a.golden = golden - }) -} - -// WithNormalizedLineBreaks allows turning off line-break normalization which -// replaces Windows' CRLF (\r\n) and Mac Classic CR (\r) line breaks with Unix's -// LF (\n) line breaks. -func WithNormalizedLineBreaks(value bool) AssertOption { - return assertOptionFunc(func(a *asserter) { - a.normalizeLineBreaks = value - }) -} - -// NewAssert returns a new Assert which exposes a number of marshaling assertion -// helpers for JSON, YAML and XML. -// -// The default encoders all specify indentation of two spaces, essentially -// enforcing pretty formatting for JSON and XML. -// -// The default decoders for JSON and YAML prohibit unknown fields which are not -// present on the provided struct. -func NewAssert(options ...AssertOption) Assert { - a := &asserter{ - golden: globalGolden, - normalizeLineBreaks: true, - } - - for _, opt := range options { - opt.apply(a) - } - - a.JSONAsserter = NewMarshalAsserter( - a.golden, "JSON", - newJSONEncoder, newJSONDecoder, - a.normalizeLineBreaks, - ) - a.XMLAsserter = NewMarshalAsserter( - a.golden, "XML", - newXMLEncoder, newXMLDecoder, - a.normalizeLineBreaks, - ) - a.YAMLAsserter = NewMarshalAsserter( - a.golden, "YAML", - newYAMLEncoder, newYAMLDecoder, - a.normalizeLineBreaks, - ) - - return a -} - -// asserter implements the Assert interface. -type asserter struct { - golden *Golden - normalizeLineBreaks bool - - JSONAsserter *MarshalAsserter - XMLAsserter *MarshalAsserter - YAMLAsserter *MarshalAsserter -} - -func (s *asserter) JSONMarshaling(t *testing.T, v interface{}) { - t.Helper() - - s.JSONAsserter.Marshaling(t, v) -} - -func (s *asserter) JSONMarshalingP( - t *testing.T, - v interface{}, - want interface{}, -) { - t.Helper() - - s.JSONAsserter.MarshalingP(t, v, want) -} - -func (s *asserter) XMLMarshaling(t *testing.T, v interface{}) { - t.Helper() - - s.XMLAsserter.Marshaling(t, v) -} - -func (s *asserter) XMLMarshalingP(t *testing.T, v, want interface{}) { - t.Helper() - - s.XMLAsserter.MarshalingP(t, v, want) -} - -func (s *asserter) YAMLMarshaling(t *testing.T, v interface{}) { - t.Helper() - - s.YAMLAsserter.Marshaling(t, v) -} - -func (s *asserter) YAMLMarshalingP(t *testing.T, v, want interface{}) { - t.Helper() - - s.YAMLAsserter.MarshalingP(t, v, want) -} - -// newJSONEncoder is the default JSONEncoderFunc used by Assert. It returns a -// *json.Encoder which is set to indent with two spaces. -func newJSONEncoder(w io.Writer) MarshalEncoder { - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - - return enc -} - -// newJSONDecoder is the default JSONDecoderFunc used by Assert. It returns a -// *json.Decoder which disallows unknown fields. -func newJSONDecoder(r io.Reader) MarshalDecoder { - dec := json.NewDecoder(r) - dec.DisallowUnknownFields() - - return dec -} - -// newXMLEncoder is the default XMLEncoderFunc used by Assert. It returns a -// *xml.Encoder which is set to indent with two spaces. -func newXMLEncoder(w io.Writer) MarshalEncoder { - enc := xml.NewEncoder(w) - enc.Indent("", " ") - - return enc -} - -// newXMLDecoder is the default XMLDecoderFunc used by Assert. -func newXMLDecoder(r io.Reader) MarshalDecoder { - return xml.NewDecoder(r) -} - -// newYAMLEncoder is the default YAMLEncoderFunc used by Assert. It returns a -// *yaml.Encoder which is set to indent with two spaces. -func newYAMLEncoder(w io.Writer) MarshalEncoder { - enc := yaml.NewEncoder(w) - enc.SetIndent(2) - - return enc -} - -// newYAMLDecoder is the default YAMLDecoderFunc used by Assert. It returns a -// *yaml.Decoder which disallows unknown fields. -func newYAMLDecoder(r io.Reader) MarshalDecoder { - dec := yaml.NewDecoder(r) - dec.KnownFields(true) - - return dec -} - -// normalizeLineBreaks replaces Windows CRLF (\r\n) and Classic MacOS CR (\r) -// line-breaks with Unix LF (\n) line breaks. -func normalizeLineBreaks(data []byte) []byte { - // Replace Windows CRLF (\r\n) with Unix LF (\n) - result := bytes.ReplaceAll(data, []byte{13, 10}, []byte{10}) - // Replace Classic MacOS CR (\r) with Unix LF (\n) - result = bytes.ReplaceAll(result, []byte{13}, []byte{10}) - - return result + defaultAsserter.YAMLMarshalingP(t, v, want) } diff --git a/assert_test.go b/assert_test.go index c88f7a6..dd336a0 100644 --- a/assert_test.go +++ b/assert_test.go @@ -8,6 +8,10 @@ import ( "testing" "time" + "github.com/jimeh/go-golden/marshal" + "github.com/jimeh/go-golden/unmarshal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "gopkg.in/yaml.v3" ) @@ -15,29 +19,30 @@ import ( // Helpers // -type Author struct { +type author struct { FirstName string `json:"first_name" yaml:"first_name" xml:"first_name"` LastName string `json:"last_name" yaml:"last_name" xml:"last_name"` } -type Book struct { +type book struct { ID string `json:"id" yaml:"id" xml:"id"` Title string `json:"title" yaml:"title" xml:"title"` - Author *Author `json:"author,omitempty" yaml:"author,omitempty" xml:"author,omitempty"` + Author *author `json:"author,omitempty" yaml:"author,omitempty" xml:"author,omitempty"` Year int `json:"year,omitempty" yaml:"year,omitempty" xml:"year,omitempty"` } -type Article struct { +type article struct { ID string `json:"id" yaml:"id" xml:"id"` Title string `json:"title" yaml:"title" xml:"title"` - Author *Author `json:"author" yaml:"author" xml:"author"` + Author *author `json:"author" yaml:"author" xml:"author"` Date *time.Time `json:"date,omitempty" yaml:"date,omitempty" xml:"date,omitempty"` Rank int `json:"-" yaml:"-" xml:"-"` order int } -type Comic struct { +// comic is used for testing custom marshal/unmarshal functions on a type. +type comic struct { ID string Name string Issue string @@ -50,11 +55,11 @@ type xmlComic struct { Issue string `xml:"issue,attr"` } -func (s *Comic) MarshalJSON() ([]byte, error) { +func (s *comic) MarshalJSON() ([]byte, error) { return []byte(fmt.Sprintf(`{"%s":"%s=%s"}`, s.ID, s.Name, s.Issue)), nil } -func (s *Comic) UnmarshalJSON(data []byte) error { +func (s *comic) UnmarshalJSON(data []byte) error { m := regexp.MustCompile(`^{\s*"(.*?)":\s*"(.*?)=(.*)"\s*}$`) matches := m.FindSubmatch(bytes.TrimSpace(data)) if matches == nil { @@ -68,11 +73,11 @@ func (s *Comic) UnmarshalJSON(data []byte) error { return nil } -func (s *Comic) MarshalYAML() (interface{}, error) { +func (s *comic) MarshalYAML() (interface{}, error) { return map[string]map[string]string{s.ID: {s.Name: s.Issue}}, nil } -func (s *Comic) UnmarshalYAML(value *yaml.Node) error { +func (s *comic) UnmarshalYAML(value *yaml.Node) error { // Horribly hacky code, but it works and specifically only needs to extract // these specific three values. if len(value.Content) == 2 { @@ -86,18 +91,18 @@ func (s *Comic) UnmarshalYAML(value *yaml.Node) error { return nil } -func (s *Comic) MarshalXML(e *xml.Encoder, start xml.StartElement) error { +func (s *comic) MarshalXML(e *xml.Encoder, start xml.StartElement) error { return e.EncodeElement( &xmlComic{ID: s.ID, Name: s.Name, Issue: s.Issue}, start, ) } -func (s *Comic) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { +func (s *comic) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { x := &xmlComic{} _ = d.DecodeElement(x, &start) - v := Comic{ID: x.ID, Name: x.Name, Issue: x.Issue} + v := comic{ID: x.ID, Name: x.Name, Issue: x.Issue} *s = v @@ -142,21 +147,21 @@ var marhalingTestCases = []struct { }, { name: "empty struct", - v: &Book{}, + v: &book{}, }, { name: "partial struct", - v: &Book{ + v: &book{ ID: "cfda163c-d5c1-44a2-909b-5d2ce3a31979", Title: "The Traveler", }, }, { name: "full struct", - v: &Book{ + v: &book{ ID: "cfda163c-d5c1-44a2-909b-5d2ce3a31979", Title: "The Traveler", - Author: &Author{ + Author: &author{ FirstName: "John", LastName: "Twelve Hawks", }, @@ -165,7 +170,7 @@ var marhalingTestCases = []struct { }, { name: "custom marshaling", - v: &Comic{ + v: &comic{ ID: "2fd5af35-b85e-4f03-8eba-524be28d7a5b", Name: "Hello World!", Issue: "Forty Two", @@ -204,26 +209,26 @@ var marshalingPTestCases = []struct { }, { name: "empty struct", - v: &Article{}, - want: &Article{}, + v: &article{}, + want: &article{}, }, { name: "partial struct", - v: &Book{ + v: &book{ ID: "10eec54d-e30a-4428-be18-01095d889126", Title: "Time Travel", }, - want: &Book{ + want: &book{ ID: "10eec54d-e30a-4428-be18-01095d889126", Title: "Time Travel", }, }, { name: "full struct", - v: &Article{ + v: &article{ ID: "10eec54d-e30a-4428-be18-01095d889126", Title: "Time Travel", - Author: &Author{ + Author: &author{ FirstName: "Doc", LastName: "Brown", }, @@ -231,10 +236,10 @@ var marshalingPTestCases = []struct { Rank: 8, order: 16, }, - want: &Article{ + want: &article{ ID: "10eec54d-e30a-4428-be18-01095d889126", Title: "Time Travel", - Author: &Author{ + Author: &author{ FirstName: "Doc", LastName: "Brown", }, @@ -243,13 +248,13 @@ var marshalingPTestCases = []struct { }, { name: "custom marshaling", - v: &Comic{ + v: &comic{ ID: "2fd5af35-b85e-4f03-8eba-524be28d7a5b", Name: "Hello World!", Issue: "Forty Two", Ignored: "don't pay attention to this :)", }, - want: &Comic{ + want: &comic{ ID: "2fd5af35-b85e-4f03-8eba-524be28d7a5b", Name: "Hello World!", Issue: "Forty Two", @@ -261,6 +266,190 @@ var marshalingPTestCases = []struct { // Tests // +func TestWithGolden(t *testing.T) { + type args struct { + golden Golden + } + tests := []struct { + name string + args args + }{ + { + name: "nil", + args: args{golden: nil}, + }, + { + name: "non-nil", + args: args{golden: New(WithSuffix(".my-custom-golden"))}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + original := New(WithSuffix(".original-golden")) + o := &asserterOptions{golden: original} + + fn := WithGolden(tt.args.golden) + fn.apply(o) + + if tt.args.golden == nil { + assert.Equal(t, original, o.golden) + } else { + assert.Equal(t, tt.args.golden, o.golden) + } + }) + } +} + +func TestNormalizedLineBreaks(t *testing.T) { + type args struct { + value bool + } + tests := []struct { + name string + args args + }{ + { + name: "true", + args: args{value: true}, + }, + { + name: "false", + args: args{value: false}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := &asserterOptions{normalizeLineBreaks: !tt.args.value} + + fn := WithNormalizedLineBreaks(tt.args.value) + fn.apply(o) + + assert.Equal(t, tt.args.value, o.normalizeLineBreaks) + }) + } +} + +func TestNewAssert(t *testing.T) { + otherGolden := New(WithSuffix(".other-golden")) + + type args struct { + options []AsserterOption + } + + tests := []struct { + name string + args args + want *asserter + }{ + { + name: "no options", + args: args{options: []AsserterOption{}}, + want: &asserter{ + json: NewMarshalingAsserter( + defaultGolden, "JSON", + marshal.JSON, unmarshal.JSON, + true, + ), + xml: NewMarshalingAsserter( + defaultGolden, "XML", + marshal.XML, unmarshal.XML, + true, + ), + yaml: NewMarshalingAsserter( + defaultGolden, "YAML", + marshal.YAML, unmarshal.YAML, + true, + ), + }, + }, + { + name: "WithGlobal option", + args: args{ + options: []AsserterOption{ + WithGolden(otherGolden), + }, + }, + want: &asserter{ + json: NewMarshalingAsserter( + otherGolden, "JSON", + marshal.JSON, unmarshal.JSON, + true, + ), + xml: NewMarshalingAsserter( + otherGolden, "XML", + marshal.XML, unmarshal.XML, + true, + ), + yaml: NewMarshalingAsserter( + otherGolden, "YAML", + marshal.YAML, unmarshal.YAML, + true, + ), + }, + }, + { + name: "WithNormalizedLineBreaks option", + args: args{ + options: []AsserterOption{ + WithNormalizedLineBreaks(false), + }, + }, + want: &asserter{ + json: NewMarshalingAsserter( + defaultGolden, "JSON", + marshal.JSON, unmarshal.JSON, + false, + ), + xml: NewMarshalingAsserter( + defaultGolden, "XML", + marshal.XML, unmarshal.XML, + false, + ), + yaml: NewMarshalingAsserter( + defaultGolden, "YAML", + marshal.YAML, unmarshal.YAML, + false, + ), + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := NewAsserter(tt.args.options...) + assert.Implements(t, (*Asserter)(nil), a) + + got, ok := a.(*asserter) + require.True( + t, ok, "failed to type assert return value to a *asserter", + ) + + assert.Equal(t, tt.want.json.Golden, got.json.Golden) + assert.Equal(t, tt.want.json.Format, got.json.Format) + assert.Equal(t, + tt.want.json.NormalizeLineBreaks, got.json.NormalizeLineBreaks, + ) + + assert.Equal(t, tt.want.xml.Golden, got.xml.Golden) + assert.Equal(t, tt.want.xml.Format, got.xml.Format) + assert.Equal(t, + tt.want.xml.NormalizeLineBreaks, got.xml.NormalizeLineBreaks, + ) + + assert.Equal(t, tt.want.yaml.Golden, got.yaml.Golden) + assert.Equal(t, tt.want.yaml.Format, got.yaml.Format) + assert.Equal(t, + tt.want.yaml.NormalizeLineBreaks, got.yaml.NormalizeLineBreaks, + ) + }) + } +} + +func Test_asserter(t *testing.T) { + a := &asserter{} + + assert.Implements(t, (*Asserter)(nil), a) +} + func TestAssertJSONMarshaling(t *testing.T) { for _, tt := range marhalingTestCases { t.Run(tt.name, func(t *testing.T) { @@ -312,7 +501,7 @@ func TestAssertYAMLMarshalingP(t *testing.T) { func TestAssert_JSONMarshaling(t *testing.T) { for _, tt := range marhalingTestCases { t.Run(tt.name, func(t *testing.T) { - assert := NewAssert() + assert := NewAsserter() assert.JSONMarshaling(t, tt.v) }) @@ -322,7 +511,7 @@ func TestAssert_JSONMarshaling(t *testing.T) { func TestAssert_JSONMarshalingP(t *testing.T) { for _, tt := range marshalingPTestCases { t.Run(tt.name, func(t *testing.T) { - assert := NewAssert() + assert := NewAsserter() assert.JSONMarshalingP(t, tt.v, tt.want) }) @@ -332,7 +521,7 @@ func TestAssert_JSONMarshalingP(t *testing.T) { func TestAssert_XMLMarshaling(t *testing.T) { for _, tt := range marhalingTestCases { t.Run(tt.name, func(t *testing.T) { - assert := NewAssert() + assert := NewAsserter() assert.XMLMarshaling(t, tt.v) }) @@ -342,7 +531,7 @@ func TestAssert_XMLMarshaling(t *testing.T) { func TestAssert_XMLMarshalingP(t *testing.T) { for _, tt := range marshalingPTestCases { t.Run(tt.name, func(t *testing.T) { - assert := NewAssert() + assert := NewAsserter() assert.XMLMarshalingP(t, tt.v, tt.want) }) @@ -352,7 +541,7 @@ func TestAssert_XMLMarshalingP(t *testing.T) { func TestAssert_YAMLMarshaling(t *testing.T) { for _, tt := range marhalingTestCases { t.Run(tt.name, func(t *testing.T) { - assert := NewAssert() + assert := NewAsserter() assert.YAMLMarshaling(t, tt.v) }) @@ -362,7 +551,7 @@ func TestAssert_YAMLMarshaling(t *testing.T) { func TestAssert_YAMLMarshalingP(t *testing.T) { for _, tt := range marshalingPTestCases { t.Run(tt.name, func(t *testing.T) { - assert := NewAssert() + assert := NewAsserter() assert.YAMLMarshalingP(t, tt.v, tt.want) }) diff --git a/asserter.go b/asserter.go new file mode 100644 index 0000000..2d5a721 --- /dev/null +++ b/asserter.go @@ -0,0 +1,178 @@ +package golden + +import ( + "github.com/jimeh/go-golden/marshal" + "github.com/jimeh/go-golden/unmarshal" +) + +// Asserter exposes a series of JSON, YAML, and XML marshaling assertion +// helpers. +type Asserter interface { + // JSONMarshaling asserts that the given "v" value JSON marshals to an + // expected value fetched from a golden file on disk, and then verifies that + // the marshaled result produces a value that is equal to "v" when + // unmarshaled. + // + // Used for objects that do NOT change when they are marshaled and + // unmarshaled. + JSONMarshaling(t TestingT, v interface{}) + + // JSONMarshalingP asserts that the given "v" value JSON marshals to an + // expected value fetched from a golden file on disk, and then verifies that + // the marshaled result produces a value that is equal to "want" when + // unmarshaled. + // + // Used for objects that change when they are marshaled and unmarshaled. + JSONMarshalingP(t TestingT, v interface{}, want interface{}) + + // XMLMarshaling asserts that the given "v" value XML marshals to an + // expected value fetched from a golden file on disk, and then verifies that + // the marshaled result produces a value that is equal to "v" when + // unmarshaled. + // + // Used for objects that do NOT change when they are marshaled and + // unmarshaled. + XMLMarshaling(t TestingT, v interface{}) + + // XMLMarshalingP asserts that the given "v" value XML marshals to an + // expected value fetched from a golden file on disk, and then verifies that + // the marshaled result produces a value that is equal to "want" when + // unmarshaled. + // + // Used for objects that change when they are marshaled and unmarshaled. + XMLMarshalingP(t TestingT, v interface{}, want interface{}) + + // YAMLMarshaling asserts that the given "v" value YAML marshals to an + // expected value fetched from a golden file on disk, and then verifies that + // the marshaled result produces a value that is equal to "v" when + // unmarshaled. + // + // Used for objects that do NOT change when they are marshaled and + // unmarshaled. + YAMLMarshaling(t TestingT, v interface{}) + + // YAMLMarshalingP asserts that the given "v" value YAML marshals to an + // expected value fetched from a golden file on disk, and then verifies that + // the marshaled result produces a value that is equal to "want" when + // unmarshaled. + // + // Used for objects that change when they are marshaled and unmarshaled. + YAMLMarshalingP(t TestingT, v interface{}, want interface{}) +} + +// NewAsserter returns a new Asserter which exposes a number of marshaling +// assertion helpers for JSON, YAML and XML. +// +// The default encoders all specify indentation of two spaces, essentially +// enforcing pretty formatting for JSON and XML. +// +// The default decoders for JSON and YAML prohibit unknown fields which are not +// present on the provided struct. +func NewAsserter(options ...AsserterOption) Asserter { + o := &asserterOptions{ + golden: defaultGolden, + normalizeLineBreaks: true, + } + + for _, opt := range options { + opt.apply(o) + } + + return &asserter{ + json: NewMarshalingAsserter( + o.golden, "JSON", + marshal.JSON, unmarshal.JSON, + o.normalizeLineBreaks, + ), + xml: NewMarshalingAsserter( + o.golden, "XML", + marshal.XML, unmarshal.XML, + o.normalizeLineBreaks, + ), + yaml: NewMarshalingAsserter( + o.golden, "YAML", + marshal.YAML, unmarshal.YAML, + o.normalizeLineBreaks, + ), + } +} + +type asserterOptions struct { + golden Golden + normalizeLineBreaks bool +} + +type AsserterOption interface { + apply(*asserterOptions) +} + +type asserterOptionFunc func(*asserterOptions) + +func (fn asserterOptionFunc) apply(c *asserterOptions) { + fn(c) +} + +// WithGolden allows setting a custom *Golden instance when calling NewAssert(). +func WithGolden(golden Golden) AsserterOption { + return asserterOptionFunc(func(a *asserterOptions) { + if golden != nil { + a.golden = golden + } + }) +} + +// WithNormalizedLineBreaks allows turning off line-break normalization which +// replaces Windows' CRLF (\r\n) and Mac Classic CR (\r) line breaks with Unix's +// LF (\n) line breaks. +func WithNormalizedLineBreaks(value bool) AsserterOption { + return asserterOptionFunc(func(a *asserterOptions) { + a.normalizeLineBreaks = value + }) +} + +// asserter implements the Assert interface. +type asserter struct { + json *MarshalingAsserter + xml *MarshalingAsserter + yaml *MarshalingAsserter +} + +func (s *asserter) JSONMarshaling(t TestingT, v interface{}) { + t.Helper() + + s.json.Marshaling(t, v) +} + +func (s *asserter) JSONMarshalingP( + t TestingT, + v interface{}, + want interface{}, +) { + t.Helper() + + s.json.MarshalingP(t, v, want) +} + +func (s *asserter) XMLMarshaling(t TestingT, v interface{}) { + t.Helper() + + s.xml.Marshaling(t, v) +} + +func (s *asserter) XMLMarshalingP(t TestingT, v, want interface{}) { + t.Helper() + + s.xml.MarshalingP(t, v, want) +} + +func (s *asserter) YAMLMarshaling(t TestingT, v interface{}) { + t.Helper() + + s.yaml.Marshaling(t, v) +} + +func (s *asserter) YAMLMarshalingP(t TestingT, v, want interface{}) { + t.Helper() + + s.yaml.MarshalingP(t, v, want) +} diff --git a/go.mod b/go.mod index 61c8115..24beb97 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,8 @@ go 1.15 require ( github.com/jimeh/envctl v0.1.0 + github.com/jimeh/go-mocktesting v0.1.0 + github.com/spf13/afero v1.6.0 github.com/stretchr/testify v1.7.0 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b ) diff --git a/go.sum b/go.sum index 078e210..33c0728 100644 --- a/go.sum +++ b/go.sum @@ -2,14 +2,32 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/jimeh/envctl v0.1.0 h1:KTv3D+pi5M4/PgFVE/W8ssWqiZP3pDJ8Cga50L+1avo= github.com/jimeh/envctl v0.1.0/go.mod h1:aM27ffBbO1yUBKUzgJGCUorS4z+wyh+qhQe1ruxXZZo= +github.com/jimeh/go-mocktesting v0.1.0 h1:y0tLABo3V4i9io7m6TiXdXbU3IVMjtPvWkr+A0+aLTM= +github.com/jimeh/go-mocktesting v0.1.0/go.mod h1:xnekQ6yP/ull2ewkOp1CbgH7Dym7nbKa/t96XWrIiH8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY= +github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/golden.go b/golden.go index 40e62a6..15da3a0 100644 --- a/golden.go +++ b/golden.go @@ -1,6 +1,6 @@ // Package golden is yet another package for working with *.golden test files, // with a focus on simplicity through it's default behavior, and marshaling -// assertion helpers to validate JSON/YAML/XML format of structs. +// assertion helpers to validate the JSON, YAML, and XML formats of structs. // // Golden file names are based on the name of the test function and any subtest // names by calling t.Name(). File names are sanitized to ensure they're @@ -61,7 +61,8 @@ // // Golden Usage // -// Typical usage would look something like this: +// If you need manual control over golden file reading and writing, this is a +// typical example: // // func TestExampleMyStruct(t *testing.T) { // got, err := json.Marshal(&MyStruct{Foo: "Bar"}) @@ -176,30 +177,37 @@ package golden import ( - "io/ioutil" "os" "path/filepath" "strings" "testing" + + "github.com/jimeh/go-golden/sanitize" + "github.com/spf13/afero" ) -const ( - DefaultDirMode = 0o755 - DefaultFileMode = 0o644 - DefaultSuffix = ".golden" - DefaultDirname = "testdata" -) +// TestingT is a interface describing a sub-set of methods of *testing.T which +// golden uses. +type TestingT interface { + Error(args ...interface{}) + Errorf(format string, args ...interface{}) + FailNow() + Fatal(args ...interface{}) + Fatalf(format string, args ...interface{}) + Helper() + Log(args ...interface{}) + Logf(format string, args ...interface{}) + Name() string +} -var DefaultUpdateFunc = EnvUpdateFunc - -var globalGolden = New() +var defaultGolden = New() // File returns the filename of the golden file for the given *testing.T // instance as determined by t.Name(). func File(t *testing.T) string { t.Helper() - return globalGolden.File(t) + return defaultGolden.File(t) } // Get returns the content of the golden file for the given *testing.T instance @@ -208,7 +216,7 @@ func File(t *testing.T) string { func Get(t *testing.T) []byte { t.Helper() - return globalGolden.Get(t) + return defaultGolden.Get(t) } // Set writes given data to the golden file for the given *testing.T instance as @@ -217,7 +225,7 @@ func Get(t *testing.T) []byte { func Set(t *testing.T, data []byte) { t.Helper() - globalGolden.Set(t, data) + defaultGolden.Set(t, data) } // FileP returns the filename of the specifically named golden file for the @@ -225,7 +233,7 @@ func Set(t *testing.T, data []byte) { func FileP(t *testing.T, name string) string { t.Helper() - return globalGolden.FileP(t, name) + return defaultGolden.FileP(t, name) } // GetP returns the content of the specifically named golden file belonging @@ -237,7 +245,7 @@ func FileP(t *testing.T, name string) string { func GetP(t *testing.T, name string) []byte { t.Helper() - return globalGolden.GetP(t, name) + return defaultGolden.GetP(t, name) } // SetP writes given data of the specifically named golden file belonging to @@ -249,7 +257,7 @@ func GetP(t *testing.T, name string) []byte { func SetP(t *testing.T, name string, data []byte) { t.Helper() - globalGolden.SetP(t, name, data) + defaultGolden.SetP(t, name, data) } // Update returns true when golden is set to update golden files. Should be used @@ -259,104 +267,228 @@ func SetP(t *testing.T, name string, data []byte) { // environment variable is set to a truthy value. To customize create a custom // *Golden instance with New() and set a new UpdateFunc value. func Update() bool { - return globalGolden.Update() + return defaultGolden.Update() } // Golden handles all interactions with golden files. The top-level package -// functions all just proxy through to a default global *Golden instance. -type Golden struct { - // DirMode determines the file system permissions of any folders created to - // hold golden files. - DirMode os.FileMode +// functions proxy through to a default global Golden instance. +type Golden interface { + // File returns the filename of the golden file for the given testing.TB + // instance as determined by t.Name(). + File(t TestingT) string - // FileMode determines the file system permissions of any created or updated - // golden files written to disk. - FileMode os.FileMode + // Get returns the content of the golden file for the given TestingT + // instance as determined by t.Name(). If no golden file can be found/read, + // it will fail the test by calling t.Fatal(). + Get(t TestingT) []byte - // Suffix determines the filename suffix for all golden files. Typically - // this should be ".golden", but can be changed here if needed. - Suffix string + // Set writes given data to the golden file for the given TestingT + // instance as determined by t.Name(). If writing fails it will fail the + // test by calling t.Fatal() with error details. + Set(t TestingT, data []byte) - // Dirname is the name of the top-level directory at the root of the package - // which holds all golden files. Typically this should "testdata", but can - // be changed here if needed. - Dirname string + // FileP returns the filename of the specifically named golden file for the + // given TestingT instance as determined by t.Name(). + FileP(t TestingT, name string) string - // UpdateFunc is used to determine if golden files should be updated or - // not. Its boolean return value is returned by Update(). - UpdateFunc UpdateFunc + // GetP returns the content of the specifically named golden file belonging + // to the given TestingT instance as determined by t.Name(). If no golden + // file can be found/read, it will fail the test with t.Fatal(). + // + // This is very similar to Get(), but it allows multiple different golden + // files to be used within the same one TestingT instance. + GetP(t TestingT, name string) []byte + + // SetP writes given data of the specifically named golden file belonging to + // the given TestingT instance as determined by t.Name(). If writing fails + // it will fail the test with t.Fatal() detailing the error. + // + // This is very similar to Set(), but it allows multiple different golden + // files to be used within the same one TestingT instance. + SetP(t TestingT, name string, data []byte) + + // Update returns true when golden is set to update golden files. Should be + // used to determine if golden.Set() or golden.SetP() should be called or + // not. + // + // Default behavior uses EnvUpdateFunc() to check if the "GOLDEN_UPDATE" + // environment variable is set to a truthy value. To customize set a new + // UpdateFunc value on *Golden. + Update() bool } -// New returns a new *Golden instance with default values correctly -// populated. This is ideally how you should create a custom *Golden, and then -// modify the relevant fields as you see fit. -func New() *Golden { - return &Golden{ - DirMode: DefaultDirMode, - FileMode: DefaultFileMode, - Suffix: DefaultSuffix, - Dirname: DefaultDirname, - UpdateFunc: DefaultUpdateFunc, +// New returns a new Golden instance. Used to create custom Golden instances. +// See the the various Option functions for details of what can be customized. +func New(options ...Option) Golden { + g := &golden{ + dirMode: 0o755, + fileMode: 0o644, + suffix: ".golden", + dirname: "testdata", + updateFunc: EnvUpdateFunc, + fs: afero.NewOsFs(), + logOnWrite: true, } + + for _, opt := range options { + opt.apply(g) + } + + return g } -// File returns the filename of the golden file for the given *testing.T -// instance as determined by t.Name(). -func (s *Golden) File(t *testing.T) string { +type Option interface { + apply(*golden) +} + +type optionFunc func(*golden) + +func (fn optionFunc) apply(g *golden) { + fn(g) +} + +// WithDirMode sets the file system permissions used for any folders created to +// hold golden files. +// +// When this option is not provided, the default value is 0o755. +func WithDirMode(mode os.FileMode) Option { + return optionFunc(func(g *golden) { + g.dirMode = mode + }) +} + +// WithFileMode sets the file system permissions used for any created or updated +// golden files written to. +// +// When this option is not provided, the default value is 0o644. +func WithFileMode(mode os.FileMode) Option { + return optionFunc(func(g *golden) { + g.fileMode = mode + }) +} + +// WithSuffix sets the filename suffix used for all golden files. +// +// When this option is not provided, the default value is ".golden". +func WithSuffix(suffix string) Option { + return optionFunc(func(g *golden) { + g.suffix = suffix + }) +} + +// WithDirname sets the name of the top-level directory used to hold golden +// files. +// +// When this option is not provided, the default value is "testdata". +func WithDirname(name string) Option { + return optionFunc(func(g *golden) { + g.dirname = name + }) +} + +// WithUpdateFunc sets the function used to determine if golden files should be +// updated or not. Essentially the provided UpdateFunc is called by Update(). +// +// When this option is not provided, the default value is EnvUpdateFunc. +func WithUpdateFunc(fn UpdateFunc) Option { + return optionFunc(func(g *golden) { + g.updateFunc = fn + }) +} + +// WithFs sets s afero.Fs instance which is used to read/write all golden files. +// +// When this option is not provided, the default value is afero.NewOsFs(). +func WithFs(fs afero.Fs) Option { + return optionFunc(func(g *golden) { + g.fs = fs + }) +} + +// WithSilentWrites silences the "golden: writing [...]" log messages whenever +// set functions write a golden file to disk. +func WithSilentWrites() Option { + return optionFunc(func(g *golden) { + g.logOnWrite = false + }) +} + +// golden is the underlying struct that implements the Golden interface. +type golden struct { + // dirMode determines the file system permissions of any folders created to + // hold golden files. + dirMode os.FileMode + + // fileMode determines the file system permissions of any created or updated + // golden files written to disk. + fileMode os.FileMode + + // suffix determines the filename suffix for all golden files. Typically + // this should be ".golden", but can be changed here if needed. + suffix string + + // dirname is the name of the top-level directory at the root of the package + // which holds all golden files. Typically this should be "testdata", but + // can be changed here if needed. + dirname string + + // updateFunc is used to determine if golden files should be updated or + // not. Its boolean return value is returned by Update(). + updateFunc UpdateFunc + + // fs is used for all file system operations. This enables providing custom + // afero.fs instances which can be useful for testing purposes. + fs afero.Fs + + // logOnWrite determines if a message is logged with t.Logf when a golden + // file is written to with either of the set methods. + logOnWrite bool +} + +// Ensure golden satisfies Golden interface. +var _ Golden = &golden{} + +func (s *golden) File(t TestingT) string { + t.Helper() + return s.file(t, "") } -// Get returns the content of the golden file for the given *testing.T instance -// as determined by t.Name(). If no golden file can be found/read, it will fail -// the test by calling t.Fatal(). -func (s *Golden) Get(t *testing.T) []byte { +func (s *golden) Get(t TestingT) []byte { + t.Helper() + return s.get(t, "") } -// Set writes given data to the golden file for the given *testing.T instance as -// determined by t.Name(). If writing fails it will fail the test by calling -// t.Fatal() with error details. -func (s *Golden) Set(t *testing.T, data []byte) { +func (s *golden) Set(t TestingT, data []byte) { + t.Helper() + s.set(t, "", data) } -// FileP returns the filename of the specifically named golden file for the -// given *testing.T instance as determined by t.Name(). -func (s *Golden) FileP(t *testing.T, name string) string { - if name == "" { - if t != nil { - t.Fatal("golden: name cannot be empty") - } +func (s *golden) FileP(t TestingT, name string) string { + t.Helper() - return "" + if name == "" { + t.Fatalf("golden: test name cannot be empty") } return s.file(t, name) } -// GetP returns the content of the specifically named golden file belonging -// to the given *testing.T instance as determined by t.Name(). If no golden file -// can be found/read, it will fail the test with t.Fatal(). -// -// This is very similar to Get(), but it allows multiple different golden files -// to be used within the same one *testing.T instance. -func (s *Golden) GetP(t *testing.T, name string) []byte { +func (s *golden) GetP(t TestingT, name string) []byte { + t.Helper() + if name == "" { t.Fatal("golden: name cannot be empty") - - return nil } return s.get(t, name) } -// SetP writes given data of the specifically named golden file belonging to -// the given *testing.T instance as determined by t.Name(). If writing fails it -// will fail the test with t.Fatal() detailing the error. -// -// This is very similar to Set(), but it allows multiple different golden files -// to be used within the same one *testing.T instance. -func (s *Golden) SetP(t *testing.T, name string, data []byte) { +func (s *golden) SetP(t TestingT, name string, data []byte) { + t.Helper() + if name == "" { t.Fatal("golden: name cannot be empty") } @@ -364,65 +496,65 @@ func (s *Golden) SetP(t *testing.T, name string, data []byte) { s.set(t, name, data) } -func (s *Golden) file(t *testing.T, name string) string { - if t.Name() == "" { - t.Fatalf("golden: could not determine filename for: %+v", t) +func (s *golden) file(t TestingT, name string) string { + t.Helper() - return "" + if t.Name() == "" { + t.Fatalf( + "golden: could not determine filename for given %T instance", t, + ) } - base := []string{s.Dirname, filepath.FromSlash(t.Name())} + base := []string{s.dirname, filepath.FromSlash(t.Name())} if name != "" { base = append(base, name) } - f := filepath.Clean(filepath.Join(base...) + s.Suffix) + f := filepath.Clean(filepath.Join(base...) + s.suffix) dirty := strings.Split(f, string(os.PathSeparator)) clean := make([]string, 0, len(dirty)) for _, s := range dirty { - clean = append(clean, sanitizeFilename(s)) + clean = append(clean, sanitize.Filename(s)) } return strings.Join(clean, string(os.PathSeparator)) } -func (s *Golden) get(t *testing.T, name string) []byte { +func (s *golden) get(t TestingT, name string) []byte { + t.Helper() + f := s.file(t, name) - b, err := ioutil.ReadFile(f) + b, err := afero.ReadFile(s.fs, f) if err != nil { - t.Fatalf("golden: failed reading %s: %s", f, err.Error()) + t.Fatalf("golden: %s", err.Error()) } return b } -func (s *Golden) set(t *testing.T, name string, data []byte) { +func (s *golden) set(t TestingT, name string, data []byte) { + t.Helper() + f := s.file(t, name) dir := filepath.Dir(f) - t.Logf("golden: writing .golden file: %s", f) - - err := os.MkdirAll(dir, s.DirMode) - if err != nil { - t.Fatalf("golden: failed to create directory: %s", err.Error()) - - return + if s.logOnWrite { + t.Logf("golden: writing golden file: %s", f) } - err = ioutil.WriteFile(f, data, s.FileMode) + err := s.fs.MkdirAll(dir, s.dirMode) + if err != nil { + t.Fatalf("golden: failed to create directory: %s", err.Error()) + } + + err = afero.WriteFile(s.fs, f, data, s.fileMode) if err != nil { t.Fatalf("golden: filed to write file: %s", err.Error()) } } -// Update returns true when golden is set to update golden files. Should be used -// to determine if golden.Set() or golden.SetP() should be called or not. -// -// Default behavior uses EnvUpdateFunc() to check if the "GOLDEN_UPDATE" -// environment variable is set to a truthy value. To customize set a new -// UpdateFunc value on *Golden. -func (s *Golden) Update() bool { - return s.UpdateFunc() +func (s *golden) Update() bool { + return s.updateFunc() } diff --git a/golden_test.go b/golden_test.go index a077a32..567cb76 100644 --- a/golden_test.go +++ b/golden_test.go @@ -4,9 +4,13 @@ import ( "io/ioutil" "os" "path/filepath" + "reflect" + "runtime" "testing" "github.com/jimeh/envctl" + "github.com/jimeh/go-mocktesting" + "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -122,6 +126,7 @@ func TestGet(t *testing.T) { func TestSet(t *testing.T) { t.Cleanup(func() { + t.Log("cleaning up golden files") err := os.RemoveAll(filepath.Join("testdata", "TestSet")) require.NoError(t, err) err = os.Remove(filepath.Join("testdata", "TestSet.golden")) @@ -344,6 +349,7 @@ func TestGetP(t *testing.T) { func TestSetP(t *testing.T) { t.Cleanup(func() { + t.Log("cleaning up golden files") err := os.RemoveAll(filepath.Join("testdata", "TestSetP")) require.NoError(t, err) }) @@ -441,3 +447,623 @@ func TestUpdate(t *testing.T) { }) } } + +func TestNew(t *testing.T) { + myUpdateFunc := func() bool { return false } + + type args struct { + options []Option + } + tests := []struct { + name string + args args + want *golden + }{ + { + name: "no options", + args: args{options: nil}, + want: &golden{ + dirMode: 0o755, + fileMode: 0o644, + suffix: ".golden", + dirname: "testdata", + updateFunc: EnvUpdateFunc, + fs: afero.NewOsFs(), + logOnWrite: true, + }, + }, + { + name: "all options", + args: args{ + options: []Option{ + WithDirMode(0o777), + WithFileMode(0o666), + WithSuffix(".gold"), + WithDirname("goldstuff"), + WithUpdateFunc(myUpdateFunc), + WithFs(afero.NewMemMapFs()), + WithSilentWrites(), + }, + }, + want: &golden{ + dirMode: 0o777, + fileMode: 0o666, + suffix: ".gold", + dirname: "goldstuff", + updateFunc: myUpdateFunc, + fs: afero.NewMemMapFs(), + logOnWrite: false, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := New(tt.args.options...) + got, ok := g.(*golden) + require.True(t, ok, "New did not returns a *golden instance") + + gotUpdateFunc := runtime.FuncForPC( + reflect.ValueOf(got.updateFunc).Pointer(), + ).Name() + wantUpdateFunc := runtime.FuncForPC( + reflect.ValueOf(tt.want.updateFunc).Pointer(), + ).Name() + + assert.Equal(t, tt.want.dirMode, got.dirMode) + assert.Equal(t, tt.want.fileMode, got.fileMode) + assert.Equal(t, tt.want.suffix, got.suffix) + assert.Equal(t, tt.want.dirname, got.dirname) + assert.Equal(t, tt.want.logOnWrite, got.logOnWrite) + assert.Equal(t, wantUpdateFunc, gotUpdateFunc) + assert.IsType(t, tt.want.fs, got.fs) + }) + } +} + +func Test_golden_File(t *testing.T) { + type fields struct { + suffix *string + dirname *string + } + tests := []struct { + name string + testName string + fields fields + want string + wantAborted bool + wantFailCount int + wantTestOutput []string + }{ + { + name: "top-level", + testName: "TestFooBar", + want: filepath.Join("testdata", "TestFooBar.golden"), + }, + { + name: "sub-test", + testName: "TestFooBar/it_is_here", + want: filepath.Join( + "testdata", "TestFooBar", "it_is_here.golden", + ), + }, + { + name: "blank test name", + testName: "", + wantAborted: true, + wantFailCount: 1, + wantTestOutput: []string{ + "golden: could not determine filename for given " + + "*mocktesting.T instance\n", + }, + }, + { + name: "custom dirname", + testName: "TestFozBar", + fields: fields{ + dirname: stringPtr("goldenfiles"), + }, + want: filepath.Join("goldenfiles", "TestFozBar.golden"), + }, + { + name: "custom suffix", + testName: "TestFozBaz", + fields: fields{ + suffix: stringPtr(".goldfile"), + }, + want: filepath.Join("testdata", "TestFozBaz.goldfile"), + }, + { + name: "custom dirname and suffix", + testName: "TestFozBar", + fields: fields{ + dirname: stringPtr("goldenfiles"), + suffix: stringPtr(".goldfile"), + }, + want: filepath.Join("goldenfiles", "TestFozBar.goldfile"), + }, + { + name: "invalid chars in test name", + testName: `TestFooBar/foo?<>:*|"bar`, + want: filepath.Join( + "testdata", "TestFooBar", "foo_______bar.golden", + ), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.fields.suffix == nil { + tt.fields.suffix = stringPtr(".golden") + } + if tt.fields.dirname == nil { + tt.fields.dirname = stringPtr("testdata") + } + + g := &golden{ + suffix: *tt.fields.suffix, + dirname: *tt.fields.dirname, + } + + mt := mocktesting.NewT(tt.testName) + + var got string + mocktesting.Go(func() { + got = g.File(mt) + }) + + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.wantAborted, mt.Aborted(), "aborted") + assert.Equal(t, + tt.wantFailCount, mt.FailedCount(), "failed count", + ) + assert.Equal(t, tt.wantTestOutput, mt.Output(), "test output") + }) + } +} + +func Test_golden_Get(t *testing.T) { + type fields struct { + suffix *string + dirname *string + } + tests := []struct { + name string + testName string + fields fields + files map[string][]byte + want []byte + wantAborted bool + wantFailCount int + wantTestOutput []string + }{ + { + name: "file exists", + testName: "TestFooBar", + files: map[string][]byte{ + filepath.Join("testdata", "TestFooBar.golden"): []byte( + "foo: bar\nhello: world", + ), + }, + want: []byte("foo: bar\nhello: world"), + }, + { + name: "file is missing", + testName: "TestFooBar", + files: map[string][]byte{}, + wantAborted: true, + wantFailCount: 1, + wantTestOutput: []string{ + "golden: open " + filepath.Join( + "testdata", "TestFooBar.golden", + ) + ": file does not exist\n", + }, + }, + { + name: "sub-test file exists", + testName: "TestFooBar/it_is_here", + files: map[string][]byte{ + filepath.Join( + "testdata", "TestFooBar", "it_is_here.golden", + ): []byte("this is really here ^_^\n"), + }, + want: []byte("this is really here ^_^\n"), + }, + { + name: "sub-test file is missing", + testName: "TestFooBar/not_really_here", + files: map[string][]byte{}, + wantAborted: true, + wantFailCount: 1, + wantTestOutput: []string{ + "golden: open " + filepath.Join( + "testdata", "TestFooBar", "not_really_here.golden", + ) + ": file does not exist\n", + }, + }, + { + name: "blank test name", + testName: "", + wantAborted: true, + wantFailCount: 1, + wantTestOutput: []string{ + "golden: could not determine filename for given " + + "*mocktesting.T instance\n", + }, + }, + { + name: "custom dirname", + testName: "TestFozBar", + fields: fields{ + dirname: stringPtr("goldenfiles"), + }, + files: map[string][]byte{ + filepath.Join("goldenfiles", "TestFozBar.golden"): []byte( + "foo: bar\nhello: world", + ), + }, + want: []byte("foo: bar\nhello: world"), + }, + { + name: "custom suffix", + testName: "TestFozBaz", + fields: fields{ + suffix: stringPtr(".goldfile"), + }, + files: map[string][]byte{ + filepath.Join("testdata", "TestFozBaz.goldfile"): []byte( + "foo: bar\nhello: world", + ), + }, + want: []byte("foo: bar\nhello: world"), + }, + { + name: "custom dirname and suffix", + testName: "TestFozBar", + fields: fields{ + dirname: stringPtr("goldenfiles"), + suffix: stringPtr(".goldfile"), + }, + files: map[string][]byte{ + filepath.Join("goldenfiles", "TestFozBar.goldfile"): []byte( + "foo: bar\nhello: world", + ), + }, + want: []byte("foo: bar\nhello: world"), + }, + { + name: "invalid chars in test name", + testName: `TestFooBar/foo?<>:*|"bar`, + files: map[string][]byte{ + filepath.Join( + "testdata", "TestFooBar", "foo_______bar.golden", + ): []byte("foo: bar\nhello: world"), + }, + want: []byte("foo: bar\nhello: world"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := afero.NewMemMapFs() + for f, b := range tt.files { + _ = afero.WriteFile(fs, f, b, 0o644) + } + + if tt.fields.suffix == nil { + tt.fields.suffix = stringPtr(".golden") + } + if tt.fields.dirname == nil { + tt.fields.dirname = stringPtr("testdata") + } + + g := &golden{ + suffix: *tt.fields.suffix, + dirname: *tt.fields.dirname, + fs: fs, + } + + mt := mocktesting.NewT(tt.testName) + + var got []byte + mocktesting.Go(func() { + got = g.Get(mt) + }) + + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.wantAborted, mt.Aborted(), "aborted") + assert.Equal(t, + tt.wantFailCount, mt.FailedCount(), "failed count", + ) + assert.Equal(t, tt.wantTestOutput, mt.Output(), "test output") + }) + } +} + +func Test_golden_FileP(t *testing.T) { + type args struct { + name string + } + type fields struct { + suffix *string + dirname *string + } + tests := []struct { + name string + testName string + args args + fields fields + want string + wantAborted bool + wantFailCount int + wantTestOutput []string + }{ + { + name: "top-level", + testName: "TestFooBar", + args: args{name: "yaml"}, + want: filepath.Join("testdata", "TestFooBar", "yaml.golden"), + }, + { + name: "sub-test", + testName: "TestFooBar/it_is_here", + args: args{name: "json"}, + want: filepath.Join( + "testdata", "TestFooBar", "it_is_here", "json.golden", + ), + }, + { + name: "blank test name", + testName: "", + args: args{name: "json"}, + wantAborted: true, + wantFailCount: 1, + wantTestOutput: []string{ + "golden: could not determine filename for given " + + "*mocktesting.T instance\n", + }, + }, + { + name: "custom dirname", + testName: "TestFozBar", + args: args{name: "xml"}, + fields: fields{ + dirname: stringPtr("goldenfiles"), + }, + want: filepath.Join("goldenfiles", "TestFozBar", "xml.golden"), + }, + { + name: "custom suffix", + testName: "TestFozBaz", + args: args{name: "toml"}, + fields: fields{ + suffix: stringPtr(".goldfile"), + }, + want: filepath.Join("testdata", "TestFozBaz", "toml.goldfile"), + }, + { + name: "custom dirname and suffix", + testName: "TestFozBar", + args: args{name: "json"}, + fields: fields{ + dirname: stringPtr("goldenfiles"), + suffix: stringPtr(".goldfile"), + }, + want: filepath.Join("goldenfiles", "TestFozBar", "json.goldfile"), + }, + { + name: "invalid chars in test name", + testName: `TestFooBar/foo?<>:*|"bar`, + args: args{name: "yml"}, + want: filepath.Join( + "testdata", "TestFooBar", "foo_______bar", "yml.golden", + ), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.fields.suffix == nil { + tt.fields.suffix = stringPtr(".golden") + } + if tt.fields.dirname == nil { + tt.fields.dirname = stringPtr("testdata") + } + + g := &golden{ + suffix: *tt.fields.suffix, + dirname: *tt.fields.dirname, + } + + mt := mocktesting.NewT(tt.testName) + + var got string + mocktesting.Go(func() { + got = g.FileP(mt, tt.args.name) + }) + + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.wantAborted, mt.Aborted(), "aborted") + assert.Equal(t, + tt.wantFailCount, mt.FailedCount(), "failed count", + ) + assert.Equal(t, tt.wantTestOutput, mt.Output(), "test output") + }) + } +} + +func Test_golden_GetP(t *testing.T) { + type args struct { + name string + } + type fields struct { + suffix *string + dirname *string + } + tests := []struct { + name string + testName string + args args + fields fields + files map[string][]byte + want []byte + wantAborted bool + wantFailCount int + wantTestOutput []string + }{ + { + name: "file exists", + testName: "TestFooBar", + args: args{name: "yaml"}, + files: map[string][]byte{ + filepath.Join("testdata", "TestFooBar", "yaml.golden"): []byte( + "foo: bar\nhello: world", + ), + }, + want: []byte("foo: bar\nhello: world"), + }, + { + name: "file is missing", + testName: "TestFooBar", + args: args{name: "yaml"}, + files: map[string][]byte{}, + wantAborted: true, + wantFailCount: 1, + wantTestOutput: []string{ + "golden: open " + filepath.Join( + "testdata", "TestFooBar", "yaml.golden", + ) + ": file does not exist\n", + }, + }, + { + name: "sub-test file exists", + testName: "TestFooBar/it_is_here", + args: args{name: "plain"}, + files: map[string][]byte{ + filepath.Join( + "testdata", "TestFooBar", "it_is_here", "plain.golden", + ): []byte("this is really here ^_^\n"), + }, + want: []byte("this is really here ^_^\n"), + }, + { + name: "sub-test file is missing", + testName: "TestFooBar/not_really_here", + args: args{name: "plain"}, + files: map[string][]byte{}, + wantAborted: true, + wantFailCount: 1, + wantTestOutput: []string{ + "golden: open " + filepath.Join( + "testdata", "TestFooBar", "not_really_here", "plain.golden", + ) + ": file does not exist\n", + }, + }, + { + name: "blank test name", + testName: "", + args: args{name: "plain"}, + wantAborted: true, + wantFailCount: 1, + wantTestOutput: []string{ + "golden: could not determine filename for given " + + "*mocktesting.T instance\n", + }, + }, + { + name: "blank name", + testName: "TestFooBar", + args: args{name: ""}, + wantAborted: true, + wantFailCount: 1, + wantTestOutput: []string{ + "golden: name cannot be empty\n", + }, + }, + { + name: "custom dirname", + testName: "TestFozBar", + args: args{name: "yaml"}, + fields: fields{ + dirname: stringPtr("goldenfiles"), + }, + files: map[string][]byte{ + filepath.Join( + "goldenfiles", "TestFozBar", "yaml.golden", + ): []byte("foo: bar\nhello: world"), + }, + want: []byte("foo: bar\nhello: world"), + }, + { + name: "custom suffix", + testName: "TestFozBaz", + args: args{name: "yaml"}, + fields: fields{ + suffix: stringPtr(".goldfile"), + }, + files: map[string][]byte{ + filepath.Join( + "testdata", "TestFozBaz", "yaml.goldfile", + ): []byte("foo: bar\nhello: world"), + }, + want: []byte("foo: bar\nhello: world"), + }, + { + name: "custom dirname and suffix", + testName: "TestFozBar", + args: args{name: "yaml"}, + fields: fields{ + dirname: stringPtr("goldenfiles"), + suffix: stringPtr(".goldfile"), + }, + files: map[string][]byte{ + filepath.Join( + "goldenfiles", "TestFozBar", "yaml.goldfile", + ): []byte("foo: bar\nhello: world"), + }, + want: []byte("foo: bar\nhello: world"), + }, + { + name: "invalid chars in test name", + testName: `TestFooBar/foo?<>:*|"bar`, + args: args{name: "trash"}, + files: map[string][]byte{ + filepath.Join( + "testdata", "TestFooBar", "foo_______bar", "trash.golden", + ): []byte("foo: bar\nhello: world"), + }, + want: []byte("foo: bar\nhello: world"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fs := afero.NewMemMapFs() + for f, b := range tt.files { + _ = afero.WriteFile(fs, f, b, 0o644) + } + + if tt.fields.suffix == nil { + tt.fields.suffix = stringPtr(".golden") + } + if tt.fields.dirname == nil { + tt.fields.dirname = stringPtr("testdata") + } + + g := &golden{ + suffix: *tt.fields.suffix, + dirname: *tt.fields.dirname, + fs: fs, + } + + mt := mocktesting.NewT(tt.testName) + + var got []byte + mocktesting.Go(func() { + got = g.GetP(mt, tt.args.name) + }) + + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.wantAborted, mt.Aborted(), "aborted") + assert.Equal(t, + tt.wantFailCount, mt.FailedCount(), "failed count", + ) + assert.Equal(t, tt.wantTestOutput, mt.Output(), "test output") + }) + } +} diff --git a/marshal/marshal.go b/marshal/marshal.go new file mode 100644 index 0000000..322f194 --- /dev/null +++ b/marshal/marshal.go @@ -0,0 +1,45 @@ +package marshal + +import ( + "bytes" + "encoding/json" + "encoding/xml" + + "gopkg.in/yaml.v3" +) + +// JSON returns the JSON encoding of v. Returned JSON is intended by two spaces +// (pretty formatted), and is not HTML escaped. +func JSON(v interface{}) ([]byte, error) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + + err := enc.Encode(v) + + return buf.Bytes(), err +} + +// XML returns the XML encoding of v. Returned XML is intended by two spaces +// (pretty formatted). +func XML(v interface{}) ([]byte, error) { + var buf bytes.Buffer + enc := xml.NewEncoder(&buf) + enc.Indent("", " ") + + err := enc.Encode(v) + + return buf.Bytes(), err +} + +// YAML returns the YAML encoding of v. Returned YAML is intended by two spaces. +func YAML(v interface{}) ([]byte, error) { + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + + err := enc.Encode(v) + + return buf.Bytes(), err +} diff --git a/marshal/marshal_test.go b/marshal/marshal_test.go new file mode 100644 index 0000000..09a3d62 --- /dev/null +++ b/marshal/marshal_test.go @@ -0,0 +1,318 @@ +package marshal_test + +import ( + "testing" + + "github.com/jimeh/go-golden/marshal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type book struct { + Title string `json:"title" yaml:"title" xml:"title"` + Author string `json:"author,omitempty" yaml:"author,omitempty" xml:"author,omitempty"` + Price int `json:"price" yaml:"price" xml:"price"` +} + +type shoe struct { + Make string `json:"make" yaml:"make" xml:"make"` + Model string `json:"model,omitempty" yaml:"model,omitempty" xml:"model,omitempty"` + Size int `json:"size" yaml:"size" xml:"size"` +} + +func TestJSON(t *testing.T) { + type args struct { + v interface{} + } + tests := []struct { + name string + args args + want []byte + wantErr string + wantErrIs error + }{ + { + name: "nil", + args: args{v: nil}, + want: []byte("null\n"), + }, + { + name: "empty struct (1)", + args: args{v: &book{}}, + want: []byte(`{ + "title": "", + "price": 0 +} +`, + ), + }, + { + name: "empty struct (2)", + args: args{v: &shoe{}}, + want: []byte(`{ + "make": "", + "size": 0 +} +`, + ), + }, + { + name: "full struct (1)", + args: args{ + v: &book{ + Title: "a", + Author: "b", + Price: 499, + }, + }, + want: []byte(`{ + "title": "a", + "author": "b", + "price": 499 +} +`, + ), + }, + { + name: "empty struct (2)", + args: args{ + v: &shoe{ + Make: "a", + Model: "b", + Size: 42, + }, + }, + want: []byte(`{ + "make": "a", + "model": "b", + "size": 42 +} +`, + ), + }, + { + name: "channel", + args: args{ + v: make(chan int), + }, + wantErr: "json: unsupported type: chan int", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := marshal.JSON(tt.args.v) + + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } + + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + + if tt.wantErr == "" && tt.wantErrIs == nil { + require.NoError(t, err) + } + + assert.Equal(t, string(tt.want), string(got)) + }) + } +} + +func TestYAML(t *testing.T) { + type args struct { + v interface{} + } + tests := []struct { + name string + args args + want []byte + wantErr string + wantErrIs error + wantPanic interface{} + }{ + { + name: "nil", + args: args{v: nil}, + want: []byte("null\n"), + }, + { + name: "empty struct (1)", + args: args{v: &book{}}, + want: []byte(`title: "" +price: 0 +`, + ), + }, + { + name: "empty struct (2)", + args: args{v: &shoe{}}, + want: []byte(`make: "" +size: 0 +`, + ), + }, + { + name: "full struct (1)", + args: args{ + v: &book{ + Title: "a", + Author: "b", + Price: 499, + }, + }, + want: []byte(`title: a +author: b +price: 499 +`, + ), + }, + { + name: "empty struct (2)", + args: args{ + v: &shoe{ + Make: "a", + Model: "b", + Size: 42, + }, + }, + want: []byte(`make: a +model: b +size: 42 +`, + ), + }, + { + name: "channel", + args: args{ + v: make(chan int), + }, + wantPanic: "cannot marshal type: chan int", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := func() (got []byte, err error, p interface{}) { + defer func() { p = recover() }() + got, err = marshal.YAML(tt.args.v) + + return + } + + got, err, p := f() + + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } + + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + + if tt.wantPanic != nil { + assert.Equal(t, tt.wantPanic, p) + } + + if tt.wantErr == "" && tt.wantErrIs == nil { + require.NoError(t, err) + } + + assert.Equal(t, string(tt.want), string(got)) + }) + } +} + +func TestXML(t *testing.T) { + type args struct { + v interface{} + } + tests := []struct { + name string + args args + want []byte + wantErr string + wantErrIs error + }{ + { + name: "nil", + args: args{v: nil}, + want: []byte(""), + }, + { + name: "empty struct (1)", + args: args{v: &book{}}, + want: []byte(` + + 0 +`, + ), + }, + { + name: "empty struct (2)", + args: args{v: &shoe{}}, + want: []byte(` + + 0 +`, + ), + }, + { + name: "full struct (1)", + args: args{ + v: &book{ + Title: "a", + Author: "b", + Price: 499, + }, + }, + want: []byte(` + a + b + 499 +`, + ), + }, + { + name: "empty struct (2)", + args: args{ + v: &shoe{ + Make: "a", + Model: "b", + Size: 42, + }, + }, + want: []byte(` + a + b + 42 +`, + ), + }, + { + name: "channel", + args: args{ + v: make(chan int), + }, + wantErr: "xml: unsupported type: chan int", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := marshal.XML(tt.args.v) + + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } + + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + + if tt.wantErr == "" && tt.wantErrIs == nil { + require.NoError(t, err) + } + + assert.Equal(t, string(tt.want), string(got)) + }) + } +} diff --git a/marshal_asserter.go b/marshaling_asserter.go similarity index 52% rename from marshal_asserter.go rename to marshaling_asserter.go index f5ae6b2..4adb940 100644 --- a/marshal_asserter.go +++ b/marshaling_asserter.go @@ -1,38 +1,24 @@ package golden import ( - "bytes" - "io" "reflect" "strings" - "testing" + "github.com/jimeh/go-golden/sanitize" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) type ( - NewEncoderFunc func(w io.Writer) MarshalEncoder - NewDecoderFunc func(r io.Reader) MarshalDecoder + MarshalFunc func(interface{}) ([]byte, error) + UnmarshalFunc func([]byte, interface{}) error ) -type MarshalEncoder interface { - Encode(v interface{}) error -} - -type MarshalDecoder interface { - Decode(v interface{}) error -} - -// MarshalAsserter allows building custom marshaling asserters, but providing -// functions which returns new encoder and decoders for the format to be -// asserted. -// -// All the AssertMarshaling helper functions uses MarshalAsserter under -// the hood. -type MarshalAsserter struct { +// MarshalingAsserter allows building marshaling asserters by providing +// functions which marshal and unmarshal objects. +type MarshalingAsserter struct { // Golden is the *Golden instance used to read/write golden files. - Golden *Golden + Golden Golden // Name of the format the MarshalAsserter handles. Format string @@ -43,39 +29,34 @@ type MarshalAsserter struct { // default. GoldName string - // NewEncoderFunc is the function used to create a new encoder for - // marshaling objects. - NewEncoderFunc NewEncoderFunc + // MarshalFunc is the function used to marshal given objects. + MarshalFunc MarshalFunc - // NewDecoderFunc is the function used to create a new decoder for - // unmarshaling objects. - NewDecoderFunc NewDecoderFunc + // UnmarshalFunc is the function used to unmarshal given objects. + UnmarshalFunc UnmarshalFunc - // NormalizeLineBreaks determines if Windows' CRLF (\r\n) and Mac Classic CR - // (\r) line breaks are replaced with Unix's LF (\n) line breaks. This - // ensure marshaling assertions works cross platform. + // NormalizeLineBreaks determines if Windows' CRLF (\r\n) and MacOS Classic + // CR (\r) line breaks are replaced with Unix's LF (\n) line breaks. This + // ensures marshaling assertions work across different platforms. NormalizeLineBreaks bool } -func NewMarshalAsserter( - golden *Golden, +// New returns a new MarshalingAsserter. +func NewMarshalingAsserter( + golden Golden, format string, - newEncoderFunc NewEncoderFunc, - newDecoderFunc NewDecoderFunc, + marshalFunc MarshalFunc, + unmarshalFunc UnmarshalFunc, normalizeLineBreaks bool, -) *MarshalAsserter { - if golden == nil { - golden = globalGolden - } +) *MarshalingAsserter { + goldName := "marshaled_" + strings.ToLower(sanitize.Filename(format)) - goldName := "marshaled_" + strings.ToLower(sanitizeFilename(format)) - - return &MarshalAsserter{ + return &MarshalingAsserter{ Golden: golden, Format: format, GoldName: goldName, - NewEncoderFunc: newEncoderFunc, - NewDecoderFunc: newDecoderFunc, + MarshalFunc: marshalFunc, + UnmarshalFunc: unmarshalFunc, NormalizeLineBreaks: normalizeLineBreaks, } } @@ -86,7 +67,7 @@ func NewMarshalAsserter( // unmarshaled. // // Used for objects that do NOT change when they are marshaled and unmarshaled. -func (s *MarshalAsserter) Marshaling(t *testing.T, v interface{}) { +func (s *MarshalingAsserter) Marshaling(t TestingT, v interface{}) { t.Helper() s.MarshalingP(t, v, v) @@ -98,8 +79,8 @@ func (s *MarshalAsserter) Marshaling(t *testing.T, v interface{}) { // when unmarshaled. // // Used for objects that change when they are marshaled and unmarshaled. -func (s *MarshalAsserter) MarshalingP( - t *testing.T, +func (s *MarshalingAsserter) MarshalingP( + t TestingT, v interface{}, want interface{}, ) { @@ -107,18 +88,17 @@ func (s *MarshalAsserter) MarshalingP( if reflect.ValueOf(want).Kind() != reflect.Ptr { require.FailNowf(t, - "only pointer types can be asserted", + "golden: only pointer types can be asserted", "%T is not a pointer type", want, ) } - var buf bytes.Buffer - err := s.NewEncoderFunc(&buf).Encode(v) - require.NoErrorf(t, err, "failed to %s marshal %T: %+v", s.Format, v, v) - - marshaled := buf.Bytes() + marshaled, err := s.MarshalFunc(v) + require.NoErrorf(t, + err, "golden: failed to %s marshal %T: %+v", s.Format, v, v, + ) if s.NormalizeLineBreaks { - marshaled = normalizeLineBreaks(marshaled) + marshaled = sanitize.LineBreaks(marshaled) } if s.Golden.Update() { @@ -127,7 +107,7 @@ func (s *MarshalAsserter) MarshalingP( gold := s.Golden.GetP(t, s.GoldName) if s.NormalizeLineBreaks { - gold = normalizeLineBreaks(gold) + gold = sanitize.LineBreaks(gold) } switch strings.ToLower(s.Format) { @@ -140,12 +120,14 @@ func (s *MarshalAsserter) MarshalingP( } got := reflect.New(reflect.TypeOf(want).Elem()).Interface() - err = s.NewDecoderFunc(bytes.NewBuffer(gold)).Decode(got) + err = s.UnmarshalFunc(gold, got) + + f := s.Golden.FileP(t, s.GoldName) require.NoErrorf(t, err, - "failed to %s unmarshal %T from %s", - s.Format, got, s.Golden.FileP(t, s.GoldName), + "golden: failed to %s unmarshal %T from %s", s.Format, got, f, ) - assert.Equal(t, want, got, - "unmarshaling from golden file does not match expected object", + assert.Equalf(t, want, got, + "golden: unmarshaling from golden file does not match "+ + "expected object; golden file: %s", f, ) } diff --git a/marshaling_asserter_test.go b/marshaling_asserter_test.go new file mode 100644 index 0000000..f2bf18e --- /dev/null +++ b/marshaling_asserter_test.go @@ -0,0 +1,98 @@ +package golden + +import ( + "testing" + + "github.com/jimeh/go-golden/marshal" + "github.com/jimeh/go-golden/unmarshal" + "github.com/stretchr/testify/assert" +) + +func TestNewMarshalingAsserter(t *testing.T) { + type args struct { + golden Golden + format string + marshalFunc MarshalFunc + unmarshalFunc UnmarshalFunc + normalizeLineBreaks bool + } + tests := []struct { + name string + args args + want *MarshalingAsserter + }{ + { + name: "json", + args: args{ + nil, + "JSON", + marshal.JSON, + unmarshal.JSON, + true, + }, + want: &MarshalingAsserter{ + Golden: nil, + Format: "JSON", + GoldName: "marshaled_json", + MarshalFunc: marshal.JSON, + UnmarshalFunc: unmarshal.JSON, + NormalizeLineBreaks: true, + }, + }, + { + name: "xml", + args: args{ + nil, + "XML", + marshal.XML, + unmarshal.XML, + true, + }, + want: &MarshalingAsserter{ + Golden: nil, + Format: "XML", + GoldName: "marshaled_xml", + MarshalFunc: marshal.XML, + UnmarshalFunc: unmarshal.XML, + NormalizeLineBreaks: true, + }, + }, + { + name: "yaml", + args: args{ + nil, + "YAML", + marshal.YAML, + unmarshal.YAML, + true, + }, + want: &MarshalingAsserter{ + Golden: nil, + Format: "YAML", + GoldName: "marshaled_yaml", + MarshalFunc: marshal.YAML, + UnmarshalFunc: unmarshal.YAML, + NormalizeLineBreaks: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewMarshalingAsserter( + tt.args.golden, + tt.args.format, + tt.args.marshalFunc, + tt.args.unmarshalFunc, + tt.args.normalizeLineBreaks, + ) + + assert.Equal(t, tt.want.Golden, got.Golden) + assert.Equal(t, tt.want.Format, got.Format) + assert.Equal(t, tt.want.GoldName, got.GoldName) + assert.Equal(t, + tt.want.NormalizeLineBreaks, + got.NormalizeLineBreaks, + ) + }) + } +} diff --git a/sanitize.go b/sanitize/filename.go similarity index 92% rename from sanitize.go rename to sanitize/filename.go index c77d741..4b1ce7d 100644 --- a/sanitize.go +++ b/sanitize/filename.go @@ -1,4 +1,4 @@ -package golden +package sanitize import ( "regexp" @@ -15,7 +15,7 @@ var ( ) ) -func sanitizeFilename(name string) string { +func Filename(name string) string { if reservedNames.MatchString(name) || winReserved.MatchString(name) { var b []byte for i := 0; i < len(name); i++ { diff --git a/sanitize_test.go b/sanitize/filename_test.go similarity index 95% rename from sanitize_test.go rename to sanitize/filename_test.go index c98b93b..a917b26 100644 --- a/sanitize_test.go +++ b/sanitize/filename_test.go @@ -1,12 +1,13 @@ -package golden +package sanitize_test import ( "testing" + "github.com/jimeh/go-golden/sanitize" "github.com/stretchr/testify/assert" ) -func Test_sanitizeFilename(t *testing.T) { +func TestFilename(t *testing.T) { tests := []struct { name string filename string @@ -69,6 +70,7 @@ func Test_sanitizeFilename(t *testing.T) { filename: "foobar.golden .. .. .. ", want: "foobar.golden", }, + // Protected Windows filenames. {name: "con", filename: "con", want: "___"}, {name: "prn", filename: "prn", want: "___"}, {name: "aux", filename: "aux", want: "___"}, @@ -116,7 +118,7 @@ func Test_sanitizeFilename(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := sanitizeFilename(tt.filename) + got := sanitize.Filename(tt.filename) assert.Equal(t, tt.want, got) }) diff --git a/sanitize/line_breaks.go b/sanitize/line_breaks.go new file mode 100644 index 0000000..2d5db86 --- /dev/null +++ b/sanitize/line_breaks.go @@ -0,0 +1,21 @@ +package sanitize + +import "bytes" + +var ( + lf = []byte{10} + cr = []byte{13} + crlf = []byte{13, 10} +) + +// LineBreaks replaces Windows CRLF (\r\n) and MacOS Classic CR (\r) +// line-breaks with Unix LF (\n) line breaks. +func LineBreaks(data []byte) []byte { + // Replace Windows CRLF (\r\n) with Unix LF (\n) + result := bytes.ReplaceAll(data, crlf, lf) + + // Replace Classic MacOS CR (\r) with Unix LF (\n) + result = bytes.ReplaceAll(result, cr, lf) + + return result +} diff --git a/sanitize/line_breaks_test.go b/sanitize/line_breaks_test.go new file mode 100644 index 0000000..31aaa0b --- /dev/null +++ b/sanitize/line_breaks_test.go @@ -0,0 +1,67 @@ +package sanitize_test + +import ( + "testing" + + "github.com/jimeh/go-golden/sanitize" + "github.com/stretchr/testify/assert" +) + +func TestLineBreaks(t *testing.T) { + type args struct { + data []byte + } + tests := []struct { + name string + args args + want []byte + }{ + { + name: "nil", + args: args{data: nil}, + want: nil, + }, + { + name: "empty", + args: args{data: []byte{}}, + want: nil, + }, + { + name: "no line breaks", + args: args{data: []byte("hello world")}, + want: []byte("hello world"), + }, + { + name: "UNIX line breaks", + args: args{data: []byte("hello\nworld\nhow are you?")}, + want: []byte("hello\nworld\nhow are you?"), + }, + { + name: "Windows line breaks", + args: args{data: []byte("hello\r\nworld\r\nhow are you?")}, + want: []byte("hello\nworld\nhow are you?"), + }, + { + name: "MacOS Classic line breaks", + args: args{data: []byte("hello\rworld\rhow are you?")}, + want: []byte("hello\nworld\nhow are you?"), + }, + { + name: "Windows and MacOS Classic line breaks", + args: args{data: []byte("hello\r\nworld\rhow are you?")}, + want: []byte("hello\nworld\nhow are you?"), + }, + { + name: "Windows, MacOS Classic, and UNIX line breaks", + args: args{data: []byte("hello\r\nworld\rhow are you?\nGood!")}, + want: []byte("hello\nworld\nhow are you?\nGood!"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := sanitize.LineBreaks(tt.args.data) + + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/testdata/TestAssertXMLMarshaling/custom_marshaling/marshaled_xml.golden b/testdata/TestAssertXMLMarshaling/custom_marshaling/marshaled_xml.golden index d1cdc64..3bf37bf 100644 --- a/testdata/TestAssertXMLMarshaling/custom_marshaling/marshaled_xml.golden +++ b/testdata/TestAssertXMLMarshaling/custom_marshaling/marshaled_xml.golden @@ -1 +1 @@ -Hello World! \ No newline at end of file +Hello World! \ No newline at end of file diff --git a/testdata/TestAssertXMLMarshaling/empty_struct/marshaled_xml.golden b/testdata/TestAssertXMLMarshaling/empty_struct/marshaled_xml.golden index 6f87e3c..556f3ee 100644 --- a/testdata/TestAssertXMLMarshaling/empty_struct/marshaled_xml.golden +++ b/testdata/TestAssertXMLMarshaling/empty_struct/marshaled_xml.golden @@ -1,4 +1,4 @@ - + - \ No newline at end of file + \ No newline at end of file diff --git a/testdata/TestAssertXMLMarshaling/full_struct/marshaled_xml.golden b/testdata/TestAssertXMLMarshaling/full_struct/marshaled_xml.golden index 6defdfd..615ca53 100644 --- a/testdata/TestAssertXMLMarshaling/full_struct/marshaled_xml.golden +++ b/testdata/TestAssertXMLMarshaling/full_struct/marshaled_xml.golden @@ -1,4 +1,4 @@ - + cfda163c-d5c1-44a2-909b-5d2ce3a31979 The Traveler @@ -6,4 +6,4 @@ Twelve Hawks 2005 - \ No newline at end of file + \ No newline at end of file diff --git a/testdata/TestAssertXMLMarshaling/partial_struct/marshaled_xml.golden b/testdata/TestAssertXMLMarshaling/partial_struct/marshaled_xml.golden index a4e50fb..9219250 100644 --- a/testdata/TestAssertXMLMarshaling/partial_struct/marshaled_xml.golden +++ b/testdata/TestAssertXMLMarshaling/partial_struct/marshaled_xml.golden @@ -1,4 +1,4 @@ - + cfda163c-d5c1-44a2-909b-5d2ce3a31979 The Traveler - \ No newline at end of file + \ No newline at end of file diff --git a/testdata/TestAssertXMLMarshalingP/custom_marshaling/marshaled_xml.golden b/testdata/TestAssertXMLMarshalingP/custom_marshaling/marshaled_xml.golden index d1cdc64..3bf37bf 100644 --- a/testdata/TestAssertXMLMarshalingP/custom_marshaling/marshaled_xml.golden +++ b/testdata/TestAssertXMLMarshalingP/custom_marshaling/marshaled_xml.golden @@ -1 +1 @@ -Hello World! \ No newline at end of file +Hello World! \ No newline at end of file diff --git a/testdata/TestAssertXMLMarshalingP/empty_struct/marshaled_xml.golden b/testdata/TestAssertXMLMarshalingP/empty_struct/marshaled_xml.golden index 2f055d7..64f793c 100644 --- a/testdata/TestAssertXMLMarshalingP/empty_struct/marshaled_xml.golden +++ b/testdata/TestAssertXMLMarshalingP/empty_struct/marshaled_xml.golden @@ -1,4 +1,4 @@ -
+
-
\ No newline at end of file +
\ No newline at end of file diff --git a/testdata/TestAssertXMLMarshalingP/full_struct/marshaled_xml.golden b/testdata/TestAssertXMLMarshalingP/full_struct/marshaled_xml.golden index 085f862..74af7c9 100644 --- a/testdata/TestAssertXMLMarshalingP/full_struct/marshaled_xml.golden +++ b/testdata/TestAssertXMLMarshalingP/full_struct/marshaled_xml.golden @@ -1,4 +1,4 @@ -
+
10eec54d-e30a-4428-be18-01095d889126 Time Travel @@ -6,4 +6,4 @@ Brown 2021-10-27T22:30:34Z -
\ No newline at end of file +
\ No newline at end of file diff --git a/testdata/TestAssertXMLMarshalingP/partial_struct/marshaled_xml.golden b/testdata/TestAssertXMLMarshalingP/partial_struct/marshaled_xml.golden index 3cfa28b..c2b9496 100644 --- a/testdata/TestAssertXMLMarshalingP/partial_struct/marshaled_xml.golden +++ b/testdata/TestAssertXMLMarshalingP/partial_struct/marshaled_xml.golden @@ -1,4 +1,4 @@ - + 10eec54d-e30a-4428-be18-01095d889126 Time Travel - \ No newline at end of file + \ No newline at end of file diff --git a/testdata/TestAssert_XMLMarshaling/custom_marshaling/marshaled_xml.golden b/testdata/TestAssert_XMLMarshaling/custom_marshaling/marshaled_xml.golden index d1cdc64..3bf37bf 100644 --- a/testdata/TestAssert_XMLMarshaling/custom_marshaling/marshaled_xml.golden +++ b/testdata/TestAssert_XMLMarshaling/custom_marshaling/marshaled_xml.golden @@ -1 +1 @@ -Hello World! \ No newline at end of file +Hello World! \ No newline at end of file diff --git a/testdata/TestAssert_XMLMarshaling/empty_struct/marshaled_xml.golden b/testdata/TestAssert_XMLMarshaling/empty_struct/marshaled_xml.golden index 6f87e3c..556f3ee 100644 --- a/testdata/TestAssert_XMLMarshaling/empty_struct/marshaled_xml.golden +++ b/testdata/TestAssert_XMLMarshaling/empty_struct/marshaled_xml.golden @@ -1,4 +1,4 @@ - + - \ No newline at end of file + \ No newline at end of file diff --git a/testdata/TestAssert_XMLMarshaling/full_struct/marshaled_xml.golden b/testdata/TestAssert_XMLMarshaling/full_struct/marshaled_xml.golden index 6defdfd..615ca53 100644 --- a/testdata/TestAssert_XMLMarshaling/full_struct/marshaled_xml.golden +++ b/testdata/TestAssert_XMLMarshaling/full_struct/marshaled_xml.golden @@ -1,4 +1,4 @@ - + cfda163c-d5c1-44a2-909b-5d2ce3a31979 The Traveler @@ -6,4 +6,4 @@ Twelve Hawks 2005 - \ No newline at end of file + \ No newline at end of file diff --git a/testdata/TestAssert_XMLMarshaling/partial_struct/marshaled_xml.golden b/testdata/TestAssert_XMLMarshaling/partial_struct/marshaled_xml.golden index a4e50fb..9219250 100644 --- a/testdata/TestAssert_XMLMarshaling/partial_struct/marshaled_xml.golden +++ b/testdata/TestAssert_XMLMarshaling/partial_struct/marshaled_xml.golden @@ -1,4 +1,4 @@ - + cfda163c-d5c1-44a2-909b-5d2ce3a31979 The Traveler - \ No newline at end of file + \ No newline at end of file diff --git a/testdata/TestAssert_XMLMarshalingP/custom_marshaling/marshaled_xml.golden b/testdata/TestAssert_XMLMarshalingP/custom_marshaling/marshaled_xml.golden index d1cdc64..3bf37bf 100644 --- a/testdata/TestAssert_XMLMarshalingP/custom_marshaling/marshaled_xml.golden +++ b/testdata/TestAssert_XMLMarshalingP/custom_marshaling/marshaled_xml.golden @@ -1 +1 @@ -Hello World! \ No newline at end of file +Hello World! \ No newline at end of file diff --git a/testdata/TestAssert_XMLMarshalingP/empty_struct/marshaled_xml.golden b/testdata/TestAssert_XMLMarshalingP/empty_struct/marshaled_xml.golden index 2f055d7..64f793c 100644 --- a/testdata/TestAssert_XMLMarshalingP/empty_struct/marshaled_xml.golden +++ b/testdata/TestAssert_XMLMarshalingP/empty_struct/marshaled_xml.golden @@ -1,4 +1,4 @@ -
+
-
\ No newline at end of file +
\ No newline at end of file diff --git a/testdata/TestAssert_XMLMarshalingP/full_struct/marshaled_xml.golden b/testdata/TestAssert_XMLMarshalingP/full_struct/marshaled_xml.golden index 085f862..74af7c9 100644 --- a/testdata/TestAssert_XMLMarshalingP/full_struct/marshaled_xml.golden +++ b/testdata/TestAssert_XMLMarshalingP/full_struct/marshaled_xml.golden @@ -1,4 +1,4 @@ -
+
10eec54d-e30a-4428-be18-01095d889126 Time Travel @@ -6,4 +6,4 @@ Brown 2021-10-27T22:30:34Z -
\ No newline at end of file +
\ No newline at end of file diff --git a/testdata/TestAssert_XMLMarshalingP/partial_struct/marshaled_xml.golden b/testdata/TestAssert_XMLMarshalingP/partial_struct/marshaled_xml.golden index 3cfa28b..c2b9496 100644 --- a/testdata/TestAssert_XMLMarshalingP/partial_struct/marshaled_xml.golden +++ b/testdata/TestAssert_XMLMarshalingP/partial_struct/marshaled_xml.golden @@ -1,4 +1,4 @@ - + 10eec54d-e30a-4428-be18-01095d889126 Time Travel - \ No newline at end of file + \ No newline at end of file diff --git a/unmarshal/unmarshal.go b/unmarshal/unmarshal.go new file mode 100644 index 0000000..3505f06 --- /dev/null +++ b/unmarshal/unmarshal.go @@ -0,0 +1,35 @@ +package unmarshal + +import ( + "bytes" + "encoding/json" + "encoding/xml" + + "gopkg.in/yaml.v3" +) + +// JSON parses the JSON-encoded data and stores the result in the value pointed +// to by v. Unknown fields in the JSON data is not allowed. +func JSON(data []byte, v interface{}) error { + dec := json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + + return dec.Decode(v) +} + +// XML parses the XML-encoded data and stores the result in the value pointed +// to by v. +func XML(data []byte, v interface{}) error { + dec := xml.NewDecoder(bytes.NewReader(data)) + + return dec.Decode(v) +} + +// YAML parses the YAML-encoded data and stores the result in the value pointed +// to by v. Unknown fields in the YAML data is not allowed. +func YAML(data []byte, v interface{}) error { + dec := yaml.NewDecoder(bytes.NewReader(data)) + dec.KnownFields(true) + + return dec.Decode(v) +} diff --git a/unmarshal/unmarshal_test.go b/unmarshal/unmarshal_test.go new file mode 100644 index 0000000..8cefac7 --- /dev/null +++ b/unmarshal/unmarshal_test.go @@ -0,0 +1,431 @@ +package unmarshal_test + +import ( + "io" + "testing" + + "github.com/jimeh/go-golden/unmarshal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type book struct { + Title string `json:"title" yaml:"title" xml:"title"` + Author string `json:"author" yaml:"author" xml:"author"` + Price int `json:"price" yaml:"price" xml:"price"` +} + +type shoe struct { + Make string `json:"make" yaml:"make" xml:"make"` + Model string `json:"model" yaml:"model" xml:"model"` + Size int `json:"size" yaml:"size" xml:"size"` +} + +func TestJSON(t *testing.T) { + type args struct { + data []byte + v interface{} + } + tests := []struct { + name string + args args + want interface{} + wantErr string + wantErrIs error + }{ + { + name: "nil", + args: args{data: nil, v: nil}, + wantErrIs: io.EOF, + }, + { + name: "empty string (1)", + args: args{ + data: []byte(""), + v: &book{}, + }, + wantErrIs: io.EOF, + }, + { + name: "empty string (2)", + args: args{ + data: []byte(""), + v: &shoe{}, + }, + wantErrIs: io.EOF, + }, + { + name: "no fields (1)", + args: args{ + data: []byte("{}"), + v: &book{}, + }, + want: &book{}, + }, + { + name: "no fields (2)", + args: args{ + data: []byte("{}"), + v: &shoe{}, + }, + want: &shoe{}, + }, + { + name: "empty fields (1)", + args: args{ + data: []byte(`{"title":"","author":"","price":0}`), + v: &book{}, + }, + want: &book{}, + }, + { + name: "empty fields (2)", + args: args{ + data: []byte(`{"make":"","model":"","size":0}`), + v: &shoe{}, + }, + want: &shoe{}, + }, + { + name: "populated fields (1)", + args: args{ + data: []byte(`{"title":"a","author":"b","price":499}`), + v: &book{}, + }, + want: &book{Title: "a", Author: "b", Price: 499}, + }, + { + name: "populated fields (2)", + args: args{ + data: []byte(`{"Make":"a","model":"b","size":42}`), + v: &shoe{}, + }, + want: &shoe{Make: "a", Model: "b", Size: 42}, + }, + { + name: "unknown field (1)", + args: args{ + data: []byte(`{"title":"a","summary":"b","price":499}`), + v: &book{}, + }, + wantErr: `json: unknown field "summary"`, + }, + { + name: "unknown field (2)", + args: args{ + data: []byte(`{"make":"a","inventory":"b","size":42}`), + v: &shoe{}, + }, + wantErr: `json: unknown field "inventory"`, + }, + { + name: "to channel", + args: args{ + data: []byte(`{"make":"a","model":"b","size":42}`), + v: make(chan int), + }, + wantErr: `json: Unmarshal(non-pointer chan int)`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := unmarshal.JSON(tt.args.data, tt.args.v) + + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } + + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + + if tt.wantErr == "" && tt.wantErrIs == nil { + require.NoError(t, err) + assert.Equal(t, tt.want, tt.args.v) + } + }) + } +} + +func TestYAML(t *testing.T) { + type args struct { + data []byte + v interface{} + } + tests := []struct { + name string + args args + want interface{} + wantErr string + wantErrIs error + }{ + { + name: "nil", + args: args{data: nil, v: nil}, + wantErrIs: io.EOF, + }, + { + name: "empty string (1)", + args: args{ + data: []byte(""), + v: &book{}, + }, + wantErrIs: io.EOF, + }, + { + name: "empty string (2)", + args: args{ + data: []byte(""), + v: &shoe{}, + }, + wantErrIs: io.EOF, + }, + { + name: "no fields (1)", + args: args{ + data: []byte("{}"), + v: &book{}, + }, + want: &book{}, + }, + { + name: "no fields (2)", + args: args{ + data: []byte("{}"), + v: &shoe{}, + }, + want: &shoe{}, + }, + { + name: "empty fields (1)", + args: args{ + data: []byte("title:\nauthor:\nprice: 0"), + v: &book{}, + }, + want: &book{}, + }, + { + name: "empty fields (2)", + args: args{ + data: []byte("make:\nmodel:\nsize: 0"), + v: &shoe{}, + }, + want: &shoe{}, + }, + { + name: "populated fields (1)", + args: args{ + data: []byte("title: a\nauthor: b\nprice: 499"), + v: &book{}, + }, + want: &book{Title: "a", Author: "b", Price: 499}, + }, + { + name: "populated fields (2)", + args: args{ + data: []byte("make: a\nmodel: b\nsize: 42"), + v: &shoe{}, + }, + want: &shoe{Make: "a", Model: "b", Size: 42}, + }, + { + name: "unknown field (1)", + args: args{ + data: []byte("title: a\nsummary: b\nprice: 499"), + v: &book{}, + }, + wantErr: "yaml: unmarshal errors:\n " + + "line 2: field summary not found in type unmarshal_test.book", + }, + { + name: "unknown field (2)", + args: args{ + data: []byte("make: a\ninventory: b\nsize: 42"), + v: &shoe{}, + }, + wantErr: "yaml: unmarshal errors:\n " + + "line 2: field inventory not found in type unmarshal_test.shoe", + }, + { + name: "to channel", + args: args{ + data: []byte("make: a\nmodel: b\nsize: 42"), + v: make(chan int), + }, + wantErr: "yaml: unmarshal errors:\n " + + "line 1: cannot unmarshal !!map into chan int", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := unmarshal.YAML(tt.args.data, tt.args.v) + + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } + + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + + if tt.wantErr == "" && tt.wantErrIs == nil { + require.NoError(t, err) + assert.Equal(t, tt.want, tt.args.v) + } + }) + } +} + +func TestXML(t *testing.T) { + type args struct { + data []byte + v interface{} + } + tests := []struct { + name string + args args + want interface{} + wantErr string + wantErrIs error + }{ + { + name: "nil", + args: args{data: nil, v: nil}, + wantErr: "non-pointer passed to Unmarshal", + }, + { + name: "empty string (1)", + args: args{ + data: []byte(""), + v: &book{}, + }, + wantErrIs: io.EOF, + }, + { + name: "empty string (2)", + args: args{ + data: []byte(""), + v: &shoe{}, + }, + wantErrIs: io.EOF, + }, + { + name: "no fields (1)", + args: args{ + data: []byte(""), + v: &book{}, + }, + want: &book{}, + }, + { + name: "no fields (2)", + args: args{ + data: []byte(""), + v: &shoe{}, + }, + want: &shoe{}, + }, + { + name: "empty fields (1)", + args: args{ + data: []byte("" + + "" + + "" + + "" + + ""), + v: &book{}, + }, + want: &book{}, + }, + { + name: "empty fields (2)", + args: args{ + data: []byte("" + + "" + + "" + + "" + + ""), + v: &shoe{}, + }, + want: &shoe{}, + }, + { + name: "populated fields (1)", + args: args{ + data: []byte("" + + "a" + + "b" + + "499" + + ""), + v: &book{}, + }, + want: &book{Title: "a", Author: "b", Price: 499}, + }, + { + name: "populated fields (2)", + args: args{ + data: []byte("" + + "a" + + "b" + + "42" + + ""), + v: &shoe{}, + }, + want: &shoe{Make: "a", Model: "b", Size: 42}, + }, + { + name: "unknown field (1)", + args: args{ + data: []byte("" + + "a" + + "b" + + "499" + + ""), + v: &book{}, + }, + want: &book{Title: "a", Author: "", Price: 499}, + }, + { + name: "unknown field (2)", + args: args{ + data: []byte("" + + "a" + + "b" + + "42" + + ""), + v: &shoe{}, + }, + want: &shoe{Make: "a", Model: "", Size: 42}, + }, + { + name: "to channel", + args: args{ + data: []byte("" + + "a" + + "b" + + "42" + + ""), + v: make(chan int), + }, + wantErr: "non-pointer passed to Unmarshal", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := unmarshal.XML(tt.args.data, tt.args.v) + + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } + + if tt.wantErrIs != nil { + assert.ErrorIs(t, err, tt.wantErrIs) + } + + if tt.wantErr == "" && tt.wantErrIs == nil { + require.NoError(t, err) + assert.Equal(t, tt.want, tt.args.v) + } + }) + } +} diff --git a/update_test.go b/update_test.go index 26937a7..0523e8a 100644 --- a/update_test.go +++ b/update_test.go @@ -41,56 +41,171 @@ var envUpdateFuncTestCases = []struct { env: map[string]string{"GOLDEN_UPDATE": "y"}, want: true, }, + { + name: "GOLDEN_UPDATE set to Y", + env: map[string]string{"GOLDEN_UPDATE": "Y"}, + want: true, + }, { name: "GOLDEN_UPDATE set to n", env: map[string]string{"GOLDEN_UPDATE": "n"}, want: false, }, + { + name: "GOLDEN_UPDATE set to N", + env: map[string]string{"GOLDEN_UPDATE": "N"}, + want: false, + }, { name: "GOLDEN_UPDATE set to t", env: map[string]string{"GOLDEN_UPDATE": "t"}, want: true, }, + { + name: "GOLDEN_UPDATE set to T", + env: map[string]string{"GOLDEN_UPDATE": "T"}, + want: true, + }, { name: "GOLDEN_UPDATE set to f", env: map[string]string{"GOLDEN_UPDATE": "f"}, want: false, }, + { + name: "GOLDEN_UPDATE set to F", + env: map[string]string{"GOLDEN_UPDATE": "F"}, + want: false, + }, { name: "GOLDEN_UPDATE set to yes", env: map[string]string{"GOLDEN_UPDATE": "yes"}, want: true, }, + { + name: "GOLDEN_UPDATE set to Yes", + env: map[string]string{"GOLDEN_UPDATE": "Yes"}, + want: true, + }, + { + name: "GOLDEN_UPDATE set to YeS", + env: map[string]string{"GOLDEN_UPDATE": "YeS"}, + want: true, + }, + { + name: "GOLDEN_UPDATE set to YES", + env: map[string]string{"GOLDEN_UPDATE": "YES"}, + want: true, + }, { name: "GOLDEN_UPDATE set to no", env: map[string]string{"GOLDEN_UPDATE": "no"}, want: false, }, + { + name: "GOLDEN_UPDATE set to No", + env: map[string]string{"GOLDEN_UPDATE": "No"}, + want: false, + }, + { + name: "GOLDEN_UPDATE set to nO", + env: map[string]string{"GOLDEN_UPDATE": "nO"}, + want: false, + }, + { + name: "GOLDEN_UPDATE set to NO", + env: map[string]string{"GOLDEN_UPDATE": "NO"}, + want: false, + }, { name: "GOLDEN_UPDATE set to on", env: map[string]string{"GOLDEN_UPDATE": "on"}, want: true, }, + { + name: "GOLDEN_UPDATE set to oN", + env: map[string]string{"GOLDEN_UPDATE": "oN"}, + want: true, + }, + { + name: "GOLDEN_UPDATE set to On", + env: map[string]string{"GOLDEN_UPDATE": "On"}, + want: true, + }, + { + name: "GOLDEN_UPDATE set to ON", + env: map[string]string{"GOLDEN_UPDATE": "ON"}, + want: true, + }, { name: "GOLDEN_UPDATE set to off", env: map[string]string{"GOLDEN_UPDATE": "off"}, want: false, }, + { + name: "GOLDEN_UPDATE set to Off", + env: map[string]string{"GOLDEN_UPDATE": "Off"}, + want: false, + }, + { + name: "GOLDEN_UPDATE set to oFF", + env: map[string]string{"GOLDEN_UPDATE": "oFF"}, + want: false, + }, + { + name: "GOLDEN_UPDATE set to OFF", + env: map[string]string{"GOLDEN_UPDATE": "OFF"}, + want: false, + }, { name: "GOLDEN_UPDATE set to true", env: map[string]string{"GOLDEN_UPDATE": "true"}, want: true, }, + { + name: "GOLDEN_UPDATE set to True", + env: map[string]string{"GOLDEN_UPDATE": "True"}, + want: true, + }, + { + name: "GOLDEN_UPDATE set to TruE", + env: map[string]string{"GOLDEN_UPDATE": "TruE"}, + want: true, + }, + { + name: "GOLDEN_UPDATE set to TRUE", + env: map[string]string{"GOLDEN_UPDATE": "TRUE"}, + want: true, + }, { name: "GOLDEN_UPDATE set to false", env: map[string]string{"GOLDEN_UPDATE": "false"}, want: false, }, + { + name: "GOLDEN_UPDATE set to False", + env: map[string]string{"GOLDEN_UPDATE": "False"}, + want: false, + }, + { + name: "GOLDEN_UPDATE set to FaLsE", + env: map[string]string{"GOLDEN_UPDATE": "FaLsE"}, + want: false, + }, + { + name: "GOLDEN_UPDATE set to FALSE", + env: map[string]string{"GOLDEN_UPDATE": "FALSE"}, + want: false, + }, { name: "GOLDEN_UPDATE set to foobarnopebbq", env: map[string]string{"GOLDEN_UPDATE": "foobarnopebbq"}, want: false, }, + { + name: "GOLDEN_UPDATE set to FOOBARNOPEBBQ", + env: map[string]string{"GOLDEN_UPDATE": "FOOBARNOPEBBQ"}, + want: false, + }, } func TestEnvUpdateFunc(t *testing.T) {