turango

module
v0.0.0-...-0c91846 Latest Latest
Warning

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

Go to latest
Published: Aug 26, 2026 License: MIT

README

turango

turango mascot

Mascot derived from the Go gopher, designed by Renee French, licensed under Creative Commons Attribution 4.0.

Go Reference

Tests CodeQL Mutation score Mutants Corpus regression

Codecov Sonar Coverage

CodeFactor OpenSSF Scorecard

Bugs Code Smells Reliability Rating Security Rating Technical Debt Maintainability Rating Vulnerabilities

Mutation testing for go test, added the same way fuzzing was: not a separate tool bolted onto the ecosystem, but a go-compatible drop-in that adds one new test mode.

turango is a transparent shim binary. turango test -mutate=. ./... runs the mutation engine; every other invocation (turango build ./..., turango vet ./..., plain turango test ./..., ...) is forwarded verbatim to the real Go toolchain, unchanged.

Mutation testing evaluates test-suite fault-detection effectiveness, not just whether code was executed. It mechanically introduces small, reversible changes ("mutants") into a package's AST — flip + to -, flip < to <=, delete a statement, empty an if body — and reruns selected tests against each one. A mutant is "killed" when that run fails or times out and "survives" when those tests pass. Both verdicts are evidence to interpret, not proof by themselves: a kill can be incidental, while a survivor can be a test gap, uncovered code, or a behaviorally-equivalent change.

See PROPOSAL.md for the full case for why this belongs in go test itself, including evidence gathered by running turango against real crypto/... stdlib packages.

Mutation testing in software engineering

Mutation testing tests the tests. Structural coverage answers whether a line or branch ran; mutation testing asks whether changing its behavior would make the suite fail. That makes a surviving mutant a concrete review target: either an assertion, input, or test boundary is missing, or the change is behaviorally irrelevant. Empirical work has found that mutant detection correlates with detection of real faults independently of code coverage, while large-scale industrial research found that developers use survivors to add useful tests and that mutants can be coupled to historical production faults. Mutation testing is still a proxy for fault detection, not proof that the program is correct.

How it works
  1. Run the original test suite. Mutation results are meaningless unless the baseline is green and repeatable.
  2. Generate many program variants, usually with one small syntactic change each: reverse a comparison, alter a boundary, replace an operator, remove a statement, or change a literal.
  3. Build and test each variant in isolation. A failing test or timeout kills the mutant; an all-green run means it survived; a variant that cannot compile is not viable.
  4. Inspect survivors, then add a contract-level test, fix an incorrect requirement or implementation, or document why the mutation should be excluded.

Turango reports its score as killed / (killed + survived). Not-viable, suppressed, and TCE-filtered equivalent mutants are outside the ratio. That accounting detail matters: tools with different operators, filters, scopes, or denominator rules do not produce directly comparable scores.

What the score does—and does not—say
  • A killed mutant shows that the selected test run fails or times out for that change. It does not show that an assertion checks the right requirement; incidental or overly coupled assertions can kill mutants too.
  • A survivor is evidence to investigate, not automatically a missing test. It may expose a genuine test gap, an equivalent mutant whose observable behavior is unchanged, or behavior the team intentionally does not test (for example, unimportant logging).
  • A high score gives confidence only over the code, operators, and test scope actually exercised. It says nothing about faults the selected mutation model cannot represent, omitted packages, missing end-to-end behavior, or a wrong specification shared by code and tests.
  • The useful unit is usually a survivor and its source diff, not a repository-wide percentage. Prefer improving important behavior over chasing an arbitrary 100% target.
Common gotchas
  • Equivalent mutants: determining semantic equivalence is not generally automatable. Treat compiler-based filtering as a useful partial technique, review suspicious survivors, and document suppressions instead of weakening the threshold silently.
  • Runtime cost: mutant count multiplied by test-suite time is only a first-order estimate; workspace creation, compilation, and tool overhead add cost. Test selection, narrower scopes, parallel workers, caching, incremental runs, and selective operators reduce cost, but scope reduction can miss tests in callers or integration packages.
  • Flaky or stateful tests: timing sensitivity, test-order dependencies, shared external state, and nondeterminism can turn mutants into false kills or inconsistent survivors. Stabilize and repeat the baseline first.
  • Test overfitting: do not mirror implementation details merely to kill a mutant. Assert externally meaningful contracts and boundary behavior; if two implementations are equivalent under the contract, suppress the mutant with a reason.
  • Metric gaming: generated code, defensive code, trivial accessors, and low-value side effects can dominate a report. Exclude them deliberately and visibly. A higher score caused only by changing the operator set or denominator is not a stronger suite.
  • CI adoption: start on changed or high-risk packages, triage survivors, and record a baseline. Ratchet against regressions rather than imposing a universal threshold on day one; run broader mutation sweeps periodically to catch cross-package gaps hidden by fast local scopes.

Research and practitioner references:

Install / build

Turango requires Go 1.26 or newer.

go build ./cmd/turango

That produces a turango binary you run instead of go. There's no alias step required for normal use — see the note on alias mode below.

Usage

turango test -mutate=. ./...

