├── .github ├── dependabot.yml ├── release.yml └── workflows │ ├── tagpr.yml │ └── testandvet.yml ├── .tagpr ├── CHANGELOG.md ├── LICENSE ├── README.md ├── checker.go ├── checker_test.go ├── go.mod ├── go.sum ├── golden.go ├── golden_test.go ├── testdata └── TestTxtarJoin │ ├── comment.golden │ ├── directory.golden │ ├── empty.golden │ ├── multi.golden │ ├── same.golden │ └── single.golden ├── txtar.go ├── txtar_test.go └── version.txt /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - tagpr 5 | -------------------------------------------------------------------------------- /.github/workflows/tagpr.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/tagpr.yml 2 | name: tagpr 3 | on: 4 | push: 5 | branches: ["main"] 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 11 | - uses: Songmu/tagpr@main 12 | env: 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | -------------------------------------------------------------------------------- /.github/workflows/testandvet.yml: -------------------------------------------------------------------------------- 1 | name: Test and Vet 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | release: 11 | types: 12 | - published 13 | - created 14 | - edited 15 | 16 | defaults: 17 | run: 18 | shell: bash 19 | 20 | jobs: 21 | test: 22 | runs-on: ubuntu-24.04 23 | 24 | steps: 25 | - name: Checkout code 26 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 27 | 28 | - name: Install Go 29 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 30 | with: 31 | go-version-file: 'go.mod' 32 | 33 | - name: Cache Go module and build cache 34 | uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3 35 | with: 36 | key: go-${{ hashFiles('**/go.sum') }} 37 | path: | 38 | ~/go/pkg/mod 39 | restore-keys: | 40 | go- 41 | 42 | - name: Install tennvet 43 | run: | 44 | GOBIN=$(pwd) go install github.com/tenntenn/tennvet@latest 45 | 46 | - name: Test and vet 47 | run: | 48 | go vet ./... 49 | go vet -vettool=$(pwd)/tennvet ./... 50 | go test -v -race ./... 51 | -------------------------------------------------------------------------------- /.tagpr: -------------------------------------------------------------------------------- 1 | # config file for the tagpr in git config format 2 | # The tagpr generates the initial configuration, which you can rewrite to suit your environment. 3 | # CONFIGURATIONS: 4 | # tagpr.releaseBranch 5 | # Generally, it is "main." It is the branch for releases. The pcpr tracks this branch, 6 | # creates or updates a pull request as a release candidate, or tags when they are merged. 7 | # 8 | # tagpr.versionFile 9 | # Versioning file containing the semantic version needed to be updated at release. 10 | # It will be synchronized with the "git tag". 11 | # Often this is a meta-information file such as gemspec, setup.cfg, package.json, etc. 12 | # Sometimes the source code file, such as version.go or Bar.pm, is used. 13 | # If you do not want to use versioning files but only git tags, specify the "-" string here. 14 | # You can specify multiple version files by comma separated strings. 15 | # 16 | # tagpr.vPrefix 17 | # Flag whether or not v-prefix is added to semver when git tagging. (e.g. v1.2.3 if true) 18 | # This is only a tagging convention, not how it is described in the version file. 19 | # 20 | # tagpr.changelog (Optional) 21 | # Flag whether or not changelog is added or changed during the release. 22 | # 23 | # tagpr.command (Optional) 24 | # Command to change files just before release. 25 | # 26 | # tagpr.tmplate (Optional) 27 | # Pull request template in go template format 28 | # 29 | # tagpr.release (Optional) 30 | # GitHub Release creation behavior after tagging [true, draft, false] 31 | # If this value is not set, the release is to be created. 32 | [tagpr] 33 | vPrefix = true 34 | releaseBranch = main 35 | versionFile = version.txt 36 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v0.5.5](https://github.com/tenntenn/golden/compare/v0.5.4...v0.5.5) - 2025-06-02 4 | - fix typo by @matsuyoshi30 in https://github.com/tenntenn/golden/pull/25 5 | - Update go1.23.9 by @tenntenn in https://github.com/tenntenn/golden/pull/28 6 | - Udpate workflow by @tenntenn in https://github.com/tenntenn/golden/pull/29 7 | - Update dependencies by @tenntenn in https://github.com/tenntenn/golden/pull/30 8 | - Add dependabot by @tenntenn in https://github.com/tenntenn/golden/pull/31 9 | 10 | ## [v0.5.4](https://github.com/tenntenn/golden/compare/v0.5.3...v0.5.4) - 2024-03-04 11 | - isJSON is test helper by @k1LoW in https://github.com/tenntenn/golden/pull/21 12 | - If a string to be interpreted as JSON begins with a number, it is not considered JSON. by @k1LoW in https://github.com/tenntenn/golden/pull/22 13 | - Support for comparison with JSON acquired with string type by @k1LoW in https://github.com/tenntenn/golden/pull/24 14 | 15 | ## [v0.5.3](https://github.com/tenntenn/golden/compare/v0.5.2...v0.5.3) - 2024-03-04 16 | - Support for comparison with JSON acquired with []byte type by @k1LoW in https://github.com/tenntenn/golden/pull/19 17 | 18 | ## [v0.5.2](https://github.com/tenntenn/golden/compare/v0.5.1...v0.5.2) - 2024-03-01 19 | - If a character that is determined to be a number is at the beginning, it is not determined to be JSON. by @k1LoW in https://github.com/tenntenn/golden/pull/16 20 | - Resolve tennvet errors by @k1LoW in https://github.com/tenntenn/golden/pull/18 21 | 22 | ## [v0.5.1](https://github.com/tenntenn/golden/compare/v0.5.0...v0.5.1) - 2023-05-02 23 | - Fix for inner struct field by @tenntenn in https://github.com/tenntenn/golden/pull/14 24 | - fix README by @takuoki in https://github.com/tenntenn/golden/pull/11 25 | 26 | ## [v0.4.0](https://github.com/tenntenn/golden/compare/v0.3.0...v0.4.0) - 2022-11-09 27 | - Add TxtarJoin by @tenntenn in https://github.com/tenntenn/golden/pull/8 28 | 29 | ## [v0.3.0](https://github.com/tenntenn/golden/compare/v0.2.0...v0.3.0) - 2022-11-03 30 | - [fix]READMEのgolden.Txtarの引数が間違っていたので修正しました by @funera1 in https://github.com/tenntenn/golden/pull/3 31 | - Add tagpr by @tenntenn in https://github.com/tenntenn/golden/pull/5 32 | - Drop supporting for go1.17 by @tenntenn in https://github.com/tenntenn/golden/pull/7 33 | - Add check by @tenntenn in https://github.com/tenntenn/golden/pull/4 34 | 35 | ## [v0.2.0](https://github.com/tenntenn/golden/compare/v0.1.0...v0.2.0) - 2021-09-17 36 | - Create testandvet.yml by @tenntenn in https://github.com/tenntenn/golden/pull/2 37 | - Add DirInit and TxtarWith by @tenntenn in https://github.com/tenntenn/golden/pull/1 38 | 39 | ## [v0.1.0](https://github.com/tenntenn/golden/commits/v0.1.0) - 2021-09-16 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Takuya Ueda 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # golden 2 | 3 | [![pkg.go.dev][gopkg-badge]][gopkg] 4 | 5 | `golden` provides utilities for golden file tests. 6 | 7 | ```go 8 | package a_test 9 | 10 | import ( 11 | "flag" 12 | "os" 13 | "path/filepath" 14 | "testing" 15 | 16 | "github.com/tenntenn/golden" 17 | ) 18 | 19 | var ( 20 | flagUpdate bool 21 | ) 22 | 23 | func init() { 24 | flag.BoolVar(&flagUpdate, "update", false, "update golden files") 25 | } 26 | 27 | func testTarget(dir string) error { 28 | if err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("hello"), 0700); err != nil { 29 | return err 30 | } 31 | 32 | if err := os.WriteFile(filepath.Join(dir, "b.txt"), []byte("world"), 0700); err != nil { 33 | return err 34 | } 35 | 36 | return nil 37 | } 38 | 39 | func Test(t *testing.T) { 40 | dir := t.TempDir() 41 | if err := testTarget(dir); err != nil { 42 | t.Fatal("unexpected error:", err) 43 | } 44 | 45 | got := golden.Txtar(t, dir) 46 | if diff := golden.Check(t, flagUpdate, "testdata", "mytest", got); diff != "" { 47 | t.Error(diff) 48 | } 49 | } 50 | ``` 51 | 52 | 53 | [gopkg]: https://pkg.go.dev/github.com/tenntenn/golden 54 | [gopkg-badge]: https://pkg.go.dev/badge/github.com/tenntenn/golden?status.svg 55 | -------------------------------------------------------------------------------- /checker.go: -------------------------------------------------------------------------------- 1 | package golden 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "reflect" 10 | "strconv" 11 | "strings" 12 | 13 | "github.com/google/go-cmp/cmp" 14 | ) 15 | 16 | // TestingT is interface for *testing.T. 17 | type TestingT interface { 18 | Helper() 19 | Fatal(args ...any) 20 | } 21 | 22 | // Checker can do golden file testing for multiple data. 23 | // Checker holds *testing.T, update flag, testdata directory, 24 | // test name and options for [go-cmp]. 25 | // 26 | // [go-cmp]: https://pkg.go.dev/github.com/google/go-cmp/cmp 27 | type Checker struct { 28 | testingT TestingT 29 | update bool 30 | testdata string 31 | name string 32 | opts []cmp.Option 33 | JSONIdent bool 34 | } 35 | 36 | // New creates a [Checker]. 37 | func New(t TestingT, update bool, testdata, name string, opts ...cmp.Option) *Checker { 38 | return &Checker{ 39 | testingT: t, 40 | update: update, 41 | testdata: testdata, 42 | name: name, 43 | opts: opts, 44 | } 45 | } 46 | 47 | // Check do a golden file test for a single data. 48 | // Check calls [Check] function with test name which combined with suffix. 49 | // 50 | // var flagUpdate bool 51 | // 52 | // func init() { 53 | // flag.BoolVar(&flagUpdate, "update", false, "update golden files") 54 | // } 55 | // 56 | // func Test(t *testing.T) { 57 | // got := doSomething() 58 | // c := golden.New(t, flagUpdate, "testdata", t.Name()) 59 | // if diff := c.Check("_someting", got); diff != "" { 60 | // t.Error(diff) 61 | // } 62 | // } 63 | func (c *Checker) Check(suffix string, data any) (diff string) { 64 | c.testingT.Helper() 65 | 66 | path := filepath.Join(c.testdata, c.name+suffix+".golden") 67 | 68 | if c.update { 69 | c.updateFile(path, data) 70 | return "" 71 | } 72 | 73 | golden, err := os.Open(path) 74 | if err != nil { 75 | c.testingT.Fatal("unexpected error:", err) 76 | } 77 | defer golden.Close() 78 | 79 | wantStr := readAll(c.testingT, c.JSONIdent, golden) 80 | if c.isBytesOrString(data) || !c.isJSON(wantStr) { 81 | gotStr := readAll(c.testingT, c.JSONIdent, data) 82 | return cmp.Diff(wantStr, gotStr, c.opts...) 83 | } 84 | 85 | got := reflect.ValueOf(data) 86 | want := reflect.New(got.Type()) 87 | dec := json.NewDecoder(strings.NewReader(wantStr)) 88 | if err := dec.Decode(want.Interface()); err != nil { 89 | c.testingT.Fatal("unexpected error:", err) 90 | } 91 | 92 | if diff := cmp.Diff(want.Elem().Interface(), data, c.opts...); diff != "" { 93 | // retry with string 94 | gotStr := readAll(c.testingT, c.JSONIdent, data) 95 | return cmp.Diff(wantStr, gotStr, c.opts...) 96 | } 97 | 98 | return "" 99 | } 100 | 101 | func (c *Checker) isJSON(s string) bool { 102 | c.testingT.Helper() 103 | 104 | var v any 105 | err := json.NewDecoder(strings.NewReader(s)).Decode(&v) 106 | 107 | if errors.Is(err, io.EOF) { 108 | return false 109 | } 110 | 111 | if serr := (*json.SyntaxError)(nil); errors.As(err, &serr) { 112 | return false 113 | } 114 | 115 | trimed := strings.TrimSpace(s) 116 | if len(trimed) > 0 { 117 | _, err := strconv.Atoi(trimed[0:1]) 118 | if err == nil { 119 | return false 120 | } 121 | } 122 | 123 | return true 124 | } 125 | 126 | func (c *Checker) isBytesOrString(v any) bool { 127 | c.testingT.Helper() 128 | switch v.(type) { 129 | case []byte: 130 | return true 131 | case string: 132 | return true 133 | default: 134 | return false 135 | } 136 | } 137 | 138 | func (c *Checker) updateFile(path string, data any) { 139 | c.testingT.Helper() 140 | 141 | f, err := os.Create(path) 142 | if err != nil { 143 | c.testingT.Fatal("unexpected error:", err) 144 | } 145 | 146 | r := newReader(c.testingT, c.JSONIdent, data) 147 | if _, err := io.Copy(f, r); err != nil { 148 | c.testingT.Fatal("unexpected error:", err) 149 | } 150 | 151 | if err := f.Close(); err != nil { 152 | c.testingT.Fatal("unexpected error:", err) 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /checker_test.go: -------------------------------------------------------------------------------- 1 | package golden_test 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | "github.com/google/go-cmp/cmp/cmpopts" 9 | "github.com/tenntenn/golden" 10 | ) 11 | 12 | func TestChecker_Check(t *testing.T) { 13 | t.Parallel() 14 | type T struct { 15 | N int 16 | M int 17 | } 18 | cases := map[string]struct { 19 | want string 20 | got any 21 | hasDiff bool 22 | opts []cmp.Option 23 | }{ 24 | "string-nodiff": {"hello", "hello", false, nil}, 25 | "bytes-nodiff": {"hello", []byte("hello"), false, nil}, 26 | "reader-nodiff": {"hello", strings.NewReader("hello"), false, nil}, 27 | "json-nodiff": {"{\"S\":\"hello\"}\n", struct{ S string }{S: "hello"}, false, nil}, 28 | "marshaler-nodiff": {"hello", marshaler("hello"), false, nil}, 29 | "bytes-json-nodiff": {"{\"S\":\"hello\"}\n", []byte("{\"S\":\"hello\"}\n"), false, nil}, 30 | "string-json-nodiff": {"{\"S\":\"hello\"}\n", "{\"S\":\"hello\"}\n", false, nil}, 31 | "ignore-field-nodiff": {`{"N":100, "M":200}`, &T{N: 100, M: 300}, false, []cmp.Option{cmpopts.IgnoreFields(T{}, "M")}}, 32 | "ignore-inner-struct-field-nodiff": {`[{"N":100, "M":200}]`, []*T{{N: 100, M: 300}}, false, []cmp.Option{cmpopts.IgnoreFields(T{}, "M")}}, 33 | 34 | "string-diff": {"Hello", "hello", true, nil}, 35 | "bytes-diff": {"Hello", []byte("hello"), true, nil}, 36 | "reader-diff": {"Hello", strings.NewReader("hello"), true, nil}, 37 | "json-diff": {"{\"S\":\"Hello\"}\n", struct{ S string }{S: "hello"}, true, nil}, 38 | "marshaler-diff": {"Hello", marshaler("hello"), true, nil}, 39 | "bytes-json-diff": {"{\"S\":\"Hello\"}\n", []byte("{\"S\":\"hello\"}\n"), true, nil}, 40 | "string-json-diff": {"{\"S\":\"Hello\"}\n", "{\"S\":\"hello\"}\n", true, nil}, 41 | "ignore-field-diff": {`{"N":100, "M":200}`, &T{N: 100, M: 300}, true, nil}, 42 | "ignore-inner-struct-field-diff": {`[{"N":100, "M":200}]`, []*T{{N: 100, M: 300}}, true, nil}, 43 | } 44 | 45 | for name, tt := range cases { 46 | name, tt := name, tt 47 | t.Run(name, func(t *testing.T) { 48 | t.Parallel() 49 | testdata := t.TempDir() 50 | 51 | // only update 52 | diff1 := golden.New(t, true, testdata, name, tt.opts...).Check("_check", tt.want) 53 | if diff1 != "" { 54 | t.Error("there are some unexpected differences:", diff1) 55 | } 56 | 57 | diff2 := golden.New(t, false, testdata, name, tt.opts...).Check("_check", tt.got) 58 | switch { 59 | case diff2 == "" && tt.hasDiff: 60 | t.Error("there are any expected differences") 61 | case diff2 != "" && !tt.hasDiff: 62 | t.Error("there are some unexpected differences:", diff2) 63 | } 64 | }) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tenntenn/golden 2 | 3 | go 1.23.9 4 | 5 | require ( 6 | github.com/google/go-cmp v0.7.0 7 | github.com/josharian/txtarfs v0.0.0-20240408113805-5dc76b8fe6bf 8 | golang.org/x/tools v0.33.0 9 | ) 10 | 11 | require github.com/josharian/mapfs v0.0.0-20210615234106-095c008854e6 // indirect 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 2 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 3 | github.com/josharian/mapfs v0.0.0-20210615234106-095c008854e6 h1:c+ctPFdISggaSNCfU1IueNBAsqetJSvMcpQlT+0OVdY= 4 | github.com/josharian/mapfs v0.0.0-20210615234106-095c008854e6/go.mod h1:Rv/momJI8DgrWnBZip+SgagpcgORIZQE5SERlxNb8LY= 5 | github.com/josharian/txtarfs v0.0.0-20240408113805-5dc76b8fe6bf h1:ZWuoyLMwZvLJ6OHUhPq1sZHa37Pikt6DXkZPhhOBzEE= 6 | github.com/josharian/txtarfs v0.0.0-20240408113805-5dc76b8fe6bf/go.mod h1:UbC32ft9G/jG+sZI8wLbIBNIrYr7vp/yqMDa9SxVBNA= 7 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 8 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 9 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 10 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 11 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 12 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 13 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 14 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 15 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 16 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 17 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 18 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 19 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 20 | golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 21 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 22 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 23 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 24 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 25 | golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= 26 | golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= 27 | golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= 28 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 29 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 30 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 31 | -------------------------------------------------------------------------------- /golden.go: -------------------------------------------------------------------------------- 1 | package golden 2 | 3 | import ( 4 | "bytes" 5 | "encoding" 6 | "encoding/json" 7 | "io" 8 | "io/fs" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "testing" 13 | 14 | "github.com/google/go-cmp/cmp" 15 | ) 16 | 17 | // Check updates a golden file when update is true otherwise compares data with the exsiting golden file by DiffWithOpts. 18 | // If update is true Check does not compare and just return "". 19 | // 20 | // var flagUpdate bool 21 | // 22 | // func init() { 23 | // flag.BoolVar(&flagUpdate, "update", false, "update golden files") 24 | // } 25 | // 26 | // func Test(t *testing.T) { 27 | // got := doSomething() 28 | // if diff := golden.Check(t, flagUpdate, "testdata", t.Name(), got); diff != "" { 29 | // t.Error(diff) 30 | // } 31 | // } 32 | func Check(t *testing.T, update bool, testdata, name string, data any, opts ...cmp.Option) string { 33 | t.Helper() 34 | if update { 35 | Update(t, testdata, name, data) 36 | return "" 37 | } 38 | return DiffWithOpts(t, testdata, name, data, opts...) 39 | } 40 | 41 | // DiffWithOpts compares between the given data and a golden file which is stored in testdata as name+".golden". 42 | // DiffWithOpts returns difference of them. 43 | // DiffWithOpts uses [go-cmp] to compare. 44 | // 45 | // [go-cmp]: https://pkg.go.dev/github.com/google/go-cmp/cmp 46 | func DiffWithOpts(t *testing.T, testdata, name string, data any, opts ...cmp.Option) string { 47 | t.Helper() 48 | return New(t, false, testdata, name, opts...).Check("", data) 49 | } 50 | 51 | // Diff compares between the given data and a golden file which is stored in testdata as name+".golden". 52 | // Diff returns difference of them. 53 | // Diff uses [go-cmp] to compare. 54 | // 55 | // [go-cmp]: https://pkg.go.dev/github.com/google/go-cmp/cmp 56 | func Diff(t *testing.T, testdata, name string, data any) string { 57 | t.Helper() 58 | return DiffWithOpts(t, testdata, name, data, nil) 59 | } 60 | 61 | func readAll(t TestingT, jsonIndent bool, data any) string { 62 | t.Helper() 63 | r := newReader(t, jsonIndent, data) 64 | b, err := io.ReadAll(r) 65 | if err != nil { 66 | t.Fatal("unexpected error:", err) 67 | } 68 | return string(b) 69 | } 70 | 71 | func newReader(t TestingT, jsonIndent bool, data any) io.Reader { 72 | t.Helper() 73 | switch data := data.(type) { 74 | case io.Reader: 75 | return data 76 | case string: 77 | return strings.NewReader(data) 78 | case []byte: 79 | return bytes.NewReader(data) 80 | case encoding.TextMarshaler: 81 | b, err := data.MarshalText() 82 | if err != nil { 83 | t.Fatal("unexpected error:", err) 84 | } 85 | return bytes.NewReader(b) 86 | default: 87 | var buf bytes.Buffer 88 | enc := json.NewEncoder(&buf) 89 | if jsonIndent { 90 | enc.SetIndent("", "\t") 91 | } 92 | if err := enc.Encode(data); err != nil { 93 | t.Fatal("unexpected error:", err) 94 | } 95 | return &buf 96 | } 97 | } 98 | 99 | // Update updates a golden file with the given data. 100 | // The golden file saved as name+".golden" in testdata. 101 | func Update(t *testing.T, testdata, name string, data any) { 102 | t.Helper() 103 | _ = New(t, true, testdata, name).Check("", data) 104 | } 105 | 106 | // RemoveAll removes all golden files which has .golden extention and is under testdata. 107 | func RemoveAll(t *testing.T, testdata string) { 108 | t.Helper() 109 | 110 | err := filepath.Walk(testdata, func(path string, info fs.FileInfo, err error) error { 111 | if err != nil { 112 | return err 113 | } 114 | 115 | if info.IsDir() || filepath.Ext(path) != ".golden" { 116 | return nil 117 | } 118 | 119 | if err := os.Remove(path); err != nil { 120 | return err 121 | } 122 | 123 | return nil 124 | }) 125 | 126 | if err != nil { 127 | t.Fatal("unexpected error", err) 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /golden_test.go: -------------------------------------------------------------------------------- 1 | package golden_test 2 | 3 | import ( 4 | "flag" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/tenntenn/golden" 9 | ) 10 | 11 | var ( 12 | flagUpdate bool 13 | ) 14 | 15 | func init() { 16 | flag.BoolVar(&flagUpdate, "update", false, "update golden files") 17 | } 18 | 19 | type marshaler string 20 | 21 | func (m marshaler) MarshalText() (text []byte, err error) { 22 | return []byte(m), nil 23 | } 24 | 25 | func TestDiff(t *testing.T) { 26 | t.Parallel() 27 | cases := map[string]struct { 28 | want string 29 | got any 30 | hasDiff bool 31 | }{ 32 | "string-nodiff": {"hello", "hello", false}, 33 | "bytes-nodiff": {"hello", []byte("hello"), false}, 34 | "reader-nodiff": {"hello", strings.NewReader("hello"), false}, 35 | "json-nodiff": {"{\"S\":\"hello\"}\n", struct{ S string }{S: "hello"}, false}, 36 | "marshaler-nodiff": {"hello", marshaler("hello"), false}, 37 | "empty-nodiff": {"", "", false}, 38 | "number-nodiff": {"3", "3", false}, 39 | "number-start-nodiff": {"3 bytes", "3 bytes", false}, 40 | "number-start-with-space-nodiff": {"\n\n \n\r3 bytes", "\n\n \n\r3 bytes", false}, 41 | 42 | "string-diff": {"Hello", "hello", true}, 43 | "bytes-diff": {"Hello", []byte("hello"), true}, 44 | "reader-diff": {"Hello", strings.NewReader("hello"), true}, 45 | "json-diff": {"{\"S\":\"Hello\"}\n", struct{ S string }{S: "hello"}, true}, 46 | "marshaler-diff": {"Hello", marshaler("hello"), true}, 47 | "number-diff": {"3", "4", true}, 48 | "number-start-diff": {"3 bytes", "4 bytes", true}, 49 | "number-start-with-space-diff": {"\n\n \n\r3 bytes", "\n\n \n\r4 bytes", true}, 50 | } 51 | 52 | for name, tt := range cases { 53 | name, tt := name, tt 54 | t.Run(name, func(t *testing.T) { 55 | t.Parallel() 56 | testdata := t.TempDir() 57 | golden.Update(t, testdata, name, tt.want) 58 | diff := golden.Diff(t, testdata, name, tt.got) 59 | switch { 60 | case diff == "" && tt.hasDiff: 61 | t.Error("there are any expected differences") 62 | case diff != "" && !tt.hasDiff: 63 | t.Error("there are some unexpected differences:", diff) 64 | } 65 | }) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /testdata/TestTxtarJoin/comment.golden: -------------------------------------------------------------------------------- 1 | comment-b 2 | comment-a 3 | -- a.txt -- 4 | hello 5 | -- b.txt -- 6 | hi 7 | -------------------------------------------------------------------------------- /testdata/TestTxtarJoin/directory.golden: -------------------------------------------------------------------------------- 1 | -- dir/a.txt -- 2 | hello 3 | -- dir/b.txt -- 4 | hi 5 | -------------------------------------------------------------------------------- /testdata/TestTxtarJoin/empty.golden: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tenntenn/golden/ee9df5440d3d2bbc032ac52feadaffebb8512da8/testdata/TestTxtarJoin/empty.golden -------------------------------------------------------------------------------- /testdata/TestTxtarJoin/multi.golden: -------------------------------------------------------------------------------- 1 | -- a.txt -- 2 | hello 3 | -- b.txt -- 4 | hi 5 | -------------------------------------------------------------------------------- /testdata/TestTxtarJoin/same.golden: -------------------------------------------------------------------------------- 1 | -- a.txt -- 2 | hello 3 | -- a.txt -- 4 | HELLO 5 | -------------------------------------------------------------------------------- /testdata/TestTxtarJoin/single.golden: -------------------------------------------------------------------------------- 1 | -- a.txt -- 2 | hello 3 | -------------------------------------------------------------------------------- /txtar.go: -------------------------------------------------------------------------------- 1 | package golden 2 | 3 | import ( 4 | "io" 5 | "io/fs" 6 | "os" 7 | "path/filepath" 8 | "sort" 9 | "testing" 10 | 11 | "github.com/josharian/txtarfs" 12 | "golang.org/x/tools/txtar" 13 | ) 14 | 15 | // DirInit creates directory by txtar format. 16 | func DirInit(t *testing.T, root, txtarStr string) { 17 | t.Helper() 18 | fsys := txtarfs.As(txtar.Parse([]byte(txtarStr))) 19 | err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) (rerr error) { 20 | if err != nil { 21 | return err 22 | } 23 | 24 | // directory would create with a file 25 | if d.IsDir() { 26 | return nil 27 | } 28 | 29 | dstPath := filepath.Join(root, filepath.FromSlash(path)) 30 | 31 | src, err := fsys.Open(path) 32 | if err != nil { 33 | return err 34 | } 35 | defer func() { 36 | if err := src.Close(); err != nil && rerr == nil { 37 | rerr = err 38 | } 39 | }() 40 | 41 | fi, err := src.Stat() 42 | if err != nil { 43 | return err 44 | } 45 | 46 | if fi.Size() == 0 { 47 | return nil 48 | } 49 | 50 | err = os.MkdirAll(filepath.Dir(dstPath), 0700) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | dst, err := os.Create(dstPath) 56 | if err != nil { 57 | return err 58 | } 59 | defer func() { 60 | if err := dst.Close(); err != nil && rerr == nil { 61 | rerr = err 62 | } 63 | }() 64 | 65 | if _, err := io.Copy(dst, src); err != nil { 66 | return err 67 | } 68 | 69 | return nil 70 | }) 71 | 72 | if err != nil { 73 | t.Fatal("unexpected error:", err) 74 | } 75 | } 76 | 77 | // Txtar converts a directory as a txtar format. 78 | func Txtar(t *testing.T, dir string) string { 79 | t.Helper() 80 | ar, err := txtarfs.From(os.DirFS(dir)) 81 | if err != nil { 82 | t.Fatal("unexpected error", err) 83 | } 84 | sortTxtar(ar) 85 | return string(txtar.Format(ar)) 86 | } 87 | 88 | // TxtarWith creates a txtar format value with given a file name and its data pairs. 89 | func TxtarWith(t *testing.T, nameAndData ...string) string { 90 | t.Helper() 91 | if len(nameAndData)%2 != 0 { 92 | t.Fatal("invalid argument:", nameAndData) 93 | } 94 | 95 | ar := &txtar.Archive{ 96 | Files: make([]txtar.File, 0, len(nameAndData)/2), 97 | } 98 | 99 | for i := 0; i < len(nameAndData); i += 2 { 100 | ar.Files = append(ar.Files, txtar.File{ 101 | Name: nameAndData[i], 102 | Data: []byte(nameAndData[i+1]), 103 | }) 104 | } 105 | sortTxtar(ar) 106 | 107 | return string(txtar.Format(ar)) 108 | } 109 | 110 | // TxtarJoin appends a txtar format values. 111 | // Its file list are sorted by the file path. 112 | func TxtarJoin(t TestingT, txtars ...string) string { 113 | t.Helper() 114 | 115 | if len(txtars) == 0 { 116 | return "" 117 | } 118 | 119 | var ar txtar.Archive 120 | 121 | for _, txtarStr := range txtars { 122 | _ar := txtar.Parse([]byte(txtarStr)) 123 | ar.Comment = append(ar.Comment, _ar.Comment...) 124 | ar.Files = append(ar.Files, _ar.Files...) 125 | } 126 | 127 | sortTxtar(&ar) 128 | return string(txtar.Format(&ar)) 129 | } 130 | 131 | func sortTxtar(ar *txtar.Archive) { 132 | sort.Slice(ar.Files, func(i, j int) bool { 133 | return ar.Files[i].Name < ar.Files[j].Name 134 | }) 135 | } 136 | -------------------------------------------------------------------------------- /txtar_test.go: -------------------------------------------------------------------------------- 1 | package golden_test 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | "github.com/tenntenn/golden" 9 | ) 10 | 11 | func TestDirInit(t *testing.T) { 12 | t.Parallel() 13 | __ := func(args ...string) string { 14 | return golden.TxtarWith(t, args...) 15 | } 16 | cases := []struct { 17 | name string 18 | initstr string 19 | }{ 20 | {"single", __("a.txt", "hello")}, 21 | {"multi", __("a.txt", "hello", "b.txt", "hi")}, 22 | {"directory", __("dir/a.txt", "hello", "b.txt", "hi")}, 23 | } 24 | 25 | for _, tt := range cases { 26 | tt := tt 27 | t.Run(tt.name, func(t *testing.T) { 28 | t.Parallel() 29 | dir := t.TempDir() 30 | golden.DirInit(t, dir, tt.initstr) 31 | got := golden.Txtar(t, dir) 32 | if diff := cmp.Diff(tt.initstr, got); diff != "" { 33 | t.Error(diff) 34 | } 35 | }) 36 | } 37 | } 38 | 39 | func TestTxtarJoin(t *testing.T) { 40 | t.Parallel() 41 | __ := func(args ...string) string { 42 | return golden.TxtarWith(t, args...) 43 | } 44 | 45 | cases := map[string][]string{ 46 | "empty": {}, 47 | "single": {__("a.txt", "hello")}, 48 | "multi": {__("a.txt", "hello"), __("b.txt", "hi")}, 49 | "directory": {__("dir/a.txt", "hello"), __("dir/b.txt", "hi")}, 50 | "same": {__("a.txt", "hello"), __("a.txt", "HELLO")}, 51 | "comment": {"comment-b\n" + __("b.txt", "hi"), "comment-a\n" + __("a.txt", "hello")}, 52 | } 53 | 54 | testdata := filepath.Join("testdata", t.Name()) 55 | for name, txtars := range cases { 56 | name, txtars := name, txtars 57 | t.Run(name, func(t *testing.T) { 58 | t.Parallel() 59 | got := golden.TxtarJoin(t, txtars...) 60 | if diff := golden.Check(t, flagUpdate, testdata, name, got); diff != "" { 61 | t.Error(diff) 62 | } 63 | }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | v0.5.5 2 | --------------------------------------------------------------------------------