├── .gitattributes ├── .github ├── FUNDING.yml └── workflows │ ├── audit.yml │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── cmd └── gotestdox │ └── main.go ├── demo.tape ├── fuzz_test.go ├── go.mod ├── go.sum ├── gotestdox.go ├── gotestdox_test.go ├── img ├── demo.gif └── gotestdox.png ├── prettifier.go ├── prettifier_test.go └── testdata ├── fuzz └── FuzzPrettify │ ├── 02d4bc9f4e567048ebe719db6d94d22ed0788a56e3bdf4aa1fa3a546b5a681a4 │ ├── 05bda4c43f0606152eb8661c3f07af35c23ea151e4c0eba13616e681baa0b76b │ ├── 1d9c428aec8af9b4c3d6f8f87bb3fd4cd88776a6518bf232ca8ece05de180c4f │ ├── 2759ad33bc85f335199a678f5b2afd245760bc670e03abcfedd22440262e7f20 │ ├── 383824675cc2c177e198f74ede3c8ca29be00e2b6797a034a7a5839a4cd9af1c │ ├── 5838cdfae7b16cde2707c04599b62223e7bade8dafdd8a1c53b8f881e8f79d99 │ ├── 68ed403ba2cdc61b143313312fd67edbd7e8bc6481570c7d1d7b6901793166ce │ ├── 7287749262c764198e0bfd313015202b1503cb011ed58da513853e0fb2e68601 │ ├── 931b61b895a37b9f6d196cc73123abb83cdd7bc05e469ad7d4b76bd1ff16db60 │ ├── 9eca0c15d9a07d7821d43cbb4e08d4b84ca610d679b1ee89a0f0a9cdf46f5b79 │ ├── aa12d4e4166bf7ad00d59c3ec5e9ef473494bd2fde7d93b48136abbc77a5acef │ ├── d72586fff5c01205 │ └── f5d26b6486179756258edcb8fde23e655aeae6f7ce1d0d22380609e5055cd6de └── script ├── debug_output_is_requested.txtar ├── events_should_be_grouped_by_package.txtar ├── examples_should_be_ignored.txtar ├── help_requested.txtar ├── json_data_is_invalid.txtar ├── package_events_interleaved.txtar ├── tests_fail.txtar ├── tests_not_sorted_alphabetically.txtar └── tests_pass.txtar /.gitattributes: -------------------------------------------------------------------------------- 1 | # Treat all files in this repo as binary, with no git magic updating line 2 | # endings. Windows users contributing to the project will need to use a modern 3 | # version of git and editors capable of LF line endings. 4 | # 5 | # See https://github.com/golang/go/issues/9281 6 | * -text -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: bitfield 4 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Security audit 2 | on: 3 | workflow_dispatch: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | 7 | jobs: 8 | security_audit: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: golang/govulncheck-action@v1 12 | with: 13 | go-version-input: 'stable' 14 | check-latest: true 15 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # Based on https://github.com/mvdan/github-actions-golang 2 | on: [pull_request, workflow_dispatch] 3 | name: CI 4 | jobs: 5 | test: 6 | strategy: 7 | matrix: 8 | go-version: ['stable'] 9 | os: [ubuntu-latest, macos-latest, windows-latest] 10 | runs-on: ${{ matrix.os }} 11 | steps: 12 | - uses: actions/setup-go@v4 13 | with: 14 | go-version: ${{ matrix.go-version }} 15 | - uses: actions/checkout@v3 16 | - run: go test ./... 17 | 18 | gocritic: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/setup-go@v4 22 | - uses: actions/checkout@v3 23 | - run: | 24 | go install github.com/go-critic/go-critic/cmd/gocritic@latest 25 | gocritic check . 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 John Arundel 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 | [![Go Reference](https://pkg.go.dev/badge/github.com/bitfield/gotestdox.svg)](https://pkg.go.dev/github.com/bitfield/gotestdox) 2 | [![Go Report Card](https://goreportcard.com/badge/github.com/bitfield/gotestdox)](https://goreportcard.com/report/github.com/bitfield/gotestdox) 3 | [![Mentioned in Awesome Go](https://awesome.re/mentioned-badge-flat.svg)](https://github.com/avelino/awesome-go) 4 | ![CI](https://github.com/bitfield/gotestdox/actions/workflows/ci.yml/badge.svg) 5 | ![Audit](https://github.com/bitfield/gotestdox/actions/workflows/audit.yml/badge.svg) 6 | 7 | ![Writing gopher logo](img/gotestdox.png) 8 | 9 | `gotestdox` is a command-line tool for formatting Go test results as readable documentation, as recommended in my book [The Power of Go: Tests](https://bitfieldconsulting.com/books/tests). 10 | 11 | Here's how to install it: 12 | 13 | ``` 14 | go install github.com/bitfield/gotestdox/cmd/gotestdox@latest 15 | ``` 16 | 17 | In any Go project, run: 18 | 19 | ``` 20 | gotestdox ./... 21 | ``` 22 | 23 | ![Animated demo](img/demo.gif) 24 | 25 | # What does it do? 26 | 27 | `gotestdox` runs your tests and reports the results, but it formats their names in a special way. It converts test names WrittenInCamelCase into ordinary sentences. 28 | 29 | For example, suppose we have some tests named like this: 30 | 31 | ``` 32 | TestValidIsTrueForValidInputs 33 | TestValidIsFalseForInvalidInputs 34 | ``` 35 | 36 | We can transform them into readably-spaced sentences that express the desired behaviour, by running `gotestdox`: 37 | 38 | **`gotestdox`** 39 | 40 | This will run the tests, and print: 41 | 42 | ``` 43 | ✔ Valid is true for valid inputs (0.00s) 44 | ✔ Valid is false for invalid inputs (0.00s) 45 | ``` 46 | 47 | # Why? 48 | 49 | I read a blog post by Dan North, which says: 50 | 51 | > My first “Aha!” moment occurred as I was being shown a deceptively simple utility called `agiledox`, written by my colleague, Chris Stevenson. It takes a JUnit test class and prints out the method names as plain sentences. 52 | > 53 | > The word “test” is stripped from both the class name and the method names, and the camel-case method name is converted into regular text. That’s all it does, but its effect is amazing. 54 | > 55 | > Developers discovered it could do at least some of their documentation for them, so they started to write test methods that were real sentences.\ 56 | —Dan North, [Introducing BDD](https://dannorth.net/introducing-bdd/) 57 | 58 | # How? 59 | 60 | The original [`testdox`](https://github.com/astubbs/testdox) tool (part of `agiledox`) was very simple, as Dan describes: it just turned a camel-case JUnit test name like `testFailsForDuplicateCustomers` into a space-separated sentence like `fails for duplicate customers`. 61 | 62 | And that's what I find neat about it: it's so simple that it hardly seems like it could be of any value, but it is. I've already used the idea to improve a lot of my test names. 63 | 64 | There are implementations of `testdox` for various languages other than Java: for example, [PHP](https://phpunit.readthedocs.io/en/9.5/textui.html#testdox), [Python](https://pypi.org/project/pytest-testdox/), and [.NET](https://testdox.wordpress.com/). I haven't found one for Go, so here it is. 65 | 66 | `gotestdox` reads the JSON output generated by the `go test -json` command. This is easier than trying to parse Go source code, for example, and also gives us pass/fail information for the tests. It ignores all events except pass/fail events for individual tests (including subtests). 67 | 68 | # Getting fancy 69 | 70 | Some more advanced ways to use `gotestdox`: 71 | 72 | ## Exit status 73 | 74 | If there are any test failures, `gotestdox` will print the output messages from the offending test and report status 1 on exit. 75 | 76 | ## Colour 77 | 78 | `gotestdox` indicates a passing test with a `✔` (check mark emoji), and a failing test with an `x`. These are displayed as green and red respectively, using the [`color`](https://github.com/fatih/color) library, which automagically detects if it's talking to a colour-capable terminal. 79 | 80 | If not (for example, when you redirect output to a file), or if the [`NO_COLOR`](https://no-color.org/) environment variable is set to any value, colour output will be disabled. 81 | 82 | ## Test flags and arguments 83 | 84 | `gotestdox`, with no arguments, will run the command `go test -json` and process its output. 85 | 86 | Any arguments you supply will be passed on to `go test`. For example: 87 | 88 | **`gotestdox -run ParseJSON`** 89 | 90 | will run the command: 91 | 92 | `go test -json -run ParseJSON` 93 | 94 | You can supply a list of packages to test, or any other arguments or flags understood by `go test`. However, `gotestdox` only prints events about *tests* (ignoring benchmarks and examples). 95 | 96 | Since fuzz test cases are autogenerated and don't tend to have useful names, these are not included in `gotestdox` output unless they are failing. 97 | 98 | ## Multiple packages 99 | 100 | To test all the packages in the current tree, run: 101 | 102 | **`gotestdox ./...`** 103 | 104 | Each package's test results will be prefixed by the fully-qualified name of the package. For example: 105 | 106 | ``` 107 | github.com/octocat/mymodule/api: 108 | ✔ NewServer errors on invalid config options (0.00s) 109 | ✔ NewServer returns a correctly configured server (0.00s) 110 | 111 | github.com/octocat/mymodule/util: 112 | x LeftPad adds the correct number of leading spaces (0.00s) 113 | util_test.go:133: want " dummy", got " dummy" 114 | ``` 115 | 116 | ## Multi-word function names 117 | 118 | There's an ambiguity about test names involving functions whose names contain more than one word. For example, suppose we're testing a function `HandleInput`, and we write a test like this: 119 | 120 | ``` 121 | TestHandleInputClosesInputAfterReading 122 | ``` 123 | 124 | Unless we do something, this will be rendered as: 125 | 126 | ``` 127 | ✔ Handle input closes input after reading 128 | ``` 129 | 130 | To let us give `gotestdox` a hint about this, there's one extra transformation rule: the first underscore marks the end of the function name. So we can name our test like this: 131 | 132 | ``` 133 | TestHandleInput_ClosesInputAfterReading 134 | ``` 135 | 136 | and this becomes: 137 | 138 | ``` 139 | ✔ HandleInput closes input after reading 140 | ``` 141 | 142 | I think this is an acceptable compromise: the `gotestdox` output is much more readable, while the extra underscore in the test name doesn't seriously interfere with its readability. 143 | 144 | The intent is not to *perfectly* render all sensible test names as sentences, in any case, but to do *something* useful with them, primarily to encourage developers to write test names that are informative descriptions of the unit's behaviour, and thus (as a side effect) read well when formatted by `gotestdox`. 145 | 146 | In other words, `gotestdox` is not the thing. It's the thing that gets us to the thing, the end goal being meaningful test names (I like the term _literate_ test names). 147 | 148 | ## Filtering standard input 149 | 150 | If you want to run `go test -json` yourself, for example as part of a shell pipeline, and pipe its output into `gotestdox`, you can do that too: 151 | 152 | **`go test -json | gotestdox`** 153 | 154 | In this case, any flags or arguments to `gotestdox` will be ignored, and it won't *run* the tests; instead, it will act purely as a text filter. However, just as when it runs the tests itself, it will report exit status 1 if there are any test failures. 155 | 156 | ## As a package 157 | 158 | See [pkg.go.dev/github.com/bitfield/gotestdox](https://pkg.go.dev/github.com/bitfield/gotestdox) for the full documentation on using `gotestdox` as a package in your own programs. 159 | 160 | # So what? 161 | 162 | Why should you care, then? What's interesting about `gotestdox`, or any `testdox`-like tool, I find, is the way its output makes you think about your tests, how you name them, and what they do. 163 | 164 | As Dan says in his blog post, turning test names into sentences is a very simple idea, but it has a powerful effect. Test names *should* be sentences. 165 | 166 | ## Test names should be sentences 167 | 168 | I don't know about you, but I've wasted a lot of time and energy over the years trying to choose good names for tests. I didn't really have a way to evaluate whether the name I chose was good or not. Now I do! 169 | 170 | In fact, I wrote a whole blog post about it: 171 | 172 | * [Test names should be sentences](https://bitfieldconsulting.com/golang/test-names) 173 | 174 | It might be interesting to show your `gotestdox` output to users, customers, or business folks, and see if it makes sense to them. If so, you're on the right lines. And it's quite likely to generate some interesting conversations (“Is that really what it does? But that's not what we asked for!”) 175 | 176 | It seems that I'm not the only one who finds this idea useful. I hear that `gotestdox` is already being used in some fairly major Go projects and companies, helping their developers to get more value out of their existing tests, and encouraging them to think in interesting new ways about what tests are really for. How nice! 177 | 178 | # Links 179 | 180 | - [Bitfield Consulting](https://bitfieldconsulting.com/) 181 | - [Test names should be sentences](https://bitfieldconsulting.com/golang/test-names) 182 | - [The Power of Go: Tests](https://bitfieldconsulting.com/books/tests) 183 | 184 | Gopher image by [MariaLetta](https://github.com/MariaLetta/free-gophers-pack) 185 | -------------------------------------------------------------------------------- /cmd/gotestdox/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/bitfield/gotestdox" 7 | ) 8 | 9 | func main() { 10 | os.Exit(gotestdox.Main()) 11 | } 12 | -------------------------------------------------------------------------------- /demo.tape: -------------------------------------------------------------------------------- 1 | # https://github.com/charmbracelet/vhs 2 | 3 | Output img/demo.gif 4 | 5 | Set Shell zsh 6 | Set FontSize 16 7 | Set Width 800 8 | Set Height 380 9 | Set Padding 5 10 | Set WindowBar Colorful 11 | Set FontFamily "Recursive Monospace" 12 | 13 | Type "gotestdox ./..." Sleep 500ms Enter 14 | 15 | Sleep 30s 16 | -------------------------------------------------------------------------------- /fuzz_test.go: -------------------------------------------------------------------------------- 1 | //go:build go1.18 2 | 3 | package gotestdox_test 4 | 5 | import ( 6 | "strings" 7 | "testing" 8 | "unicode" 9 | 10 | "github.com/bitfield/gotestdox" 11 | ) 12 | 13 | func FuzzPrettify(f *testing.F) { 14 | for _, tc := range Cases { 15 | f.Add(tc.input) 16 | } 17 | f.Fuzz(func(t *testing.T, input string) { 18 | if len(input) > 0 && unicode.IsLower([]rune(input)[0]) { 19 | t.Skip() 20 | } 21 | got := gotestdox.Prettify(input) 22 | if got == "" { 23 | t.Skip() 24 | } 25 | if strings.ContainsRune(got, '_') { 26 | t.Errorf("%q: contains underscore %q", input, got) 27 | } 28 | if strings.ContainsRune(got, '/') { 29 | t.Errorf("%q: contains slash %q", input, got) 30 | } 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/bitfield/gotestdox 2 | 3 | go 1.22.0 4 | 5 | require ( 6 | github.com/fatih/color v1.15.0 7 | github.com/google/go-cmp v0.5.9 8 | github.com/mattn/go-isatty v0.0.19 9 | github.com/rogpeppe/go-internal v1.11.0 10 | golang.org/x/text v0.11.0 11 | ) 12 | 13 | require ( 14 | github.com/mattn/go-colorable v0.1.13 // indirect 15 | golang.org/x/sys v0.10.0 // indirect 16 | golang.org/x/tools v0.11.0 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs= 2 | github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw= 3 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 4 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 5 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 6 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 7 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 8 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 9 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 10 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 11 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 12 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 13 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 14 | golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= 15 | golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 16 | golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= 17 | golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 18 | golang.org/x/tools v0.11.0 h1:EMCa6U9S2LtZXLAMoWiR/R8dAQFRqbAitmbJ2UKhoi8= 19 | golang.org/x/tools v0.11.0/go.mod h1:anzJrxPjNtfgiYQYirP2CPGzGLxrH2u2QBhn6Bf3qY8= 20 | -------------------------------------------------------------------------------- /gotestdox.go: -------------------------------------------------------------------------------- 1 | package gotestdox 2 | 3 | import ( 4 | "bufio" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "os" 9 | "os/exec" 10 | "sort" 11 | "strings" 12 | 13 | "github.com/fatih/color" 14 | "github.com/mattn/go-isatty" 15 | ) 16 | 17 | const Usage = `gotestdox is a command-line tool for turning Go test names into readable sentences. 18 | 19 | Usage: 20 | 21 | gotestdox [ARGS] 22 | 23 | This will run 'go test -json [ARGS]' in the current directory and format the results in a readable 24 | way. You can use any arguments that 'go test -json' accepts, including a list of packages, for 25 | example. 26 | 27 | If the standard input is not an interactive terminal, gotestdox will assume you want to pipe JSON 28 | data into it. For example: 29 | 30 | go test -json |gotestdox 31 | 32 | See https://github.com/bitfield/gotestdox for more information.` 33 | 34 | // Main runs the command-line interface for gotestdox. The exit status for the 35 | // binary is 0 if the tests passed, or 1 if the tests failed, or there was some 36 | // error. 37 | func Main() int { 38 | if len(os.Args) > 1 && os.Args[1] == "-h" { 39 | fmt.Println(Usage) 40 | return 0 41 | } 42 | td := NewTestDoxer() 43 | if isatty.IsTerminal(os.Stdin.Fd()) { 44 | td.ExecGoTest(os.Args[1:]) 45 | } else { 46 | td.Filter() 47 | } 48 | if !td.OK { 49 | return 1 50 | } 51 | return 0 52 | } 53 | 54 | // TestDoxer holds the state and config associated with a particular invocation 55 | // of 'go test'. 56 | type TestDoxer struct { 57 | Stdin io.Reader 58 | Stdout, Stderr io.Writer 59 | OK bool 60 | } 61 | 62 | // NewTestDoxer returns a [*TestDoxer] configured with the default I/O streams: 63 | // [os.Stdin], [os.Stdout], and [os.Stderr]. 64 | func NewTestDoxer() *TestDoxer { 65 | return &TestDoxer{ 66 | Stdin: os.Stdin, 67 | Stdout: os.Stdout, 68 | Stderr: os.Stderr, 69 | } 70 | } 71 | 72 | // ExecGoTest runs the 'go test -json' command, with any extra args supplied by 73 | // the user, and consumes its output. Any errors are reported to td's Stderr 74 | // stream, including the full command line that was run. If all tests passed, 75 | // td.OK will be true. If there was a test failure, or 'go test' returned some 76 | // error, then td.OK will be false. 77 | func (td *TestDoxer) ExecGoTest(userArgs []string) { 78 | args := []string{"test", "-json"} 79 | args = append(args, userArgs...) 80 | cmd := exec.Command("go", args...) 81 | goTestOutput, err := cmd.StdoutPipe() 82 | if err != nil { 83 | fmt.Fprintln(td.Stderr, cmd.Args, err) 84 | return 85 | } 86 | cmd.Stderr = td.Stderr 87 | if err := cmd.Start(); err != nil { 88 | fmt.Fprintln(td.Stderr, cmd.Args, err) 89 | return 90 | } 91 | td.Stdin = goTestOutput 92 | td.Filter() 93 | if err := cmd.Wait(); err != nil { 94 | td.OK = false 95 | fmt.Fprintln(td.Stderr, cmd.Args, err) 96 | return 97 | } 98 | } 99 | 100 | // Filter reads from td's Stdin stream, line by line, processing JSON records 101 | // emitted by 'go test -json'. 102 | // 103 | // For each Go package it sees records about, it will print the full name of 104 | // the package to td.Stdout, followed by a line giving the pass/fail status and 105 | // the prettified name of each test, sorted alphabetically. 106 | // 107 | // If all tests passed, td.OK will be true at the end. If not, or if there was 108 | // a parsing error, it will be false. Errors will be reported to td.Stderr. 109 | func (td *TestDoxer) Filter() { 110 | td.OK = true 111 | results := map[string][]Event{} 112 | outputs := map[string][]string{} 113 | scanner := bufio.NewScanner(td.Stdin) 114 | for scanner.Scan() { 115 | event, err := ParseJSON(scanner.Text()) 116 | if err != nil { 117 | td.OK = false 118 | fmt.Fprintln(td.Stderr, err) 119 | return 120 | } 121 | switch { 122 | case event.IsPackageResult(): 123 | fmt.Fprintf(td.Stdout, "%s:\n", event.Package) 124 | tests := results[event.Package] 125 | sort.Slice(tests, func(i, j int) bool { 126 | return tests[i].Sentence < tests[j].Sentence 127 | }) 128 | for _, r := range tests { 129 | fmt.Fprintln(td.Stdout, r.String()) 130 | if r.Action == ActionFail { 131 | for _, line := range outputs[r.Test] { 132 | fmt.Fprint(td.Stdout, line) 133 | } 134 | } 135 | } 136 | fmt.Fprintln(td.Stdout) 137 | case event.IsOutput(): 138 | outputs[event.Test] = append(outputs[event.Test], event.Output) 139 | case event.IsTestResult(), event.IsFuzzFail(): 140 | event.Sentence = Prettify(event.Test) 141 | results[event.Package] = append(results[event.Package], event) 142 | if event.Action == ActionFail { 143 | td.OK = false 144 | } 145 | } 146 | } 147 | } 148 | 149 | // ParseJSON takes a string representing a single JSON test record as emitted 150 | // by 'go test -json', and attempts to parse it into an [Event], returning any 151 | // parsing error encountered. 152 | func ParseJSON(line string) (Event, error) { 153 | event := Event{} 154 | err := json.Unmarshal([]byte(line), &event) 155 | if err != nil { 156 | return Event{}, fmt.Errorf("parsing JSON: %w\ninput: %s", err, line) 157 | } 158 | return event, nil 159 | } 160 | 161 | const ( 162 | ActionPass = "pass" 163 | ActionFail = "fail" 164 | ) 165 | 166 | // Event represents a Go test event as recorded by the 'go test -json' command. 167 | // It does not attempt to unmarshal all the data, only those fields it needs to 168 | // know about. It is based on the (unexported) 'event' struct used by Go's 169 | // [cmd/internal/test2json] package. 170 | type Event struct { 171 | Action string 172 | Package string 173 | Test string 174 | Sentence string 175 | Output string 176 | Elapsed float64 177 | } 178 | 179 | // String formats a test Event for display. The prettified test name will be 180 | // prefixed by a ✔ if the test passed, or an x if it failed. 181 | // 182 | // The sentence generated by [Prettify] from the name of the test will be 183 | // shown, followed by the elapsed time in parentheses, to 2 decimal places. 184 | // 185 | // # Colour 186 | // 187 | // If the program is attached to an interactive terminal, as determined by 188 | // [github.com/mattn/go-isatty], and the NO_COLOR environment variable is not 189 | // set, check marks will be shown in green and x's in red. 190 | func (e Event) String() string { 191 | status := color.RedString("x") 192 | if e.Action == ActionPass { 193 | status = color.GreenString("✔") 194 | } 195 | return fmt.Sprintf(" %s %s (%.2fs)", status, e.Sentence, e.Elapsed) 196 | } 197 | 198 | // IsTestResult determines whether or not the test event is one that we are 199 | // interested in (namely, a pass or fail event on a test). Events on non-tests 200 | // (for example, examples) are ignored, and all events on tests other than pass 201 | // or fail events (for example, run or pause events) are also ignored. 202 | func (e Event) IsTestResult() bool { 203 | // Skip events on benchmarks, examples, and fuzz tests 204 | if strings.HasPrefix(e.Test, "Benchmark") { 205 | return false 206 | } 207 | if strings.HasPrefix(e.Test, "Example") { 208 | return false 209 | } 210 | if strings.HasPrefix(e.Test, "Fuzz") { 211 | return false 212 | } 213 | if e.Test == "" { 214 | return false 215 | } 216 | if e.Action == ActionPass || e.Action == ActionFail { 217 | return true 218 | } 219 | return false 220 | } 221 | 222 | func (e Event) IsFuzzFail() bool { 223 | if !strings.HasPrefix(e.Test, "Fuzz") { 224 | return false 225 | } 226 | if e.Action != ActionFail { 227 | return false 228 | } 229 | return true 230 | } 231 | 232 | // IsPackageResult determines whether or not the test event is a package pass 233 | // or fail event. That is, whether it indicates the passing or failing of a 234 | // package as a whole, rather than some individual test within the package. 235 | func (e Event) IsPackageResult() bool { 236 | if e.Test != "" { 237 | return false 238 | } 239 | if e.Action == ActionPass || e.Action == ActionFail { 240 | return true 241 | } 242 | return false 243 | } 244 | 245 | // IsOutput determines whether or not the event is a test output (for example 246 | // from [testing.T.Error]), excluding status messages automatically generated 247 | // by 'go test' such as "--- FAIL: ..." or "=== RUN / PAUSE / CONT". 248 | func (e Event) IsOutput() bool { 249 | if e.Action != "output" { 250 | return false 251 | } 252 | if strings.HasPrefix(e.Output, "---") { 253 | return false 254 | } 255 | if strings.HasPrefix(e.Output, "===") { 256 | return false 257 | } 258 | return true 259 | } 260 | -------------------------------------------------------------------------------- /gotestdox_test.go: -------------------------------------------------------------------------------- 1 | package gotestdox_test 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/bitfield/gotestdox" 12 | "github.com/fatih/color" 13 | "github.com/google/go-cmp/cmp" 14 | "github.com/rogpeppe/go-internal/testscript" 15 | ) 16 | 17 | func TestMain(m *testing.M) { 18 | os.Exit(testscript.RunMain(m, map[string]func() int{ 19 | "gotestdox": gotestdox.Main, 20 | })) 21 | } 22 | 23 | func TestGotestdoxProducesCorrectOutputWhen(t *testing.T) { 24 | t.Parallel() 25 | testscript.Run(t, testscript.Params{ 26 | Dir: "testdata/script", 27 | }) 28 | } 29 | 30 | func TestParseJSON_ReturnsValidDataForValidJSON(t *testing.T) { 31 | t.Parallel() 32 | input := `{"Time":"2022-02-28T15:53:43.532326Z","Action":"pass","Package":"github.com/bitfield/script","Test":"TestFindFilesInNonexistentPathReturnsError","Elapsed":0.12}` 33 | want := gotestdox.Event{ 34 | Action: "pass", 35 | Package: "github.com/bitfield/script", 36 | Test: "TestFindFilesInNonexistentPathReturnsError", 37 | Elapsed: 0.12, 38 | } 39 | 40 | got, err := gotestdox.ParseJSON(input) 41 | if err != nil { 42 | t.Fatal(err) 43 | } 44 | if !cmp.Equal(want, got) { 45 | t.Error(cmp.Diff(want, got)) 46 | } 47 | } 48 | 49 | func TestParseJSON_ErrorsOnInvalidJSON(t *testing.T) { 50 | t.Parallel() 51 | input := `invalid` 52 | _, err := gotestdox.ParseJSON(input) 53 | if err == nil { 54 | t.Error("want error") 55 | } 56 | } 57 | 58 | func TestEventString_FormatsPassAndFailEventsDifferently(t *testing.T) { 59 | t.Parallel() 60 | pass := gotestdox.Event{ 61 | Action: "pass", 62 | Test: "TestFooDoesX", 63 | }.String() 64 | fail := gotestdox.Event{ 65 | Action: "fail", 66 | Test: "TestFooDoesX", 67 | }.String() 68 | if pass == fail { 69 | t.Errorf("both pass and fail events formatted as %q", pass) 70 | } 71 | } 72 | 73 | func TestIsFuzzFail_IsTrueForFuzzFailEvents(t *testing.T) { 74 | t.Parallel() 75 | event := gotestdox.Event{ 76 | Action: "fail", 77 | Test: "FuzzBar", 78 | } 79 | if !event.IsFuzzFail() { 80 | t.Errorf("false for %q event on %q", event.Action, event.Test) 81 | } 82 | } 83 | 84 | func TestIsFuzzFail_IsFalseForNonFuzzFailEvents(t *testing.T) { 85 | t.Parallel() 86 | tcs := []gotestdox.Event{ 87 | { 88 | Action: "pass", 89 | Test: "FuzzBar", 90 | }, 91 | { 92 | Action: "fail", 93 | Test: "TestFooDoesX", 94 | }, 95 | } 96 | for _, event := range tcs { 97 | if event.IsFuzzFail() { 98 | t.Errorf("true for %q event on %q", event.Action, event.Test) 99 | } 100 | } 101 | } 102 | 103 | func TestIsTestResult_IsTrueForTestPassOrFailEvents(t *testing.T) { 104 | t.Parallel() 105 | tcs := []gotestdox.Event{ 106 | { 107 | Action: "pass", 108 | Test: "TestFooDoesX", 109 | }, 110 | { 111 | Action: "fail", 112 | Test: "TestFooDoesX", 113 | }, 114 | } 115 | for _, event := range tcs { 116 | if !event.IsTestResult() { 117 | t.Errorf("false for %q event on %q", event.Action, event.Test) 118 | } 119 | } 120 | } 121 | 122 | func TestIsTestResult_IsFalseForNonTestPassFailEvents(t *testing.T) { 123 | t.Parallel() 124 | tcs := []gotestdox.Event{ 125 | { 126 | Action: "pass", 127 | Test: "ExampleFooDoesX", 128 | }, 129 | { 130 | Action: "fail", 131 | Test: "BenchmarkFooDoesX", 132 | }, 133 | { 134 | Action: "pass", 135 | Test: "", 136 | }, 137 | { 138 | Action: "fail", 139 | Test: "", 140 | }, 141 | { 142 | Action: "pass", 143 | Test: "FuzzBar", 144 | }, 145 | { 146 | Action: "run", 147 | Test: "TestFooDoesX", 148 | }, 149 | } 150 | for _, event := range tcs { 151 | if event.IsTestResult() { 152 | t.Errorf("true for %q event on %q", event.Action, event.Test) 153 | } 154 | } 155 | } 156 | 157 | func TestIsPackageResult_IsTrueForPackageResultEvents(t *testing.T) { 158 | t.Parallel() 159 | tcs := []gotestdox.Event{ 160 | { 161 | Action: "pass", 162 | Test: "", 163 | }, 164 | { 165 | Action: "fail", 166 | Test: "", 167 | }, 168 | } 169 | for _, event := range tcs { 170 | if !event.IsPackageResult() { 171 | t.Errorf("false for package result event %#v", event) 172 | } 173 | } 174 | } 175 | 176 | func TestIsPackageResult_IsFalseForNonPackageResultEvents(t *testing.T) { 177 | t.Parallel() 178 | tcs := []gotestdox.Event{ 179 | { 180 | Action: "pass", 181 | Test: "TestSomething", 182 | }, 183 | { 184 | Action: "fail", 185 | Test: "TestSomething", 186 | }, 187 | { 188 | Action: "output", 189 | Test: "", 190 | }, 191 | } 192 | for _, event := range tcs { 193 | if event.IsPackageResult() { 194 | t.Errorf("true for non package result event %#v", event) 195 | } 196 | } 197 | } 198 | 199 | func TestNewTestDoxer_ReturnsTestdoxerWithStandardIOStreams(t *testing.T) { 200 | t.Parallel() 201 | td := gotestdox.NewTestDoxer() 202 | if td.Stdin != os.Stdin { 203 | t.Error("want stdin os.Stdin") 204 | } 205 | if td.Stdout != os.Stdout { 206 | t.Error("want stdout os.Stdout") 207 | } 208 | if td.Stderr != os.Stderr { 209 | t.Error("want stderr os.Stderr") 210 | } 211 | } 212 | 213 | func TestExecGoTest_SetsOKToFalseWhenCommandErrors(t *testing.T) { 214 | t.Parallel() 215 | td := gotestdox.TestDoxer{ 216 | Stdout: io.Discard, 217 | Stderr: io.Discard, 218 | } 219 | td.ExecGoTest([]string{"bogus"}) 220 | if td.OK { 221 | t.Error("want not ok") 222 | } 223 | } 224 | 225 | func ExampleTestDoxer_Filter() { 226 | input := `{"Action":"pass","Package":"demo","Test":"TestItWorks"} 227 | {"Action":"pass","Package":"demo","Elapsed":0}` 228 | td := gotestdox.NewTestDoxer() 229 | td.Stdin = strings.NewReader(input) 230 | color.NoColor = true 231 | td.Filter() 232 | // Output: 233 | // demo: 234 | // ✔ It works (0.00s) 235 | } 236 | 237 | func ExampleEvent_String() { 238 | event := gotestdox.Event{ 239 | Action: "pass", 240 | Sentence: "It works", 241 | } 242 | color.NoColor = true 243 | fmt.Println(event.String()) 244 | // Output: 245 | // ✔ It works (0.00s) 246 | } 247 | 248 | func ExampleEvent_IsTestResult_true() { 249 | event := gotestdox.Event{ 250 | Action: "pass", 251 | Test: "TestItWorks", 252 | } 253 | fmt.Println(event.IsTestResult()) 254 | // Output: 255 | // true 256 | } 257 | 258 | func ExampleEvent_IsTestResult_false() { 259 | event := gotestdox.Event{ 260 | Action: "fail", 261 | Test: "ExampleEventsShouldBeIgnored", 262 | } 263 | fmt.Println(event.IsTestResult()) 264 | // Output: 265 | // false 266 | } 267 | 268 | func ExampleParseJSON() { 269 | input := `{"Action":"pass","Package":"demo","Test":"TestItWorks","Output":"","Elapsed":0.2}` 270 | event, err := gotestdox.ParseJSON(input) 271 | if err != nil { 272 | log.Fatal(err) 273 | } 274 | fmt.Printf("%#v\n", event) 275 | // Output: 276 | // gotestdox.Event{Action:"pass", Package:"demo", Test:"TestItWorks", Sentence:"", Output:"", Elapsed:0.2} 277 | } 278 | -------------------------------------------------------------------------------- /img/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfield/gotestdox/32a7dcc2f44fc85a497253b56aa28e0262006821/img/demo.gif -------------------------------------------------------------------------------- /img/gotestdox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfield/gotestdox/32a7dcc2f44fc85a497253b56aa28e0262006821/img/gotestdox.png -------------------------------------------------------------------------------- /prettifier.go: -------------------------------------------------------------------------------- 1 | package gotestdox 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "strings" 8 | "unicode" 9 | 10 | "golang.org/x/text/cases" 11 | "golang.org/x/text/language" 12 | ) 13 | 14 | // Prettify takes a string input representing the name of a Go test, and 15 | // attempts to turn it into a readable sentence, by replacing camel-case 16 | // transitions and underscores with spaces. 17 | // 18 | // input is expected to be a valid Go test name, as produced by 'go test 19 | // -json'. For example, input might be the string: 20 | // 21 | // TestFoo/has_well-formed_output 22 | // 23 | // Here, the parent test is TestFoo, and this data is about a subtest whose 24 | // name is 'has well-formed output'. Go's [testing] package replaces spaces in 25 | // subtest names with underscores, and unprintable characters with the 26 | // equivalent Go literal. 27 | // 28 | // Prettify does its best to reverse this transformation, yielding (something 29 | // close to) the original subtest name. For example: 30 | // 31 | // Foo has well-formed output 32 | // 33 | // # Multiword function names 34 | // 35 | // Because Go function names are often in camel-case, there's an ambiguity in 36 | // parsing a test name like this: 37 | // 38 | // TestHandleInputClosesInputAfterReading 39 | // 40 | // We can see that this is about a function named HandleInput, but Prettify has 41 | // no way of knowing that. Without this information, it would produce: 42 | // 43 | // Handle input closes input after reading 44 | // 45 | // To give it a hint, we can add an underscore after the name of the function: 46 | // 47 | // TestHandleInput_ClosesInputAfterReading 48 | // 49 | // This will be interpreted as marking the end of a multiword function name: 50 | // 51 | // HandleInput closes input after reading 52 | // 53 | // # Debugging 54 | // 55 | // If the GOTESTDOX_DEBUG environment variable is set, Prettify will output 56 | // (copious) debug information to the [DebugWriter] stream, elaborating on its 57 | // decisions. 58 | func Prettify(input string) string { 59 | var prefix string 60 | p := &prettifier{ 61 | words: []string{}, 62 | debug: io.Discard, 63 | } 64 | if os.Getenv("GOTESTDOX_DEBUG") != "" { 65 | p.debug = DebugWriter 66 | } 67 | p.log("input:", input) 68 | if strings.HasPrefix(input, "Fuzz") { 69 | input = strings.TrimPrefix(input, "Fuzz") 70 | prefix = "[fuzz] " 71 | } 72 | p.input = []rune(strings.TrimPrefix(input, "Test")) 73 | for state := betweenWords; state != nil; { 74 | state = state(p) 75 | } 76 | result := prefix + strings.Join(p.words, " ") 77 | p.log(fmt.Sprintf("result: %q", result)) 78 | return result 79 | } 80 | 81 | // Heavily inspired by Rob Pike's talk on 'Lexical Scanning in Go': 82 | // https://www.youtube.com/watch?v=HxaD_trXwRE 83 | type prettifier struct { 84 | debug io.Writer 85 | input []rune 86 | start, pos int 87 | words []string 88 | inSubTest bool 89 | seenUnderscore bool 90 | } 91 | 92 | func (p *prettifier) backup() { 93 | p.pos-- 94 | } 95 | 96 | func (p *prettifier) skip() { 97 | p.start = p.pos 98 | } 99 | 100 | func (p *prettifier) prev() rune { 101 | return p.input[p.pos-1] 102 | } 103 | 104 | func (p *prettifier) next() rune { 105 | next := p.peek() 106 | p.pos++ 107 | return next 108 | } 109 | 110 | func (p *prettifier) peek() rune { 111 | if p.pos >= len(p.input) { 112 | return eof 113 | } 114 | next := p.input[p.pos] 115 | return next 116 | } 117 | 118 | func (p *prettifier) inInitialism() bool { 119 | // deal with Is and As corner cases 120 | if len(p.input) > p.start+1 && p.input[p.start+1] == 's' { 121 | return false 122 | } 123 | for _, r := range p.input[p.start:p.pos] { 124 | if unicode.IsLower(r) && r != 's' { 125 | return false 126 | } 127 | } 128 | return true 129 | } 130 | 131 | func (p *prettifier) emit() { 132 | word := string(p.input[p.start:p.pos]) 133 | switch { 134 | case len(p.words) == 0: 135 | // This is the first word, capitalise it 136 | word = cases.Title(language.Und, cases.NoLower).String(word) 137 | case len(word) == 1: 138 | // Single letter word such as A 139 | word = cases.Lower(language.Und).String(word) 140 | case p.inInitialism(): 141 | // leave capitalisation as is 142 | default: 143 | word = cases.Lower(language.Und).String(word) 144 | } 145 | p.log(fmt.Sprintf("emit %q", word)) 146 | p.words = append(p.words, word) 147 | p.skip() 148 | } 149 | 150 | func (p *prettifier) multiWordFunction() { 151 | var fname string 152 | for _, w := range p.words { 153 | fname += cases.Title(language.Und, cases.NoLower).String(w) 154 | } 155 | p.log("multiword function", fname) 156 | p.words = []string{fname} 157 | p.seenUnderscore = true 158 | } 159 | 160 | func (p *prettifier) log(args ...interface{}) { 161 | fmt.Fprintln(p.debug, args...) 162 | } 163 | 164 | func (p *prettifier) logState(stateName string) { 165 | next := "EOF" 166 | if p.pos < len(p.input) { 167 | next = string(p.input[p.pos]) 168 | } 169 | p.log(fmt.Sprintf("%s: [%s] -> %s", 170 | stateName, 171 | string(p.input[p.start:p.pos]), 172 | next, 173 | )) 174 | } 175 | 176 | type stateFunc func(p *prettifier) stateFunc 177 | 178 | func betweenWords(p *prettifier) stateFunc { 179 | for { 180 | p.logState("betweenWords") 181 | switch p.next() { 182 | case eof: 183 | return nil 184 | case '_', '/': 185 | p.skip() 186 | default: 187 | return inWord 188 | } 189 | } 190 | } 191 | 192 | func inWord(p *prettifier) stateFunc { 193 | for { 194 | p.logState("inWord") 195 | switch r := p.peek(); { 196 | case r == eof: 197 | p.emit() 198 | return nil 199 | case r == '_': 200 | p.emit() 201 | if !p.seenUnderscore && !p.inSubTest { 202 | // special 'end of function name' marker 203 | p.multiWordFunction() 204 | } 205 | return betweenWords 206 | case r == '/': 207 | p.emit() 208 | p.inSubTest = true 209 | return betweenWords 210 | case unicode.IsUpper(r): 211 | if p.prev() == '-' { 212 | // inside hyphenated word 213 | p.next() 214 | continue 215 | } 216 | if p.inInitialism() { 217 | // keep going 218 | p.next() 219 | continue 220 | } 221 | p.emit() 222 | return betweenWords 223 | case unicode.IsDigit(r): 224 | if unicode.IsDigit(p.prev()) { 225 | // in a multi-digit number 226 | p.next() 227 | continue 228 | } 229 | if p.prev() == '-' { 230 | // in a negative number 231 | p.next() 232 | continue 233 | } 234 | if p.prev() == '=' { 235 | // in some phrase like 'n=3' 236 | p.next() 237 | continue 238 | } 239 | if p.inInitialism() { 240 | // keep going 241 | p.next() 242 | continue 243 | } 244 | p.emit() 245 | return betweenWords 246 | default: 247 | if p.pos-p.start <= 1 { 248 | // word too short 249 | p.next() 250 | continue 251 | } 252 | if p.input[p.start] == '\'' { 253 | // inside a quoted word 254 | p.next() 255 | continue 256 | } 257 | if !p.inInitialism() { 258 | // keep going 259 | p.next() 260 | continue 261 | } 262 | if p.inInitialism() && r == 's' { 263 | p.next() 264 | p.emit() 265 | return betweenWords 266 | } 267 | // start a new word 268 | p.backup() 269 | p.emit() 270 | } 271 | } 272 | } 273 | 274 | const eof rune = 0 275 | 276 | // DebugWriter identifies the stream to which debug information should be 277 | // printed, if desired. By default it is [os.Stderr]. 278 | var DebugWriter io.Writer = os.Stderr 279 | -------------------------------------------------------------------------------- /prettifier_test.go: -------------------------------------------------------------------------------- 1 | package gotestdox_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/bitfield/gotestdox" 8 | "github.com/google/go-cmp/cmp" 9 | ) 10 | 11 | func TestPrettify(t *testing.T) { 12 | t.Parallel() 13 | for _, tc := range Cases { 14 | t.Run(tc.name, func(t *testing.T) { 15 | got := gotestdox.Prettify(tc.input) 16 | if tc.want != got { 17 | t.Errorf("%s:\ninput: %q:\nresult: %s", tc.name, tc.input, cmp.Diff(tc.want, got)) 18 | } 19 | }) 20 | } 21 | } 22 | 23 | func BenchmarkPrettify(b *testing.B) { 24 | input := "TestParseJSON_CorrectlyParsesASingleGoTestJSONOutputLine" 25 | for range b.N { 26 | _ = gotestdox.Prettify(input) 27 | } 28 | } 29 | 30 | func ExamplePrettify() { 31 | input := "TestFoo/has_well-formed_output" 32 | fmt.Println(gotestdox.Prettify(input)) 33 | // Output: 34 | // Foo has well-formed output 35 | } 36 | 37 | func ExamplePrettify_underscoreHint() { 38 | input := "TestHandleInput_ClosesInputAfterReading" 39 | fmt.Println(gotestdox.Prettify(input)) 40 | // Output: 41 | // HandleInput closes input after reading 42 | } 43 | 44 | var Cases = []struct { 45 | name, input, want string 46 | }{ 47 | { 48 | name: "accepts a single-letter test name", 49 | input: "TestS", 50 | want: "S", 51 | }, 52 | { 53 | name: "accepts a single-word test name", 54 | input: "TestSum", 55 | want: "Sum", 56 | }, 57 | { 58 | name: "replaces camel-case transitions with spaces", 59 | input: "TestSumCorrectlySumsInputNumbers", 60 | want: "Sum correctly sums input numbers", 61 | }, 62 | { 63 | name: "preserves capitalisation of initialisms such as 'PDF'", 64 | input: "TestFooGeneratesValidPDFFile", 65 | want: "Foo generates valid PDF file", 66 | }, 67 | { 68 | name: "does not hang when name ends with initialism", 69 | input: "TestFooGeneratesValidPDF", 70 | want: "Foo generates valid PDF", 71 | }, 72 | { 73 | name: "preserves capitalisation of initialism when it is the first word", 74 | input: "TestJSONSucks", 75 | want: "JSON sucks", 76 | }, 77 | { 78 | name: "preserves capitalisation of two-letter initialisms such as 'OK'", 79 | input: "TestFilterReturnsOKIfThereAreNoTestFailures", 80 | want: "Filter returns OK if there are no test failures", 81 | }, 82 | { 83 | name: "preserves longer all-caps words", 84 | input: "TestCategoryTrimsLEADINGSpacesFromValidCategory", 85 | want: "Category trims LEADING spaces from valid category", 86 | }, 87 | { 88 | name: "treats numbers as word separators", 89 | input: "TestFooDoes8Things", 90 | want: "Foo does 8 things", 91 | }, 92 | { 93 | name: "keeps a trailing digit as part of an initialism", 94 | input: "TestFooGeneratesUTF8Correctly", 95 | want: "Foo generates UTF8 correctly", 96 | }, 97 | { 98 | name: "knows that just 'Test' is a valid test name", 99 | input: "Test", 100 | want: "", 101 | }, 102 | { 103 | name: "treats underscores as word breaks", 104 | input: "Test_Foo_GeneratesValidPDFFile", 105 | want: "Foo generates valid PDF file", 106 | }, 107 | { 108 | name: "treats consecutive underscores as a single word break", 109 | input: "Test_Foo__Works", 110 | want: "Foo works", 111 | }, 112 | { 113 | name: "doesn't incorrectly title-case single-letter words", 114 | input: "TestFooDoesAThing", 115 | want: "Foo does a thing", 116 | }, 117 | { 118 | name: "renders subtest names without the slash, and with underscores replaced by spaces", 119 | input: "TestSliceSink/Empty_line_between_two_existing_lines", 120 | want: "Slice sink empty line between two existing lines", 121 | }, 122 | { 123 | name: "inserts a word break before subtest names beginning with a lowercase letter", 124 | input: "TestExec/go_help", 125 | want: "Exec go help", 126 | }, 127 | { 128 | name: "is okay with test names not in the form of a sentence", 129 | input: "TestMatch", 130 | want: "Match", 131 | }, 132 | { 133 | name: "treats a single underscore as marking the end of a multiword function name", 134 | input: "TestFindFiles_WorksCorrectly", 135 | want: "FindFiles works correctly", 136 | }, 137 | { 138 | name: "retains capitalisation of initialisms in a multiword function name", 139 | input: "TestParseJSON_CorrectlyParsesASingleGoTestJSONOutputLine", 140 | want: "ParseJSON correctly parses a single go test JSON output line", 141 | }, 142 | { 143 | name: "treats a single underscore before the first slash as marking the end of a multiword function name", 144 | input: "TestFindFiles_/WorksCorrectly", 145 | want: "FindFiles works correctly", 146 | }, 147 | { 148 | name: "handles multiple underscores, with the first marking the end of a multiword function name", 149 | input: "TestFindFiles_Does_Stuff", 150 | want: "FindFiles does stuff", 151 | }, 152 | { 153 | name: "does not treat an underscore in a subtest name as marking the end of a multiword function name", 154 | input: "TestCallingTheFunction/Does_Stuff", 155 | want: "Calling the function does stuff", 156 | }, 157 | { 158 | name: "eliminates any words containing underscores after splitting", 159 | input: "TestSentence/does_x,_correctly", 160 | want: "Sentence does x, correctly", 161 | }, 162 | { 163 | name: "retains hyphenated words in their original form", 164 | input: "TestFoo/has_well-formed_output", 165 | want: "Foo has well-formed output", 166 | }, 167 | { 168 | name: "retains apostrophised words in their original form", 169 | input: "TestFoo/does_what's_required", 170 | want: "Foo does what's required", 171 | }, 172 | { 173 | name: "retains quoted words as quoted", 174 | input: "TestFoo/handles_'Bar'_correctly", 175 | want: "Foo handles 'bar' correctly", 176 | }, 177 | { 178 | name: "does not erase the final digit in words that end with a digit", 179 | input: "TestExtractFiles/Truncated_bzip2_which_will_return_an_error", 180 | want: "Extract files truncated bzip 2 which will return an error", 181 | }, 182 | { 183 | name: "recognises a dash followed by a digit as a negative number", 184 | input: "TestColumnSelects/column_-1_of_input", 185 | want: "Column selects column -1 of input", 186 | }, 187 | { 188 | name: "keeps numbers within a hyphenated word", 189 | input: "TestReadExtended/nyc-taxi-data-100k.csv", 190 | want: "Read extended nyc-taxi-data-100k.csv", 191 | }, 192 | { 193 | name: "keeps together hyphenated words with initial capitals", 194 | input: "TestListObjectsVersionedFolders/Erasure-Test", 195 | want: "List objects versioned folders erasure-test", 196 | }, 197 | { 198 | name: "keeps together digits in numbers that are standalone words", 199 | input: "TestLex11", 200 | want: "Lex 11", 201 | }, 202 | { 203 | name: "handles a test with no name, but with subtests", 204 | input: "Test/default/issue12839", 205 | want: "Default issue 12839", 206 | }, 207 | { 208 | name: "does not break words when a digit follows an '=' sign", 209 | input: "TestUniformFactorial/n=3", 210 | want: "Uniform factorial n=3", 211 | }, 212 | { 213 | name: "preserves initialisms containing digits", 214 | input: "TestS390XOperandParser", 215 | want: "S390X operand parser", 216 | }, 217 | { 218 | name: "preserves initialisms containing digits with two or more leading alpha characters", 219 | input: "TestBC35A", 220 | want: "BC35A", 221 | }, 222 | { 223 | name: "preserves plural initialisms", 224 | input: "TestFooReturnsIDsAValue", 225 | want: "Foo returns IDs a value", 226 | }, 227 | { 228 | name: "does not treat 'Is' or 'As' as initialisms", 229 | input: "TestThisIsAsItShouldBe", 230 | want: "This is as it should be", 231 | }, 232 | { 233 | name: "does not panic when a single-letter word ends the sentence", 234 | input: "TestShiftTransforms255To0", 235 | want: "Shift transforms 255 to 0", 236 | }, 237 | { 238 | name: "correctly formats fuzz test names", 239 | input: "FuzzPrettify", 240 | want: "[fuzz] Prettify", 241 | }, 242 | } 243 | -------------------------------------------------------------------------------- /testdata/fuzz/FuzzPrettify/02d4bc9f4e567048ebe719db6d94d22ed0788a56e3bdf4aa1fa3a546b5a681a4: -------------------------------------------------------------------------------- 1 | go test fuzz v1 2 | string("A/") 3 | -------------------------------------------------------------------------------- /testdata/fuzz/FuzzPrettify/05bda4c43f0606152eb8661c3f07af35c23ea151e4c0eba13616e681baa0b76b: -------------------------------------------------------------------------------- 1 | go test fuzz v1 2 | string("AA/") 3 | -------------------------------------------------------------------------------- /testdata/fuzz/FuzzPrettify/1d9c428aec8af9b4c3d6f8f87bb3fd4cd88776a6518bf232ca8ece05de180c4f: -------------------------------------------------------------------------------- 1 | go test fuzz v1 2 | string("Aa0/") 3 | -------------------------------------------------------------------------------- /testdata/fuzz/FuzzPrettify/2759ad33bc85f335199a678f5b2afd245760bc670e03abcfedd22440262e7f20: -------------------------------------------------------------------------------- 1 | go test fuzz v1 2 | string("A_0") 3 | -------------------------------------------------------------------------------- /testdata/fuzz/FuzzPrettify/383824675cc2c177e198f74ede3c8ca29be00e2b6797a034a7a5839a4cd9af1c: -------------------------------------------------------------------------------- 1 | go test fuzz v1 2 | string("A00A_") 3 | -------------------------------------------------------------------------------- /testdata/fuzz/FuzzPrettify/5838cdfae7b16cde2707c04599b62223e7bade8dafdd8a1c53b8f881e8f79d99: -------------------------------------------------------------------------------- 1 | go test fuzz v1 2 | string("") 3 | -------------------------------------------------------------------------------- /testdata/fuzz/FuzzPrettify/68ed403ba2cdc61b143313312fd67edbd7e8bc6481570c7d1d7b6901793166ce: -------------------------------------------------------------------------------- 1 | go test fuzz v1 2 | string("A00/") 3 | -------------------------------------------------------------------------------- /testdata/fuzz/FuzzPrettify/7287749262c764198e0bfd313015202b1503cb011ed58da513853e0fb2e68601: -------------------------------------------------------------------------------- 1 | go test fuzz v1 2 | string("A00A/") 3 | -------------------------------------------------------------------------------- /testdata/fuzz/FuzzPrettify/931b61b895a37b9f6d196cc73123abb83cdd7bc05e469ad7d4b76bd1ff16db60: -------------------------------------------------------------------------------- 1 | go test fuzz v1 2 | string("Ꮕ") 3 | -------------------------------------------------------------------------------- /testdata/fuzz/FuzzPrettify/9eca0c15d9a07d7821d43cbb4e08d4b84ca610d679b1ee89a0f0a9cdf46f5b79: -------------------------------------------------------------------------------- 1 | go test fuzz v1 2 | string("A00a/") 3 | -------------------------------------------------------------------------------- /testdata/fuzz/FuzzPrettify/aa12d4e4166bf7ad00d59c3ec5e9ef473494bd2fde7d93b48136abbc77a5acef: -------------------------------------------------------------------------------- 1 | go test fuzz v1 2 | string("Aa0a/") 3 | -------------------------------------------------------------------------------- /testdata/fuzz/FuzzPrettify/d72586fff5c01205: -------------------------------------------------------------------------------- 1 | go test fuzz v1 2 | string("0a0sV") 3 | -------------------------------------------------------------------------------- /testdata/fuzz/FuzzPrettify/f5d26b6486179756258edcb8fde23e655aeae6f7ce1d0d22380609e5055cd6de: -------------------------------------------------------------------------------- 1 | go test fuzz v1 2 | string("A0/_") 3 | -------------------------------------------------------------------------------- /testdata/script/debug_output_is_requested.txtar: -------------------------------------------------------------------------------- 1 | env GOTESTDOX_DEBUG=1 2 | stdin input.json 3 | exec gotestdox 4 | cmp stdout golden.txt 5 | cmp stderr debug.txt 6 | 7 | -- input.json -- 8 | {"Action":"pass","Package":"dummy","Test":"TestItWorks"} 9 | {"Action":"pass","Package":"dummy","Elapsed":0.18} 10 | -- golden.txt -- 11 | dummy: 12 | ✔ It works (0.00s) 13 | 14 | -- debug.txt -- 15 | input: TestItWorks 16 | betweenWords: [] -> I 17 | inWord: [I] -> t 18 | inWord: [It] -> W 19 | emit "It" 20 | betweenWords: [] -> W 21 | inWord: [W] -> o 22 | inWord: [Wo] -> r 23 | inWord: [Wor] -> k 24 | inWord: [Work] -> s 25 | inWord: [Works] -> EOF 26 | emit "works" 27 | result: "It works" 28 | -------------------------------------------------------------------------------- /testdata/script/events_should_be_grouped_by_package.txtar: -------------------------------------------------------------------------------- 1 | stdin input.json 2 | exec gotestdox -v 3 | cmp stdout golden.txt 4 | 5 | -- input.json -- 6 | {"Action":"pass","Package":"a","Test":"TestA"} 7 | {"Action":"pass","Package":"a","Test":"TestB"} 8 | {"Action":"pass","Package":"a"} 9 | -- golden.txt -- 10 | a: 11 | ✔ A (0.00s) 12 | ✔ B (0.00s) 13 | 14 | -------------------------------------------------------------------------------- /testdata/script/examples_should_be_ignored.txtar: -------------------------------------------------------------------------------- 1 | stdin passing.json 2 | exec gotestdox 3 | cmp stdout golden.txt 4 | 5 | -- passing.json -- 6 | {"Action":"run","Package":"dummy","Test":"TestDummy"} 7 | {"Action":"output","Package":"dummy","Test":"TestDummy","Output":"=== RUN TestDummy\n"} 8 | {"Action":"output","Package":"dummy","Test":"TestDummy","Output":"--- PASS: TestDummy (0.00s)\n"} 9 | {"Action":"pass","Package":"dummy","Test":"TestDummy"} 10 | {"Action":"run","Package":"dummy","Test":"ExampleShouldBeIgnored"} 11 | {"Action":"output","Package":"dummy","Test":"ExampleShouldBeIgnored","Output":"=== RUN ExampleShouldBeIgnored\n"} 12 | {"Action":"output","Package":"dummy","Test":"ExampleShouldBeIgnored","Output":"--- PASS: ExampleShouldBeIgnored (0.00s)\n"} 13 | {"Action":"pass","Package":"dummy","Test":"ExampleShouldBeIgnored"} 14 | {"Action":"output","Package":"dummy","Output":"PASS\n"} 15 | {"Action":"output","Package":"dummy","Output":"ok \tdummy\t0.180s\n"} 16 | {"Action":"pass","Package":"dummy","Elapsed":0.18} 17 | -- golden.txt -- 18 | dummy: 19 | ✔ Dummy (0.00s) 20 | 21 | -------------------------------------------------------------------------------- /testdata/script/help_requested.txtar: -------------------------------------------------------------------------------- 1 | exec gotestdox -h 2 | stdout 'Usage' -------------------------------------------------------------------------------- /testdata/script/json_data_is_invalid.txtar: -------------------------------------------------------------------------------- 1 | stdin invalid.json 2 | ! exec gotestdox 3 | 4 | -- invalid.json -- 5 | bogus 6 | -------------------------------------------------------------------------------- /testdata/script/package_events_interleaved.txtar: -------------------------------------------------------------------------------- 1 | stdin input.json 2 | ! exec gotestdox 3 | cmp stdout golden.txt 4 | 5 | -- input.json -- 6 | {"Action":"fail","Package":"p","Test":"Test_B"} 7 | {"Action":"pass","Package":"q","Test":"TestB"} 8 | {"Action":"pass","Package":"p","Test":"TestA"} 9 | {"Action":"pass","Package":"p","Test":"TestC"} 10 | {"Action":"pass","Package":"q","Test":"TestA"} 11 | {"Action":"pass","Package":"p"} 12 | {"Action":"pass","Package":"q"} 13 | -- golden.txt -- 14 | p: 15 | ✔ A (0.00s) 16 | x B (0.00s) 17 | ✔ C (0.00s) 18 | 19 | q: 20 | ✔ A (0.00s) 21 | ✔ B (0.00s) 22 | 23 | -------------------------------------------------------------------------------- /testdata/script/tests_fail.txtar: -------------------------------------------------------------------------------- 1 | stdin failing.json 2 | ! exec gotestdox 3 | cmp stdout golden.txt 4 | 5 | -- failing.json -- 6 | {"Action":"run","Package":"dummy","Test":"TestDummy"} 7 | {"Action":"output","Package":"dummy","Test":"TestDummy","Output":"=== RUN TestDummy\n"} 8 | {"Action":"output","Package":"dummy","Test":"TestDummy","Output":"--- FAIL: TestDummy (0.00s)\n"} 9 | {"Action":"output","Package":"dummy","Test":"TestDummy","Output":" dummy_test.go:23: oh no\n"} 10 | {"Action":"fail","Package":"dummy","Test":"TestDummy"} 11 | {"Action":"output","Package":"dummy","Output":"FAIL\n"} 12 | {"Action":"output","Package":"dummy","Output":"exit status 1\n"} 13 | {"Action":"output","Package":"dummy","Output":"FAIL\tdummy\t0.222s\n"} 14 | {"Action":"fail","Package":"dummy","Elapsed":0.222} 15 | -- golden.txt -- 16 | dummy: 17 | x Dummy (0.00s) 18 | dummy_test.go:23: oh no 19 | 20 | -------------------------------------------------------------------------------- /testdata/script/tests_not_sorted_alphabetically.txtar: -------------------------------------------------------------------------------- 1 | stdin input.json 2 | ! exec gotestdox 3 | cmp stdout golden.txt 4 | 5 | -- input.json -- 6 | {"Action":"fail","Package":"p","Test":"Test_B"} 7 | {"Action":"pass","Package":"p","Test":"TestA"} 8 | {"Action":"pass","Package":"p","Test":"TestC"} 9 | {"Action":"fail","Package":"p"} 10 | -- golden.txt -- 11 | p: 12 | ✔ A (0.00s) 13 | x B (0.00s) 14 | ✔ C (0.00s) 15 | 16 | -------------------------------------------------------------------------------- /testdata/script/tests_pass.txtar: -------------------------------------------------------------------------------- 1 | stdin passing.json 2 | exec gotestdox 3 | cmp stdout golden.txt 4 | 5 | -- passing.json -- 6 | {"Action":"run","Package":"dummy","Test":"TestDummy"} 7 | {"Action":"output","Package":"dummy","Test":"TestDummy","Output":"=== RUN TestDummy\n"} 8 | {"Action":"output","Package":"dummy","Test":"TestDummy","Output":"--- PASS: TestDummy (0.00s)\n"} 9 | {"Action":"pass","Package":"dummy","Test":"TestDummy"} 10 | {"Action":"output","Package":"dummy","Output":"PASS\n"} 11 | {"Action":"output","Package":"dummy","Output":"ok \tdummy\t0.180s\n"} 12 | {"Action":"pass","Package":"dummy","Elapsed":0.18} 13 | -- golden.txt -- 14 | dummy: 15 | ✔ Dummy (0.00s) 16 | 17 | --------------------------------------------------------------------------------