HOME INFO PROJECT BLOG
Article Projects Lab
article |

Mutation test란?

LLM이 어느정도 상용화되면서 소프트웨어 품질을 높이기 위한 시도가 많이 보이는데 테스트 코드도 그 중 하나입니다.

코드를 빠르고 쉽게 생성할 수 있게 되면서 테스트 생성 비용은 낮아졌지만, 사람이 모든 테스트를 직접 읽고 그 품질을 판단하는 비용은 여전히 남아 있습니다.

저같은 경우 AI가 산출한 모든 output에 대해서 테스트가 포함되도록 가이드하고 있는데요 테스트 코드를 모두 검수하는 대신 테스트가 실제 결함을 잡아낼 수 있는지를 보여주는 시그널을 줄 수 있는 방법이 있지 않을까 고민했습니다.

그 방법 중 하나는 mutation test입니다.

코드 커버리지의 빈틈

다음과 같은 정책과 테스트가 있다고 가정해봅시다.

public class ReservationPolicy {
    public boolean canReserve(int currentCount, int capacity) {
        return currentCount < capacity;
    }
}

@Test
void 정원이_남으면_예약할_수_있다() {
    assertThat(policy.canReserve(0, 10)).isTrue();
}

이 테스트는 메서드의 반환문을 실행하 라인 커버리지는 100%로 보입니다.

하지만 테스트가 확인한 입력은 (0, 10)뿐이고 정원이 정확히 다 찬 (10, 10) 상황은 확인하지 않았습니다.

이때 구현을 다음처럼 바꿔도 테스트는 통과합니다.

return currentCount <= capacity;   // 변형된 코드

원본과 변형본의 결과가 달라지는 입력은 currentCount == capacity입니다.

  • 원본: false
  • 변형본: true

즉, 테스트는 코드를 실행했지만 경계 조건을 검증하지 않았습니다.

Mutation test

Mutation test는 프로그램에 작은 결함을 의도적으로 심고, 기존 테스트가 그 결함을 탐지하는지 확인하는 기법입니다.

Java/Kotlin 환경에서 널리 쓰이는 도구는 PIT(PITest)입니다. 이 글에서는 PIT을 기준으로 설명합니다.

이렇게 결함을 의도적으로 심어서 만들어진 변형 프로그램을 mutant라고 부르고 일반적으로 mutation operator를 한 번 적용해 원본과 한 부분이 달라지는 1차 mutant를 생성합니다.

앞의 예시에서 <를 <=로 바꾼 변형이 하나의 mutant입니다.

  • 테스트가 실패하면 mutant는 Killed
  • 모든 테스트가 통과하면 Survived
  • 해당 변형 지점을 실행하는 테스트가 없으면 No coverage

PIT은 여기에 더해 Timed Out, Non viable, Memory error, Run error 상태를 사용합니다.

Timed Out은 변형으로 무한 루프가 생겨 실행이 끝나지 않은 경우이고, Non viable은 JVM이 로드할 수 없는 바이트코드가 만들어진 경우입니다. PIT 기본 개념

Mutation score

Mutation testing에서는 보통 다음과 같은 지표를 사용합니다.

mutation score = killed / 전체 mutant × 100

PIT은 여기에 더해 test strength라는 지표를 함께 보고합니다.

test strength = killed / (killed + survived)

즉, 실행조차 되지 않은 mutant는 분모에서 제외하는데, 이는 커버리지가 없어서 살아남은 것과, 테스트가 실행했는데도 못 잡은 것은 원인이 다르기 때문입니다.

PIT의 내부 작동

PIT은 소스 파일이 아니라 컴파일된 바이트코드를 변형합니다. 소스를 고쳐 다시 컴파일하지 않아도 되므로 빠르고 빌드에 통합하기 쉽다고 설명합니다.

그런데 바이트코드를 변형한다는 말이 구체적으로 무엇을 의미하는지 보려면, 먼저 우리가 쓴 조건문이 바이트코드에서 어떤 모습인지 확인해야 합니다.