-mutate works like -run/-bench/-fuzz: its value is a regular expression matched against function/method names, not a package selector. -mutate=. matches every function (mirroring -bench=.'s "run every benchmark" convention); package selection is the ordinary trailing argument(s), exactly as with those three flags. Package-level declarations are not inside a function, so they remain eligible regardless of the function-name regexp.

Everything that isn't test with a -mutate= flag goes straight to the real go command:

turango build ./...     # identical to `go build ./...`
turango test ./...      # identical to `go test ./...` — no mutation, -mutate not set
Flags (mutation mode only)

All of turango's own flags require the -flag=value form (a bare -flag value is rejected as ambiguous). They're only recognized following test.

Flag Default Meaning
-mutate=<regexp> Required to enter mutation mode. A regular expression matched against function/method names in the target packages — behaves like -run/-bench/-fuzz, not a package selector. -mutate=. matches every function (mirroring -bench=.). Package selection is the ordinary trailing argument(s) after test, exactly as with those three flags, e.g. turango test -mutate=. ./....
-mutatescope=full|package|impact full How much of the test suite reruns per mutant. full reruns go test ./... for the whole module (catches mutants killed by a neighboring package's tests); package reruns only the mutated file's own package (much cheaper, recommended when mutating a package that lives inside a slow module — see corpus/example/README.md); impact builds a per-test coverage map once and reruns only the tests that actually cover the mutated line.
-mutateoperators=<comma-list> all registered operators Restrict which mutation operators run (see the operator list below for names).
-mutateparallel=<n> GOMAXPROCS Worker-pool size. Parallelizes at the file level, since one file's AST is mutated in place across its own mutants.
-mutatetimeout=<duration> baseline-derived Per-mutant budget. If unset, turango times the real suite three times, averages, and scales by CPU count, so a mutant that produces an infinite loop (e.g. i++ flipped to i--) doesn't stall the run to go test's own ~10-minute default. A timeout counts as a kill.
-mutateoutput=<dir> none Write a JSON report (mutate-report.json) into this directory.
-mutatemin=<float> none Exit with status 3 if the resulting mutation score falls below this threshold — for CI gating. Scores themselves range from 0 to 1.
-mutatemutant=<id> none Replay exactly one mutant by the ID printed for it (in the console survivor listing or a JSON report's MutantResult.ID). The engine still walks every file and node — that part is cheap — but only the matching mutation is ever run, so the result holds at most one mutant.
-mutatetce=true|false false Trivial Compiler Equivalence: filter a mutant whose compiled output exactly matches a per-package baseline before it ever reaches the test suite, reporting it separately (see below) instead of as an ordinary survivor. Off by default — see "Filtering equivalent mutants" below for why.
-mutateworkspace=copy|worktree copy How a mutant's throwaway workspace is built when turango needs the full module. copy recursively copies it (works everywhere, no git dependency). worktree uses git worktree add, sharing the repository's object store. Under package/impact, a safe dependency-closure copy takes precedence over either setting. worktree is opt-in and falls back to copy when the target is not inside a clean git working tree.
-mutateestimate=true|false false Preview a run instead of executing it: walk every file/node to count how many mutants would be generated, per package, then time one baseline sample per package (whole-module under -mutatescope=full, per-package otherwise) to extrapolate a rough serial and -mutateparallel-divided time estimate — both explicitly hedged, since real speedup is sub-linear under contention. No mutation is ever applied to disk and no mutant's go test is ever spawned to classify it. Incompatible with -mutateoutput/-mutatemin, which are rejected at parse time (nothing was classified, so there's no report to write or score to gate on).
-mutatecache=<dir> none Persist executed and TCE-classified verdicts to a JSON-Lines file (mutate-cache.jsonl) inside this directory, and reuse them on later runs against unchanged source. Uncovered impact survivors are recomputed without running tests. Keys contain more than the mutant ID (a same-position, same-width literal/identifier edit can otherwise collide) — see "Resuming after interruption" below. Incompatible with -mutateestimate; compatible with -mutatemutant (replay bypasses cache reads but writes its result).
Suppressing a mutant: //nomutant

A //nomutant (or //nomutant: reason) comment above a statement excludes it from mutation. On a compound statement (if/for/switch) the suppression cascades into its body. See corpus/example/README.md for a worked example, including how it affects the reported score.

Exit codes
  • 0 success
  • 1 failure/error
  • 2 bad usage
  • 3 mutation score below -mutatemin

Flag deep dives

Scope trade-offs: -mutatescope

full (the default) reruns go test ./... for the whole module against every mutant — the only scope that can't miss a kill, since a package's behavior is frequently only asserted on by its callers' tests, which live in other packages entirely. package and impact are real speed/coverage trade-offs against that: package reruns only the mutated file's own package, missing a kill from a neighboring package's integration-style test; impact goes further, building a per-test coverage map once and rerunning only the tests that actually cover the mutated line, so a mutant on an uncovered line is reported as a survivor without a test run at all. Neither narrower scope is a strictly-safe default — that's why full stays the default rather than the fastest option winning by default, the same reasoning go test itself applies by never skipping a package's tests based on a coverage guess.

Each mutant requiring execution needs its own throwaway workspace, and how much gets copied is scope-dependent for a hard reason, not just a performance tuning knob: under package/impact scope, turango resolves the mutated package's forward import closure (via golang.org/x/tools/go/packages) and copies its ordinary source files, same-module dependencies, and go.mod/go.sum. Cases that cannot be copied safely as a closure — including vendoring, //go:embed assets, and local replacement modules — fall back to a full-module workspace. Under full scope the closure optimization is wrong to apply: full's reason depends on the reverse closure (every package that could call into the target), which a forward-closure copy says nothing about — a workspace built that way, run under go test ./..., would silently only contain (and therefore only run) the target package's own tests, indistinguishable in outcome from package scope but still labeled full. So full always uses a full-module workspace — recursive copy or requested worktree — with no closure computation attempted. This is a correctness boundary, not a missed optimization.

Filtering equivalent mutants: -mutatetce

TCE (Trivial Compiler Equivalence) is a named, published technique, not a turango invention — see PROPOSAL.md's "Related work" for the real academic citation (and one for mutant subsumption, a related technique turango deliberately doesn't implement).

Some mutations are real, syntactically-different edits that nonetheless compile to identical code — a classic equivalent mutant (i*1 vs i, or deleting a dead store nothing downstream reads). A Survived verdict on one of these is noise: the suite didn't miss a real behavioral gap, because there was no behavioral difference to notice.

-mutatetce=true catches this class specifically: it builds one normalized go build -gcflags=-S assembly baseline per unmutated package, then compares each mutant's normalized assembly against that baseline. A match means the mutant never reaches go test at all; it's reported under the JSON report's Equivalents array (and a summary equivalent: N line) rather than as a MutantResult. Comparing normalized -S disassembly rather than raw compiled archive bytes was a deliberate choice, not the first thing tried — a spike comparing raw archives directly found it unreliable, tripping on build-metadata differences (embedded paths, timestamps) that have nothing to do with whether the mutant actually changed behavior.

Off by default. Unlike -mutatescope, where a conservative choice only under-reports kills, a false positive here would silently discard a real mutant — so this stays opt-in until it has more real-world runs behind it. It's also not a free optimization even when correct: the compile-and-compare check runs for every mutant, not just the ones that turn out equivalent (there's no way to know in advance which ones will), so its cost is a per-mutant tax on 100% of mutants in exchange for skipping go test on whatever fraction actually is equivalent. Measured directly against a real stdlib fixture, that fraction needed to be higher than it was to pay for itself — TCE was ~66% slower overall for that run, because the tax on every mutant outweighed the savings on the ~2% it filtered. The general lesson generalizes past TCE specifically: turango's knobs (-mutatescope, -mutateparallel, -mutatetce) are levers whose correct setting depends on the target codebase's own shape, not a single universally-right configuration — measure before enabling one against a different codebase.

