├── .github
└── workflows
│ └── test.yml
├── .gitignore
├── LICENSE
├── README.md
├── chart.go
├── docs
├── img1.png
├── img2.png
├── img3.png
├── img4.png
└── logo.svg
├── go.mod
├── go.sum
├── html.go
├── main.go
├── node_modules
├── .package-lock.json
└── plotly.js-dist
│ ├── LICENSE
│ ├── README.md
│ ├── package.json
│ └── plotly.js
├── package-lock.json
├── package.json
├── parser.go
├── parser_test.go
├── server.go
└── testdata
├── golden.html
├── golden.json
└── test.json
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 | pull_request:
7 |
8 | jobs:
9 |
10 | test:
11 | name: Run Tests
12 | runs-on: ubuntu-latest
13 |
14 | steps:
15 | - name: Check out code
16 | uses: actions/checkout@v3
17 |
18 | - name: Set up Go
19 | uses: actions/setup-go@v4
20 | with:
21 | go-version: '1.23'
22 |
23 | - name: Get dependencies
24 | run: go mod download
25 |
26 | - name: Run tests
27 | run: go test -v ./...
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/roblaszczak/vgt/766bbdec893fc7f84b2c56c8e5d66c130f5a7635/.gitignore
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Robert Laszczak
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.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # vgt - Visualise Go Tests
2 |
3 |
4 |
5 |
6 |
7 | ---
8 |
9 | `vgt` is a tool for visualising Go test results in a browser.
10 |
11 | It's helpful with understanding parallelism of tests and identifying slow tests.
12 | More information can be found in our [blog post about optimising Go tests parallelism](https://threedots.tech/post/go-test-parallelism/).
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | ## Installation
26 |
27 | ```bash
28 | go install github.com/roblaszczak/vgt@latest
29 | ```
30 |
31 | You can also run without installing by running `go run github.com/roblaszczak/vgt@latest`.
32 |
33 | ## Usage
34 |
35 | For visualising test results, run `go test` with the `-json` flag and pipe the output to `vgt`.
36 |
37 | ```bash
38 | go test -json ./... | vgt
39 | ```
40 |
41 | or with `go run`:
42 |
43 | ```bash
44 | go test -json ./... | go run github.com/roblaszczak/vgt@latest
45 | ```
46 |
47 | > [!WARNING]
48 | > When you are piping go tests output to `vgt`, `vgt` will exit with 1 when tests failed.
49 |
50 | or just `vgt` with a custom flags after `--` to run tests and visualise them:
51 |
52 | ```bash
53 | $ vgt -- ./... -count=1 -short
54 | 10:26PM INF Running go test command="[go test -json ./... -count=1 -short]"
55 | ```
56 |
57 | After tests were executed, a browser window will open with the visualisation.
58 |
59 | If you want to preserve the output, you can pipe test logs to file and later pass it to `vgt`:
60 |
61 | ```bash
62 | go test -json ./... > test.json
63 | cat test.json | vgt
64 | ```
65 |
66 | ### Additional flags
67 |
68 | ```bash
69 | Usage of vgt:
70 | -debug
71 | enable debug mode
72 | -dont-pass-output
73 | don't print output received to stdin
74 | -duration-cutoff string
75 | threshold for test duration cutoff, under which tests are not shown in the chart (default "100µs")
76 | -from-file string
77 | read input from file instead of stdin
78 | -keep-running
79 | keep browser running after page was opened
80 | -print-html
81 | print html to stdout instead of opening browser
82 | ```
83 |
84 | ## Development
85 |
86 | If you have an idea for a feature or found a bug, feel free to open an issue or a pull request.
87 |
88 | Before making a big change, it's a good idea to open an issue to discuss it first.
89 |
90 | ### Running tests
91 |
92 | Tests are not really sophisticated, and are based on checking changes in golden files and checking in browser if
93 | it looks good.
94 |
95 | ### Updating golden files
96 |
97 | If you made a change and want to update golden files, you can run:
98 |
99 | ```bash
100 | go test . -update-golden
101 | ```
102 |
--------------------------------------------------------------------------------
/chart.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "log/slog"
6 | "strings"
7 | "time"
8 | )
9 |
10 | func generateCharts(pr ParseResult) []PlotlyChart {
11 | var charts []PlotlyChart
12 |
13 | testNames := pr.TestNamesOrderedByStart()
14 |
15 | for _, tn := range testNames {
16 | ch := PlotlyChart{
17 | Type: "bar",
18 | Orientation: "h",
19 | Hoverinfo: "text",
20 | Textposition: "inside",
21 | }
22 |
23 | pause, hasPause := pr.TestPauses.ByTestName(tn)
24 | run, hasRun := pr.TestRuns.ByTestName(tn)
25 |
26 | if !hasRun {
27 | slog.Debug("Test was not executed", "test", tn)
28 | continue
29 | }
30 |
31 | packageNameParts := strings.Split(tn.Package, "/")
32 |
33 | var packageName string
34 | if len(packageNameParts) != 0 {
35 | packageName = packageNameParts[len(packageNameParts)-1]
36 | } else {
37 | slog.Warn("Package name is empty", "test", tn.Package)
38 | }
39 |
40 | packageNameFull := fmt.Sprintf("%s.%s", packageName, tn.TestName)
41 | y := packageNameFull
42 |
43 | if !run.Passed {
44 | y += " (failed)"
45 | }
46 |
47 | if hasPause {
48 | startAfter := pause.Start.Sub(pr.Start)
49 | duration := pause.Duration()
50 |
51 | slog.Debug("Test was paused", "startAfter", startAfter, "duration", duration, "test", tn)
52 |
53 | ch.Add(
54 | fmt.Sprintf("%s PAUSE (%s)", packageNameFull, duration.Round(time.Millisecond).String()),
55 | y,
56 | startAfter,
57 | duration,
58 | "rgba(108,122,137,1)",
59 | )
60 | }
61 |
62 | {
63 | startAfter := run.Start.Sub(pr.Start)
64 | duration := run.Duration()
65 |
66 | slog.Debug("Test was executed", "startAfter", startAfter, "duration", duration, "test", tn)
67 |
68 | ch.Add(
69 | fmt.Sprintf("%s RUN (%s)", packageNameFull, duration.Round(time.Millisecond)),
70 | y,
71 | startAfter,
72 | duration,
73 | durationToRgb(run, pr.MaxDuration),
74 | )
75 | }
76 |
77 | slog.Debug("PlotlyChart", "chart", ch)
78 |
79 | charts = append(charts, ch)
80 | }
81 |
82 | return charts
83 | }
84 |
--------------------------------------------------------------------------------
/docs/img1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/roblaszczak/vgt/766bbdec893fc7f84b2c56c8e5d66c130f5a7635/docs/img1.png
--------------------------------------------------------------------------------
/docs/img2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/roblaszczak/vgt/766bbdec893fc7f84b2c56c8e5d66c130f5a7635/docs/img2.png
--------------------------------------------------------------------------------
/docs/img3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/roblaszczak/vgt/766bbdec893fc7f84b2c56c8e5d66c130f5a7635/docs/img3.png
--------------------------------------------------------------------------------
/docs/img4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/roblaszczak/vgt/766bbdec893fc7f84b2c56c8e5d66c130f5a7635/docs/img4.png
--------------------------------------------------------------------------------
/docs/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/roblaszczak/vgt
2 |
3 | go 1.22
4 |
5 | require (
6 | github.com/lmittmann/tint v1.0.5
7 | github.com/muesli/cancelreader v0.2.2
8 | github.com/stretchr/testify v1.9.0
9 | )
10 |
11 | require (
12 | github.com/davecgh/go-spew v1.1.1 // indirect
13 | github.com/pmezard/go-difflib v1.0.0 // indirect
14 | golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a // indirect
15 | gopkg.in/yaml.v3 v3.0.1 // indirect
16 | )
17 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3 | github.com/lmittmann/tint v1.0.5 h1:NQclAutOfYsqs2F1Lenue6OoWCajs5wJcP3DfWVpePw=
4 | github.com/lmittmann/tint v1.0.5/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
5 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
6 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
9 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
10 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
11 | golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a h1:ppl5mZgokTT8uPkmYOyEUmPTr3ypaKkg5eFOGrAmxxE=
12 | golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
13 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
14 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
15 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
16 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
17 |
--------------------------------------------------------------------------------
/html.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "html/template"
7 | "log/slog"
8 | "math"
9 | "slices"
10 | "strings"
11 | "time"
12 | )
13 |
14 | type PlotlyChart struct {
15 | Type string `json:"type"`
16 | Y []string `json:"y"`
17 | X []float64 `json:"x"`
18 | Orientation string `json:"orientation"`
19 | Base []float64 `json:"base"`
20 | Text []string `json:"text"`
21 | Textposition string `json:"textposition"`
22 | Width []float64 `json:"width"`
23 | Marker struct {
24 | Color []string `json:"color"`
25 | } `json:"marker"`
26 | Hoverinfo string `json:"hoverinfo"`
27 | }
28 |
29 | func (c *PlotlyChart) Add(
30 | label, y string,
31 | start, duration time.Duration,
32 | color string,
33 | ) {
34 | c.Y = append(c.Y, y)
35 |
36 | c.X = append(c.X, duration.Round(time.Millisecond*10).Seconds())
37 | c.Base = append(c.Base, start.Round(time.Millisecond*10).Seconds())
38 | c.Text = append(c.Text, label)
39 | c.Width = append(c.Width, 0.9)
40 | c.Marker.Color = append(c.Marker.Color, color)
41 | }
42 |
43 | func render(pr ParseResult, charts []PlotlyChart, callOnLoad bool) (string, error) {
44 | settings := map[string]any{
45 | "showlegend": false,
46 | "yaxis": map[string]any{
47 | "visible": false,
48 | },
49 | "xaxis": map[string]any{
50 | "ticksuffix": "s",
51 | },
52 | }
53 |
54 | slices.Reverse(charts)
55 |
56 | chartsJSON, err := json.MarshalIndent(charts, "", " ")
57 | if err != nil {
58 | return "", fmt.Errorf("error marshalling charts: %w", err)
59 | }
60 |
61 | settingsJSON, err := json.MarshalIndent(settings, "", " ")
62 | if err != nil {
63 | return "", fmt.Errorf("error marshalling settings: %w", err)
64 | }
65 |
66 | slog.Debug("Generated HTML with charts", "charts", string(chartsJSON))
67 |
68 | html := `
69 |
70 |
71 |
72 |
73 | Test Results ({{.duration}} {{.passed}} passed, {{.failed}} failed)
74 |
75 |
76 |
77 |
78 |
79 | ×
80 |
You can zoom chart with controls or by clicking and selecting area to zoom.