Skip to content
mutastic

Java edition

Control flow

Operators that change where the code goes: a switch arm that stops matching, or an exception that’s no longer caught. Together they ask whether your tests reach every case the code was written for.

OperatorChangesSet
mutastic/remove-switch-arman arm of a switch, its values sent to the defaultextended
mutastic/drop-catch-typeone type of a multi-catch, no longer caughtextended

Each example is a class in samples/operator-docs with a test that misses its mutant, and each card is what mutastic run samples/operator-docs --operators=all prints for it. Run these with --operators=defaults,extended.

mutastic/remove-switch-arm, in the extended set. One case of a switch is removed, and its values go to the default. Each label is its own mutant, so case 1: case 2: has two.

The bug it stands for: a case that’s missing or never worked, so its values get the default’s behaviour: the wrong rate, the wrong message, or nothing happening at all. It costs in tables of business rules written as a switch. Tests tend to try one or two arms, and the others can be wrong for a long time, as the default gives an answer that looks fine.

public int days(int zone) {
return switch (zone) {
case 1 -> near;
case 2 -> far;
default -> abroad;
};
}

The tests try the near zone and one abroad, and check every zone is within a week, which the default is too:

private final Zones zones = new Zones(1, 3, 7);
@Test
void nearAndAbroad() {
assertEquals(1, zones.days(1));
assertEquals(7, zones.days(9));
}
@Test
void everyZoneWithinAWeek() {
for (int zone = 1; zone <= 3; zone++) {
assertTrue(zones.days(zone) <= 7);
}
}
Terminal window
✗ MISSED no test needs case 2 in days()
┌─ src/main/java/com/example/control/Zones.java:18
│
17 │ case 1 -> near;
18 │ case 2 -> far;
│ ^^^^^^^^^^^^^^ arm removed
19 │ default -> abroad;
│
= mutant - case 2 -> far;
= note: inputs seen: zone = 1, 2, 3
covered by ZonesTest#everyZoneWithinAWeek (its 1 covering test still passes)
= help: add a test with a value that reaches case 2, and assert what it does
// in ZonesTest
@Test
void daysWithZone2ForNear1AndFar3AndAbroad7() {
var zones = new Zones(1, 3, 7); // as ZonesTest builds it
assertEquals(3, zones.days(2)); // today 3 · with this mutant 7
}
check today's value is the one you intend: the test pins what the code does now
done when mutastic verify mc76ca574 samples/operator-docs passes
reproduce mutastic run --only=mc76ca574 samples/operator-docs
or ignore mutastic ignore mc76ca574 --reason="<why>" samples/operator-docs

The test that catches it: one with a value that reaches the arm, asserting what it does.

@Test
void theFarZoneTakesThreeDays() {
assertEquals(3, zones.days(2));
}

Edition notes: it covers old-style case, arrows, yield, and a switch on a String, an enum or a pattern. The mutant is on the arm’s own line, so only the tests that reach the arm run for it. An enum’s arm has no name in the bytecode, so its card says “this arm” and points at the line. A switch with no default of its own, on every constant of an enum or every type of a sealed interface, has one javac writes that throws: with an arm removed, a value that reached it throws, and the card says so. A test that reaches the arm catches that whatever it asserts, so a catch there says the arm is reached, not that its result is checked. javac’s own parts have no mutants: the first of the two switches a String switch compiles to, which only hashes, and the gaps in a table. An arm with no code of its own, case 1: break;, is skipped as equivalent where the default goes to the same place. The Rust edition has this operator too, mutastic/remove-switch-arm, in stronger, where an arm stops matching and its values go to the next arm that does, and adds mutastic/swap-break, which turns break into continue and back.

mutastic/drop-catch-type, in the extended set, and experimental. A multi-catch stops catching one of its types, one mutant for each.

The bug it stands for: an exception nobody meant to let out, or a type added to a catch that no one checked. It costs in the failure that only happens in production: a timeout, a bad file. The handler looks complete, and only one of its types has ever been tried.

/// What `source` says, or `cached` when it can't be read.
public static String load(Source source, String cached) {
try {
return source.read();
} catch (IOException | TimeoutException e) {
return cached;
}
}

The test fails a read with an IOException, and never with a timeout:

@Test
void aFailedReadFallsBackToTheCache() {
assertEquals("cached", Reader.load(ReaderTest::unreadable, "cached"));
}
private static String unreadable() throws IOException {
throw new IOException("the file is gone");
}
Terminal window
✗ MISSED no test has TimeoutException reach this catch in load()
┌─ src/main/java/com/example/control/Reader.java:16
│
15 │ return source.read();
16 │ } catch (IOException | TimeoutException e) {
│ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ no longer catches it
17 │ return cached;
│
= mutant - } catch (IOException | TimeoutException e) {
+ } catch (IOException e) {
= note: covered by ReaderTest#aFailedReadFallsBackToTheCache (its 1 covering test still passes)
= help: add a test where the try throws TimeoutException, and assert what the catch does
done when mutastic verify m548495eb samples/operator-docs passes
reproduce mutastic run --only=m548495eb samples/operator-docs
or ignore mutastic ignore m548495eb --reason="<why>" samples/operator-docs

The test that catches it: one where the try throws that type, asserting what the handler does.

@Test
void aTimeoutFallsBackToTheCache() {
assertEquals("cached", Reader.load(ReaderTest::tooSlow, "cached"));
}
private static String tooSlow() throws TimeoutException {
throw new TimeoutException("no answer in time");
}

Edition notes: Java only: Rust has no multi-catch, and an error path is remove throw’s and mutastic/unwrap-try’s there. Separate catch blocks aren’t changed, and a finally can’t be: javac copies it onto every way out of the try, so there’s nothing to remove.