Integration test flakiness review
Integration tests in this repo run on real VMs against live ESS stacks, on hosts that vary in speed, distro, kernel, and concurrent load. The dominant failure mode is a test that passes locally and in the PR run, then flakes weeks later on a slower host, a different distro, or after an unrelated product change. This skill encodes the failure modes actually observed in a year of flake fixes (see references/flake-taxonomy.md for the evidence: every rule below cites real incidents).
Method
- Read the diff. For each added or modified test function, also read enough surrounding file context to understand the test's setup, assertions, and cleanup — flakes usually live in the interaction, not the changed lines alone.
- Run the Tier 1 mechanical scan over the changed files.
- For each test, walk the Tier 2 semantic questions that match what the test does.
- Check Tier 3 idioms: is the test hand-rolling something a shared helper does safely?
- Report findings:
file:line, category, why it will flake (concrete scenario on a slow/parallel/different-OS host), and the fix. Rank by likelihood × blast radius. A test that can leave an installed agent, a root-owned dir, or a mutated shared Fleet object behind poisons other tests — rank that above a self-contained flake.
Judge only realistic nondeterminism. Do not flag a wait as "racy" if the condition is polled with a generous budget; do flag budgets with no headroom over known product intervals (see Tier 2 §Timeouts).
Tier 1 — mechanical red flags (grep the diff first)
Each of these has caused real CI failures; they are close to always-wrong.
| Pattern | Why it breaks | Fix |
|---|---|---|
require.*(t, ...) or assert.*(t, ...) inside an Eventually/EventuallyWithT closure (outer t, not the *assert.CollectT param) |
First transient error fails the test instead of retrying; FailNow off the test goroutine can panic |
Assert on the CollectT parameter; use EventuallyWithT |
require/assert on t inside any go func |
On failure after the test ends: panic: Fail in goroutine after test has completed — kills the whole binary |
Join via t.Cleanup(wg.Wait) registered right after wg.Add; never inline wg.Wait() on the happy path only |
context.Background() for waits/queries in a test |
Ignores the test deadline; hangs mask the real failure | t.Context(); for cleanups context.WithoutCancel(t.Context()) |
os.CreateTemp when only file content is needed |
Held handle (no FILE_SHARE_DELETE) makes t.TempDir() cleanup fail on Windows → passing test marked failed |
os.WriteFile(filepath.Join(t.TempDir(), ...), data, 0o600) |
Hardcoded localhost:9200 / 127.0.0.1:9200 as a placeholder output |
Output health reporting makes the agent DEGRADED at a nondeterministic moment; health asserts become a race | Per-test mock ES (integration.StartMockES) or IsHealthyOrDegradedFromOutput |
Hardcoded "namespace": "default" in Fleet request bodies |
Runner assigns a per-test namespace; writes hit the wrong data stream or get rejected | Interpolate info.Namespace |
Test-spawned OTel collector config without service.telemetry.metrics.level: none |
Every collector binds :8888 by default → port collision between parallel tests |
Disable internal telemetry metrics |
| Fixed listening ports of any kind | Parallel tests on one host collide | :0, or disable the listener if unasserted |
Exact-count asserts on live-pipeline data: Equal(0, len(...)), exact doc counts, exact component counts from one status snapshot |
Retries, restarts, and re-ingestion legitimately vary counts | Threshold (LessOrEqual/GreaterOrEqual) or poll until stable |
== on a monotonic counter computed as snapshot + 1 (e.g. policy revision) |
Any concurrent bump skips past the expected value forever | Use the value returned by the mutating API call, compare with >= (IsMinPolicyRevision) |
| Duplicate keys in inline YAML configs | Parser silently keeps the last; the test believes it configured something it didn't | Deduplicate; read the final merged config critically |
filelog/filestream receiver reading a file the test pre-populates, without start_at: beginning |
Defaults to end; if the collector starts after the write, content is silently skipped forever |
start_at: beginning |
Non-fatal assert.Eventually* followed by unguarded indexing (Hits.Hits[0]) |
On timeout, execution continues into an index-out-of-range panic | require.Eventually*, or guard the access |
t.Skip without an issue link, before define.Require, or unconditional for a platform-specific problem |
Untracked coverage loss; skips must stay discoverable and self-expiring | t.Skip("... issues/NNN") placed after define.Require, scoped to the affected platform/version |
Zero-valued fields in Kibana/Fleet API request structs — check every Create*/Update* call (CreateFleetProxy, CreateDownloadSource, …); a missing ID marshals as "id": "" when the struct lacks omitempty |
Server-side validation tightening turns tolerated "" into a hard 400 |
Set explicit unique values (name + uuid) on every such call, not just the ones near assertions |
Tier 2 — semantic questions
Every Elasticsearch query the test asserts on
- Who generates the matching data, and is production guaranteed? A test that waits for ambient events (auditd, network traffic, host logs) it never causes will pass or fail with host activity. The test must trigger its own events — ideally inside the poll loop, so each retry tick regenerates the signal (rule activation, restarts, and slow pipelines are then covered automatically).
- Is the filter tight enough to match only that data? Broad
exists:queries plusHits[0]select an arbitrary ambient document. Filter on values the test controls: its own tag, namespace,process.name, generated message. If two documents are compared (e.g. process-runtime vs otel-runtime), both sides must provably be the same underlying event, and build-metadata / event-type-specific fields (agent.version,auditd.data.*) belong in the ignore list. - Is a
@timestamp >= watermarkbound captured before the action that produces the document? Capturingtime.Now()afterInstall/Enrollreturns excludes events emitted during the call — the query can then never match. (This exact bug shipped twice.)
Every wait / Eventually
- Poll the value you assert, not a weaker proxy. File existence ≠ file has content; process exists ≠ upgrade progressing; Kibana 200 on a policy PUT ≠ agent applied it (gate on the applied policy revision instead).
- Read agent-local state locally. Fleet's agent document only advances on checkin,
which the test cannot force. If the value exists in
elastic-agent status/fixture.ExecStatus, poll that, never Kibana. This explicitly includes upgrade state:UpgradeDetailsin Kibana's agent doc is a checkin-lagged mirror of agent-local state, not "Fleet-side state" — raising the timeout on the Kibana copy is the wrong fix when the local status has the value immediately. - "Reset then assert absence" on async streams is a race. Draining a log buffer and requiring zero later occurrences fails when late writes land after the drain. Assert monotonic cumulative counts or post-marker occurrences instead.
- Drive state machines by observed condition, not magic counts. "Restart 3 times, sleep 10s" encodes internal timing; loop while the observable condition (reported version, state) still holds, under a bounded context.
Timeouts
- Compare every budget against the product interval that bounds it. Fleet checkin long-poll is 5 minutes: any wait for a Fleet-reported status transition must strictly exceed it (6m+). A budget equal to the interval has zero headroom and will flake on slow hosts.
- Nested budgets: an outer context must exceed the sum of the serial inner
Eventuallybudgets it wraps. - Big blind timeouts are a smell in both directions. For long operations (artifact
downloads are ~450MB), don't just raise the number — poll the reported state
(
UpgradeDetails.State,DownloadPercent), fail fast on FAILED/ROLLBACK, detect stalls; then a generous ceiling is safe.
Health assertions
- Does the config intentionally break something? Unreachable output, invalid input,
missing credentials — then global-HEALTHY is wrong by construction. Assert only the
units the test controls (
IsHealthyOrDegradedFromOutput, or per-unit checks), or give the test a real mock ES. - Is the asserted field guaranteed populated? e.g.
VersionInfo.Nameis intermittently empty for some monitoring components; empty vs wrong must be distinguishable.
Environment & versions
- OS/distro/arch: kernel-coupled behavior (auditd, systemd, package managers) must
pin the distros it was validated on in
define.Requirements. Windows: file handles block deletion; slower hosts widen race windows. - Runtime topology: don't assume beats run as separate processes (PID scraping,
process-name regexes, process-only metric fields, one shared build hash). Pin
_runtime_experimentalexplicitly when behavior depends on it; switch onVersionInfo.Name(beat-v2-clientvsbeats-receiver). - Dynamically resolved versions: "latest snapshot" can equal the local build
(
rpm -Urefuses; migration assertions become invalid) and "previous minor" may not exist for the platform (windows/arm64 < 9.3.0). Handle the equal-version case; gate on capability, not availability. - Third-party fixtures: leave nothing security/protocol-relevant implicit
(
ssl_enabled => falseif the client speaks plain TCP) — vendor defaults are computed from the host and change across versions. - Exact product log strings in allow-lists/assertions silently break when the product rewords errors; prefer stable substrings or structured fields.
Shared state & cleanup (blast radius: other tests)
- Cluster-global ES objects (index templates, pipelines, policies) must be named per-test/namespace — a hardcoded shared name means the second test silently overwrites the first.
- Never mutate well-known shared Fleet objects (
fleet-default-output) mid-suite on a shared stack; it bumps policy revisions for every enrolled agent. If unavoidable, do it before enrollment. - Cleanup must survive the failure path: registered via
t.Cleanup(LIFO), with credentials (uninstall tokens) re-fetched after the state changes they must survive. A failed cleanup that leaves an installed/tamper-protected agent or root-owned/tmpdir breaks unrelated later tests. - Anything writing to stdout (progress bars, spinners) must be terminated —
go testoutput is machine-parsed and pollution fails other tests.
Tier 3 — use the shared tooling
Hand-rolled equivalents of these have all caused flakes:
- Agent lifecycle:
define.NewFixtureFromLocalBuild→Prepare/Configure→fixture.Install(...). NeverPrepareAgentCommand+cmd.Start()+ stdout scraping — it yields no diagnostics on failure and leaks agent processes. - Config switching:
fixture.Configure(reload) instead of teardown/restart. - Logs:
fixture.Exec(ctx, []string{"logs", ...})instead of scraping process output. - Health:
fixture.IsHealthyonly when everything is genuinely expected healthy;IsHealthyOrDegradedFromOutputwhen the output is intentionally absent. - Policy propagation barrier:
tools.IsMinPolicyRevisionwith the revision returned byUpdatePolicy. - Placeholder ES:
integration.StartMockES, not a deadlocalhost:9200(and neverstatus_reporting.enabled: false— it masks real product bugs). - Doc comparisons across runtimes: reuse the established ignore-list machinery
(
assert_tools.go) rather than fresh map-diff code.
When in doubt about whether a pattern has bitten before, check references/flake-taxonomy.md — it maps every category to the commits/PRs that fixed real instances, useful for citing precedent in review comments.