[테스트] Jacoco를 통한 코드 커버리지 측정

JeongYong Park·2023년 9월 21일
2
post-custom-banner

개요

현재 코드스쿼드 피드백 중에 종종 들은 말이 있습니다.

"코드 커버리지는 어느정도 되세요?"

이 질문이 들어올 때마다 저는 "아직 측정해보지 않았습니다."라는 답변을 할 수 밖에 없었는데요, 이번 글에서는 왜 코드 커버리지 측정이 필요하고 어떻게 측정을 수행했는지 작성해보려 합니다.

코드 커버리지가 뭔데!

코드 커버리지는 테스트코드가 실제 코드를 얼마나 실행했는지를 백분율로 나타내는 지표입니다. 코드 커버리지를 통해 작성된 테스트코드의 수가 충분한지 확인할 수 있습니다.

측정 기준

그렇다면 코드 커버리지는 어떤 기준으로 측정되는 걸까요? 측정 기준에는 다음과 같은 것들이 있습니다.

  • 함수 커버리지
  • 구문 커버리지
  • 브랜치 커버리지
  • 조건 커버리지

함수 커버리지

함수 커버리지는 프로그램 내의 각 함수가 한 번이라도 호출되었는지를 검사합니다.

public int foo(int x, int y) {
    int z = 0;
    if ((x > 0) && (y > 0)) {
        z = x;
    }
    return z;
}

public int bar(int x, int y) {
    // ...
}

위와 같은 foo, bar 함수가 있을 때 foo함수가 호출됐으면 함수 커버리지는 50%를 달성하게 됩니다.

  • 함수커버리지 = (실행된 함수의 개수) / (전체 함수의 개수) * 100

구문 커버리지

구문 커버리지는 프로그램 내의 각 구문(statement)이 한 번이라도 실행됐는지를 검사합니다.
함수 커버리지의 예제를 다시 가져와 보겠습니다.

public int foo(int x, int y) {
    int z = 0;  // statement 1
    if ((x > 0) && (y > 0)) {  // statement 2
        z = x;  // statement 3
    }
    return z;   // statement 4
}

위와 같이 foo 함수가 있을 때 foo(1, 1)을 호출하면 구문 커버리지는 100%를 달성하게 됩니다. 왜냐하면 z=x까지 모든 구문이 실행됐기 때문입니다. 그런데 만약 foo(1, -1)로 호출하게 되면 z=x구문을 실행하지 않게 되고 커버리지는 75%를 달성하게 됩니다.

  • 구문 커버리지 = (실행된 구문의 개수) / (전체 구문의 개수) * 100

브랜치 커버리지

브랜치 커버리지는 결정 커버리지라고 부르기도 합니다. 브랜치 커버리지는 모든 조건식이 true/false를 가지게 되면 충족합니다.

public int foo(int x, int y) {
    int z = 0;
    if ((x > 0) && (y > 0)) {
        z = x;
    }
    return z;
}

위와 같은 코드가 있을 때 나올 수 있는 테스트 케이스를 생각해봅니다. if 문의 조건에 대해 true/false를 모두 가질 수 있는 테스트 케이스는 다음과 같습니다.

  • foo(1, 1)
    • x > 0 && y > 0을 모두 만족하기 때문에 true가 나오게 됩니다.
  • foo(-1, 1)
    • x > 0 && y > 0에서 x > 0을 만족하지 못하기 때문에 false가 나오게 됩니다.

모든 조건식에 대해 브랜치 커버리지가 100%가 됩니다.

조건 커버리지

조건 커버리지는 결정 커버리지와는 다르게 전체 조건식이 아닌 개별 조건식이 모두 true/false를 한 번씩 갖도록 하면 만족하는 커버리지 입니다.

public int foo(int x, int y) {
    int z = 0;
    if ((x > 0) && (y > 0)) {
        z = x;
    }
    return z;
}

