flakylint

module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 18, 2026 License: MIT

README

flakylint

Static analysis for flaky Go tests — catch the patterns before they flake in CI.

CI Go Reference

$ flakylint ./...
worker_test.go:24:2: time.Sleep synchronizes the test on real time and flakes
    under CI load; use testing/synctest (Go 1.25+) or explicit synchronization
    (channel, sync.WaitGroup)
server_test.go:31:2: httptest server is never closed; the leaked port and
    goroutines can make later tests flaky

Why

A flaky test fails your CI on Tuesday, passes on retry, and slowly teaches your team to ignore red builds. The industry answer so far has been runtime detection: rerun the suite, score the history, quarantine the noisy tests. Useful — but it only tells you a test became flaky, after it has already cost you reruns and trust.

The thing is, most flaky Go tests are not mysterious. They follow a handful of well-known, statically detectable patterns: sleeping instead of synchronizing, leaking an httptest.Server, racing parallel tests on shared package state, killing the test binary before cleanup runs. Even Kubernetes has a test that sleeps ten seconds with a comment admitting "there is no guarantee... we will just wait 10s, which is long enough in most cases."

flakylint flags those patterns at review time, as a standard go/analysis linter — the same machinery as go vet. It is the missing static half of your flaky-test strategy. Use it alongside your runtime tooling, not instead of it.

Corpus evaluation

Before the first release we ran flakylint against four large open-source codebases and hand-triaged every reported finding (or a random sample of 25 per check where there were more):

Repo Findings Triaged Correctly flagged False positives
kubernetes/kubernetes 130 28 28 0
grafana/grafana 104 46 46 0
prometheus/prometheus 26 26 26 0
testcontainers-go 4 4 4 0

0 rule misclassifications across 104 reviewed findings — 52 actionable, 52 technically correct but low-value. Every diagnostic pointed at code that really does what the check says it does; whether that code is worth changing is a separate, human call. Full triage notes live in docs/corpus/.

Broken out by check:

Check Findings Triaged Correctly flagged False positives
sleepassert 239 79 79 0
httptestclose 24 24 24 0
exitfatal 1 1 1 0
parallelglobal 0

parallelglobal produced no findings on the corpus — the pattern is rare in mature codebases, which is exactly why it survives review when it does appear.

The three v0.2.0 checks (hardport, maporder, eventuallyeffect) went through the same gate before release. The first corpus pass caught 3 rule misclassifications, and both offending rules were narrowed (plain last-write-wins captures and per-iteration accumulators are now silent by design). A large batch of grafana eventuallyeffect hits were correct under the old rule but idiomatic noise — that signal is what drove the narrowing. On the re-run the narrowed checks add no findings across all four repos: a compatibility result — mature suites already hold ports on :0, sort before asserting, and keep polling callbacks pure — verified against synthetic positive controls, not a claim about how often the checks fire in the wild. Full write-up: docs/corpus/2026-07-18-corpus-v020.md.

Some favorites the corpus run surfaced:

  • a Grafana test sleeping through an async DB insert, annotated by its own author with // TOFIX: this is a hack;
  • an httptest.Server leaked inside a table-driven loop — in a file that correctly calls defer server.Close() in three other places;
  • the self-aware ten-second sleep in Kubernetes quoted above.

The checks

httptestclose · sleepassert · parallelglobal · exitfatal · hardport · maporder · eventuallyeffect

httptestclose — leaked httptest servers
func TestHandler(t *testing.T) {
	srv := httptest.NewServer(mux)   // ← never closed
	resp, _ := http.Get(srv.URL + "/health")
	// ...
}

An unclosed httptest.Server keeps a port bound and goroutines running after the test returns. Eventually the GC finalizer closes it — racing the HTTP client's connection pool, which produces sporadic connection broken: CloseIdleConnections called failures. Google's go-github hit exactly this (google/go-github#4210).

Autofix (-fix): inserts t.Cleanup(srv.Close). Stays silent when the server escapes the function (returned, passed to a helper, stored in a struct) — someone else may own its lifecycle.

sleepassert — sleeping instead of synchronizing
func TestWorker(t *testing.T) {
	go worker.Run()
	time.Sleep(100 * time.Millisecond) // ← races the scheduler
	if got := worker.Count(); got != 5 { ... }
}

A sleep that "waits for the goroutine" is a bet that the CI runner is as fast as your laptop. It loses under load. The diagnostic points you to testing/synctest (Go 1.25+), which makes such tests deterministic and instant, or to plain channel/WaitGroup synchronization. testing/synctest is stable since Go 1.25; earlier releases shipped it only as a GOEXPERIMENT, with a different API.

Stays silent for sleeps inside polling/retry loops, inside synctest bubbles, in goroutines and helpers, and for time.Sleep(0). This is the most opinionated check — on integration-heavy suites treat it as advisory, or disable it: -sleepassert=false.

parallelglobal — parallel tests sharing package state
var counter int

func TestIncrement(t *testing.T) {
	t.Parallel()
	counter = 0        // ← races every other parallel test in the package
	// ...
}

t.Parallel() plus a package-level variable write couples tests that are supposed to be independent: what one test observes now depends on how the scheduler interleaves it with every other parallel test in the package — the definition of a flake. The write is flagged whether or not a mutex guards it. A lock removes the memory race but not the logical cross-test interference and order-dependence, so two parallel tests mutating shared state generally break each other's assumptions either way.

exitfatal — killing the test binary
func TestConfig(t *testing.T) {
	cfg, err := Load("testdata/cfg.yaml")
	if err != nil {
		log.Fatal(err)   // ← kills the whole binary, skips ALL cleanup
	}
	// ...
}

os.Exit and log.Fatal terminate the entire test process: t.Cleanup callbacks and deferred teardown (containers, temp dirs, servers) never run, poisoning every test that follows. In TestMain, os.Exit(m.Run()) is flagged only when the function has pending defers that it would silently skip.

Autofix (-fix): rewrites log.Fatal*t.Fatal* — except where the test's receiver name is shadowed at the call site, where the finding stands but the rewrite is withheld (the diagnostic then asks you to route the failure through the test's *testing.T rather than naming t.Fatal as the drop-in).

hardport — hardcoded ports in tests
func TestServer(t *testing.T) {
	ln, _ := net.Listen("tcp", ":8080") // ← fixed port, taken under parallelism
	// ...
}

A test that binds a fixed, non-zero port is a bet that nothing else on the host wants it. It loses when go test -p runs package tests in parallel, when several CI jobs share a runner, or when a leaked process from an earlier run still holds the port — and the failure looks like a flake, not a bug. The fix is to listen on ":0" and read the real address back:

ln, _ := net.Listen("tcp", ":0")
addr := ln.Addr().String() // ← kernel-assigned free port

or let httptest.NewServer pick the port for you.

Flags net.Listen / net.ListenPacket (only for a constant tcp*/udp* network) and http.ListenAndServe / ListenAndServeTLS. Stays silent for named ports (":http"), computed addresses (fmt.Sprintf), unix sockets and other non-port networks, non-constant networks, out-of-range ports, the wildcard port ":0", and Dial-side literals — those are not the bug.

maporder — asserting on map iteration order
func TestKeys(t *testing.T) {
	var got []string
	for k := range m {
		got = append(got, k) // ← collected in random order
	}
	assert.Equal(t, []string{"a", "b", "c"}, got) // ← flaky
}

The Go spec leaves map iteration order unspecified, and the runtime varies it between runs, so a slice or string built by ranging over a map has no stable order. An order-sensitive assertion against a fixed expected value passes only when the order happens to line up — the test can go green for months and then flake for no visible reason. Sort the accumulator first, or assert order-insensitively:

sort.Strings(got)
assert.Equal(t, []string{"a", "b", "c"}, got)
// or
assert.ElementsMatch(t, []string{"a", "b", "c"}, got)

Flags assert/require Equal/EqualValues, reflect.DeepEqual, and slices.Equal/EqualFunc on a value accumulated in a map-range loop (testify in its package-level form). The check is source-order aware: an assertion is flagged only when it runs after the map-range loop that fills the accumulator, and a sort.*/slices.Sort* call silences only the assertions that follow it — a sort placed after the assertion does not help. Stays silent when the accumulator is sorted before the assertion, asserted order-insensitively (ElementsMatch, Len, Contains, Subset), used only as a testify message argument, or escapes the test — passed to a helper, returned, reassigned, address-taken, captured by a nested closure, or mixed with appends from outside the loop. No autofix: choosing between sorting and ElementsMatch is a semantic call only the author can make.

eventuallyeffect — side effects in polling callbacks
require.Eventually(t, func() bool {
	resp, _ := http.Post(url, "application/json", body)
	attempts++                                          // ← grows with the poll count
	return resp.StatusCode == http.StatusOK
}, time.Second, 10*time.Millisecond)

testify's Eventually/Never/EventuallyWithT retry their condition callback until it passes — an unpredictable number of times that depends on machine speed and scheduling. A callback that mutates state shared with the test makes the outcome depend on that poll count: attempts ends up holding however many ticks happened to fit on this run. The condition must be a pure observation — act once, then poll on a read-only predicate, or assert on captured results after Eventually returns:

resp, _ := http.Post(url, "application/json", body) // act once
require.Eventually(t, func() bool {
	return ready(resp) // pure observation
}, time.Second, 10*time.Millisecond)

