-
Notifications
You must be signed in to change notification settings - Fork 0
[프로젝트 세팅] Jacoco & SonarCloud 적용
주말의집 프로젝트는 테스트 코드의 작성을 강제화 ( 의무화 )하여 CI를 통한 코드의 안정성을 높이기 위해 노력을 기울이고 있다.
테스트코드의 성공/실패 여부는 CI에서 레포팅을 해주고 있다.
이렇게 레포팅을 자동 생성하도록 하는 이유는 코드 리뷰에 참고사항으로 사용하기 위함이다. 이것 외에도 정형화된 패턴에 대해서 소스 코드를 분석해주는 도구인 코드 정적 분석 도구를 이용해 소스 코드 분석을 도입하고 싶어 SonarQube와 Jacoco를 도입하려고 한다.
코드를 분석하여 중복, 테스트 커버리지, 코드 복잡도, 버그, 보안 취약성 등을 레포팅 해주며, IDE, 빌드 도구, CI 도구와 통합하여 사용할 수 있다.
Java 코드의 커버리지를 측정하는 라이브러리이다. 테스트코드를 돌리고 그 커버리지 결과를 레포팅해준다. 팀 내에서 정한 커러버지 기준을 만족하는지 확인하는 기능도 있다.
소프트웨어의 테스트 케이스가 얼마나 충족되었는지를 나타내는 지표 중 하나가 코드 커버리지라고 위키 피디아에서 정의하고 있다.
코드 커버리지를 체크하는 Java 진영의 대표적인 라이브러리가 Jacoco이다. 주말의집은 코틀린 기반 프로젝트이나, Jacoco 적용이 가능한 점을 확인하고 도입하기로 결정했다.
코드 커버리지는 소스 코드를 기반으로 수행하는 화이트 박스 테스트
를 통해 측정된다.
종류 | 설명 |
---|---|
블랙 박스 테스트 | 1. 소프트웨어의 내부 구조나 작동원리를 모르는 상태에서 동작을 검사하는 방식 2. 올바른 입력과 올바르지 않은 입력을 하여 올바른 출력이 나오는지 테스트하는 기법 3.사용자 관점의 테스트 방법 |
화이트 박스 테스트 | 1. 응용 프로그램의 내부 구조와 동작을 검사하는 테스트 방식 2. 소프트웨어 내부 소스 코드를 테스트하는 기법 3. 개발자 관점의 단위 테스트 방법 |
기준에 사용되는 코드 구조는 3가지가 있다.
- 구문 ( Statement )
- 조건 ( Condition )
- 결정 ( Decision )
위의 구조를 기반으로 얼마나 커버했느냐
에 따라 측정 기준이 나뉘게 된다.
- 구문
Line Coverage 라고 불린다. 코드 한 줄이 한 번 이상 실행된다면 충족된다.
- 조건
모든 조건식의 내부 조건이 true/false를 가지게 되면 충족된다.
아래 예시 코드를 보자.
void foo (int x, int y) {
system.out("start line"); // 1번
if (x > 0 && y < 0) { // 2번
system.out("middle line"); // 3번
}
system.out("last line"); // 4번
}
내부 조건은 조건식 내부의 각각의 조건이라고 보면 된다.
위의 코드에서 모든 조건식으로는 (2)의 if문
이 있고, 그 중 내부 조건은 조건식 내부의 x >0 , y < 0
을 말한다.
해당 코드를 테스트한다고 가정하면, 조건 커버리지를 만족하는 테스트 케이스는 x=1, y=-1
이 있다. 이는 x>0
내부 조건에 대해 T/F 를 만족하고, y < 0
내부 조건에 대해 T/F 를 만족한다. 하지만 if문에 대해서는 false를 반환한다.
즉, 내부조건에 대해서는 만족하지만 if문을 보면 false에 해당하는 결과만 발생한다. 이는 조건 커버리지는 만족할지 언정 if문 내부 코드인 (3) 이 실행되지 않았으므로 라인 커버리지를 만족하지 못한다.
추가로 if문의 false에 해당하는 시나리오만 체크되었기 때문에 뒤에 브랜치 커버리지도 만족하지 못한다.
-
결론
위의 테스트 케이스에서 모든 조건식은 if문 조건절이다.
위의 테스트 케이스에서 내부 조건은 x > 0, y < 0이다.
위의 테스트 케이스에서 if문에 대해 실패하는 시나리오만을 체크했기 때문에 브랜치 커버리지를 충족하지 못한다.
위의 테스트 케이스에서 if문에 대해 실패하는 시나리오만을 체크했기 때문에 (3) 라인이 실행되지 않아 라인 커버리지를 충족하지 못한다.
브랜치 커버리지라고도 불린다.
모든 조건식이 true/false를 가지게 되면 충족된다.
void foo (int x, int y) {
system.out("start line"); // 1번
if (x > 0 && y < 0) { // 2번
system.out("middle line"); // 3번
}
system.out("last line"); // 4번
}
위의 테스트 코드에서 if문 조건에 대해 T/F 를 모두 만족할 수 있는 테스트 케이스로는 [ x= 1, y = -1, x = -1, y = 1 ]이 있다.
해당 케이스는 if문 관점에서 T/F를 모두 만족하므로 결정 커버리지를 충족한다.
위의 3가지 커버리지 중 가장 많이 사용되는 것이 “구문 커버리지”이다.
조건 커버리지나 결정 커버리지의 경우, 코드 실행에 대한 테스트보다는 “로직의 시나리오에 대한 테스트”에 더 가깝다.
조건문이 존재하지 않는 코드의 경우, 조건 커버리지와 결정 커버리지의 대상에서 완전히 제외된다. 때문에 라인 커버리지를 더 많이 사용한다.
테스트 코드의 중요성과 이어지는 내용이다.
테스트 코드를 작성함으로써 얻을 수 있는 장점은 아래와 같다.
- 제품의 안정성을 높여준다.
- 기능의 추가 및 수정으로 인한 Side-Effect를 줄일 수 있다.
- 불안감 없이 코드 작성을 할 수 있도록 도와준다.
- 디버깅을 쉽게 해준다.
- 개발 과정에서 반복적인 작업들을 하지 않도록 도와준다.
- 더 깔끔하고 재사용성이 좋은 코드 작성을 가능하게 해준다.
메서드 시그니처를 수정하여 테스트하기 좋은 메서드로 만들기
테스트 코드는 발생할 수 있는 모든 시나리오에 대해 작성되어야 한다. 개발자가 직접 작성하는 테스트로는 모든 경우를 커버하지 못할 수도 있다. 또한, 비즈니스 코드가 때에 따라 매우 복잡하게 작성되는 경우도 있다.
이렇게 테스트에서 놓칠 수 있는 부분들을 코드 커버리지를 통해 확인할 수 있다. 결과에 따라 부족한 테스트를 추가로 작성할 수 있다.
정리하면, 코드 커버리지는 휴먼 에러를 최대한 방지
할 수 있도록 도와주는 용도라고 생각해도 될 것이다.
메모리 관점에서 살펴보자. 코드 수가 많아지면, 소프트웨어가 차지하는 메모리 영역 중 Code Segment가 차지하는 양이 늘어난다. 때문에 불필요한 코드와 중복되는 코드의 수를 줄여 전체적인 소프트웨어의 메모리 사이즈를 줄여야 한다.
6번의 실패 후, 성공한 적용 과정을 정리해볼게요.
[build.gradle.kt]
plugins {
id("org.springframework.boot") version "2.7.8"
id("io.spring.dependency-management") version "1.0.15.RELEASE"
id("org.asciidoctor.jvm.convert") version "3.3.2"
id("org.sonarqube") version "3.5.0.2730"
kotlin("jvm") version "1.6.21"
kotlin("plugin.spring") version "1.6.21"
kotlin("plugin.jpa") version "1.6.21"
kotlin("plugin.allopen") version "1.4.32"
jacoco // 이 부분 추가하기
}
...
tasks {
test {
// test 시, jacoco 동작하도록 설정
extensions.configure(JacocoTaskExtension::class) {
destinationFile = file("$buildDir/jacoco/jacoco.exec")
}
finalizedBy(jacocoTestReport)
outputs.dir(snippetsDir)
}
asciidoctor {
inputs.dir(snippetsDir)
configurations(asciidoctorExt.name)
dependsOn(test)
doLast {
copy {
from("build/docs/asciidoc")
into("src/main/resources/static/docs")
}
}
}
build {
dependsOn(asciidoctor)
}
}
jacoco {
toolVersion = "0.8.7" // 버전 명시
}
tasks.jacocoTestReport {
reports { // 분석 결과 파일 형식 지정
html.isEnabled = true
html.destination = file("$buildDir/reports/jhouse-report.html")
csv.isEnabled = true
xml.isEnabled = true
}
var excludes = mutableListOf<String>() // 테스트 제외할 대상 선정
excludes.add("**/global/**")
excludes.add("**/domain/**/dto/**")
excludes.add("**/domain/**/entity/**")
excludes.add("**/JhouseServerApplicationKt*")
excludes.add("com/example/jhouse_server/admin/user/**")
excludes.add("**/*Converter*.kt")
excludes.add("**/resources/**")
classDirectories.setFrom(
sourceSets.main.get().output.asFileTree.matching {
exclude(excludes)
}
)
finalizedBy(tasks.jacocoTestCoverageVerification)
}
tasks.jacocoTestCoverageVerification { // 커버리지 측정
violationRules {
rule {
enabled = true // rule을 on/off
element = "CLASS" // class 단위로 rule 체크
limit { // 라인 커버리지 최소 80% 충족
counter = "LINE"
value = "COVEREDRATIO"
minimum = "0.50".toBigDecimal()
}
limit {// 빈 줄 제외한 코드 라인수 최대 1000라인으로 제한한다.
counter = "LINE"
value = "TOTALCOUNT"
maximum = "550.0".toBigDecimal()
}
}
}
var excludes = mutableListOf<String>()
excludes.add("com/example/jhouse_server/global/**")
excludes.add("com/example/jhouse_server/domain/**/dto/**")
excludes.add("com/example/jhouse_server/domain/**/entity/**")
excludes.add("**/JhouseServerApplicationKt*")
excludes.add("com/example/jhouse_server/admin/user/**")
excludes.add("com/example/jhouse_server/domain/board/entity/BoardCategoryConverter.kt")
excludes.add("com/example/jhouse_server/domain/board/entity/PrefixCategoryConverter.kt")
excludes.add("**/resources/**")
classDirectories.setFrom(
sourceSets.main.get().output.asFileTree.matching {
exclude(excludes)
}
)
}
val testCoverage by tasks.registering { // 위의 태스크 연결
group = "verification"
description = "Runs the unit tests with coverage"
dependsOn(":test",
":jacocoTestReport",
":jacocoTestCoverageVerification")
tasks["jacocoTestReport"].mustRunAfter(tasks["test"])
tasks["jacocoTestCoverageVerification"].mustRunAfter(tasks["jacocoTestReport"])
}
위의 스크립트와 같이 jacoco 설정을 해준다. 주말의 집은 라인 커버리지만을 측정하고 있으며, 그 단위는 클래스이다. entity, dto, global, converter 등 테스트에서 제외 되어야 할 대상을 명시하지 않으면, 커버리지를 충족할 수 없다.
이렇게 설정한 뒤, build 를 진행한다.

build 후 build 폴더로 이동하면 reports > jhouse-report.html > index.html 파일이 있다. 해당 파일을 웹 브라우저로 조회하면 위의 이미지 처럼 확인할 수 있다.
이제 이 측정 데이터를 sonarcloud와 연동해보자.
[build.gradle.kt]
plugins {
id("org.springframework.boot") version "2.7.8"
id("io.spring.dependency-management") version "1.0.15.RELEASE"
id("org.asciidoctor.jvm.convert") version "3.3.2"
id("org.sonarqube") version "3.5.0.2730" // 이거 추가해주자.
kotlin("jvm") version "1.6.21"
kotlin("plugin.spring") version "1.6.21"
kotlin("plugin.jpa") version "1.6.21"
kotlin("plugin.allopen") version "1.4.32"
jacoco
}
sonarqube { // 연동을 위해 작성하는 기본 설정값
properties {
property ("sonar.projectKey", "ODOICHON_server")
property ("sonar.organization", "odoichon")
property ("sonar.host.url", "https://sonarcloud.io")
property("sonar.sources", "src")
property("sonar.sourceEncoding", "UTF-8")
property("sonar.test.inclusions", "**/*Test.kt")
property("sonar.exclusions", "**/test/**, **/resources/**, **/docs/**, **/*Application*.kt, **/dto/**, **/*Exception*.kt, **/*ErrorCode*.kt, **/*Category*.kt" )
property("sonar.java.coveragePlugin", "jacoco")
property("sonar.coverage.jacoco.xmlReportPaths", "${buildDir}/reports/jacoco/html/jacocoTestReport.xml")
}
}
위의 스크립트 파일 작성을 위해서는 우선 sonarcloud 에 가입을 해야 한다. 깃헙으로 간편 로그인을 진행한 뒤, 연동하고자 하는 레포지토리를 선택한다. 단, 주말의집 같은 경우 organization 에 속한 레포지토리이므로 owner를 조직으로 설정해야 한다.
가입 시, only-repository 를 선택하여 server 레포지토리만 접근할 수 있도록 한다.

