From bc86ba7a6d9f4374c9fb15f78e51eedf50df04d2 Mon Sep 17 00:00:00 2001 From: Jim Myhrberg Date: Mon, 24 Mar 2025 13:17:17 +0000 Subject: [PATCH] feat(do): add new Do function to simplify golden usage (#11) Add new Do function whihc ecapsulates the common pattern of writing to golden file if the update mechnism evaluates to true, followed by immediately reading the file. Compresses four lines into one. --- Makefile | 1 + README.md | 20 +++-- example_test.go | 33 ++++---- golden.go | 195 +++++++++++++++++++++++++++++++----------------- golden_test.go | 187 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 339 insertions(+), 97 deletions(-) diff --git a/Makefile b/Makefile index 0c7c4f1..89b79de 100644 --- a/Makefile +++ b/Makefile @@ -33,6 +33,7 @@ SHELL := env \ # # Tools # + # external tool define tool # 1: binary-name, 2: go-import-path TOOLS += $(TOOLDIR)/$(1) diff --git a/README.md b/README.md index 2488c42..62eb45a 100644 --- a/README.md +++ b/README.md @@ -28,15 +28,12 @@ import "github.com/jimeh/go-golden" ```go func TestExampleMyStruct(t *testing.T) { - got, err := json.Marshal(&MyStruct{Foo: "Bar"}) - require.NoError(t, err) + got, err := json.Marshal(&MyStruct{Foo: "Bar"}) + require.NoError(t, err) - if golden.Update() { - golden.Set(t, got) - } - want := golden.Get(t) + want := golden.Do(t, got) - assert.Equal(t, want, got) + assert.Equal(t, want, got) } ``` @@ -44,6 +41,15 @@ The above example will read/write to: - `testdata/TestExampleMyStruct.golden` +The call to `golden.Do()` is equivalent to: + +```go +if golden.Update() { + golden.Set(t, got) +} +want := golden.Get(t) +``` + To update the golden file (have `golden.Update()` return `true`), simply set the `GOLDEN_UPDATE` environment variable to one of `1`, `y`, `t`, `yes`, `on`, or `true` when running tests. diff --git a/example_test.go b/example_test.go index 39c5384..3f4549e 100644 --- a/example_test.go +++ b/example_test.go @@ -10,6 +10,9 @@ import ( "github.com/stretchr/testify/require" ) +// The tests in this file are examples from the README and the package-level Go +// documentation. + type MyStruct struct { Foo string `json:"foo,omitempty"` } @@ -21,10 +24,7 @@ func TestExampleMyStruct(t *testing.T) { got, err := json.Marshal(&MyStruct{Foo: "Bar"}) require.NoError(t, err) - if golden.Update() { - golden.Set(t, got) - } - want := golden.Get(t) + want := golden.Do(t, got) assert.Equal(t, want, got) } @@ -46,10 +46,7 @@ func TestExampleMyStructTabular(t *testing.T) { got, err := json.Marshal(tt.obj) require.NoError(t, err) - if golden.Update() { - golden.Set(t, got) - } - want := golden.Get(t) + want := golden.Do(t, got) assert.Equal(t, want, got) }) @@ -64,13 +61,11 @@ func TestExampleMyStructP(t *testing.T) { gotJSON, _ := json.Marshal(&MyStruct{Foo: "Bar"}) gotXML, _ := xml.Marshal(&MyStruct{Foo: "Bar"}) - if golden.Update() { - golden.SetP(t, "json", gotJSON) - golden.SetP(t, "xml", gotXML) - } + wantJSON := golden.DoP(t, "json", gotJSON) + wantXML := golden.DoP(t, "xml", gotXML) - assert.Equal(t, golden.GetP(t, "json"), gotJSON) - assert.Equal(t, golden.GetP(t, "xml"), gotXML) + assert.Equal(t, wantJSON, gotJSON) + assert.Equal(t, wantXML, gotXML) } // TestExampleMyStructTabularP reads/writes the following golden file: @@ -92,13 +87,11 @@ func TestExampleMyStructTabularP(t *testing.T) { gotJSON, _ := json.Marshal(tt.obj) gotXML, _ := xml.Marshal(tt.obj) - if golden.Update() { - golden.SetP(t, "json", gotJSON) - golden.SetP(t, "xml", gotXML) - } + wantJSON := golden.DoP(t, "json", gotJSON) + wantXML := golden.DoP(t, "xml", gotXML) - assert.Equal(t, golden.GetP(t, "json"), gotJSON) - assert.Equal(t, golden.GetP(t, "xml"), gotXML) + assert.Equal(t, wantJSON, gotJSON) + assert.Equal(t, wantXML, gotXML) }) } } diff --git a/golden.go b/golden.go index bc80c5d..c797b79 100644 --- a/golden.go +++ b/golden.go @@ -1,31 +1,35 @@ // Package golden is yet another package for working with *.golden test files, // with a focus on simplicity through it's default behavior. // -// Golden file names are based on the name of the test function and any subtest +// Golden file names are based on the name of the test function and any sub-test // names by calling t.Name(). File names are sanitized to ensure they're // compatible with Linux, macOS and Windows systems regardless of what crazy -// characters might be in a subtest's name. +// characters might be in a sub-test's name. // // # Usage // // Typical usage should look something like this: // // func TestExampleMyStruct(t *testing.T) { -// got, err := json.Marshal(&MyStruct{Foo: "Bar"}) -// require.NoError(t, err) +// got, err := json.Marshal(&MyStruct{Foo: "Bar"}) +// require.NoError(t, err) // -// if golden.Update() { -// golden.Set(t, got) -// } -// want := golden.Get(t) +// want := golden.Do(t, got) // -// assert.Equal(t, want, got) +// assert.Equal(t, want, got) // } // // The above example will read/write to: // // testdata/TestExampleMyStruct.golden // +// The call to golden.Do() is equivalent to: +// +// if golden.Update() { +// golden.Set(t, got) +// } +// want := golden.Get(t) +// // To update the golden file (have golden.Update() return true), simply set the // GOLDEN_UPDATE environment variable to one of "1", "y", "t", "yes", "on", or // "true" when running tests. @@ -33,29 +37,26 @@ // # Sub-Tests // // As the golden filename is based on t.Name(), it works with sub-tests too, -// ensuring each sub-test gets it's own golden file. For example: +// ensuring each sub-test gets its own golden file. For example: // // func TestExampleMyStructTabular(t *testing.T) { -// tests := []struct { -// name string -// obj *MyStruct -// }{ -// {name: "empty struct", obj: &MyStruct{}}, -// {name: "full struct", obj: &MyStruct{Foo: "Bar"}}, -// } -// for _, tt := range tests { -// t.Run(tt.name, func(t *testing.T) { -// got, err := json.Marshal(tt.obj) -// require.NoError(t, err) +// tests := []struct { +// name string +// obj *MyStruct +// }{ +// {name: "empty struct", obj: &MyStruct{}}, +// {name: "full struct", obj: &MyStruct{Foo: "Bar"}}, +// } +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// got, err := json.Marshal(tt.obj) +// require.NoError(t, err) // -// if golden.Update() { -// golden.Set(t, got) -// } -// want := golden.Get(t) +// want := golden.Do(t, got) // -// assert.Equal(t, want, got) -// }) -// } +// assert.Equal(t, want, got) +// }) +// } // } // // The above example will read/write to: @@ -70,16 +71,14 @@ // instance. // // func TestExampleMyStructP(t *testing.T) { -// gotJSON, _ := json.Marshal(&MyStruct{Foo: "Bar"}) -// gotXML, _ := xml.Marshal(&MyStruct{Foo: "Bar"}) +// gotJSON, _ := json.Marshal(&MyStruct{Foo: "Bar"}) +// gotXML, _ := xml.Marshal(&MyStruct{Foo: "Bar"}) // -// if golden.Update() { -// golden.SetP(t, "json", gotJSON) -// golden.SetP(t, "xml", gotXML) -// } +// wantJSON := golden.DoP(t, "json", gotJSON) +// wantXML := golden.DoP(t, "xml", gotXML) // -// assert.Equal(t, golden.GetP(t, "json"), gotJSON) -// assert.Equal(t, golden.GetP(t, "xml"), gotXML) +// assert.Equal(t, wantJSON, gotJSON) +// assert.Equal(t, wantXML, gotXML) // } // // The above example will read/write to: @@ -90,27 +89,25 @@ // This works with tabular tests too of course: // // func TestExampleMyStructTabularP(t *testing.T) { -// tests := []struct { -// name string -// obj *MyStruct -// }{ -// {name: "empty struct", obj: &MyStruct{}}, -// {name: "full struct", obj: &MyStruct{Foo: "Bar"}}, -// } -// for _, tt := range tests { -// t.Run(tt.name, func(t *testing.T) { -// gotJSON, _ := json.Marshal(tt.obj) -// gotXML, _ := xml.Marshal(tt.obj) +// tests := []struct { +// name string +// obj *MyStruct +// }{ +// {name: "empty struct", obj: &MyStruct{}}, +// {name: "full struct", obj: &MyStruct{Foo: "Bar"}}, +// } +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// gotJSON, _ := json.Marshal(tt.obj) +// gotXML, _ := xml.Marshal(tt.obj) // -// if golden.Update() { -// golden.SetP(t, "json", gotJSON) -// golden.SetP(t, "xml", gotXML) -// } +// wantJSON := golden.DoP(t, "json", gotJSON) +// wantXML := golden.DoP(t, "xml", gotXML) // -// assert.Equal(t, golden.GetP(t, "json"), gotJSON) -// assert.Equal(t, golden.GetP(t, "xml"), gotXML) -// }) -// } +// assert.Equal(t, wantJSON, gotJSON) +// assert.Equal(t, wantXML, gotXML) +// }) +// } // } // // The above example will read/write to: @@ -148,6 +145,15 @@ var ( DefaultUpdateFunc = EnvUpdateFunc ) +// Do is a convenience function for calling Update(), Set(), and Get() in a +// single call. If Update() returns true, data will be written to the golden +// file using Set(), before reading it back with Get(). +func Do(t TestingT, data []byte) []byte { + t.Helper() + + return Default.Do(t, data) +} + // File returns the filename of the golden file for the given *testing.T // instance as determined by t.Name(). func File(t TestingT) string { @@ -174,6 +180,15 @@ func Set(t TestingT, data []byte) { Default.Set(t, data) } +// DoP is a convenience function for calling Update(), SetP(), and GetP() in a +// single call. If Update() returns true, data will be written to the golden +// file using SetP(), before reading it back with GetP(). +func DoP(t TestingT, name string, data []byte) []byte { + t.Helper() + + return Default.DoP(t, name, data) +} + // FileP returns the filename of the specifically named golden file for the // given *testing.T instance as determined by t.Name(). func FileP(t TestingT, name string) string { @@ -228,7 +243,7 @@ type Golden struct { FileMode os.FileMode // Suffix determines the filename suffix for all golden files. Typically - // this should be ".golden", but can be changed here if needed. + // this would be ".golden". Suffix string // Dirname is the name of the top-level directory at the root of the package @@ -241,9 +256,7 @@ type Golden struct { UpdateFunc UpdateFunc } -// 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. +// New returns a new *Golden instance with default values correctly populated. func New() *Golden { return &Golden{ DirMode: DefaultDirMode, @@ -254,9 +267,24 @@ func New() *Golden { } } +// Do is a convenience function for calling Update(), Set(), and Get() in a +// single call. If Update() returns true, data will be written to the golden +// file using Set(), before reading it back with Get(). +func (s *Golden) Do(t TestingT, data []byte) []byte { + t.Helper() + + if s.Update() { + s.Set(t, data) + } + + return s.Get(t) +} + // File returns the filename of the golden file for the given *testing.T // instance as determined by t.Name(). func (s *Golden) File(t TestingT) string { + t.Helper() + return s.file(t, "") } @@ -264,6 +292,8 @@ func (s *Golden) File(t TestingT) string { // 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 TestingT) []byte { + t.Helper() + return s.get(t, "") } @@ -271,12 +301,33 @@ func (s *Golden) Get(t TestingT) []byte { // determined by t.Name(). If writing fails it will fail the test by calling // t.Fatal() with error details. func (s *Golden) Set(t TestingT, data []byte) { + t.Helper() + s.set(t, "", data) } +// DoP is a convenience function for calling Update(), SetP(), and GetP() in a +// single call. If Update() returns true, data will be written to the golden +// file using SetP(), before reading it back with GetP(). +func (s *Golden) DoP(t TestingT, name string, data []byte) []byte { + t.Helper() + + if name == "" { + t.Fatalf("golden: name cannot be empty") + } + + if s.Update() { + s.SetP(t, name, data) + } + + return s.GetP(t, name) +} + // 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 TestingT, name string) string { + t.Helper() + if name == "" { t.Fatalf("golden: name cannot be empty") } @@ -291,6 +342,8 @@ func (s *Golden) FileP(t TestingT, name string) string { // 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 TestingT, name string) []byte { + t.Helper() + if name == "" { t.Fatalf("golden: name cannot be empty") } @@ -305,6 +358,8 @@ func (s *Golden) GetP(t TestingT, name string) []byte { // 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 TestingT, name string, data []byte) { + t.Helper() + if name == "" { t.Fatalf("golden: name cannot be empty") } @@ -312,9 +367,19 @@ func (s *Golden) SetP(t TestingT, name string, data []byte) { s.set(t, name, data) } +// 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) file(t TestingT, name string) string { if t.Name() == "" { - t.Fatalf("golden: could not determine filename for: %+v", t) + t.Fatalf("golden: could not determine filename") } base := []string{s.Dirname, filepath.FromSlash(t.Name())} @@ -360,13 +425,3 @@ func (s *Golden) set(t TestingT, name string, data []byte) { 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() -} diff --git a/golden_test.go b/golden_test.go index ab4bf60..b384ff0 100644 --- a/golden_test.go +++ b/golden_test.go @@ -100,6 +100,102 @@ func TestNew(t *testing.T) { assert.Equal(t, wantFuncName, gotFuncName) } +func TestDo(t *testing.T) { + t.Cleanup(func() { + err := os.RemoveAll(filepath.Join("testdata", "TestDo")) + require.NoError(t, err) + err = os.Remove(filepath.Join("testdata", "TestDo.golden")) + require.NoError(t, err) + }) + + // + // Test when Update is false + // + content := []byte("This is the golden file for TestDo") + err := os.MkdirAll("testdata", 0o755) + require.NoError(t, err) + + err = os.WriteFile( + filepath.Join("testdata", "TestDo.golden"), + content, 0o600, + ) + require.NoError(t, err) + + newContent := []byte("This should not be written") + t.Setenv("GOLDEN_UPDATE", "false") + got := Do(t, newContent) + assert.Equal(t, content, got) + + // Verify file wasn't changed + fileContent, err := os.ReadFile( + filepath.Join("testdata", "TestDo.golden"), + ) + require.NoError(t, err) + assert.Equal(t, content, fileContent) + + // + // Test when Update is true + // + updatedContent := []byte("This is the updated content for TestDo") + t.Setenv("GOLDEN_UPDATE", "true") + got = Do(t, updatedContent) + assert.Equal(t, updatedContent, got) + + // Verify file was updated + fileContent, err = os.ReadFile( + filepath.Join("testdata", "TestDo.golden"), + ) + require.NoError(t, err) + assert.Equal(t, updatedContent, fileContent) + + // + // Test with sub-tests + // + tests := []struct { + name string + content []byte + }{ + { + name: "simple", + content: []byte("Simple content for sub-test"), + }, + { + name: "complex/path", + content: []byte("Complex path content for sub-test"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test with Update true + t.Setenv("GOLDEN_UPDATE", "true") + got := Do(t, tt.content) + assert.Equal(t, tt.content, got) + + // Verify file was written with correct content + f := File(t) + fileContent, err := os.ReadFile(f) + require.NoError(t, err) + assert.Equal(t, tt.content, fileContent) + + // Test with Update false + t.Setenv("GOLDEN_UPDATE", "false") + + newContent := []byte( + "This should not be written in sub-test", + ) + got = Do(t, newContent) + assert.Equal(t, tt.content, got) + + // Verify file wasn't changed + f = File(t) + fileContent, err = os.ReadFile(f) + require.NoError(t, err) + assert.Equal(t, tt.content, fileContent) + }) + } +} + func TestFile(t *testing.T) { got := File(t) @@ -275,6 +371,97 @@ func TestSet(t *testing.T) { } } +func TestDoP(t *testing.T) { + t.Cleanup(func() { + err := os.RemoveAll(filepath.Join("testdata", "TestDoP")) + require.NoError(t, err) + }) + + // + // Test when Update is false + // + name := "test-format" + content := []byte("This is the golden file for TestDoP") + err := os.MkdirAll(filepath.Join("testdata", "TestDoP"), 0o755) + require.NoError(t, err) + + goldenFile := filepath.Join("testdata", "TestDoP", name+".golden") + err = os.WriteFile(goldenFile, content, 0o600) + require.NoError(t, err) + + newContent := []byte("This should not be written") + t.Setenv("GOLDEN_UPDATE", "false") + got := DoP(t, name, newContent) + assert.Equal(t, content, got) + + // Verify file wasn't changed + fileContent, err := os.ReadFile(goldenFile) + require.NoError(t, err) + assert.Equal(t, content, fileContent) + + // + // Test when Update is true + // + updatedContent := []byte("This is the updated content for TestDoP") + t.Setenv("GOLDEN_UPDATE", "true") + got = DoP(t, name, updatedContent) + assert.Equal(t, updatedContent, got) + + // Verify file was updated + fileContent, err = os.ReadFile(goldenFile) + require.NoError(t, err) + assert.Equal(t, updatedContent, fileContent) + + // + // Test with sub-tests + // + tests := []struct { + testName string + name string + content []byte + }{ + { + testName: "json format", + name: "json", + content: []byte(`{"key": "value"}`), + }, + { + testName: "xml format", + name: "xml", + content: []byte(`value`), + }, + } + + for _, tt := range tests { + t.Run(tt.testName, func(t *testing.T) { + // Test with Update true + t.Setenv("GOLDEN_UPDATE", "true") + got := DoP(t, tt.name, tt.content) + assert.Equal(t, tt.content, got) + + // Verify file was written with correct content + f := FileP(t, tt.name) + fileContent, err := os.ReadFile(f) + require.NoError(t, err) + assert.Equal(t, tt.content, fileContent) + + // Test with Update false + t.Setenv("GOLDEN_UPDATE", "false") + newContent := []byte( + "This should not be written in sub-test", + ) + got = DoP(t, tt.name, newContent) + assert.Equal(t, tt.content, got) + + // Verify file wasn't changed + f = FileP(t, tt.name) + fileContent, err = os.ReadFile(f) + require.NoError(t, err) + assert.Equal(t, tt.content, fileContent) + }) + } +} + func TestFileP(t *testing.T) { got := FileP(t, "sub-name") assert.Equal(t,