앞의 canReserve를 컴파일해 javap -c로 열어보면 다음과 같습니다.

public boolean canReserve(int, int);
  Code:
     0: iload_1
     1: iload_2
     2: if_icmpge     9      // currentCount >= capacity 이면 9번으로 점프
     5: iconst_1             // true
     6: goto          10
     9: iconst_0             // false
    10: ireturn

소스에서는 currentCount < capacity인데 바이트코드에는 if_icmpge, 즉 크거나 같으면 점프가 들어 있습니다. 분기를 만들 때 컴파일러가 조건을 부정해 점프시키기 때문입니다.

그렇다면 <=로 쓴 코드는 어떻게 컴파일될까요?

public boolean lte(int, int);
  Code:
     0: iload_1
     1: iload_2
     2: if_icmpgt     9
     5: iconst_1
     6: goto          10
     9: iconst_0
    10: ireturn

다른 것은 2번 명령어 하나뿐입니다. if_icmpge가 if_icmpgt로 바뀌었을 뿐 나머지는 완전히 같습니다.

즉 바이트코드에서 명령어 하나를 갈아끼우는 일이 소스에서 <를 <=로 고치는 일과 같아집니다. PIT이 소스를 건드리지 않고도 경계 조건을 흔들 수 있는 이유입니다.

여기서 이 치환 과정은 임의로 하는 것이 아니라 mutation operator마다 정해진 표를 따릅니다.

경계를 옮기는 ConditionalsBoundaryMutator의 규칙 중 정수 비교에 해당하는 부분은 다음과 같습니다.

원본변형
IF_ICMPLTIF_ICMPLE
IF_ICMPLEIF_ICMPLT
IF_ICMPGTIF_ICMPGE
IF_ICMPGEIF_ICMPGT

앞에서 본 if_icmpge → if_icmpgt가 이 표의 마지막 줄에 해당하겠네요.

같은 명령어라도 어떤 operator를 적용하느냐에 따라 다른 mutant가 됩니다. 조건을 통째로 뒤집는 NegateConditionalsMutator는 동일한 IF_ICMPGE를 IF_ICMPLT로 바꾸는데, 이는 소스에서 <를 >=로 고친 것에 해당합니다.

실제로 PIT을 돌려보면 어떤 operator가 어디를 건드렸는지 리포트에 그대로 남습니다.

<mutatedMethod>canReserve</mutatedMethod>
<methodDescription>(II)Z</methodDescription>
<mutator>...gregor.mutators.ConditionalsBoundaryMutator</mutator>
<indexes><index>5</index></indexes>
<description>changed conditional boundary</description>

(II)Z는 int 두 개를 받아 boolean을 반환한다는 메서드 디스크립터이고, index는 메서드 안에서 몇 번째 명령어를 바꿨는지를 가리킵니다. (javap가 표시하는 바이트 오프셋이 아니라 PIT이 명령어에 매기는 자체 순번)

결국 mutant 하나는 어떤 operator를 어느 명령어에 적용했는가라는 좌표로 표현됩니다.

Mutation testing의 한계

equivalent mutant 문제가 있습니다. 변형은 적용되었지만 프로그램의 관찰 가능한 동작이 오리지널과 같아서 어떤 테스트로도 죽일 수 없는 mutant입니다.

예를 들어.. a + 0을 a - 0으로 바꾸는 경우가 이에 해당합니다.

더 곤란한 것은 어떤 mutant가 equivalent인지 자동으로 판별하는 일이 일반적으로 불가능하다는 점입니다.

또한 mutation testing은 계산 비용이 큽니다. PIT의 FAQ에서도 가장 효과적인 사용법은 변경 중인 코드로 분석 범위를 한정하는 것이라고 언급하고 있는;;

맺음

이 글을 작성하게된 이유는 Kotlin에서 PITest를 사용하면서 몇 가지 불편함을 겪었던 경험때문인데 이를 어떻게 타개(?)할 수 있을지에 대한 고민을 담은 글로 이어가겠습니다.


출처

Comments