그러면 조건 커버리지의 경우 생각할 수 있는 테스트 케이스는 다음과 같습니다.

  • foo(1, 1)
    • x > 0, y > 0 모두 true가 나오게 됩니다.
  • foo(-1, 1)
    • x > 0은 false가 나오게 됩니다.
    • 이때 boolean 연산자의 lazy-evaluation 때문에 y > 0은 검사하지 않습니다.
  • foo(1, -1)
    • x > 0은 true, y > 0은 false가 나오게 됩니다.

보통 구문 커버리지가 많이 사용되고 있습니다. 왜냐하면 조건 커버리지나 분기 커버리지는 코드 실행보다는 로직의 시나리오 테스트에 가깝기 때문입니다.

그렇다고 코드 커버리지가 높다고 반드시 좋은 코드는 아닙니다.

public int add(int x, int y) {
    return a + b;
}

위와 같은 더하는 함수가 있다고 했을 때 a + ba * b로 바뀌어도 100%를 달성할 수도 있기 때문입니다. 또한 다음과 같은 경우도 발생할 수 있습니다.

public int getX() {
    return x;
}

위의 getX 함수를 호출해도 100%를 달성하겠지만 이를 위한 테스트코드를 짜야 할까요? 저는 테스트하고자하는 것이 무엇인지를 생각하고 테스트코드를 짜야한다고 생각하는 편인데, getX는 아무런 로직이 없이 단순히 x를 반환하기 때문에 테스트할 필요가 없다고 생각합니다.

이처럼 코드 커버리지에 얽매이지 않고 좋은 테스트코드를 작성하는 것이 좋다고 생각합니다.


여기까지 코드 커버리지가 무엇인지 알아봤는데요. 저희 프로젝트에서는 이 코드 커버리지 측정이 왜 필요했을까요?

왜 필요한가?

프로젝트를 진행하며 테스트 커버리지를 측정하고 싶은 순간들이 있었습니다.

  • 어떤 것들이 테스트가 안된거지?
    • 어떤 기능을 구현할 때 바로 테스트코드까지 작성하면 좋겠지만, 마감기한이 얼마 남지 않았을 때는 기능만 빠르게 구현해보고 PR을 날리는 경우가 있었습니다. 이후 다시 기능 개발을 시작하기 전 어떤 테스트를 작성하지 않았는지 기억이 나지 않았습니다.
  • 현재 작성된 테스트코드가 프로덕션 코드의 어느정도를 커버하고 있지?
    • 테스트코드를 작성했음에도 실제 프로덕션 코드의 어느 부분까지 테스트하는지에 대한 확신은 없었습니다.

Jacoco를 통한 코드 커버리지 측정

이를 해결하기 위해 정적 분석 도구인 jacoco를 사용해보려고 합니다.

Jacoco?

Jacoco는 자바진영에서 테스트코드 커버리지를 분석해주는 무료 라이브러리입니다.
이번에 우리 팀은 Jacoco를 도입해 코드 커버리지를 분석해보려합니다.

Jacoco 적용하기

우선 Jacoco를 적용하기 위해서는 build.gradle에 다음과 같은 작업이 필요합니다.
gradle 문서에도 친절하게 어떻게 설정하는지에 대해 나와있습니다.

먼저 완성된 gradle 파일을 보여드리고 나머지에 대해 설명하겠습니다.


plugins {
    // ...
    id 'jacoco'
}

jacoco {
    toolVersion = "0.8.9"
}

/** Jacoco start **/
test {
    finalizedBy jacocoTestReport
}

jacocoTestReport {
    dependsOn test

    reports {
        xml.required.set(true)
        html.required.set(true)

        // QueryDSL Q클래스 제외
        def Qdomains = []
        for (qPattern in "**/QA".."**/QZ") {
            Qdomains.add(qPattern + "*")
        }

        afterEvaluate {
            classDirectories.setFrom(files(classDirectories.files.collect {
                fileTree(dir: it,
                        exclude: [] + Qdomains)
            }))
        }

        xml.destination file("${buildDir}/jacoco/index.xml")
        html.destination file("${buildDir}/jacoco/index.html")
    }
}
/** Jacoco end **/

먼저 plugin에 jacoco를 추가해주고 버전을 명시해줍니다.

plugins {
    // ...
    id 'jacoco'
}

jacoco {
    toolVersion = "0.8.9"
}

여기까지 수행했다면 아래와 같이 test.exec 파일이 생성된 것을 확인할 수 있습니다. 이 파일은 jacoco가 테스트코드를 실행하고 코드 커버리지를 분석해 만들어준 보고서 파일입니다.

image

하지만 이 파일은 바이너리 파일이기 때문에 우리가 읽을 수 없습니다.

XML, HTML 파일로 커버리지 보고서 생성

test {
    finalizedBy jacocoTestReport
}

jacoco report가 항상 테스트 수행이후에 만들어지도록 설정합니다.

jacocoTestReport {
    dependsOn test // 테스트 이후에 수행하도록

    reports {
        xml.required.set(true)  // github actions 에서 사용하기 위해 리포트를 xml 파일 생성
        html.required.set(true) // 우리가 읽을 수 있는 html 파일 리포트 생성

        // QueryDSL Q클래스 제외 (커버리지를 측정할 필요가 없는 클래스 제외)
        def Qdomains = []
        for (qPattern in "**/QA".."**/QZ") {
            Qdomains.add(qPattern + "*")
        }

        afterEvaluate {
            classDirectories.setFrom(files(classDirectories.files.collect {
                fileTree(dir: it,
                        exclude: [] + Qdomains)
            }))
        }

        xml.destination file("${buildDir}/jacoco/index.xml")   // `build` 디렉토리에 리포트 파일 생성
        html.destination file("${buildDir}/jacoco/index.html")
    }
}
/** Jacoco end **/

Jacoco를 통해 우리가 읽을 수 있는 XML, CSV, HTML 파일로 리포트를 작성할 수 있습니다.
Jacoco Gradle 플러그인은 jacocoTestReport라는 태스크가 존재합니다. 이 태스크는 리포트를 읽을 수 있는 형태로 출력해주는 역할을 합니다.

이제 테스트가 실행되고 나면 아래와 같이 .html, .xml 파일을 얻을 수 있습니다.

image

.csv 파일도 얻을 수 있습니다.

이제 직접 .html 파일을 열어 확인해보면 아래와 같이 커버리지가 측정된 모습을 확인할 수 있습니다.

image

Github actions를 통한 커비리지 리포트 생성

이제 jacoco를 통한 코드 커버리지를 알 수 있습니다. 그런데 PR마다 커버리지가 어느정도 되는지 알고 싶어 아래와 같이 github actions를 통해 커버리지 측정 결과를 PR에 코멘트 형식으로 달도록 설정했습니다.

      # 테스트 커버리지를 PR에 코멘트로 등록합니다
      - name: Comment test coverage on PR
        id: jacoco
        uses: madrapps/jacoco-report@v1.2
        with:
          title: 📝 테스트 커버리지 리포트
          paths: ${{ github.workspace }}/build/jacoco/index.xml
          token: ${{ secrets.PRIVATE_REPO_ACCESS_TOKEN }}
          min-coverage-overall: 50
          min-coverage-changed-files: 50
  • min-coverage-overall
    • 프로젝트 전체 테스트커버리지에 대한 기준입니다.
  • min-coverage-chaged-files
    • 변경이 일어난 파일의 테스트 커버리지에 대한 기준입니다.

결론

이번에 코드 커버리지를 측정하는 작업을 수행했습니다.
제가 생각하는 장점은 다음과 같습니다.

  • 배포시 현재 우리의 테스트코드가 프로덕션 코드의 몇 퍼센트를 커버하고 있는지 알 수 있어 자신감(?)이 생기는 것 같습니다.
  • 리팩토링할 때 테스트코드가 이 로직은 커버를 하고 있으니까 일단 해보자라는 마인드가 생겼습니다. 로직은 테스트코드가 검증하니까!

참고자료

https://en.wikipedia.org/wiki/Code_coverage
https://tecoble.techcourse.co.kr/post/2020-10-24-code-coverage/
https://docs.gradle.org/current/userguide/jacoco_plugin.html

profile
다음 단계를 고민하려고 노력하는 사람입니다
post-custom-banner

0개의 댓글