plugins { alias(libs.plugins.spring.boot) apply false alias(libs.plugins.spring.dependency.management) apply false alias(libs.plugins.pitest) apply false id 'jacoco' } allprojects { group = 'com.project.auth' version = '0.0.1-SNAPSHOT' } // ============================================================= // 커버리지/뮤테이션/속성 기반 테스트 정책 // // 본 정책의 근거와 Tier 분류는 docs/testing-coverage-policy.md 참조. // 요약: // Tier 1 (Critical): 예외 핸들러·매퍼·LogSanitizer·도메인 예외 // → JaCoCo Line 95~100% / Branch 90~100% / PIT 85~95% // Tier 2 (Core) : usecase·controller·DTO·repo adapter // → JaCoCo Line 80~90% / Branch 75~85% / PIT 70~75% // Tier 3 (Support) : security 어댑터·audit·필터 // → JaCoCo Line 75~85% / Branch 70~80% / PIT 60~70% // // @Configuration / main / generated 코드는 Tier가 아니라 jacocoExclusions에서 // 통째로 제외한다. 통합 테스트가 컨텍스트 로딩 과정에서 자연스럽게 거치므로 // 별도 단위 테스트가 무가치한 영역이다. // // 모듈별 임계치는 각 모듈의 jacocoTestCoverageVerification에서 강제한다. // 점진적 상향: 6개월마다 +5%p, 또는 신규 PR이 baseline을 떨어뜨리지 않게 게이트. // ============================================================= ext { // Spring 컨벤션 기반 정밀 제외: // - *Configuration: @Configuration 빈 와이어링 // - *Config: @Configuration의 짧은 변형 (OpenApiConfig 등) // - *Properties: @ConfigurationProperties 보일러플레이트 // - *Application: Spring Boot main // config 패키지 안의 핸들러/필터/팩토리/어댑터(예: ApiErrorController, // SecurityResponseExceptionHandler, RequestBoundApiResultFactory, // TraceIdFilter, SecurityAuditTrailWriter 등)는 모두 측정 대상에 포함된다. jacocoExclusions = [ '**/*Application.class', '**/*Configuration.class', '**/*Config.class', '**/*Properties.class', '**/dto/**/*Request.class', '**/dto/**/*Response.class', '**/Q*.class', '**/*$Builder.class', '**/generated-sources/**' ] } subprojects { apply plugin: 'java-library' apply plugin: 'io.spring.dependency-management' apply plugin: 'jacoco' java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } dependencyManagement { imports { mavenBom "org.springframework.boot:spring-boot-dependencies:${libs.versions.springBoot.get()}" } } dependencies { compileOnly libs.lombok annotationProcessor libs.lombok testImplementation 'org.junit.jupiter:junit-jupiter' testImplementation 'org.assertj:assertj-core' testImplementation libs.jqwik testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } jacoco { toolVersion = '0.8.13' } tasks.named('test') { useJUnitPlatform { includeEngines 'junit-jupiter', 'jqwik' } finalizedBy 'jacocoTestReport' } tasks.named('jacocoTestReport') { dependsOn tasks.named('test') reports { xml.required = true html.required = true csv.required = false } classDirectories.setFrom( files(sourceSets.main.output.classesDirs.collect { fileTree(dir: it, exclude: rootProject.ext.jacocoExclusions) }) ) } tasks.named('jacocoTestCoverageVerification') { classDirectories.setFrom( files(sourceSets.main.output.classesDirs.collect { fileTree(dir: it, exclude: rootProject.ext.jacocoExclusions) }) ) } // 게이트는 명시적 ./gradlew coverageGate 호출 시에만 실행한다. // 일반 ./gradlew build는 측정만 한다(현재 baseline 안정 전이므로 깨지면 안 된다). } // ============================================================= // 모듈별 커버리지 게이트 + PIT 설정 // ============================================================= // ---- domain (Tier 1) ----------------------------------------- project(':domain') { tasks.named('jacocoTestCoverageVerification') { dependsOn tasks.named('test') violationRules { rule { element = 'BUNDLE' limit { counter = 'LINE' minimum = 0.95 } limit { counter = 'BRANCH' minimum = 0.90 } } } } } // ---- application (Tier 1 for support.exception, Tier 2 elsewhere) ---- project(':application') { tasks.named('jacocoTestCoverageVerification') { dependsOn tasks.named('test') violationRules { // Tier 1: 예외 코드/타입 시스템 rule { element = 'PACKAGE' includes = ['com.project.auth.application.support.exception'] limit { counter = 'LINE' minimum = 0.95 } limit { counter = 'BRANCH' minimum = 0.90 } } // Tier 1: LogSanitizer rule { element = 'CLASS' includes = ['com.project.auth.application.support.logging.LogSanitizer'] limit { counter = 'LINE' minimum = 1.00 } limit { counter = 'BRANCH' minimum = 1.00 } } // Tier 2: 나머지 application 코드 (usecase/service) rule { element = 'BUNDLE' limit { counter = 'LINE' minimum = 0.80 } limit { counter = 'BRANCH' minimum = 0.75 } } } } } // ---- presentation (Tier 1 for support.exception, Tier 2 controllers) ---- project(':presentation') { tasks.named('jacocoTestCoverageVerification') { dependsOn tasks.named('test') violationRules { rule { element = 'PACKAGE' includes = ['com.project.auth.presentation.support.exception'] limit { counter = 'LINE' minimum = 0.95 } limit { counter = 'BRANCH' minimum = 0.90 } } rule { element = 'BUNDLE' limit { counter = 'LINE' minimum = 0.80 } limit { counter = 'BRANCH' minimum = 0.75 } } } } } // ---- infrastructure (Tier 3) --------------------------------- project(':infrastructure') { tasks.named('jacocoTestCoverageVerification') { dependsOn tasks.named('test') violationRules { rule { element = 'BUNDLE' limit { counter = 'LINE' minimum = 0.75 } limit { counter = 'BRANCH' minimum = 0.70 } } } } } // ---- bootstrap (Tier 1 for handlers; @Configuration excluded) ---- project(':bootstrap') { apply plugin: 'org.springframework.boot' tasks.named('bootJar') { enabled = true } tasks.named('jar') { enabled = false } tasks.named('jacocoTestCoverageVerification') { dependsOn tasks.named('test') violationRules { // Tier 1: 핸들러/필터/매퍼 어댑터 // @Configuration·main은 jacocoExclusions에서 제외됨(별도 Tier 없음). rule { element = 'CLASS' includes = [ 'com.project.auth.config.web.ApiErrorController', 'com.project.auth.config.web.SecurityResponseExceptionHandler', 'com.project.auth.config.web.InfrastructureExceptionHandler', 'com.project.auth.config.auth.security.SecurityExceptionHandler' ] limit { counter = 'LINE' minimum = 0.95 } limit { counter = 'BRANCH' minimum = 0.90 } } } } } // ============================================================= // PIT (mutation testing) // ============================================================= // // PIT는 무겁다. CI 게이트로는 "변경된 클래스만" 돌리는 것이 현실적이지만, // 우선은 모듈별로 핵심 패키지를 타깃팅하여 baseline을 만든다. // 게이트 임계치는 baseline 측정 후 한 번 점검하고 활성화한다. // ============================================================= configure([project(':application'), project(':presentation'), project(':bootstrap'), project(':domain')]) { apply plugin: 'info.solidsoft.pitest' pitest { junit5PluginVersion = libs.versions.pitestJunit5.get() pitestVersion = libs.versions.pitest.get() timestampedReports = false outputFormats = ['XML', 'HTML'] threads = Runtime.runtime.availableProcessors().intdiv(2) ?: 1 avoidCallsTo = [ 'kotlin.jvm.internal', 'org.slf4j', 'java.util.logging' ] // 동적 프록시 기반 단위 테스트가 PIT 기본 timeout(10s)에 걸리는 사례가 있어 여유를 둔다. timeoutFactor = 2.0 timeoutConstInMillis = 15000 } } project(':domain') { pitest { targetClasses = ['com.project.auth.domain.user.exception.*'] excludedClasses = [] } } project(':application') { pitest { targetClasses = [ 'com.project.auth.application.support.exception.*', 'com.project.auth.application.support.logging.*' ] // Tier 1 PIT 임계치(85~95%) 통과 확인 후 게이트 활성화. 현재 측정값 ≈ 94%. mutationThreshold = 90 coverageThreshold = 90 } } project(':presentation') { pitest { targetClasses = [ 'com.project.auth.presentation.support.exception.*', 'com.project.auth.presentation.support.response.*' ] // 단위 테스트 추가 후 measured ≈ 79%. 현실적 마진 두고 75% 게이트. // Tier 1 목표 85%까지는 후속 작업으로 살아남은 변이 분석/제거. mutationThreshold = 75 coverageThreshold = 90 } } project(':bootstrap') { pitest { targetClasses = [ 'com.project.auth.config.web.ApiErrorController', 'com.project.auth.config.web.SecurityResponseExceptionHandler', 'com.project.auth.config.web.InfrastructureExceptionHandler', 'com.project.auth.config.web.RequestBoundApiResultFactory', 'com.project.auth.config.auth.security.SecurityExceptionHandler' ] // bootstrap은 통합 테스트가 무거우므로 PIT는 로컬/주간 CI에서만 권장 } } // ============================================================= // 통합 게이트 태스크 // ./gradlew coverageGate -> JaCoCo 임계치 검증 (baseline 안정 후 활성화) // ./gradlew mutationBaseline -> PIT 측정 (게이트 없이 점수만 본다) // ============================================================= tasks.register('coverageGate') { group = 'verification' description = 'Run JaCoCo coverage verification across all modules.' subprojects.each { sub -> dependsOn ":${sub.name}:jacocoTestCoverageVerification" } } tasks.register('mutationBaseline') { group = 'verification' description = 'Run PIT mutation testing on declared target classes (baseline; no threshold).' // domain은 현재 wrapper 예외만 있어 변이가 생성되지 않으므로 제외 — 도메인 모델이 자라면 추가. dependsOn ':application:pitest', ':presentation:pitest' } // ============================================================= // 커버리지 리포트 영구 보관 (버전 + 날짜 기반 누적 기록) // // ./gradlew archiveCoverageReport -Plabel=2026-05-04-jqwik-after // // 산출 경로: // coverage-history/{project.version}/{label}/{module}/{tool}/... // // 예) coverage-history/v0.0.1-SNAPSHOT/2026-05-04-jqwik-after/application/jacoco/... // // 라벨 규칙: // - 형식: {YYYY-MM-DD}-{kebab-case 사건명} // - 같은 버전 내 같은 라벨 중복 금지(덮어쓰기 차단) // - 라벨 누락 시 빌드 실패 // // 버전 prefix는 {project.version}을 그대로 따른다. 릴리스 버전이 바뀌면 // 자동으로 새 디렉토리에 적재되어, 시간 순서가 아니라 버전 단위 회귀 비교가 // 가능해진다. 같은 버전 안에서는 날짜+사건명 라벨로 누적된다. // ============================================================= tasks.register('archiveCoverageReport') { group = 'verification' description = 'Snapshot JaCoCo + PIT reports into coverage-history/{version}/{label}/ for cumulative tracking. ' + 'Requires -Plabel=YYYY-MM-DD- (e.g. -Plabel=2026-05-04-jqwik-after).' doLast { if (!project.hasProperty('label')) { throw new GradleException( "archiveCoverageReport requires -Plabel=YYYY-MM-DD-. " + "Example: ./gradlew archiveCoverageReport -Plabel=2026-05-04-jqwik-after. " + "See docs/testing-history/README.md for the naming convention." ) } String label = project.property('label') if (!(label ==~ /^\d{4}-\d{2}-\d{2}-[a-z0-9][a-z0-9-]*$/)) { throw new GradleException( "label '${label}' does not match the required format YYYY-MM-DD-. " + "Example: 2026-05-04-jqwik-after, 2026-08-12-aggregate-before." ) } String versionDir = "v${project.version}" File destinationRoot = new File(rootDir, "coverage-history/${versionDir}/${label}") if (destinationRoot.exists()) { throw new GradleException( "coverage-history/${versionDir}/${label} already exists. " + "Pick a different event-name suffix or delete the existing snapshot first." ) } destinationRoot.mkdirs() subprojects.each { sub -> File jacocoSrc = new File(sub.buildDir, 'reports/jacoco/test') if (jacocoSrc.exists()) { File jacocoDst = new File(destinationRoot, "${sub.name}/jacoco") copy { from jacocoSrc into jacocoDst } } File pitestSrc = new File(sub.buildDir, 'reports/pitest') if (pitestSrc.exists()) { File pitestDst = new File(destinationRoot, "${sub.name}/pitest") copy { from pitestSrc into pitestDst } } } // 루트의 aggregate 리포트가 존재하면 함께 보관 File aggregateSrc = new File(rootProject.buildDir, 'reports/jacoco/aggregate') if (aggregateSrc.exists()) { copy { from aggregateSrc into new File(destinationRoot, 'aggregate') } } // 한 줄 요약 파일도 함께 남긴다 (XML 파싱 없이 빠른 비교용) File summary = new File(destinationRoot, 'summary.txt') summary.text = "version: ${project.version}\nlabel: ${label}\ngenerated_at: ${new Date()}\n\n" subprojects.each { sub -> File xml = new File(sub.buildDir, 'reports/jacoco/test/jacocoTestReport.xml') if (xml.exists()) { summary.append("[${sub.name}] jacoco xml: ${xml.absolutePath}\n") } File pitestIndex = new File(sub.buildDir, 'reports/pitest/index.html') if (pitestIndex.exists()) { summary.append("[${sub.name}] pit html : ${pitestIndex.absolutePath}\n") } } println "Coverage snapshot archived to: ${destinationRoot}" } } // ============================================================= // 멀티모듈 통합 커버리지 리포트 // // ./gradlew jacocoAggregateReport // // bootstrap의 통합 테스트가 다른 모듈(presentation/application/infrastructure) // 클래스를 실행한 흔적까지 한 번에 모아 단일 리포트로 만든다. 모듈별 단위 측정에서는 // "presentation 24.8%" 처럼 낮게 보이는 핸들러들이 통합 테스트로 실제 얼마나 커버되는지 // aggregate에서 정직한 수치로 드러난다. // // 산출물: build/reports/jacoco/aggregate/{html,xml}/ // ============================================================= tasks.register('jacocoAggregateReport', JacocoReport) { group = 'verification' description = 'Combine JaCoCo execution data from every subproject into a single aggregated report.' subprojects.each { sub -> dependsOn ":${sub.name}:test" } def coveredProjects = subprojects.findAll { it.plugins.hasPlugin('jacoco') } // task 실행 시점에 fileTree로 .exec 파일을 다시 스캔한다 (lazy). executionData.from(fileTree(rootDir) { include '*/build/jacoco/test.exec' }) sourceDirectories.from(files(coveredProjects.collect { it.sourceSets.main.allSource.srcDirs })) classDirectories.from(files(coveredProjects.collect { sub -> sub.sourceSets.main.output.classesDirs.collect { dir -> fileTree(dir: dir, exclude: rootProject.ext.jacocoExclusions) } }.flatten())) reports { xml.required = true html.required = true csv.required = false html.outputLocation = layout.buildDirectory.dir('reports/jacoco/aggregate/html') xml.outputLocation = layout.buildDirectory.file('reports/jacoco/aggregate/jacocoAggregateReport.xml') } }