Skip to content

date: 2026-06-04 tags: [mutation, dto, laravel-data, testing-technique, equivalent-mutant] status: active graduated_to:

Kill app/Data DTO boundary-cast mutants by hydrating from wrong-typed and missing raw values

Correction (2026-06-13, warm runs in PR #499) — read this first. The technique below does not kill scalar (string)/(int)/(bool) casts on a non-strict DTO whose constructor uses typed promoted properties (public string $id, public int $priority). PHP coerces the value at the property boundary regardless, so removing the cast is an equivalent mutant: fromTodoist(['id' => 123])->id === '123' passes with and without the cast and can't kill it. The original "reached 0 survivors" was a cold-cache false positive (see 2026-06-04-cold-cache-mutation-run-lies.md, same day); warm runs left every such scalar cast standing. Resolution: reason-ignore the scalar casts as equivalents (one @pest-mutate-ignore on the new self(...) statement). Still genuinely killable and worth the test: (array) feeding array_map/collect (load-bearing — a scalar input TypeErrors without it), EmptyStringToNotEmpty/??-default branches, and the Eloquent casts() method (2026-06-13-casts-method-mutants-are-killable.md).

SymptomTask::fromTodoist / GmailMessage::fromApi survived RemoveStringCast/RemoveIntegerCast/RemoveBooleanCast/RemoveArrayCast/EmptyStringToNotEmpty mutants despite being exercised by dozens of tests.

Root cause — the killable ones (the (array) cast, the empty-string/??-default branches) only ever saw well-typed raw payloads, so removing them was behaviourally identical for that input. The scalar (string)/(int)/(bool) casts are a different beast — on a non-strict typed-property DTO they are genuine equivalents, not test gaps (see the correction above).

Fix — one dedicated DTO test per fromX() that hydrates the boundary as it really arrives, pinning the killable mutants: fromTodoist(['labels' => 'focus']) pins the (array) cast, fromTodoist([]) / fromTodoist(['description' => '']) pin the defaults and empty→null branches. See tests/Unit/TaskDataTest.php, tests/Unit/GmailMessageTest.php. The remaining scalar casts are reason-ignored at the new self(...) statement.

Guard — convention for app/Data DTOs: a from*() hydrator gets a test for the load-bearing boundary behaviour (array coercion, defaults, empty→null); scalar casts that merely duplicate a typed promoted property's own coercion are reason-ignored as equivalents — and the classification is verified on a warm composer mutate run, never a cold one. (Candidate for .claude/rules/testing.md if it recurs.)