Skip to content

테스트 자동화 인프라 개선 및 커버리지 향상 #1

@jh226

Description

@jh226

테스트 자동화 인프라 개선 및 커버리지 향상

📋 작업 배경 (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) ⚠️
전체 코드 커버리지: 미측정 (도구 없음) 🔴

비즈니스 임팩트

  1. 프로덕션 리스크: 데이터베이스 쿼리 및 API 엔드포인트 검증 부재로 인한 운영 장애 가능성
  2. 유지보수성 저하: 리팩토링 시 회귀 버그 검출 불가
  3. 배포 신뢰도: 자동화된 품질 게이트 부재로 수동 검증 필요
  4. 기술 부채: 테스트 인프라 개선 지연 시 향후 개선 비용 증가

🎯 작업 내용 (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.kts
  • module/persistence/build.gradle.kts
  • server/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개):

  1. ArticleRepositoryAdapter.java
  2. ArticleCommentRepositoryAdapter.java
  3. ArticleFavoriteRepositoryAdapter.java
  4. TagRepositoryAdapter.java
  5. UserRepositoryAdapter.java
  6. UserRelationshipRepositoryAdapter.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개):

  1. ArticleController.java
  2. ArticleCommentController.java
  3. ArticleFavoriteController.java
  4. TagController.java
  5. UserController.java
  6. UserRelationshipController.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.java
  • AuthTokenConverter.java
  • AuthTokenProvider.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 e2eTest

6.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%

🎯 성공 지표

정량적 지표

  1. 코드 커버리지: 70% 이상
  2. 테스트 통과율: 100%
  3. 빌드 안정성: 실패율 < 5%
  4. 테스트 실행 시간: < 5분

정성적 지표

  1. 회귀 버그 검출: 리팩토링 시 기존 기능 보호
  2. 배포 신뢰도: 자동화된 품질 게이트 통과
  3. 개발 생산성: 테스트로 인한 피드백 루프 단축
  4. 코드 품질: 지속적인 리팩토링 가능

📅 타임라인

주차 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

💡 참고사항

테스트 작성 원칙

  1. AAA 패턴 준수: Arrange (Given) - Act (When) - Assert (Then)
  2. DisplayName 필수: 모든 테스트에 명확한 설명 추가
  3. 테스트 독립성: 각 테스트는 독립적으로 실행 가능해야 함
  4. 명확한 실패 메시지: 실패 시 원인 파악이 쉬워야 함
  5. Given-When-Then 주석: 가독성 향상을 위해 명시적으로 구분

우선순위 기준

  • 🔴 CRITICAL: 프로덕션 배포 전 필수 완료
  • 🟡 HIGH: 품질 보장을 위해 조속히 완료
  • 🟢 MEDIUM: 장기적 안정성을 위해 순차 진행

협업 가이드

  • 각 Phase는 독립적인 PR로 진행
  • 테스트 커버리지 70% 미달 시 PR 머지 블로킹
  • 코드 리뷰 시 테스트 품질 중점 검토
  • 테스트 실패 시 우선 해결 후 다음 작업 진행

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions