dev.to

The Test Looked Redundant. The Ninth Bug Needed It

중복처럼 보였던 테스트가 아홉 번째 버그를 잡았습니다

유한한 오답 구현 목록에서 고유한 실패를 만들지 못한 테스트라도, 목록에 새로운 구현을 추가하면 유일한 검출 수단이 될 수 있습니다. 글은 mutation testing 점수와 테스트 제거 기준이 후보 카탈로그와 테스트 하네스에 의존한다는 점을 Python 실험으로 보여줍니다.

AI 요약

이 글은 테스트가 실제로 어떤 잘못된 구현을 배제하는지 오답 구현 카탈로그로 검증하는 방법을 다시 살펴봅니다. 출발점은 이전 글에서 제시한 질문입니다. “이 테스트가 거부하는 그럴듯한 잘못된 구현은 무엇인가?” 글쓴이는 이 질문을 실행 가능한 절차로 바꾸기 위해 여러 오답 구현을 준비하고, 테스트 스위트를 각각 실행한 뒤 어떤 후보를 거부하는지 세었습니다. 다만 특정 카탈로그에서 어떤 테스트가 고유한 검출을 만들지 못했다는 사실만으로 해당 테스트를 삭제해도 된다고 판단해서는 안 된다고 설명합니다.

■ 작은 Python fixture에서 확인한 mutation testing 결과

예제 함수는 주문 목록에서 상태(status)에 따라 주문을 필터링합니다. 필터를 생략하거나 None으로 전달하면 모든 주문을 반환하고, 빈 리스트를 전달하면 아무 주문도 반환하며, 상태 목록을 전달하면 일치하는 주문만 반환해야 합니다. 올바른 구현은 statuses가 None일 때 list(orders)를 반환하고, 그 외에는 각 주문의 status가 statuses에 포함되는지 검사합니다.

여기서 조건을 if not statuses로 바꾸면 회귀(regression)가 생깁니다. Python에서는 None과 빈 리스트가 모두 falsey로 취급되므로, 빈 필터를 전달했을 때도 모든 주문을 반환하게 됩니다. 기본 호출 테스트와 paid 상태 선택 테스트만 있으면 올바른 구현과 이 잘못된 구현을 모두 통과합니다. 빈 리스트가 결과를 비워야 한다는 assertion이 두 구현을 구분합니다.

Vinh Nguyen은 이 fixture에 mutmut를 실행한 결과, 생성된 mutation 후보에 조건을 None 비교에서 truthiness 검사로 바꾸는 변형이 포함되지 않았다고 지적했습니다. 글쓴이는 CPython 3.14.6과 mutmut 3.7.0으로 비교를 재현했습니다. 두 개의 테스트만 사용할 때 올바른 구현은 5개 후보 중 5개를 검출했고, 빈 리스트 테스트를 추가한 뒤에도 5/5였습니다. 반면 잘못된 구현은 두 개의 테스트만으로는 5/5를 기록하며 살아남았고, 세 번째 테스트를 포함하면 기준 테스트 자체에서 실패하므로 mutation score를 받을 수 없었습니다.

도구가 올바른 구현에 적용한 변형은 identity 비교 반전, list(orders)를 list(None)으로 변경, status 키의 두 가지 변경, membership 검사 반전 등이었습니다. 일부 후보는 예외를 일으켰고 테스트 스위트는 이 역시 검출했습니다. 그러나 identity 비교를 truthiness 검사로 바꾸는 후보는 자동 생성되지 않았습니다. 글쓴이는 이 누락된 후보를 직접 추가했고, 그 결과 두 테스트 스위트는 같은 6개 후보에 대해 각각 5/6과 6/6을 거부했습니다. 새로 생긴 차이는 전적으로 빈 필터 요구사항에서 파생된 후보를 검출한 데서 나왔습니다.

■ 8개 후보에서는 중복이었던 테스트

howcani가 제시한 더 큰 카탈로그에는 필터 조건 변경, 필터 무시, 잘못된 비교, 리스트 identity, 출력 순서, 반복 selector, 입력 변경과 관련된 8개 대안이 포함되어 있었습니다. 원본 파일은 없었기 때문에 글쓴이는 각 검사에 새 입력과 독립적인 기대값을 사용하는 방식으로 후보를 재구성했습니다. 재구성된 누적 결과는 다음과 같습니다. 기존 두 검사는 8개 중 3개를 거부했고, 빈 리스트 검사를 추가하면 4/8, 새 리스트 객체를 요구하면 5/8, 순서를 검사하면 7/8, 호출자의 입력이 바뀌지 않았는지 검사하면 8/8이 됐습니다.

이 과정에서 반복 상태(repeated status) 검사는 이미 순서 검사가 잡는 후보에 대해서는 별도의 거부를 추가하지 못했습니다. 예를 들어 요청된 statuses를 먼저 순회한 뒤 orders를 순회하는 후보는 결과 순서를 바꾸거나, 같은 status가 두 번 요청될 때 주문을 중복해서 반환할 수 있습니다. 순서 검사와 “동일한 상태를 두 번 요청해도 같은 주문을 한 번만 반환해야 한다”는 검사가 모두 이 후보를 잡습니다. 따라서 8개 후보만 놓고 보면 반복 상태 검사는 유일한 검출을 만들지 않는 중복 테스트처럼 보입니다.

■ 아홉 번째 구현이 바꾼 결론

글쓴이는 앞선 후보들과 다른 방식으로 결함을 분리하는 아홉 번째 구현을 직접 구성했습니다. 이 duplicate_only 구현은 statuses가 None이면 주문을 복사해 반환하고, 그 외에는 각 주문에 대해 해당 주문의 status가 요청 목록에 몇 번 등장하는지를 세어 그 횟수만큼 주문을 추가합니다. 따라서 고유한 상태만 요청하면 올바른 필터처럼 동작합니다. 입력 순서를 보존하고, 새 리스트를 만들며, 입력을 변경하지 않고, None과 빈 리스트도 올바르게 처리합니다. 그러나 상태가 반복되면 일치하는 주문을 여러 번 반환합니다.

이 아홉 번째 구현은 앞서 8개 후보를 모두 거부했던 여섯 가지 검사도 통과합니다. 반복 상태 검사가 추가되면 이 구현이 실패하므로, 전체 결과는 8/9가 되고 해당 검사를 유지하면 9/9가 됩니다. 즉 테스트 자체의 요구사항이 바뀐 것이 아니라, 이전 카탈로그에 “순서는 보존하지만 반복 상태에서 주문을 중복하는 구현”이 없었기 때문에 테스트가 중복처럼 보였던 것입니다. 글쓴이는 이 후보가 버그 발생 빈도나 일반적인 테스트 커버리지를 보여주는 held-out sample이 아니며, 단지 오답 카탈로그의 구성에 따라 고유 검출 여부가 달라질 수 있음을 보여주는 반례라고 제한합니다.

■ 테스트 하네스와 기대값 구성도 결과에 포함됩니다

재구성한 카탈로그를 공개 아카이브의 기존 runner로 실행했을 때는 예상한 3/8과 4/8이 아니라 4/8과 5/8이 나왔습니다. 차이를 만든 후보는 함수가 올바른 반환값을 만든 뒤 입력 리스트에 sentinel을 추가하는 구현이었습니다. 원래 기본 테스트는 함수의 결과를 함수에 전달한 ORDERS와 비교했는데, 함수 실행 뒤에는 기대값으로 사용한 mutable list가 변경됩니다. 반환된 복사본은 변경되지 않으므로 assertion이 실패합니다.

호출 전 snapshot을 독립적으로 저장해 기대값으로 사용하면 출력 비교는 통과하고, 입력 보존 여부는 별도의 assertion이 잡습니다. 따라서 두 runner는 같은 후보를 대상으로 하더라도 기대값을 언제 만들었는지와 상태 격리를 어떻게 했는지에 따라 서로 다른 것을 측정합니다. 글쓴이는 이 차이가 howcani의 수치가 틀렸다는 뜻은 아니며, 정확한 구현과 테스트 하네스를 알 수 없기 때문에 함수 목록과 결과 숫자만으로는 재현성이 확보되지 않는다고 설명합니다. mutation score 역시 숫자만으로 존재하는 값이 아니라, 계약(contract), 후보 구현, probe, 기대값, 실행 환경을 함께 기록한 시스템의 결과입니다.

■ 요구사항을 먼저 고정해야 합니다

원래 세 가지 규칙은 어떤 주문이 반환되어야 하는지를 설명했지만, 바깥 리스트가 새 객체여야 하는지, 출력 순서를 안정적으로 유지해야 하는지, 입력을 변경하지 않아야 하는지까지 명시하지는 않았습니다. 글쓴이는 별도의 요구사항을 정의하지 않은 상태에서 reference implementation이 list(orders)를 사용한다는 이유만으로 새 리스트 반환을 제품 계약으로 간주해서는 안 된다고 지적합니다. 특정 함수가 좁은 해석에서 올바른 membership과 multiplicity를 반환한다고 해도, 실제 호출자에게는 입력 변경이나 순서 변경이 문제가 될 수 있습니다.

이를 확인하기 위해 입력과 필터 조합으로 구성한 유한한 1,134개 호출 도메인에서 네 가지 대안을 검사했습니다. 반환 리스트의 alias를 그대로 돌려주는 구현, 출력 순서를 뒤집는 구현, 입력을 정렬하는 구현, 결과를 만든 뒤 입력에 항목을 추가하는 구현은 모든 호출에서 올바른 multiset을 반환할 수 있었습니다. 하지만 이 결과는 제품 수준의 정합성을 증명하지 않습니다. 입력 변경, 순서, 객체 소유권이 호환성 계약이라면 별도의 테스트가 필요합니다. 각 후보를 버그로 부르기 전에 어떤 요구사항을 위반하는지 연결해야 합니다.

■ 에이전트 수리 루프에 적용하는 기준

글쓴이는 AI agent의 repair loop에서도 먼저 서면 요구사항과 지원 입력을 정하고, 각 후보에 실행 가능한 counterexample과 검토된 기대값을 붙이며, candidate matrix로 빠진 구분을 찾아야 한다고 제안합니다. 구현이 바뀌는 동안 승인된 regression check는 안정적으로 유지해야 합니다.

어떤 테스트가 고유한 후보를 하나도 거부하지 않는다면 즉시 삭제하지 말고, 다른 테스트와의 겹침이 후보 구현이 두 가지 독립적인 실수를 한꺼번에 포함해서 생긴 것인지 확인해야 합니다. 여기서는 selector를 바깥 루프에 둔 구현이 출력 순서 변경과 중복 반환을 동시에 일으켰습니다. 반복 구조를 분리한 아홉 번째 후보를 추가하자 두 요구사항이 서로 다른 결함을 검출한다는 사실이 드러났습니다. 유한 카탈로그 안에서만 목적이 후보 구분이라면 테스트를 줄일 수 있지만, 특정 요구사항에 연결된 regression check를 제거하려면 지원 입력 전반에서 같은 동작을 보장한다는 더 넓은 근거가 필요합니다.

저장소에는 원본 아카이브, 네 가지 mutation-test 설정, 생성된 함수, 재구성 후보, 아홉 번째 challenge, 결과를 재실행하는 명령이 포함되어 있습니다. 글의 결론은 반복 상태 요구사항이 아홉 번째 구현을 추가한 뒤 새로 생긴 것이 아니라, 그 요구사항을 순서 검사와 결합하지 않고 위반하는 후보가 뒤늦게 카탈로그에 들어왔다는 것입니다. 따라서 테스트의 중복 여부는 테스트 문장만이 아니라 어떤 오답 공간을 구성했는지와 함께 판단해야 합니다.

원문: dev.to / 번역·요약: Trawling