-
Notifications
You must be signed in to change notification settings - Fork 0
Description
테스트 자동화 인프라 개선 및 커버리지 향상
📋 작업 배경 (Background)
현재 상황 분석
프로젝트의 테스트 자동화 인프라에 대한 종합 분석 결과, 중간 수준의 테스트 커버리지를 보유하고 있으나 프로덕션 준비를 위해서는 심각한 개선이 필요한 상황입니다.
주요 발견사항:
- ✅ 강점: 서비스 레이어 100% 커버리지, 우수한 테스트 품질, 견고한 테스트 픽스처 인프라
⚠️ 약점: 레포지토리 통합 테스트 0%, 컨트롤러 테스트 0%, 코드 커버리지 도구 부재- 🔴 고위험 영역: 데이터베이스 쿼리 미검증, HTTP API 엔드포인트 미검증, CI/CD 자동화 부재
정량적 메트릭스
테스트 파일 수: 16개
테스트 메서드 수: ~80개
서비스 커버리지: 100% (5/5) ✅
레포지토리 커버리지: 0% (0/18) 🔴
컨트롤러 커버리지: 0% (0/6) 🔴
모델 커버리지: 18% (4/22) ⚠️
DTO 커버리지: 6% (1/17) ⚠️
전체 코드 커버리지: 미측정 (도구 없음) 🔴
비즈니스 임팩트
- 프로덕션 리스크: 데이터베이스 쿼리 및 API 엔드포인트 검증 부재로 인한 운영 장애 가능성
- 유지보수성 저하: 리팩토링 시 회귀 버그 검출 불가
- 배포 신뢰도: 자동화된 품질 게이트 부재로 수동 검증 필요
- 기술 부채: 테스트 인프라 개선 지연 시 향후 개선 비용 증가
🎯 작업 내용 (Tasks)
Phase 1: 기반 인프라 구축 (우선순위: 🔴 CRITICAL)
1.1 코드 커버리지 도구 추가
목표: 테스트 커버리지 가시성 확보 및 측정 자동화
작업 상세:
// build.gradle.kts에 추가
plugins {
jacoco
}
jacoco {
toolVersion = "0.8.11"
}
tasks.test {
finalizedBy(tasks.jacocoTestReport)
}
tasks.jacocoTestReport {
dependsOn(tasks.test)
reports {
xml.required.set(true)
html.required.set(true)
csv.required.set(false)
}
}
tasks.jacocoTestCoverageVerification {
violationRules {
rule {
limit {
minimum = "0.70".toBigDecimal()
}
}
}
}
// check 태스크에 커버리지 검증 추가
tasks.check {
dependsOn(tasks.jacocoTestCoverageVerification)
}예상 결과:
- HTML 커버리지 리포트:
build/reports/jacoco/test/html/index.html - XML 리포트 (CI용):
build/reports/jacoco/test/jacocoTestReport.xml - 최소 70% 커버리지 미달 시 빌드 실패
관련 파일:
build.gradle.kts(루트)module/core/build.gradle.ktsmodule/persistence/build.gradle.ktsserver/api/build.gradle.kts
1.2 GitHub Actions CI 파이프라인 구축
목표: PR 및 커밋마다 자동 테스트 실행 및 품질 게이트 적용
작업 상세:
# .github/workflows/test.yml
name: Test & Coverage
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'gradle'
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Run tests with coverage
run: ./gradlew test jacocoTestReport
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./build/reports/jacoco/test/jacocoTestReport.xml
fail_ci_if_error: true
- name: Archive test results
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: |
**/build/test-results/test/*.xml
**/build/reports/jacoco/
e2e:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'gradle'
- name: Start application
run: ./gradlew bootRun &
- name: Wait for application
run: |
timeout 60 sh -c 'until curl -f http://localhost:8080/api/tags; do sleep 2; done'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Run E2E tests
run: |
cd api-docs
chmod +x run-api-tests.sh
./run-api-tests.sh예상 결과:
- PR마다 자동 테스트 실행
- 테스트 실패 시 PR 머지 차단
- 커버리지 리포트 자동 업로드
- E2E 테스트 자동 실행
관련 파일:
.github/workflows/test.yml(신규 생성)api-docs/run-api-tests.sh(기존)
Phase 2: 레포지토리 통합 테스트 추가 (우선순위: 🔴 CRITICAL)
2.1 레포지토리 어댑터 테스트 작성
목표: 데이터베이스 쿼리 및 JPA 매핑 검증
대상 클래스 (총 6개):
ArticleRepositoryAdapter.javaArticleCommentRepositoryAdapter.javaArticleFavoriteRepositoryAdapter.javaTagRepositoryAdapter.javaUserRepositoryAdapter.javaUserRelationshipRepositoryAdapter.java
테스트 템플릿:
// module/persistence/src/test/java/io/zhc1/realworld/persistence/ArticleRepositoryAdapterTest.java
package io.zhc1.realworld.persistence;
import io.zhc1.realworld.model.Article;
import io.zhc1.realworld.model.ArticleFacets;
import io.zhc1.realworld.model.TestArticle;
import io.zhc1.realworld.model.TestUser;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import org.springframework.context.annotation.Import;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@Import(ArticleRepositoryAdapter.class)
@DisplayName("ArticleRepositoryAdapter - Database Integration Tests")
class ArticleRepositoryAdapterTest {
@Autowired
private ArticleRepositoryAdapter repository;
@Autowired
private TestEntityManager entityManager;
private TestUser author;
@BeforeEach
void setUp() {
author = new TestUser(UUID.randomUUID(), "[email protected]", "author", "password");
entityManager.persist(author);
entityManager.flush();
}
@Test
@DisplayName("findBySlug should return article when article exists")
void whenFindBySlugWithExistingArticle_thenShouldReturnArticle() {
// given
Article article = new Article(author, "Test Title", "Description", "Body");
entityManager.persist(article);
entityManager.flush();
// when
Optional<Article> found = repository.findBySlug("test-title");
// then
assertThat(found).isPresent();
assertThat(found.get().getTitle()).isEqualTo("Test Title");
assertThat(found.get().getSlug()).isEqualTo("test-title");
}
@Test
@DisplayName("findBySlug should return empty when article does not exist")
void whenFindBySlugWithNonExistingArticle_thenShouldReturnEmpty() {
// when
Optional<Article> found = repository.findBySlug("non-existing-slug");
// then
assertThat(found).isEmpty();
}
@Test
@DisplayName("save should persist article with tags")
void whenSaveArticleWithTags_thenShouldPersistSuccessfully() {
// given
Article article = new Article(author, "New Article", "Description", "Body");
Collection<String> tags = List.of("java", "spring", "testing");
// when
repository.save(article, tags);
entityManager.flush();
entityManager.clear();
// then
Optional<Article> saved = repository.findBySlug("new-article");
assertThat(saved).isPresent();
assertThat(saved.get().getTags()).extracting("name").containsExactlyInAnyOrder("java", "spring", "testing");
}
@Test
@DisplayName("findAll with facets should return paginated results")
void whenFindAllWithFacets_thenShouldReturnPaginatedResults() {
// given - create 15 articles
for (int i = 1; i <= 15; i++) {
Article article = new Article(author, "Title " + i, "Description", "Body");
entityManager.persist(article);
}
entityManager.flush();
ArticleFacets facets = new ArticleFacets(null, null, null, 1, 10);
// when
List<Article> articles = repository.findAll(facets);
// then
assertThat(articles).hasSize(10);
}
@Test
@DisplayName("delete should remove article from database")
void whenDeleteArticle_thenShouldRemoveFromDatabase() {
// given
Article article = new Article(author, "To Delete", "Description", "Body");
entityManager.persist(article);
entityManager.flush();
String slug = article.getSlug();
// when
repository.delete(article);
entityManager.flush();
entityManager.clear();
// then
Optional<Article> deleted = repository.findBySlug(slug);
assertThat(deleted).isEmpty();
}
@Test
@DisplayName("existsByTitle should return true when title exists")
void whenExistsByTitleWithExistingTitle_thenShouldReturnTrue() {
// given
Article article = new Article(author, "Unique Title", "Description", "Body");
entityManager.persist(article);
entityManager.flush();
// when
boolean exists = repository.existsByTitle("Unique Title");
// then
assertThat(exists).isTrue();
}
@Test
@DisplayName("existsByTitle should return false when title does not exist")
void whenExistsByTitleWithNonExistingTitle_thenShouldReturnFalse() {
// when
boolean exists = repository.existsByTitle("Non Existing Title");
// then
assertThat(exists).isFalse();
}
}커버해야 할 테스트 시나리오:
- CRUD 기본 동작 (생성, 조회, 수정, 삭제)
- 쿼리 메서드 정확성 (
findBySlug,findByAuthor,existsByTitle) - 페이지네이션 동작 (
ArticleFacets사용) - 연관관계 매핑 (Article ↔ Tag, Article ↔ User)
- 트랜잭션 경계 테스트
- 캐시 동작 검증 (해당되는 경우)
예상 테스트 수: ~60개 (레포지토리당 10개 × 6개)
관련 파일:
module/persistence/src/test/java/io/zhc1/realworld/persistence/(모든 어댑터 테스트)
2.2 JPA Specifications 테스트
목표: 복잡한 쿼리 로직 검증
대상 클래스:
ArticleSpecifications.java
테스트 예시:
@Test
@DisplayName("Specification with author filter should return only author's articles")
void whenSpecificationWithAuthorFilter_thenShouldReturnOnlyAuthorsArticles() {
// given
TestUser author1 = createUser("author1");
TestUser author2 = createUser("author2");
createArticle(author1, "Article by Author 1");
createArticle(author2, "Article by Author 2");
ArticleFacets facets = new ArticleFacets("author1", null, null, 0, 20);
Specification<Article> spec = ArticleSpecifications.fromFacets(facets);
// when
List<Article> articles = articleJpaRepository.findAll(spec);
// then
assertThat(articles).hasSize(1);
assertThat(articles.get(0).getAuthor().getUsername()).isEqualTo("author1");
}예상 테스트 수: ~10개
Phase 3: 컨트롤러 통합 테스트 추가 (우선순위: 🔴 CRITICAL)
3.1 API 엔드포인트 테스트 작성
목표: HTTP 요청/응답, 인증/인가, 상태 코드 검증
대상 컨트롤러 (총 6개):
ArticleController.javaArticleCommentController.javaArticleFavoriteController.javaTagController.javaUserController.javaUserRelationshipController.java
테스트 템플릿:
// server/api/src/test/java/io/zhc1/realworld/api/ArticleControllerTest.java
package io.zhc1.realworld.api;
import io.zhc1.realworld.model.Article;
import io.zhc1.realworld.model.ArticleDetails;
import io.zhc1.realworld.model.ArticleFacets;
import io.zhc1.realworld.service.ArticleService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(ArticleController.class)
@DisplayName("ArticleController - REST API Integration Tests")
class ArticleControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ArticleService articleService;
@Test
@DisplayName("GET /api/articles should return 200 with article list")
void whenGetArticles_thenReturns200WithArticleList() throws Exception {
// given
ArticleDetails article = createArticleDetails();
when(articleService.getArticles(any(UUID.class), any(ArticleFacets.class)))
.thenReturn(List.of(article));
// when & then
mockMvc.perform(get("/api/articles")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.articles").isArray())
.andExpect(jsonPath("$.articles[0].title").value("Test Article"))
.andExpect(jsonPath("$.articlesCount").value(1));
}
@Test
@WithMockUser(username = "testuser")
@DisplayName("POST /api/articles should return 201 when authenticated")
void whenCreateArticleWithAuth_thenReturns201() throws Exception {
// given
String requestBody = """
{
"article": {
"title": "New Article",
"description": "Description",
"body": "Body content",
"tagList": ["java", "spring"]
}
}
""";
Article article = createArticle();
when(articleService.write(any(), any(), anyCollection())).thenReturn(article);
// when & then
mockMvc.perform(post("/api/articles")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.article.title").value("New Article"))
.andExpect(jsonPath("$.article.slug").value("new-article"));
}
@Test
@DisplayName("POST /api/articles should return 401 when not authenticated")
void whenCreateArticleWithoutAuth_thenReturns401() throws Exception {
// given
String requestBody = """
{
"article": {
"title": "New Article",
"description": "Description",
"body": "Body content"
}
}
""";
// when & then
mockMvc.perform(post("/api/articles")
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "testuser")
@DisplayName("PUT /api/articles/:slug should return 200 when author edits")
void whenUpdateArticleAsAuthor_thenReturns200() throws Exception {
// given
String requestBody = """
{
"article": {
"title": "Updated Title",
"description": "Updated description"
}
}
""";
Article article = createArticle();
when(articleService.edit(any(), anyString(), anyString(), anyString(), anyCollection()))
.thenReturn(article);
// when & then
mockMvc.perform(put("/api/articles/test-slug")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isOk())
.andExpect(jsonPath("$.article.title").exists());
}
@Test
@DisplayName("GET /api/articles/:slug should return 404 when article not found")
void whenGetNonExistingArticle_thenReturns404() throws Exception {
// given
when(articleService.getArticle(any(), anyString()))
.thenThrow(new NoSuchElementException("Article not found"));
// when & then
mockMvc.perform(get("/api/articles/non-existing-slug")
.contentType(MediaType.APPLICATION_JSON))
.andExpect(status().isNotFound());
}
@Test
@DisplayName("POST /api/articles with invalid request should return 400")
void whenCreateArticleWithInvalidRequest_thenReturns400() throws Exception {
// given - missing required fields
String requestBody = """
{
"article": {
"description": "Description only"
}
}
""";
// when & then
mockMvc.perform(post("/api/articles")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isBadRequest());
}
private Article createArticle() {
// ... helper method
}
private ArticleDetails createArticleDetails() {
// ... helper method
}
}커버해야 할 시나리오:
- 성공 시나리오 (2xx 응답)
- 인증 필요 엔드포인트 (401 Unauthorized)
- 권한 부족 시나리오 (403 Forbidden)
- 리소스 없음 (404 Not Found)
- 잘못된 요청 (400 Bad Request)
- JSON 직렬화/역직렬화
- 쿼리 파라미터 바인딩
- 경로 변수 바인딩
예상 테스트 수: ~72개 (컨트롤러당 12개 × 6개)
관련 파일:
server/api/src/test/java/io/zhc1/realworld/api/(모든 컨트롤러 테스트)
3.2 보안 설정 통합 테스트
목표: 인증/인가 규칙 검증
대상 클래스:
SecurityConfiguration.javaAuthTokenConverter.javaAuthTokenProvider.java
테스트 예시:
@SpringBootTest
@AutoConfigureMockMvc
@DisplayName("Security Configuration - Authentication & Authorization Tests")
class SecurityConfigurationTest {
@Autowired
private MockMvc mockMvc;
@Test
@DisplayName("Public endpoints should be accessible without authentication")
void whenAccessPublicEndpoint_thenReturns200() throws Exception {
mockMvc.perform(get("/api/tags"))
.andExpect(status().isOk());
mockMvc.perform(get("/api/articles"))
.andExpect(status().isOk());
}
@Test
@DisplayName("Protected endpoints should return 401 without token")
void whenAccessProtectedEndpointWithoutToken_thenReturns401() throws Exception {
mockMvc.perform(post("/api/articles")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("Valid JWT token should grant access to protected endpoints")
void whenAccessProtectedEndpointWithValidToken_thenReturns2xx() throws Exception {
String token = generateValidJWT();
mockMvc.perform(get("/api/user")
.header("Authorization", "Bearer " + token))
.andExpect(status().is2xxSuccessful());
}
@Test
@DisplayName("Invalid JWT token should return 401")
void whenAccessProtectedEndpointWithInvalidToken_thenReturns401() throws Exception {
mockMvc.perform(get("/api/user")
.header("Authorization", "Bearer invalid.token.here"))
.andExpect(status().isUnauthorized());
}
@Test
@DisplayName("Expired JWT token should return 401")
void whenAccessProtectedEndpointWithExpiredToken_thenReturns401() throws Exception {
String expiredToken = generateExpiredJWT();
mockMvc.perform(get("/api/user")
.header("Authorization", "Bearer " + expiredToken))
.andExpect(status().isUnauthorized());
}
}예상 테스트 수: ~15개
Phase 4: DTO 및 모델 테스트 보완 (우선순위: 🟡 HIGH)
4.1 DTO 매핑 테스트
목표: Request/Response DTO 직렬화 및 필드 매핑 검증
대상 DTO (16개):
- Request: EditArticleRequest, LoginUserRequest, SignupRequest, UpdateUserRequest, WriteArticleRequest, WriteCommentRequest
- Response: ArticleCommentResponse, ArticleResponse, MultipleArticlesResponse, MultipleCommentsResponse, ProfilesResponse, SingleArticleResponse, SingleCommentResponse, TagsResponse, UserResponse, UsersResponse
테스트 템플릿:
@DisplayName("ArticleResponse - DTO Mapping Tests")
class ArticleResponseTest {
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
}
@Test
@DisplayName("ArticleResponse should map all fields correctly from Article")
void whenMappingFromArticle_thenAllFieldsShouldBeCorrect() {
// given
User author = new User("[email protected]", "author", "password");
Article article = new Article(author, "Test Title", "Description", "Body");
// when
ArticleResponse response = ArticleResponse.from(article, false);
// then
assertThat(response.slug()).isEqualTo("test-title");
assertThat(response.title()).isEqualTo("Test Title");
assertThat(response.description()).isEqualTo("Description");
assertThat(response.body()).isEqualTo("Body");
assertThat(response.favorited()).isFalse();
assertThat(response.favoritesCount()).isZero();
assertThat(response.author()).isNotNull();
assertThat(response.author().username()).isEqualTo("author");
}
@Test
@DisplayName("ArticleResponse should serialize to JSON correctly")
void whenSerializingToJson_thenShouldProduceCorrectFormat() throws Exception {
// given
User author = new User("[email protected]", "author", "password");
Article article = new Article(author, "Test Title", "Description", "Body");
ArticleResponse response = ArticleResponse.from(article, false);
// when
String json = objectMapper.writeValueAsString(response);
// then
assertThat(json).contains("\"slug\":\"test-title\"");
assertThat(json).contains("\"title\":\"Test Title\"");
assertThat(json).contains("\"favorited\":false");
assertThat(json).doesNotContain("null");
}
@Test
@DisplayName("ArticleResponse should deserialize from JSON correctly")
void whenDeserializingFromJson_thenShouldProduceCorrectObject() throws Exception {
// given
String json = """
{
"slug": "test-slug",
"title": "Test Title",
"description": "Test Description",
"body": "Test Body",
"tagList": ["java", "spring"],
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z",
"favorited": false,
"favoritesCount": 0,
"author": {
"username": "author",
"bio": null,
"image": null,
"following": false
}
}
""";
// when
ArticleResponse response = objectMapper.readValue(json, ArticleResponse.class);
// then
assertThat(response.slug()).isEqualTo("test-slug");
assertThat(response.title()).isEqualTo("Test Title");
assertThat(response.tagList()).containsExactly("java", "spring");
}
@Test
@DisplayName("ArticleResponse should handle null optional fields gracefully")
void whenMappingWithNullOptionalFields_thenShouldNotFail() {
// given
User author = new User("[email protected]", "author", "password");
author.setBio(null);
author.setImage(null);
Article article = new Article(author, "Test Title", "Description", "Body");
// when
ArticleResponse response = ArticleResponse.from(article, false);
// then
assertThat(response.author().bio()).isNull();
assertThat(response.author().image()).isNull();
}
}예상 테스트 수: ~64개 (DTO당 4개 × 16개)
4.2 모델 테스트 보완
목표: 도메인 모델 검증 로직 및 비즈니스 규칙 테스트
대상 모델 (10개 추가):
- Article, ArticleComment, ArticleDetails, ArticleFavorite, ArticleTag, UserRegistry 등
테스트 예시:
@DisplayName("Article - Domain Model Tests")
class ArticleTest {
@Test
@DisplayName("Article should generate slug from title")
void whenCreatingArticle_thenSlugShouldBeGeneratedFromTitle() {
// given
User author = new User("[email protected]", "author", "password");
// when
Article article = new Article(author, "My Test Article!", "Description", "Body");
// then
assertThat(article.getSlug()).isEqualTo("my-test-article");
}
@Test
@DisplayName("Article slug should handle special characters")
void whenTitleHasSpecialCharacters_thenSlugShouldBeNormalized() {
User author = new User("[email protected]", "author", "password");
Article article = new Article(author, "C++ & Java: A Comparison!", "Desc", "Body");
assertThat(article.getSlug()).matches("[a-z0-9-]+");
assertThat(article.getSlug()).doesNotContain("&", ":", "!");
}
@Test
@DisplayName("Article should not allow null or blank title")
void whenCreatingArticleWithNullTitle_thenShouldThrowException() {
User author = new User("[email protected]", "author", "password");
assertThatThrownBy(() -> new Article(author, null, "Description", "Body"))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("title");
}
@Test
@DisplayName("Article should track creation and update timestamps")
void whenArticleCreated_thenTimestampsShouldBeSet() {
User author = new User("[email protected]", "author", "password");
Article article = new Article(author, "Title", "Description", "Body");
assertThat(article.getCreatedAt()).isNotNull();
assertThat(article.getUpdatedAt()).isNotNull();
}
@Test
@DisplayName("Editing article should update the updatedAt timestamp")
void whenArticleEdited_thenUpdatedAtShouldChange() throws InterruptedException {
User author = new User("[email protected]", "author", "password");
Article article = new Article(author, "Title", "Description", "Body");
Instant originalUpdatedAt = article.getUpdatedAt();
Thread.sleep(10); // Ensure time difference
article.setTitle("New Title");
assertThat(article.getUpdatedAt()).isAfter(originalUpdatedAt);
}
@Test
@DisplayName("Article equals should be based on business key (slug)")
void whenComparingArticlesWithSameSlug_thenShouldBeEqual() {
User author = new User("[email protected]", "author", "password");
Article article1 = new Article(author, "Same Title", "Desc1", "Body1");
Article article2 = new Article(author, "Same Title", "Desc2", "Body2");
assertThat(article1).isEqualTo(article2);
assertThat(article1.hashCode()).isEqualTo(article2.hashCode());
}
}예상 테스트 수: ~40개 (모델당 4개 × 10개)
Phase 5: 고급 테스트 시나리오 (우선순위: 🟢 MEDIUM)
5.1 캐시 동작 검증
목표: Caffeine 캐시 설정 및 동작 확인
테스트 예시:
@SpringBootTest
@DisplayName("Cache Behavior Tests")
class CacheBehaviorTest {
@Autowired
private UserService userService;
@Autowired
private UserRepository userRepository;
@Autowired
private CacheManager cacheManager;
@Test
@DisplayName("User lookup should be cached")
void whenLookingUpUserTwice_thenShouldHitCache() {
// given
UUID userId = UUID.randomUUID();
User user = new User("[email protected]", "testuser", "password");
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
// when
userService.getUser(userId); // Cache miss
userService.getUser(userId); // Cache hit
// then
verify(userRepository, times(1)).findById(userId);
}
@Test
@DisplayName("User update should invalidate cache")
void whenUpdatingUser_thenCacheShouldBeInvalidated() {
UUID userId = UUID.randomUUID();
User user = new User("[email protected]", "testuser", "password");
userService.getUser(userId); // Cache
userService.updateUser(userId, "[email protected]", null, null, null);
userService.getUser(userId); // Should fetch fresh data
verify(userRepository, times(2)).findById(userId);
}
}예상 테스트 수: ~10개
5.2 동시성 테스트
목표: 동시 요청 시 데이터 일관성 보장
테스트 예시:
@Test
@DisplayName("Concurrent article creation with same title should result in one success and one failure")
void whenCreatingArticlesConcurrentlyWithSameTitle_thenOnlyOneShouldSucceed() throws Exception {
// given
User author = new User("[email protected]", "author", "password");
ExecutorService executor = Executors.newFixedThreadPool(2);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failureCount = new AtomicInteger(0);
// when
executor.invokeAll(Arrays.asList(
() -> {
try {
articleService.write(author, "Duplicate Title", "Desc", "Body", Collections.emptyList());
successCount.incrementAndGet();
} catch (IllegalArgumentException e) {
failureCount.incrementAndGet();
}
return null;
},
() -> {
try {
articleService.write(author, "Duplicate Title", "Desc", "Body", Collections.emptyList());
successCount.incrementAndGet();
} catch (IllegalArgumentException e) {
failureCount.incrementAndGet();
}
return null;
}
));
// then
assertThat(successCount.get()).isEqualTo(1);
assertThat(failureCount.get()).isEqualTo(1);
}예상 테스트 수: ~5개
5.3 성능 테스트
목표: 응답 시간 및 처리량 벤치마킹
테스트 예시:
@Tag("performance")
@Test
@DisplayName("Article listing with large dataset should complete within 500ms")
void articleListingPerformance_withLargeDataset_shouldMeetSLA() {
// given - create 10,000 articles
for (int i = 0; i < 10000; i++) {
Article article = new Article(author, "Article " + i, "Desc", "Body");
articleRepository.save(article, Collections.emptyList());
}
ArticleFacets facets = new ArticleFacets(null, null, null, 1, 20);
// when
long startTime = System.currentTimeMillis();
List<ArticleDetails> articles = articleService.getArticles(null, facets);
long duration = System.currentTimeMillis() - startTime;
// then
assertThat(duration).isLessThan(500); // 500ms SLA
assertThat(articles).hasSize(20);
}예상 테스트 수: ~8개
Phase 6: E2E 테스트 개선 (우선순위: 🟢 MEDIUM)
6.1 E2E 테스트 Gradle 통합
목표: Gradle 태스크로 E2E 테스트 실행 자동화
작업 상세:
// server/api/build.gradle.kts
tasks.register<Exec>("e2eTest") {
group = "verification"
description = "Run E2E tests using Newman"
dependsOn("bootJar")
doFirst {
// Start application in background
val app = ProcessBuilder("java", "-jar", "build/libs/realworld-api-0.0.1-SNAPSHOT.jar")
.redirectOutput(ProcessBuilder.Redirect.PIPE)
.redirectError(ProcessBuilder.Redirect.PIPE)
.start()
// Wait for application to start
Thread.sleep(10000)
// Run Newman tests
workingDir = file("../../api-docs")
commandLine("sh", "run-api-tests.sh")
// Cleanup
Runtime.getRuntime().addShutdownHook(Thread {
app.destroy()
})
}
}실행 방법:
./gradlew e2eTest6.2 E2E 테스트 확장
목표: 실패 시나리오 및 엣지 케이스 커버리지
추가할 시나리오:
- 인증 실패 (잘못된 토큰, 만료된 토큰)
- 권한 부족 (다른 사용자의 게시글 수정 시도)
- 존재하지 않는 리소스 조회
- 잘못된 요청 페이로드
- 중복 데이터 생성 시도 (동일 username, email, article title)
- 페이지네이션 경계값 테스트
- 대용량 요청 페이로드
예상 추가 테스트 수: ~20개
✅ 인수 조건 (Acceptance Criteria)
Phase 1: 기반 인프라 (Week 1)
- JaCoCo 플러그인 설정 완료 및 빌드 성공
- 코드 커버리지 리포트 HTML/XML 형식으로 생성
- GitHub Actions CI 워크플로우 추가 및 정상 실행
- PR마다 자동 테스트 실행 확인
- Codecov 통합 완료 및 커버리지 배지 README 추가
Phase 2: 레포지토리 테스트 (Week 2-3)
- 6개 레포지토리 어댑터 테스트 작성 완료 (최소 60개 테스트)
- ArticleSpecifications 테스트 작성 완료 (최소 10개 테스트)
- 모든 레포지토리 테스트 통과 (Green)
- 레포지토리 계층 커버리지 75% 이상 달성
Phase 3: 컨트롤러 테스트 (Week 3-4)
- 6개 컨트롤러 테스트 작성 완료 (최소 72개 테스트)
- 보안 설정 테스트 작성 완료 (최소 15개 테스트)
- 모든 HTTP 상태 코드 검증 (200, 201, 400, 401, 403, 404, 500)
- 컨트롤러 계층 커버리지 80% 이상 달성
Phase 4: DTO 및 모델 테스트 (Week 4-5)
- 16개 DTO 매핑 테스트 작성 완료 (최소 64개 테스트)
- 10개 모델 테스트 추가 작성 완료 (최소 40개 테스트)
- JSON 직렬화/역직렬화 검증 완료
- DTO 계층 커버리지 50% 이상, 모델 계층 60% 이상 달성
Phase 5: 고급 테스트 (Week 5-6)
- 캐시 동작 테스트 작성 완료 (최소 10개)
- 동시성 테스트 작성 완료 (최소 5개)
- 성능 테스트 작성 완료 (최소 8개)
- 모든 고급 테스트 통과
Phase 6: E2E 테스트 개선 (Week 6)
- E2E 테스트 Gradle 통합 완료
- E2E 테스트 확장 (최소 20개 시나리오 추가)
- E2E 테스트 CI 파이프라인 통합 완료
전체 목표 (Final)
- 전체 코드 커버리지 70% 이상 달성
- 모든 테스트 통과 (Green Build)
- CI 파이프라인 안정화 (실패율 < 5%)
- 테스트 실행 시간 5분 이내 유지
- PR 머지 전 자동 품질 게이트 통과 필수화
📊 예상 산출물
코드 변경
- 신규 테스트 파일: ~35개
- 총 테스트 메서드: ~380개 (기존 80 + 신규 300)
- 설정 파일: 4개 (build.gradle.kts, GitHub Actions workflow, Codecov config)
문서
- 테스트 전략 문서 (
claudedocs/test-strategy.md) - 커버리지 리포트 (자동 생성)
- CI/CD 파이프라인 문서
메트릭스 개선 목표
| 메트릭 | 현재 | 목표 | 개선율 |
|---|---|---|---|
| 전체 커버리지 | 미측정 | 70%+ | N/A |
| 서비스 커버리지 | 100% | 100% | 유지 |
| 레포지토리 커버리지 | 0% | 75%+ | +75% |
| 컨트롤러 커버리지 | 0% | 80%+ | +80% |
| 모델 커버리지 | 18% | 60%+ | +42% |
| DTO 커버리지 | 6% | 50%+ | +44% |
| 총 테스트 수 | 80 | 380+ | +375% |
🎯 성공 지표
정량적 지표
- 코드 커버리지: 70% 이상
- 테스트 통과율: 100%
- 빌드 안정성: 실패율 < 5%
- 테스트 실행 시간: < 5분
정성적 지표
- 회귀 버그 검출: 리팩토링 시 기존 기능 보호
- 배포 신뢰도: 자동화된 품질 게이트 통과
- 개발 생산성: 테스트로 인한 피드백 루프 단축
- 코드 품질: 지속적인 리팩토링 가능
📅 타임라인
| 주차 | Phase | 주요 작업 | 산출물 |
|---|---|---|---|
| Week 1 | Phase 1 | JaCoCo, GitHub Actions 설정 | CI 파이프라인 |
| Week 2-3 | Phase 2 | 레포지토리 통합 테스트 | 60개 테스트 |
| Week 3-4 | Phase 3 | 컨트롤러 통합 테스트 | 87개 테스트 |
| Week 4-5 | Phase 4 | DTO, 모델 테스트 보완 | 104개 테스트 |
| Week 5-6 | Phase 5 | 고급 테스트 시나리오 | 23개 테스트 |
| Week 6 | Phase 6 | E2E 테스트 개선 | 20개 시나리오 |
총 기간: 6주
총 테스트 증가: ~300개
🔗 관련 리소스
문서
내부 참조
- 프로젝트 README:
/README.md - 현재 테스트:
module/core/src/test/,module/persistence/src/test/,server/api/src/test/ - 데이터베이스 스키마:
module/persistence/src/main/resources/schema.sql - API 명세:
api-docs/openapi.yaml
💡 참고사항
테스트 작성 원칙
- AAA 패턴 준수: Arrange (Given) - Act (When) - Assert (Then)
- DisplayName 필수: 모든 테스트에 명확한 설명 추가
- 테스트 독립성: 각 테스트는 독립적으로 실행 가능해야 함
- 명확한 실패 메시지: 실패 시 원인 파악이 쉬워야 함
- Given-When-Then 주석: 가독성 향상을 위해 명시적으로 구분
우선순위 기준
- 🔴 CRITICAL: 프로덕션 배포 전 필수 완료
- 🟡 HIGH: 품질 보장을 위해 조속히 완료
- 🟢 MEDIUM: 장기적 안정성을 위해 순차 진행
협업 가이드
- 각 Phase는 독립적인 PR로 진행
- 테스트 커버리지 70% 미달 시 PR 머지 블로킹
- 코드 리뷰 시 테스트 품질 중점 검토
- 테스트 실패 시 우선 해결 후 다음 작업 진행