Building each mutant's workspace: -mutateworkspace

Every mutant requiring execution runs against its own throwaway workspace — the mutated file has to sit somewhere outside the real source tree, with enough of the module graph to resolve imports (sibling packages, replace directives, vendor/, //go:embed assets). Cache hits and uncovered impact-scope survivors do not need one. Under narrow scopes turango first tries a dependency-closure copy; unsafe cases fall back to a full workspace. By default (copy) that fallback is a recursive filesystem copy. -mutateworkspace=worktree instead uses git worktree add: a worktree shares the repository's object store rather than duplicating files, so it can be cheaper on a large git repo.

It's opt-in, not a smarter default, because it has a real precondition a filesystem copy doesn't: git worktree add checks out HEAD, the last commit, never the on-disk working copy. Requesting worktree against a target with any uncommitted change anywhere in the repository (not just under the mutated package) — or against a target that isn't a git repository at all — falls back to copy on its own, silently and safely, so it's never wrong to ask for it; it just doesn't always help.

Resuming after interruption: -mutatecache

A real mutation sweep is expensive — hours, for a large module — and until now turango had no way to pick back up after a kill (SIGKILL, an OOM reaper, a killed CI job) or even a graceful Ctrl+C: relaunching re-ran every mutant from scratch, including the ones that had already finished.

-mutatecache=<dir> writes each executed or TCE-classified verdict to <dir>/mutate-cache.jsonl as soon as it is produced, then consults that file before spawning a mutant's go test on a later run. Re-running the same command against unchanged source resumes quickly. Uncovered impact-scope survivors are not cached because recomputing them does not run tests.

The cache key is deliberately more than a mutant's ID. A mutant ID hashes a position — file, line, column, operator, mutation index — not the node's own bytes, so a same-width edit at the same position (x + 5x + 7, same column) produces an identical ID for genuinely different code. Keying on ID alone would risk serving the first edit's verdict for the second. Instead, the key also folds in a content fingerprint for its workspace scope (the whole module under -mutatescope=full; the dependency closure when that optimization is safe), plus -mutatescope, -mutatetce, and a toolchain identifier (go version + GOOS/GOARCH). An edit invalidates all entries covered by that fingerprint, which can conservatively mean every cached mutant under full scope.

Safe to delete at any time — an empty or missing cache is just a first run, the same way $GOCACHE behaves. Keep cache directories scoped to one build environment: timeout, build tags, CGO settings, and most other go env values are not part of the key, so changing them can reuse a stale verdict (including a timeout classified as a kill).

Before/after source in the JSON report

Every MutantResult in -mutateoutput's JSON report carries Before/ After: the mutated node's printed source text, immediately before and after the mutation — the actual diff Description only summarises (e.g. Description: "< -> >=", Before: "v < lo", After: "v >= lo"), usable without hand-deriving it from File/Line and a checkout of the source at that point. An empty After marks a removed statement rather than repeating Before. The console survivor listing is unchanged — Description is still the only per-row text shown there, to keep the table scannable.

Reproducing one mutant: MutantResult.ID

Every mutant gets a stable ID: a SHA-256 hash of its file path (relative to the module root), line, column, operator name, and its index within that operator's offered mutations, truncated to 12 hex characters (the length of a git short SHA). It's stable across re-runs of unchanged source — the whole point — but not across edits to the file above the mutated line, since what's hashed is a position in a specific AST, not self-contained bytes.

That position alone isn't always enough to be unique, either: on a left-associative binary-expression chain (a ^ b ^ c ^ d ^ e), Go's ast.BinaryExpr.Pos() returns the same leftmost-operand position for every nested sub-expression in the chain, so several distinct operator/binary mutations at different nesting depths could otherwise hash to the same ID. A per-file collision counter (rank, based on how many times a given position/operator/index tuple has already been seen during the walk) is folded into the hash only when it's actually needed, so this doesn't change any ID in the overwhelmingly common, non-colliding case.

A CI failure or a //nomutant-adjacent comment can point at one exactly:

turango test -mutatemutant=a1b2c3d4e5f6 -mutate=. ./pkg/...

-mutate still needs a value (it's a required regexp, not optional); -mutatemutant narrows further to one exact mutant once mutation mode is already active.

Mutation operators

Fourteen operators, across six packages:

  • controlcontrol/if, control/else, control/case: remove a conditional body.

  • expressionexpression/remove: eliminate a &&/|| short-circuit operand.

  • statementstatement/remover: delete a statement.

  • operatoroperator/assignment, operator/binary, operator/boundary (<<=, >>= — the classic off-by-one mutant), operator/inc_dec, operator/unary: token-swap mutations.

  • literalliteral/number (shifts an integer literal by ±1, or a float literal by a small relative nudge in each direction), literal/boolean (truefalse).

  • identifieridentifier/constswap: swaps a package-level const reference for a same-type sibling in the same const(...) block, or for a same-type constant declared elsewhere in the same file when no block sibling exists; identifier/localconstswap: swaps a function-local variable used as a comparison operand (<, <=, >, >=, ==, !=) for a type-compatible package-level constant declared in the same file — the pair that reproduces the historical strconv ParseUint overflow bug (#21278), which was a local-var-to-constant substitution. Both are built on go/types rather than go/ast alone, the only operators in this codebase that are — every other operator only ever looks at a node's own syntax, deliberately, so a run that doesn't select an identifier operator never pays for type checking a module that may not even type-check cleanly.

    Both operators are scoped hard on purpose, not just tuned: an unrestricted version — matching every identifier reference against every same-type identifier visible at that point per Go's scoping rules — would offer one mutation per same-type sibling in scope for nearly every identifier use in a typical file, dwarfing what every other operator combined produces on the same code. identifier/localconstswap's first version, restricted only to type and comparison-operand matching, hit exactly this in practice — a real 24x mutant-count blowup against actual code — and was fixed by further restricting candidates to constants declared in the same file as the local variable's use, verified stable afterward against a real fixture.

Architecture

Turango loads and plans once, then processes source files through a bounded worker pool. Mutants within one file run sequentially because they share an AST that is changed and reverted in place. A single consumer collects and sorts mutant, suppression, and TCE-equivalent results.

flowchart TD
    CLI["turango test -mutate=..."] --> Prepare["Load packages and operators; resolve scope and timeout"]
    Prepare --> Pool["Bounded worker pool: one job per source file"]

    subgraph Worker["Per-file worker: mutations run sequentially on one AST"]
        Parse["Parse/type-check once; scan //nomutant"] --> Mutate["Walk AST; apply one mutation"]
        Mutate --> Resolve["Resolve via impact coverage, cache, TCE, or isolated tests"]
        Resolve --> Verdict["Killed / survived / not viable"]
        Resolve --> Equivalent["Equivalent"]
        Verdict --> Revert["Revert mutation"]
        Equivalent --> Revert
    end

    Pool --> Parse
    Revert --> Collect["Collect and sort results"]
    Collect --> Output["Console summary and optional JSON"]

Try it

  • corpus/example/ — ordinary, deliberately-imperfect order-pricing code with an ordinary test suite. corpus/example/README.md has a runnable command and explains what surviving mutants and //nomutant suppressions do to the score.
  • corpus/example/legacy/ — the original go-turango prototype's demo package, ported unchanged: one coarse assertion, 74 mutants, 29 survivors — a clean illustration of what a single overall-result check misses.

Alias mode (experimental, opt-in)

Renaming or symlinking turango to go and placing it ahead of the real toolchain in PATH routes every tool on the machine that shells out to go (editors, gopls, CI, go generate, ...) through turango's passthrough. That's a large blast radius for one unhandled verb or flag, so it's gated behind TURANGO_EXPERIMENTAL_ALIAS=1 and not advertised as a default way to use turango. Use turango test -mutate=. ./... directly instead.

More

  • PROPOSAL.md — the case for adding -mutate to go test itself, modeled on the real -fuzz design draft, with crypto-package evidence and a historical-bug validation case study.
  • Go proposal issue #80892 — exploratory discussion of opt-in mutation testing in go test.
  • BENCHMARKS.md — a real, captured go test -bench transcript of -mutatescope/-mutatetce/-mutateparallel's actual cost, including the full data behind the TCE cost-tradeoff numbers cited above.

License

MIT — see LICENSE.

Directories

Path Synopsis
cmd
turango command
Command turango is a drop-in replacement for the go command that adds mutation testing as a `go test` flag.
Command turango is a drop-in replacement for the go command that adds mutation testing as a `go test` flag.
corpus
example
Package example is a small, deliberately imperfect package used to demonstrate turango end to end.
Package example is a small, deliberately imperfect package used to demonstrate turango end to end.
example/legacy
Package legacy is ported, unchanged in behavior, from the example package in the author's earlier prototype (jh125486/go-turango, 2019).
Package legacy is ported, unchanged in behavior, from the example package in the author's earlier prototype (jh125486/go-turango, 2019).
internal
corpus
Package corpus discovers turango's checked-in mutation-testing regression fixtures under the repo's corpus/ directory and parses each one's golden.json into an Entry that internal/corpus's own tests turn into an internal/mutate.Options run.
Package corpus discovers turango's checked-in mutation-testing regression fixtures under the repo's corpus/ directory and parses each one's golden.json into an Entry that internal/corpus's own tests turn into an internal/mutate.Options run.
goproxy
Package goproxy locates the real Go toolchain binary and forwards an invocation to it.
Package goproxy locates the real Go toolchain binary and forwards an invocation to it.
mutate
Persistent mutant verdict cache.
Persistent mutant verdict cache.
mutator
Package mutator defines the contract every mutation operator implements and the name-keyed plugin registry the mutation engine discovers operators through.
Package mutator defines the contract every mutation operator implements and the name-keyed plugin registry the mutation engine discovers operators through.
mutator/control
Package control implements the body-removal mutation operators.
Package control implements the body-removal mutation operators.
mutator/expression
Package expression implements the mutation operators that rewrite Go expressions.
Package expression implements the mutation operators that rewrite Go expressions.
mutator/identifier
Package identifier implements mutation operators whose eligibility depends on static type information, answering questions purely syntactic operators (control, expression, literal, operator, statement) cannot: "is this identifier interchangeable with that one?"
Package identifier implements mutation operators whose eligibility depends on static type information, answering questions purely syntactic operators (control, expression, literal, operator, statement) cannot: "is this identifier interchangeable with that one?"
mutator/literal
Package literal implements mutation operators that change a literal value in source rather than swapping an operator or removing a statement: a numeric constant shifted — an integer by one in each direction (the classic off-by-one — `x < 0` becomes `x < 1`), a float by a small relative nudge in each direction (`x < 0.95` becomes `x < 0.95095`) — or a boolean literal flipped (`true` becomes `false`).
Package literal implements mutation operators that change a literal value in source rather than swapping an operator or removing a statement: a numeric constant shifted — an integer by one in each direction (the classic off-by-one — `x < 0` becomes `x < 1`), a float by a small relative nudge in each direction (`x < 0.95` becomes `x < 0.95095`) — or a boolean literal flipped (`true` becomes `false`).
mutator/operator
Package operator implements the token-level mutation operators: the mutators that change a program's meaning by substituting one Go operator for another (`+` for `-`, `==` for `!=`, `++` for `--`) or by stripping a unary operator off the expression it negates.
Package operator implements the token-level mutation operators: the mutators that change a program's meaning by substituting one Go operator for another (`+` for `-`, `==` for `!=`, `++` for `--`) or by stripping a unary operator off the expression it negates.
mutator/statement
Package statement implements mutation operators that delete whole statements from a function body, answering the question "does the test suite notice when this line never runs?".
Package statement implements mutation operators that delete whole statements from a function body, answering the question "does the test suite notice when this line never runs?".

Jump to

Keyboard shortcuts

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