Java

돌연변이 테스트(Mutation Testing)

dev_roach 2024. 3. 25. 23:48
728x90

발단

책 Effective testing 을 보다가 돌연변이 테스트(Mutation testing) 이라는 키워드를 발견했다. 돌연변이 테스트라는 키워드를 발견한 후 키워드가 궁금해서 찾아보게 되었고, 바이트 코드를 조작하여 기존 테스트 케이스들로 인해 "죽은 돌연변이" 들 혹은 "살아남은 돌연변이들", "실행조차 안된 돌연변이" 들 등의 수치들이 있었다.  이 모든 수치들을 보면서 이 수치가 무엇을 의미하는가에 대한 궁금함이 생겼고, 그렇다면 이 돌연변이들의 생사 지표가 어떻게 프로덕션 코드의 퀄리티를 측정할 수 있지? 라는 궁극적인 의문이 생겼다.

개념

기본적으로 Mutation testing 이 동작하는 방식을 설명하자면 기본적으로 바이트 코드를 조작하는 몇가지 동작(Operation) 들이 존재한다. 모든 예시를 들 수 없으니 그 중 하나인 Conditional Boundary Operation 에 대해서만 예시를 들어 설명을 해보겠다. 

사진을 보면 Original Conditional 이 우리가 기존 코드에 적은 조건문이고, 이게 돌연변이화(Mutate) 되면 `<=` 로 변해서 테스트를 진행하게 된다. 즉, 아래 코드가 돌연변이화(Mutate) 되면 어떻게 변할까?

if (a < b) {
  // do something
}

아래 코드는 돌연변이화 될 경우 아래와 같이 변한다.

if (a <= b) {
  // do something
}

즉, 우리가 기존에 적은 코드가 변한(돌연변이화 된) 상태로 테스트를 실행하게 되는 것이다. 그래서 현재 우리의 코드를 조작하며 몇개의 돌연변이가 생겼고, 테스트 코드를 통과하다가 몇개의 돌연변이가 죽었으며(Kill), 살아남았으며(Live), 그리고 테스트 케이스에서 실행조차(No Coverage) 되지 않았는지 지표를 제공한다.

사고

위에서 설명한 지표들이 나타내는 것 (돌연변이의 생 과 사) 과 검색결과를 토대로 생각해 보았을때 이 모든 지표들은 우리의 시스템이 가지고 있는 "테스트 시스템(테스트 케이스) 가 변경점(Mutation) 이 생겼을때 이를 잘 감지할 수 있는가?" 에 대한 지표로 이용될 수 있다는 생각이 들었다. 즉, 예를 들면 우리의 시스템에 테스트 코드를 제외 한 "5000 줄 정도의 코드라인" 이 있고, 과장해서 500 개의 테스트가 있다고 했을때 돌연변이가 죽은 개수가 거의 없 다면, 이는 확률적으로 우리의 테스트 시스템이 변화를 잘 감지하지 못하고 있는 것 일 수 있다.

셋업

셋업 과정은 살짝 귀찮으니 생략해보겠다. 다만 내 Github Link 를 걸어둘테니 커밋의 첫번째 부분을 확인해주면 좋겠다. 

이 글에서는 테스트 프레임워크로는 Kotest 를 돌연변이 테스팅을 위해서는 PiTest 를 이용할 것이다.

실험

일단 Mutation 이 얼마나 죽었는지를 테스트 해보기 위해서는 우리가 작성한 기본 코드와 그것을 검증하기 위한 테스트 코드가 존재해야 한다. 아주 가볍게 심플한 코드부터 작성해보도록 하자.

package com.example

class NCondition {

    fun fooIfIsNumberIsPositive(number: Int): String =
        if (number > 0) "foo" else "bar"
}

 

위와 같이 적었다면 우리가 "0 보다 클때 foo 를 반환해라" 라는 테스트 케이스 하나만 적어보도록 하자.

package com.example

import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe

class NConditionTest : FunSpec({

    context("fooIfIsNumberIsPositive") {
        test("should return foo if number is positive") {
            NCondition().fooIfIsNumberIsPositive(5) shouldBe "foo"
        }

        test("should return bar if number is zero") {
            NCondition().fooIfIsNumberIsPositive(-1) shouldBe "bar"
        }
    }
})

 

(ㅋㅋㅋㅋ 책처럼 한번 적어보았다)
만약 로치가 위와 같이 케이스 작성하고 "양수일때와 음수일때 케이스를 잘 확인하고 있다" 하고 퇴근하려고 생각했다고 가정해보자. 로치는 가기전 마지막으로 현재의 테스트 케이스가 변화를 잘 감지하는지 확인하기 위해 PiTest 를 돌려보았다. PiTest 를 돌려보니 결과는 아래와 같았다.

돌연변이 세명 중 `changed conditional bundary` 를 마주했을 것이다. 우리의 조건은 `>` 에서 `>=` 으로 변경되었을 것이고, 그렇게 되면 당연하게도 0 일 경우 기존에는 "bar" 를 반환했지만, "foo" 를 리턴하는 변화(Change)가 생길것이다. 즉, 우리의 코드는 현재 0 일경우에 대한 케이스 및 반대 케이스에 대한 테스트 케이스가 존재하지 않는다.

 

로치는 Mutation Testing 지표를 보고 아래와 같은 경계에 있는 값(0)에 대한 테스트를 추가했다.

package com.example

import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe

class NConditionTest : FunSpec({

    context("fooIfIsNumberIsPositive") {
        test("should return foo if number is positive") {
            NCondition().fooIfIsNumberIsPositive(5) shouldBe "foo"
        }

        test("should return foo if number is zero") {
            NCondition().fooIfIsNumberIsPositive(0) shouldBe "bar"
        }

        test("should return bar if number is zero") {
            NCondition().fooIfIsNumberIsPositive(-1) shouldBe "bar"
        }
    }
})

 

그리고 퇴근하기에 앞서 다시 PiTest 를 돌려보았다.

현재까지 활성화된 돌연변이들은 모두 케이스를 통과한 것을 확인할 수 있었다. 

고민

이 테스팅 과정을 책에서 보면서 이전에 한번도 못본 방식이라 신기하기도 했고, 이걸 프로덕션 CI 과정에 도입해보면 어떤 성과를 얻을 수 있을까 고민이 들기도 했다. 블로그 글 자체는 컨셉을 소개하기 위해 가볍게 적어보았다. 더 많은 Operation 들이 있고, 이걸 어떻게 활용하면 좋을지에 대한 고민은 조금 더 해봐야 할것 같다.

 

각종 아이디어가 떠오르시는 분들은 언제나 커피챗 또는 Linked-In DM 환영입니다 :)

728x90