From 138981b7d42d00749d390fc5fd5248fd71f02b60 Mon Sep 17 00:00:00 2001 From: Jim Myhrberg Date: Mon, 21 Mar 2022 23:09:43 +0000 Subject: [PATCH] wip: refactor Golden to a interface, improve testability of main implementation --- go.mod | 3 + go.sum | 21 +- golden.go | 347 +++++++--- example_test.go => golden_example_test.go | 0 golden_test.go | 630 ++++++++++++++++++ sanitize.go => sanitize/filename.go | 4 +- sanitize_test.go => sanitize/filename_test.go | 8 +- sanitize/line_breaks.go | 21 + sanitize/line_breaks_test.go | 67 ++ 9 files changed, 987 insertions(+), 114 deletions(-) rename example_test.go => golden_example_test.go (100%) rename sanitize.go => sanitize/filename.go (92%) rename sanitize_test.go => sanitize/filename_test.go (95%) create mode 100644 sanitize/line_breaks.go create mode 100644 sanitize/line_breaks_test.go diff --git a/go.mod b/go.mod index 6182b53..4da1858 100644 --- a/go.mod +++ b/go.mod @@ -4,5 +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 // indirect ) diff --git a/go.sum b/go.sum index 341eccf..33c0728 100644 --- a/go.sum +++ b/go.sum @@ -2,13 +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.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +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 948d369..6f4e253 100644 --- a/golden.go +++ b/golden.go @@ -3,7 +3,7 @@ // // 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 -// compatible with Linux, macOS and Windows systems regardless of what crazy +// compatible with Linux, macOS and Windows systems regardless of what // characters might be in a subtest's name. // // Usage @@ -123,30 +123,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 global = 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 global.File(t) + return defaultGolden.File(t) } // Get returns the content of the golden file for the given *testing.T instance @@ -155,7 +162,7 @@ func File(t *testing.T) string { func Get(t *testing.T) []byte { t.Helper() - return global.Get(t) + return defaultGolden.Get(t) } // Set writes given data to the golden file for the given *testing.T instance as @@ -164,7 +171,7 @@ func Get(t *testing.T) []byte { func Set(t *testing.T, data []byte) { t.Helper() - global.Set(t, data) + defaultGolden.Set(t, data) } // FileP returns the filename of the specifically named golden file for the @@ -172,7 +179,7 @@ func Set(t *testing.T, data []byte) { func FileP(t *testing.T, name string) string { t.Helper() - return global.FileP(t, name) + return defaultGolden.FileP(t, name) } // GetP returns the content of the specifically named golden file belonging @@ -184,7 +191,7 @@ func FileP(t *testing.T, name string) string { func GetP(t *testing.T, name string) []byte { t.Helper() - return global.GetP(t, name) + return defaultGolden.GetP(t, name) } // SetP writes given data of the specifically named golden file belonging to @@ -196,7 +203,7 @@ func GetP(t *testing.T, name string) []byte { func SetP(t *testing.T, name string, data []byte) { t.Helper() - global.SetP(t, name, data) + defaultGolden.SetP(t, name, data) } // Update returns true when golden is set to update golden files. Should be used @@ -206,104 +213,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 global.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") } @@ -311,65 +442,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/example_test.go b/golden_example_test.go similarity index 100% rename from example_test.go rename to golden_example_test.go diff --git a/golden_test.go b/golden_test.go index a077a32..b78d2e2 100644 --- a/golden_test.go +++ b/golden_test.go @@ -4,13 +4,21 @@ 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" ) +func stringPtr(s string) *string { + return &s +} + func TestFile(t *testing.T) { got := File(t) @@ -122,6 +130,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 +353,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 +451,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/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) + }) + } +}