콘텐츠로 이동

변이 테스트 이해하기

Spec: ISO/IEC/IEEE 29119-4 Spec: PHPUnit Evidence: Test-backed

라인 커버리지는 테스트 스위트 실행 중 어떤 라인이 실행되었는지를 알려줍니다. 그러나 그 라인이 틀렸을 때 어떤 테스트가 실패했을지는 알려주지 않습니다. 변이 테스트는 코드를 의도적으로 망가뜨린 뒤 테스트가 이를 감지하는지 확인해 그 간극을 메웁니다. 이 페이지에서는 변이 점수가 무엇을 의미하는지, 그리고 NextPDF가 이를 트로피가 아니라 진단 도구로 어떻게 사용하는지 설명합니다.

커버리지는 테스트에서 가장 신뢰받는 지표이면서도 가장 오해받기 쉬운 지표 중 하나입니다. 메서드를 호출하면서 아무것도 검증하지 않는 테스트도 그 안의 모든 라인을 실행합니다. 완벽한 커버리지에 감지율은 0입니다. 표준 문헌은 커버리지 기준 사이의 순서가 결함을 드러내는 능력에 대해서는 아무것도 알려주지 않는다는 점을 분명히 밝히고 있습니다. 그 능력이 바로 테스트 효과성이라고 부르는 속성입니다(ISO/IEC/IEEE 29119-4, §C.2.4). 커버리지 비율과 결함 발견 보장은 서로 다른 주장입니다.

PDF 엔진에서는 이것이 탁상공론이 아닙니다. 서명 바이트 범위 검사, 상호 참조 오프셋, 인코딩 분기 — 테스트는 정작 중요한 값을 한 번도 검증하지 않으면서도 이 모든 것을 완전히 “커버”할 수 있습니다. 약한 테스트 위에 올라선 녹색 스위트는 솔직한 공백보다 나쁩니다. 아무도 들여다보지 않도록 적극적으로 막기 때문입니다.

  • 변이 테스트는 소스에 수천 개의 작고 의도적인 수정(변이체)을 가합니다 — <<=로, +-로 뒤집거나 return 값을 바꾸는 식입니다 — 그리고 각 변이체에 대해 테스트를 다시 실행합니다.
  • 어떤 테스트가 변이체에서 실패하면 그 변이체는 죽은 것입니다. 어떤 테스트가 실제로 그 동작을 검증했다는 뜻입니다. 모든 테스트가 여전히 통과하면 그 변이체는 탈출한 것입니다. 동작은 실행되었지만 한 번도 확인되지 않았다는 뜻입니다.
  • **변이 점수 지표(MSI)**는 대략 죽은 변이체를 전체 비동등 변이체로 나눈 값입니다. 이는 테스트가 코드를 실행하는지가 아니라 변경을 감지하는지를 측정합니다.
  • 일부 변이체는 동등합니다 — 관찰 가능한 동작을 바꿀 수 없으므로 어떤 테스트도 이들을 죽일 수 없습니다. 이를 실패로 세는 것은 정직하지 않습니다. NextPDF는 이들을 비공식적으로 무시하는 대신 증명하고 원장에 기록합니다.
  • NextPDF는 MSI를 사용하여 약한 테스트를 찾아 강화합니다. 이는 마케팅 수치가 아니라 지속적 통합에서 작동하는 진단 게이트입니다.

엔진의 변이는 Infection 변이기를 사용해 실행됩니다. 이는 프로덕션 소스 트리에 대해 구성되며, 산술, 불리언, 조건 경계, 동등성, 반환값, 제거 변이기 계열이 활성화됩니다 — 정확히 “실행되었지만 검증되지 않은” 로직을 드러내는 연산자들입니다. 흐름은 기계적입니다:

  1. Start green The suite must pass before mutation begins.
  2. Mutate Apply one small, deliberate change to the source.
  3. Re-run Run the tests that cover the mutated line.
  4. Killed A test failed — the behaviour is genuinely asserted.
  5. Escaped All tests still pass — a weak spot to strengthen.
  6. Equivalent No test can kill it because behaviour is unchanged — proven and ledgered, not scored as a miss.
NextPDF 가 실행하는 변이 테스트 루프: 통과한 테스트를 가져와 변이체를 생성하고, 이를 커버하는 테스트를 다시 실행한 뒤, 변이체를 죽임(테스트가 잡아냄), 탈출(커버는 되었으나 검증이 없는, 고쳐야 할 간극), 또는 동등성 입증(어떤 테스트도 죽일 수 없음; 원장에 기록되며 점수에 불리하게 집계되지 않음)으로 분류합니다.

두 가지 설계 선택이 그 숫자를 신뢰할 수 있게 만듭니다. 첫째, 그 점수는 게이트로 연결되어 있습니다. 지속적 통합은 최소 MSI(및 최소 커버드 MSI)를 강제하고, 변경된 라인에 대해 diff 범위로 한정된 변이를 실행합니다. 그 결과, 코드는 추가하지만 실제 검증은 추가하지 않는 변경은 나중이 아니라 리뷰 단계에서 잡힙니다. 둘째, NextPDF는 불편한 변이체를 조용히 깎아내리지 않습니다. 엄격한 타이핑이 두 피연산자가 같은 타입임을 보장할 때의 !==!=처럼 진정으로 의미상 동등한 변이체는 명시적인 동등성 증명 테스트와 함께 변이 원장에 기록됩니다. 그 결과, 탈출 수치는 장부 정리가 아니라 실제 간극을 반영합니다. PHPStan Level 10에 strict_types와 타입이 지정된 프로퍼티가 더해져 그러한 동등성 증명을 견고하게 만듭니다.

Evidence: Test-backed 변이 테스트는 엔진에서 동작을 드러내는 변이기 계열을 활성화한 채 프로덕션 소스 디렉터리에 대해 구성됩니다. 이는 최소 MSI와 diff 범위로 한정된 변이를 갖춘 지속적 통합 게이트에서 강제됩니다. 이는 나중에 덧붙이는 일이 아니라 빌드 검사입니다.

Evidence: Test-backed 동등 변이체 문제는 정직하게 처리됩니다. 의미상 동등한 변이체는 분류되어 변이 원장 안의 전용 동등성 증명 테스트에 의해 뒷받침되며, 각 증명의 견고함은 PHPStan Level 10과 엄격한 타이핑에 기반합니다. 따라서 탈출 수치는 죽일 수 없는 잡음 때문에 점수가 실제보다 나빠 보이도록 부풀려진 것이 아니라 실제로 감지되지 않은 동작을 나타냅니다.

Evidence: Standard-backed 변이는 인정받은 기법이며, NextPDF 가 발명한 것이 아닙니다. Spec: ISO/IEC/IEEE 29119-4, §B.2.4 는 명세의 요소에 일반적인 변이를 적용하여 테스트를 위한 구체적인 변이를 도출하는 것을 설명합니다. 이 기법이 애초에 필요한 이유는 바로 그 표준이 커버리지 기준의 포함(subsumes) 순서가 이들을 결함을 드러내는 능력에 따라 정렬하지 않는다고 명시하기 때문입니다 (ISO/IEC/IEEE 29119-4, §C.2.4).

Evidence: Standard-backed 커버리지 자체는 잘 정의되어 있지만 한계가 있습니다. Spec: PHPUnit 은 라인, 분기, 경로 커버리지를 구분합니다. 라인 커버리지는 실행 가능한 라인이 실행되었다는 것만 기록합니다. 그 정의를 알면 그 한계가 분명해집니다.

핵심은 명령어가 아니라 — 탈출한 변이체가 무엇을 알려 주는지입니다:

<?php
declare(strict_types=1);
final class ByteRange
{
// Suppose the production guard is:
// if ($offset < 0) { throw new InvalidByteRange(); }
public function assertNonNegative(int $offset): void
{
if ($offset < 0) {
throw new InvalidByteRange('offset must be >= 0');
}
}
}
// A test that EXECUTES this line but does not assert the boundary:
// $byteRange->assertNonNegative(5); // no exception expected, none asserted
// gives 100% line coverage of assertNonNegative().
//
// Mutation flips `< 0` to `<= 0`. Behaviour now differs ONLY at $offset === 0.
// If no test passes 0 and asserts what happens, every test still passes:
// the mutant ESCAPED. Coverage said "tested"; mutation said "the boundary
// is unasserted". The fix is a test that pins offset === 0, not a higher
// target.
//
// composer mutation:diff → mutate only changed lines, enforce min MSI
// composer mutation:full → full-tree mutation gate

그 탈출한 변이체에 핵심 가치가 있습니다. 이는 커버리지 보고서가 완전히 테스트되었다고 평가했지만 실제로는 빠져 있던 구체적인 검증을 찾아냈습니다.

가장 큰 오해는 변이 점수가 극대화해야 할 성적이라는 것입니다. 변이체를 죽이기 위해 테스트를 작성하여 얻은 매우 높은 MSI는, 검증 없이 메서드를 호출하여 얻은 높은 커버리지만큼이나 공허합니다. 그렇게 되면 지표가 조작되어 더 이상 감지를 측정하지 못합니다. NextPDF는 MSI를 사용하여 약한 테스트를 찾습니다. 결과물은 더 나은 검증이며, 자랑은 명백히 목적이 아닙니다.

두 번째 오해는 탈출한 모든 변이체가 테스트의 결함이라는 것입니다. 일부 변이체는 진정으로 동등하며 관찰 가능한 동작을 바꾸지 않기 때문에 어떤 테스트로도 죽일 수 없습니다. 이를 실패로 취급하면 정직하지 않으며 인위적으로 낮은 점수가 나오고, 사람들이 보고서를 무시하도록 길들입니다. NextPDF의 해법은 동등성을 명시적으로 증명하고 원장에 기록하는 것이지, 이를 조용히 억누르거나 숫자를 실제보다 나쁘게 보이게 하는 것이 아닙니다.

변이 테스트는 테스트가 주입된 변경을 감지하는지를 측정합니다. 이는 코드가 올바르다는 것을 증명하지 않습니다. 성능이나 적합성을 측정하지도 않습니다. 진정으로 동등한 변이체를 죽일 수도 없습니다. 현재 변이 점수, 적용 중인 최소 MSI 임곗값, 원장에 기록된 동등체의 수, 그리고 모든 커버리지 수치는 지속적 통합 산출물에서 생성되어 빌드와 함께 게시되는 살아 있는 품질 신호입니다. 이들은 여기서 의도적으로 빠져 있습니다. 산문에 붙여 넣은 숫자는 오래되어 작은 거짓말이 되기 때문입니다. 이 페이지가 명시하는 유일한 안정적 사실은 PHPStan Level 10이며, 이는 측정값이 아니라 동등성 증명을 뒷받침하는 구성 속성입니다.

변이기 선택, 임곗값, 원장 정책은 엔진의 변이 구성이 소유하며 변경될 수 있습니다. 그 구성이 이 페이지와 다를 경우, 그 구성이 권위를 갖습니다. 여기서는 다른 어떤 라이브러리의 테스트 효과성에 대해서도 주장하지 않습니다.

  • 변이체 — 소스에 가하는 단 하나의 의도적인 작은 변경으로, 테스트 스위트가 그 변경을 알아차리는지 시험하는 데 사용됩니다.
  • 죽은 변이체 — 적어도 하나의 테스트를 실패하게 만든 변이체로, 그 동작이 진정으로 검증되었음을 뜻합니다.
  • 탈출한 변이체 — 모든 테스트를 그대로 통과한 변이체. 동작은 실행되었지만 한 번도 검증되지 않았습니다 — 고쳐야 할 약점입니다.
  • 동등 변이체 — 관찰 가능한 동작을 바꿀 수 없어 어떤 테스트도 죽일 수 없는 변이체. NextPDF는 이들을 증명하고 원장에 기록합니다.
  • MSI(변이 점수 지표) — 대략 죽은 변이체를 전체 비동등 변이체로 나눈 값으로, 실행이 아닌 감지를 측정하는 지표입니다.
  • 라인 커버리지 — 스위트 실행 중에 실행 가능한 라인이 실행되었다는 것만 기록하는 지표로, PHPUnit 이 정의하며 그 자체만으로는 불충분합니다.