The Problem

Hello, in this short and sweet post, I will show a handy bash script to catch slow go tests based on their durations and their go cache status, but first we need to define the problem.

Our current problem is when I run go test -v ./... we get simply huge output that is overwhelming with subtest outputs as well as t.Parallel pauses/continues. Plus, my inner OCD strikes when some things are always cached and some things are always not cached, I could never guess why and I never have time to look at plain log that has 1 million lines.

So I came up with a solution that mimicks better output and displays what is cached or not. You could run this in your CI/CD pipelines etc… to keep track of test duration increases/decreases or test cache statuses.

The Solution

One important note is that my solution only works for “passed” tests, if your tests are skipping or failing, my solution will ignore them.

For an example, I have used test file below replicated across some Guinea pig packages for representation purposes.

import (
	"testing"
	"time"
)

func TestQuickSleep(t *testing.T) {
	time.Sleep(100 * time.Millisecond)
}

func TestMediumSleep(t *testing.T) {
	time.Sleep(500 * time.Millisecond)
}

func TestLongSleep(t *testing.T) {
	t.Run("Wait1.5Seconds", func(t *testing.T) {
		time.Sleep(1500 * time.Millisecond)
	})
	
	t.Run("Wait2.5Seconds", func(t *testing.T) {
		time.Sleep(2500 * time.Millisecond)
	})
}

func TestParallelSleep(t *testing.T) {
	t.Parallel()
	time.Sleep(750 * time.Millisecond)
}

This was roughly outputting this verbose output which is what -v means sure, we could ignore using -v but then I have no idea which specific test case is causing issues and increasing its duration or ignoring its cache.

❯ go test -v ./...
?       github.com/lingua-sensei/lingua-sensei  [no test files]
=== RUN   TestQuickSleep
--- PASS: TestQuickSleep (0.10s)
=== RUN   TestMediumSleep
--- PASS: TestMediumSleep (0.50s)
=== RUN   TestLongSleep
=== RUN   TestLongSleep/Wait1.5Seconds
=== RUN   TestLongSleep/Wait2.5Seconds
--- PASS: TestLongSleep (4.00s)
    --- PASS: TestLongSleep/Wait1.5Seconds (1.50s)
    --- PASS: TestLongSleep/Wait2.5Seconds (2.50s)
=== RUN   TestParallelSleep
=== PAUSE TestParallelSleep
=== RUN   TestParallel2Sleep
=== PAUSE TestParallel2Sleep
=== CONT  TestParallelSleep
=== CONT  TestParallel2Sleep
--- PASS: TestParallel2Sleep (0.75s)
--- PASS: TestParallelSleep (0.75s)
PASS
ok      github.com/lingua-sensei/lingua-sensei/pkg1     (cached)
=== RUN   TestParallel2Sleep
=== PAUSE TestParallel2Sleep
=== CONT  TestParallel2Sleep
--- PASS: TestParallel2Sleep (0.50s)
PASS
ok      github.com/lingua-sensei/lingua-sensei/pkg2     (cached)
=== RUN   TestParallel2Sleep
=== PAUSE TestParallel2Sleep
=== RUN   TestParallel3Sleep
=== PAUSE TestParallel3Sleep
=== CONT  TestParallel2Sleep
=== CONT  TestParallel3Sleep
--- PASS: TestParallel3Sleep (0.50s)
--- PASS: TestParallel2Sleep (0.50s)
PASS
ok      github.com/lingua-sensei/lingua-sensei/pkg2/pkg3        0.504s

I have come up with this handy bash script below

#!/bin/bash

TEST_ARGS="${@:-./...}"

go test -v -json $TEST_ARGS \
  | jq -r '
    if .Action == "pass" and .Test != null then
      [.Package, .Test, (.Elapsed | tostring), ""] | join(",")
    elif .Action == "output" and (.Output | contains("ok")) then
      [.Package, "", "", .Output] | join(",")
    else
      empty
    end' \
  | awk -F, '
    BEGIN {
      line = "════════════════════════════════════════════════════════════════════════════════"
      printf "\n%-60s %-8s %s\n", "Test Name", "Duration", "Status"
      printf "%s\n\n", line
    }
    
    # Store results by package
    $2 != "" { 
      pkg_tests[$1][$2] = $3 
    }
    
    # Store package cache status
    $4 ~ /ok.*/ { 
      cached = ($4 ~ /cached/) ? "(cached)" : ""
      pkgs[$1] = cached
    }
    
    END {
      for (pkg in pkg_tests) {
        printf "%s:\n", pkg
        
        # Get all tests for each package and sort
        n = asorti(pkg_tests[pkg], sorted)
        for (i = 1; i <= n; i++) {
          test = sorted[i]
          printf "  %-58s %6.2fs %s\n", test, pkg_tests[pkg][test], pkgs[pkg]
        }
        printf "\n"
      }
      printf "%s\n", line
    }'

What happens is we use jq to filter the json format of our go tests then we use awk to make it pretty and it ends up as a result below

Test Name                                                    Duration Status
════════════════════════════════════════════════════════════════════════════════

github.com/lingua-sensei/lingua-sensei/pkg2/pkg3:
  TestParallel2Sleep                                           0.50s 
  TestParallel3Sleep                                           0.50s 

github.com/lingua-sensei/lingua-sensei/pkg1:
  TestLongSleep                                                4.00s (cached)
  TestLongSleep/Wait1.5Seconds                                 1.50s (cached)
  TestLongSleep/Wait2.5Seconds                                 2.50s (cached)
  TestMediumSleep                                              0.50s (cached)
  TestParallel2Sleep                                           0.75s (cached)
  TestParallelSleep                                            0.75s (cached)
  TestQuickSleep                                               0.10s (cached)

github.com/lingua-sensei/lingua-sensei/pkg2:
  TestParallel2Sleep                                           0.50s (cached)

════════════════════════════════════════════════════════════════════════════════

The Ending

Thanks for reading, I swear I don’t like bazel and shady shell scripts lying around my repos.

References