현재 코드스쿼드 피드백 중에 종종 들은 말이 있습니다.
"코드 커버리지는 어느정도 되세요?"
이 질문이 들어올 때마다 저는 "아직 측정해보지 않았습니다."라는 답변을 할 수 밖에 없었는데요, 이번 글에서는 왜 코드 커버리지 측정이 필요하고 어떻게 측정을 수행했는지 작성해보려 합니다.
코드 커버리지는 테스트코드가 실제 코드를 얼마나 실행했는지를 백분율로 나타내는 지표입니다. 코드 커버리지를 통해 작성된 테스트코드의 수가 충분한지 확인할 수 있습니다.
그렇다면 코드 커버리지는 어떤 기준으로 측정되는 걸까요? 측정 기준에는 다음과 같은 것들이 있습니다.
함수 커버리지는 프로그램 내의 각 함수가 한 번이라도 호출되었는지를 검사합니다.
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%를 달성하게 됩니다.
구문 커버리지는 프로그램 내의 각 구문(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%를 달성하게 됩니다.
브랜치 커버리지는 결정 커버리지라고 부르기도 합니다. 브랜치 커버리지는 모든 조건식이 true/false
를 가지게 되면 충족합니다.
public int foo(int x, int y) {
int z = 0;
if ((x > 0) && (y > 0)) {
z = x;
}
return z;
}
위와 같은 코드가 있을 때 나올 수 있는 테스트 케이스를 생각해봅니다. if 문의 조건에 대해 true/false를 모두 가질 수 있는 테스트 케이스는 다음과 같습니다.
x > 0 && y > 0
을 모두 만족하기 때문에 true가 나오게 됩니다.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;
}
그러면 조건 커버리지의 경우 생각할 수 있는 테스트 케이스는 다음과 같습니다.
x > 0
, y > 0
모두 true가 나오게 됩니다.x > 0
은 false가 나오게 됩니다.y > 0
은 검사하지 않습니다.x > 0
은 true, y > 0
은 false가 나오게 됩니다.보통 구문 커버리지가 많이 사용되고 있습니다. 왜냐하면 조건 커버리지나 분기 커버리지는 코드 실행보다는 로직의 시나리오 테스트에 가깝기 때문입니다.
그렇다고 코드 커버리지가 높다고 반드시 좋은 코드는 아닙니다.
public int add(int x, int y) {
return a + b;
}
위와 같은 더하는 함수가 있다고 했을 때 a + b
가 a * b
로 바뀌어도 100%를 달성할 수도 있기 때문입니다. 또한 다음과 같은 경우도 발생할 수 있습니다.
public int getX() {
return x;
}
위의 getX
함수를 호출해도 100%를 달성하겠지만 이를 위한 테스트코드를 짜야 할까요? 저는 테스트하고자하는 것이 무엇인지를 생각하고 테스트코드를 짜야한다고 생각하는 편인데, getX는 아무런 로직이 없이 단순히 x를 반환하기 때문에 테스트할 필요가 없다고 생각합니다.
이처럼 코드 커버리지에 얽매이지 않고 좋은 테스트코드를 작성하는 것이 좋다고 생각합니다.
여기까지 코드 커버리지가 무엇인지 알아봤는데요. 저희 프로젝트에서는 이 코드 커버리지 측정이 왜 필요했을까요?
프로젝트를 진행하며 테스트 커버리지를 측정하고 싶은 순간들이 있었습니다.
이를 해결하기 위해 정적 분석 도구인 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가 테스트코드를 실행하고 코드 커버리지를 분석해 만들어준 보고서 파일입니다.
하지만 이 파일은 바이너리 파일이기 때문에 우리가 읽을 수 없습니다.
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 파일을 얻을 수 있습니다.
.csv 파일도 얻을 수 있습니다.
이제 직접 .html 파일을 열어 확인해보면 아래와 같이 커버리지가 측정된 모습을 확인할 수 있습니다.
이제 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
이번에 코드 커버리지를 측정하는 작업을 수행했습니다.
제가 생각하는 장점은 다음과 같습니다.
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