Appearance
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 (see2026-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-ignoreon thenew self(...)statement). Still genuinely killable and worth the test:(array)feedingarray_map/collect(load-bearing — a scalar input TypeErrors without it),EmptyStringToNotEmpty/??-default branches, and the Eloquentcasts()method (2026-06-13-casts-method-mutants-are-killable.md).
Symptom — Task::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.)