Files
mutator/README.md
T

6.3 KiB

mutator

Mutation testing for Clojure, Java, Go, TypeScript, Rust, and Python. One run detects the language of each source file, applies that language's mutation rules, and writes the snapshot uml-viewer already reads.

Clojure follows clj-mutate. Go follows mutate4go. Java follows mutate4java. TypeScript, Rust, and Python use the same decisions as Java, spelled in that language. Function names and namespaces come from crapper, which is what uml-viewer joins to .metrics/crap.edn.

A mutant is killed when the tests fail or time out. It survives when the tests still pass. A site on a line the coverage report does not hit is uncovered and is not run. The score uml-viewer paints is killed / (killed + survived).

Run

./mutator

The first run creates .venv and installs the tool. crapper has to be checked out next to this directory (../crapper), because that is where the namespace and function names come from. From a project root the tool walks the tree, skips test, spec, vendor, node_modules, and target, and writes .metrics/mutate/<namespace>.edn.

The first run of a tree executes every covered mutant. Start with the file you are working on.

./mutator src/demo/core.clj          # one file, or a directory
./mutator --scan src/demo           # list sites, do not run tests
./mutator --changed                 # git additions and edits
./mutator --mutate-all              # rerun killed mutants too
./mutator --no-coverage             # do not skip uncovered lines
./mutator --use-existing-coverage   # read reports already on disk
./mutator --test-command "pytest -q tests/test_demo.py"

Once a snapshot exists, the next run is differential. It reruns survivors and every site in a function whose text changed. Killed mutants in an unchanged function stay killed. --since-last-run is that same selection. --mutate-all ignores it.

Exit 0 when every executed mutant was killed. Exit 2 when the baseline tests fail. Exit 3 when a mutant survives. A failed baseline does not rewrite the snapshot.

Mutants run one at a time in the project tree. The original file is restored after each one. An interrupted run leaves a copy under target/mutator-backup/, and the next run puts it back before reading source.

Snapshot

uml-viewer loads every *.edn file under .metrics/mutate. Each file is one namespace. A Java file with an inner class, or a Go file with a function and a method, therefore writes more than one file. Two Go files in the same package share one snapshot, and rerunning one of them keeps the other's forms.

{:version 1
 :source "src/demo/board.py"
 :namespace "demo.board.Board"
 :outcomes {"[\"src/demo/board.py\",\"demo.board.Board\",\"defn/place\",10,11,\">\",\">=\"]" :survived}
 :forms [{:id "defn/place"
          :kind "defn"
          :file "src/demo/board.py"
          :line 2
          :end-line 4
          :hash "…"
          :killed 1
          :survived 1
          :uncovered 0
          :sites 2}
         {:id "defn-/_hide"
          :kind "defn-"
          :killed 0
          :survived 0
          :uncovered 1
          :sites 1}]}

:namespace is the class key crapper uses. The form id is what the viewer splits:

Form id Operation Private
defn/place place no
defn-/hide hide yes

:sites is every mutation in the function, including uncovered ones. The viewer prints ---no mutation sites--- when that count is zero.

Language :namespace :name
Clojure the ns the defn / defn- name
Java package.Class, or package.Outer.Inner the method name
Go the package import path, or import/path.Receiver the function or method name
TypeScript the dotted module path, or module.Class the function or method name
Rust crate::module, or crate::module::Type the function or method name
Python the dotted module path, or module.Class the function or method name

defn- is Clojure's private form. Elsewhere a private method, an unexported Go function, a Python name that starts with _, a Rust function that is not pub, or a TypeScript private / # name uses defn-/.

What each language mutates

Tokens inside strings, comments, and quoted Clojure forms are left alone. A replacement that does not compile fails the test run and counts as killed.

Language Mutations
Clojure clj-mutate's head symbols: +/-, * to /, inc/dec, comparisons, =/not=, if/if-not, when/when-not, and/or, and the seq, predicate, and coercion pairs. true/false and 0/1 anywhere.
Java true/false, ==/!=, </<=, >/>=, +/-, *//, &&/`
Go mutate4go: +/-, * to / only, comparisons, ==/!=, true/false, &&/`
TypeScript the Java set, plus ===/!==.
Rust the Java set (&&/`
Python the Java set, spelled and/or, not, and True/False.

Tests and coverage

The baseline command has to pass before any mutant runs. A mutant's timeout is ten times that baseline, and at least two seconds. --timeout-factor changes the multiple. --test-command replaces the default. Defaults:

Language Command Where
Clojure clj -M:spec --tag ~no-mutate when Speclj is on the classpath, otherwise clj -M:test or bb spec nearest deps.edn or bb.edn
Java mvn -q test -DexcludeTags=no-mutate nearest pom.xml
Go go test -count=1 of the file's package nearest go.mod
TypeScript npm test nearest package.json
Rust cargo test nearest Cargo.toml
Python python -m pytest, or unittest discover nearest project file

Coverage is generated with crapper's commands unless --use-existing-coverage or --no-coverage is set. A file missing from the report is treated as uncovered, and the run says so.

Development

python3 -m venv .venv
.venv/bin/pip install -e '.[dev]'
.venv/bin/pytest