sonarcloud 메인 화면에 위와 같이 추가한 프로젝트가 보여지게 된다.
주말의집은 github-actions를 이용해 CI 과정과 정적 코드 분석을 같이 진행한다. SonarCloud 에서 제공하는 CI 툴 중 github-actions를 선택한다. sonarcloud 측에서 github-actions와 연동에 사용되는 토큰 값을 제공한다. 해당 값을 레포지토리 setting에 actions secrets 으로 등록한다. "SONAR_TOKEN" 이라는 이름으로 제공한다.
이와 더불어 gradle 설정 스크립트와 yml 스크립트를 함께 제공한다. 이 코드를 기반으로 커스터마이징하면 된다.
프로젝트에서 사용한 스크립트에서는 코드 분석에서 제외할 패키지를 지정하고, 인코딩 설정, jacoco 플러그인 등록, jacoco 분석 레포팅 path 지정을 추가로 해주었다. 이는 코드를 보면 바로 확인 가능하다.
[ci-sonar.yml]
name: SonarCloud
on:
push:
branches:
- dev
pull_request:
branches:
- dev
types:
- opened
- synchronize
- reopened
workflow_dispatch:
schedule:
- cron: '00 23 * * *' # 08:00
- cron: '00 11 * * *' # 20:00
permissions:
checks: write
pull-requests: write
jobs:
build:
name: Build and analyze
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Set up JDK 11
uses: actions/setup-java@v3
with:
java-version: 11
distribution: 'temurin' # Alternative distribution options are available
- name: Cache SonarCloud packages
uses: actions/cache@v3
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar
- name: Gradle Caching
uses: actions/cache@v3
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name : Update gradlew access authorized
run: chmod +x gradlew
#test를 위한 mysql설정
- name: Start MySQL
uses: samin/[email protected]
with:
host port: 3303
container port: 3303
mysql database: 'test_db'
mysql user: 'test'
mysql password: 'test_pw'
#테스트를 위한 redis 설정
- name: Start Redis
uses: supercharge/[email protected]
with:
redis-port: 6376
#테스트를 위한 test yml 설정
- name: Make application-test.yml
run: |
cd ./src/test/resources
touch ./application-test.yml
echo "${{ secrets.PROPERTIES_TEST }}" > ./application-test.yml
shell: bash
- name: Build and analyze
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
run: ./gradlew build sonarqube --info
github-actions에서 수행할 ci 워크플로우를 작성한 파일이다. 기존에 있는 ci와는 독립적으로 수행되는 플로우이다. ( 추후 병렬 실행이 되는지 테스트를 해보고 싶어서 파일을 분리하였다. )
이 코드 역시 sonarcloud에서 기본으로 제공하는 코드에 주말의집 ci 환경에 필요한 작업을 추가 하였다.
코드로 작성해야 하는 부분은 여기까지 이다.
이제 github에 코드를 PR로 등록해보자!

워크플로우가 정상적으로 실행되면 다음과 같이 댓글로써 결과가 레포팅된다.

또한, sonarcloud 에서도 pr이 등록된 모습을 확인할 수 있다.
이론적 내용은 여기를 참고하여 정리하였습니다.