Flags, inside the callback of assert/require Eventually, Eventuallyf, Never, Neverf, EventuallyWithT, and EventuallyWithTf (package-level form), only count-dependent effects on a captured or package-level variable — increments (x++, x--), compound assignments (x += ..., x |= ...), and self-appends (x = append(x, ...)) — plus a send on a captured or package-level channel. A send on a channel created inside the callback (ch := make(...); ch <- v) never escapes a single tick and stays silent. A plain overwrite (x = v, including multi-assign a, b = f()) is silent: it is last-write-wins, the idiomatic way to capture the final successful tick's result, and flagging it produced false positives on real code. Stays silent, too, for variables declared inside the callback, the blank identifier, and — a deliberate v1 boundary — keyed or field writes through a captured map, slice, or pointer (m[k] = v, p.f = v), which are common in idempotent polling. Method calls (HTTP/DB effects, mutex, t.Log, the *assert.CollectT of EventuallyWithT) are out of scope in v1. No autofix. This ports testing-library's no-wait-for-side-effects idea to Go.

Install

go install github.com/malikov73/flakylint/cmd/flakylint@latest

Or grab a prebuilt binary from the releases page.

Usage

Run on your module:

flakylint ./...

Apply the suggested fixes (httptestclose, exitfatal):

flakylint -fix ./...

Add -diff to preview: flakylint -fix -diff ./... prints the rewrites as a unified diff without touching files, and exits 0 even when findings exist.

Disable an individual check:

flakylint -sleepassert=false ./...

As a go vet tool (integrates with existing tooling and editors):

go vet -vettool=$(which flakylint) ./...

In GitHub Actions:

- name: flakylint
  run: |
    # pin a release in CI; @latest suits interactive install only
    go install github.com/malikov73/flakylint/cmd/flakylint@v0.2.0
    flakylint ./...

flakylint only ever reports on _test.go files, so it is safe to run over your whole module. The exit code depends on how you invoke it:

Invocation With findings Exit code
flakylint ./... (text) yes 3
flakylint ./... (text) none 0
flakylint -json ./... yes 0
flakylint -fix ./... yes 0
flakylint -fix -diff ./... yes 0
go vet -vettool=$(which flakylint) ./... yes 1

Treat the text mode's non-zero exit (3) as the CI gate. The -json and -fix modes exit 0 even with findings so they compose with other tooling, and under go vet the standard vet exit code (1) applies.

Suppressing a finding

Silence a specific finding with a //nolint comment on the reported line or the line directly above it:

time.Sleep(10 * time.Millisecond) //nolint:sleepassert // waiting on a real socket

The name after the colon is the check name (httptestclose, sleepassert, parallelglobal, exitfatal, hardport, maporder, eventuallyeffect); list several comma-separated to silence more than one. A bare //nolint suppresses every check on that line.

Design philosophy

When in doubt, stay silent. A linter earns trust by being right, not by being loud. Every check ships with explicit silence heuristics (escaping servers, polling loops, synctest bubbles, goroutine literals, canonical TestMain patterns), and each heuristic is locked in by regression tests. The 0-misclassification corpus result above is this philosophy, measured.

flakylint deliberately does not try to detect flakiness at runtime. Retries, quarantine and scoring are a different job, done well by other tools — flakylint prevents the patterns those tools would later catch.

Roadmap

  • time.Now() used as an expected value in assertions
  • static goroutine-leak check (complementing runtime goleak)
  • minimum-duration threshold option for sleepassert
  • golangci-lint integration

Contributing

Issues and PRs welcome. Each analyzer lives in its own package under analyzers/ with analysistest-based tests — a new check is one package plus a registration line in cmd/flakylint/main.go.

License

MIT

Directories

Path Synopsis
analyzers
eventuallyeffect
Package eventuallyeffect reports side effects inside testify Eventually-style polling callbacks.
Package eventuallyeffect reports side effects inside testify Eventually-style polling callbacks.
exitfatal
Package exitfatal reports os.Exit and log.Fatal calls in tests.
Package exitfatal reports os.Exit and log.Fatal calls in tests.
hardport
Package hardport reports tests that bind a hardcoded, non-zero port.
Package hardport reports tests that bind a hardcoded, non-zero port.
httptestclose
Package httptestclose reports httptest servers that are never closed.
Package httptestclose reports httptest servers that are never closed.
maporder
Package maporder reports order-sensitive assertions on data accumulated by ranging over a map.
Package maporder reports order-sensitive assertions on data accumulated by ranging over a map.
parallelglobal
Package parallelglobal reports writes to package-level variables from tests that call t.Parallel().
Package parallelglobal reports writes to package-level variables from tests that call t.Parallel().
sleepassert
Package sleepassert reports time.Sleep calls in test bodies.
Package sleepassert reports time.Sleep calls in test bodies.
cmd
flakylint command
Command flakylint reports flaky-test patterns in Go test files.
Command flakylint reports flaky-test patterns in Go test files.
internal
nolint
Package nolint makes analyzers honor //nolint suppression comments.
Package nolint makes analyzers honor //nolint suppression comments.
testfuncs
Package testfuncs provides shared helpers for recognizing Go test functions, subtests, and calls in _test.go files.
Package testfuncs provides shared helpers for recognizing Go test functions, subtests, and calls in _test.go files.
version
Package version formats flakylint build metadata for the version command.
Package version formats flakylint build metadata for the version command.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL