TDD (Test-Driven Development)

1.1 TDD란 무엇인가?

  • **TDD(Test-Driven Development)**란:
    • 테스트 코드를 먼저 작성하고, 그 테스트를 통과하기 위한 구현 코드를 작성하는 개발 방법론.
    • 코드 작성 전에 테스트를 통해 문제 정의와 요구사항을 명확히 이해.
  • 핵심 개념:
    • 테스트 작성 → 테스트 통과 → 코드 리팩토링.
    • 코드 품질을 높이고, 안정성을 확보.
  • TDD의 슬로건:
    • “테스트부터 작성하자. 그리고 통과시키자!”

1.2 TDD의 기본 사이클: Red-Green-Refactor

  1. Red (실패를 즐기자):
    • 실패를 두려워하지 않고, 실패를 통해 요구사항을 이해.
    • 실패한 테스트는 현재 요구사항이 무엇인지를 알려줌.
    import org.junit.jupiter.api.Test;
    import static org.junit.jupiter.api.Assertions.assertEquals;
    
    public class CalculatorTest {
        @Test
        void testAddition() {
            Calculator calculator = new Calculator();
            int result = calculator.add(2, 3);
            assertEquals(5, result); // 실패! 왜? 구현 코드가 없으니까.
        }
    }
    
  2. Green (테스트 통과는 최소 노력으로):
    • 테스트를 통과시키는 코드를 작성.
    • 복잡하게 고민하지 않고, 테스트 요구사항만 충족.
    public class Calculator {
        public int add(int a, int b) {
            return a + b; // 통과!
        }
    }
    
  3. Refactor (이제 진짜 멋지게):
    • 코드의 가독성, 유지보수성을 개선.
    • 코드 동작은 동일하게 유지하면서, 구조를 더 깔끔하고 확장 가능하게 변경.
    public class Calculator {
        public int add(int a, int b) {
            return calculate(a, b, "+");
        }
    
        private int calculate(int a, int b, String operation) {
            switch (operation) {
                case "+":
                    return a + b;
                default:
                    throw new UnsupportedOperationException("Operation not supported");
            }
        }
    }
    

1.3 TDD가 중요한 이유

  1. 코드 안정성 보장:
    • 모든 기능에 대해 자동화된 테스트 작성.
    • 버그를 줄이고, 코드 변경에도 안정성 유지.
  2. 생산성 향상:
    • 코드 리팩토링 부담 감소.
    • 변경 사항이 발생해도 테스트를 통해 빠르게 문제 발견.
  3. 코드 품질 향상:
    • 테스트를 기반으로 설계된 코드는 요구사항에 부합하고, 명확하며 유지보수가 쉬움.
  4. 개발 프로세스 개선:
    • 코드 작성 전에 요구사항을 구체화.
    • 의미 없는 코드 작성을 방지.

1.4 TDD의 장단점

장점 단점

코드 신뢰성과 품질 향상. 초기 학습 곡선이 높음.
요구사항 변경에도 코드 구조를 쉽게 유지 가능. 테스트 작성에 시간이 추가적으로 소요.
디버깅 시간 절약 및 문제 사전 예방. 잘못된 테스트 설계 시 오히려 개발 속도 저하.
자동화된 테스트로 코드 변경 시 빠른 피드백 가능. 작은 프로젝트에서는 과도한 테스트 작성 가능성.

1.5 DDD (Domain-Driven Design)와의 관계

  1. TDD:
    • 코드를 테스트하면서 어떻게 구현할지에 초점.
    • 코드 안정성과 품질을 높이기 위해 활용.
  2. DDD:
    • 비즈니스 도메인을 설계하여 무엇을 구현할지에 초점.
    • 요구사항을 분석하고, 도메인 모델을 설계하여 복잡한 비즈니스 로직을 제대로 반영.
  3. TDD와 DDD의 차이:
  TDD DDD
목적 코드의 동작 보장 비즈니스 요구사항을 시스템에 반영
초점 테스트 작성 → 코드 작성 → 리팩토링 요구사항 분석 → 도메인 설계 → 코드 작성
중요성 코드 안정성과 품질 확보 비즈니스 로직의 정확한 반영

1.6 TDD 실습 시 추가 학습이 필요한 주제

  1. 테스트 작성 기법:
    • 단위 테스트(Unit Test), 통합 테스트(Integration Test)의 차이와 작성 방법.
    • Mocking 라이브러리(예: Mockito, MockK) 활용.
  2. 테스트 자동화 도구:
    • CI/CD 파이프라인에서 TDD를 통합하기 위한 Jenkins, GitHub Actions.
  3. 테스트 피라미드:
    • 테스트 종류(단위 테스트, 통합 테스트, UI 테스트)와 비중.
  4. 테스트 커버리지:
    • 코드의 어느 부분이 테스트되고, 어느 부분이 테스트되지 않는지 분석.
  5. 리팩토링 패턴:
    • 코드 리팩토링 시 자주 사용하는 디자인 패턴과 사례.

결론

TDD는 코드 안정성과 품질을 높이는 데 중요한 역할을 하는 개발 방법론입니다. 코드를 작성하기 전에 테스트를 먼저 작성함으로써 요구사항을 명확히 하고, 불필요한 작업을 줄일 수 있습니다. 또한, TDD는 DDD와 함께 사용하면 비즈니스 로직 설계와 구현을 더욱 효율적으로 진행할 수 있습니다.

TDD를 실무에서 성공적으로 적용하기 위해서는 단위 테스트 작성 기법, Mocking, 테스트 피라미드와 같은 추가 개념을 학습하는 것이 중요합니다. 😊

 

테스트 코드

📌 “내 코드, 정말 제대로 작동할까?”라는 질문에 대한 답을 미리 준비하는 개발자의 무기

  • 프로그램의 기능이 의도대로 동작 하는지 자동으로 확인
  • 안만든다는 선택지도 분명 존재는 하나, 서버를 주력으로 다루는 백엔드에서 오류가 안나도록 테스트를 진행하는 것은 사실상 필수라고 봐야한다.

 

Mock

 

1. Mock이란?

  • Mock(모의 객체):
    • 테스트 환경에서 실제 객체를 대신하여 동작하는 가짜 객체. [실제 객체는 DB,네트워크와 같은 외부 의존성에 의해 움직일 수 있는데, 테스트 시에는 이런게 없으면 편하니 유사 객체를 만든 것]
    • 외부 의존성을 제거하고, 테스트 대상 코드에만 집중할 수 있도록 도와줌.
    • 객체의 특정 동작을 미리 정의하거나 호출 여부를 검증하는 데 사용.

2. Mock의 필요성

  1. 외부 의존성 제거:
    • 데이터베이스, 네트워크, 파일 시스템 등 외부 시스템과 상호작용 없이 독립적인 테스트 가능.
  2. 테스트 성능 향상:
    • 실제 객체 대신 Mock 객체를 사용하여 빠르고 가벼운 테스트 환경 구현.
  3. 예외 및 경계 조건 테스트:
    • 실제 객체가 처리하지 못하는 특정 조건(예: 네트워크 장애, DB 연결 실패)을 Mock으로 시뮬레이션.
  4. 상태 검증:
    • Mock 객체를 통해 특정 메서드가 호출되었는지, 호출 횟수 등을 확인.

3. Mock과 Stub, Spy의 차이

항목 Mock Stub Spy

정의 테스트 중 동작을 시뮬레이션하는 가짜 객체 고정된 응답을 제공하는 가짜 객체 실제 객체를 감싸서 호출을 감시하고 일부 동작을 대체
주요 기능 호출 검증, 동작 시뮬레이션 미리 정의된 값을 반환 호출 여부와 횟수를 확인하며 일부 메서드를 모킹
사용 목적 메서드 호출 여부, 횟수, 전달된 인수 검증 정해진 동작을 반환 실제 객체와 모킹 동작을 혼합
예시 네트워크 요청 여부 검증 특정 메서드가 항상 같은 값을 반환하도록 설정 데이터베이스 객체의 동작 일부를 검증

4. Mock 구현 예제

Java - Mockito 사용

import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import static org.mockito.Mockito.*;

class UserServiceTest {

    @Test
    void testGetUserName() {
        // Mock 객체 생성
        UserRepository mockRepo = mock(UserRepository.class);

        // Mock 동작 정의
        when(mockRepo.getUserName(1)).thenReturn("Alice");

        // Mock 객체를 사용하는 서비스
        UserService userService = new UserService(mockRepo);

        // 테스트
        String name = userService.getUserName(1);
        assertEquals("Alice", name);

        // 호출 여부 검증
        verify(mockRepo, times(1)).getUserName(1);
    }
}

Kotlin - MockK 사용

import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlin.test.assertEquals

class UserServiceTest {

    @Test
    fun `test getUserName`() {
        // Mock 객체 생성
        val mockRepo = mockk<UserRepository>()

        // Mock 동작 정의
        every { mockRepo.getUserName(1) } returns "Alice"

        // Mock 객체를 사용하는 서비스
        val userService = UserService(mockRepo)

        // 테스트
        val name = userService.getUserName(1)
        assertEquals("Alice", name)

        // 호출 여부 검증
        verify(exactly = 1) { mockRepo.getUserName(1) }
    }
}

5. Mock의 주요 활용 사례

  1. 데이터베이스(Mock DB):
    • 실제 DB 연결 없이 테스트 수행.
    • 데이터 삽입/조회/삭제를 Mock으로 처리.
  2. 네트워크 호출(Mock API):
    • 외부 API의 응답을 Mock으로 설정하여 테스트.
  3. 예외 처리 테스트:
    • 특정 메서드 호출 시 예외를 발생하도록 설정.
  4. 의존성 많은 서비스(Mock Dependency):
    • 복잡한 의존성을 가진 클래스 테스트 시 간소화.

6. Mock 작성 시 주의사항

  1. 과도한 Mock 사용:
    • Mock을 너무 많이 사용하면 테스트의 신뢰성이 떨어질 수 있음.
    • 필요한 부분만 Mock하고, 가능한 실제 객체와 함께 테스트.
  2. 복잡한 동작 정의:
    • Mock 객체의 동작이 지나치게 복잡하면 테스트 가독성 저하.
    • 간단한 동작 정의를 선호.
  3. Mock 객체와 실제 객체의 혼용:
    • 동일한 테스트에서 Mock 객체와 실제 객체를 혼합 사용 시 결과를 이해하기 어려워질 수 있음.
  4. 잘못된 동작 정의:
    • 테스트 시 실제와 다르게 동작하도록 Mock을 정의하면 테스트 결과가 부정확해질 수 있음.

7. Mock 관련 추가 학습 주제

  1. 테스트 대역(Test Double):
    • Mock, Stub, Spy, Fake, Dummy의 차이와 사용법.
  2. Mock 라이브러리 심화:
    • Mockito(Java), MockK(Kotlin), Jest(JavaScript) 등 주요 라이브러리 활용.
  3. Mock을 활용한 통합 테스트:
    • 단위 테스트와 통합 테스트에서 Mock의 적절한 사용 시점 학습.
  4. CI/CD와 Mock:
    • CI/CD 파이프라인에서 Mock을 활용한 자동화 테스트.
  5. 비동기 및 동시성 테스트:
    • 비동기 작업(Mock의 비동기 동작 정의)과 동시성 처리 검증.

8. 정리

항목 설명
Mock이란? 외부 의존성을 제거하고, 테스트 대상 코드에 집중하기 위한 가짜 객체.
필요성 외부 시스템 제거, 테스트 성능 향상, 예외 및 경계 조건 테스트, 호출 검증.
Mock과 Stub의 차이 Mock은 호출 검증과 동작 정의를 지원하며, Stub은 정해진 동작만 수행.
활용 사례 데이터베이스(Mock DB), 네트워크 호출(Mock API), 예외 처리 테스트 등.
주의사항 과도한 Mock 사용 지양, 동작 정의 간소화, Mock 객체와 실제 객체의 혼용 주의.
추가 학습 주제 테스트 대역, Mock 라이브러리 심화, 통합 테스트, CI/CD와 Mock, 비동기 테스트.

+ Mocking : 테스트 환경에서 실제 객체를 대신하여 동작하는 가짜 객체(Mock Object)를 생성하고 사용하는 과정.

 

단위 테스트 (Unit Test)

 

1. 단위 테스트란?

  • 단위(Unit):
    • 애플리케이션의 가장 작은 구성 요소(메서드, 클래스 등)를 의미.
    • 단위 테스트는 이 작은 구성 요소가 올바르게 동작하는지 검증하는 테스트.
  • 단위 테스트의 목적:
    • 각 구성 요소의 정확성 보장.
    • 변경된 코드로 인해 기존 코드에 **부작용(Side Effect)**이 없는지 확인.
    • 버그를 사전에 방지하고, 유지보수를 쉽게 만듦.

2. 단위 테스트의 특징

  1. 독립성:
    • 테스트는 서로 독립적으로 실행되어야 함.
    • 하나의 테스트가 실패해도 다른 테스트에 영향을 주지 않아야 함.
  2. 빠른 실행:
    • 단위 테스트는 작은 단위의 코드만 검증하므로 빠르게 실행.
  3. Mocking 활용:
    • 외부 의존성을 제거하기 위해 Mock 객체를 사용.
    • 예: 데이터베이스, 네트워크 요청 등을 실제로 호출하지 않음.
  4. 작은 범위:
    • 클래스 또는 메서드 단위로 테스트 작성.
    • 외부 시스템이나 다른 모듈과 상호작용하지 않음.

3. 단위 테스트의 장점

장점 설명
빠른 피드백 코드 변경 후 테스트를 통해 즉시 결과 확인 가능.
버그 감소 개발 초기에 코드의 정확성을 검증하여 버그 발생 가능성을 줄임.
리팩토링 안전성 보장 테스트가 작성된 코드는 리팩토링 시에도 기능이 보장됨.
개발 속도 향상 초기에는 시간이 더 들지만, 유지보수 시간 단축으로 장기적으로 개발 속도가 향상됨.
문서화 역할 테스트 코드 자체가 해당 코드의 사용 방법과 요구사항을 문서화하는 역할을 함.

4. 단위 테스트 작성 방법

1) 단위 테스트 구성 요소

  1. Given (준비):
    • 테스트 환경 및 필요한 데이터를 설정.
  2. When (실행):
    • 테스트 대상 메서드 또는 기능을 호출.
  3. Then (검증):
    • 기대하는 결과와 실제 결과를 비교하여 테스트 통과 여부 확인.

2) 단위 테스트 예제 Java JUnit 예제

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

public class CalculatorTest {
    @Test
    void testAddition() {
        // Given: 준비
        Calculator calculator = new Calculator();

        // When: 실행
        int result = calculator.add(2, 3);

        // Then: 검증
        assertEquals(5, result, "2 + 3은 5여야 합니다.");
    }
}

Kotlin JUnit 예제

import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test

class CalculatorTest {
    @Test
    fun testAddition() {
        // Given: 준비
        val calculator = Calculator()

        // When: 실행
        val result = calculator.add(2, 3)

        // Then: 검증
        assertEquals(5, result, "2 + 3은 5여야 합니다.")
    }
}

class Calculator {
    fun add(a: Int, b: Int): Int {
        return a + b
    }
}

5. 단위 테스트 작성 시 고려사항

  1. 단일 책임 원칙:
    • 하나의 테스트는 하나의 기능만 검증해야 함.
  2. 테스트 케이스 명확화:
    • 테스트 이름은 무엇을 테스트하는지 명확히 나타내야 함.
    • 예: testAddition_ShouldReturnSum_WhenGivenTwoIntegers.
  3. Mock 객체 사용:
    • 외부 의존성(데이터베이스, API 등)을 실제로 호출하지 않기 위해 Mocking 라이브러리 활용.
    • 예: Mockito(Java), MockK(Kotlin).
  4. 테스트 독립성 보장:
    • 각 테스트는 독립적으로 실행되어야 함.
    • 테스트 순서에 따라 결과가 달라지지 않도록 작성.
  5. 실패 케이스 테스트:
    • 정상 동작뿐 아니라, 예외 상황(Invalid Input, Null 등)도 검증.

6. 단위 테스트 도구 및 프레임워크

  1. Java:
    • JUnit: 가장 널리 사용되는 테스트 프레임워크.
    • Mockito: Mock 객체 생성 및 관리.
  2. Kotlin:
    • JUnit5: Kotlin에서도 기본적으로 사용 가능.
    • MockK: Kotlin용 Mocking 라이브러리.
  3. Python:
    • unittest: 표준 라이브러리로 제공되는 테스트 프레임워크.
    • pytest: Python의 강력한 테스트 도구.
  4. JavaScript:
    • Jest: Facebook에서 개발한 JavaScript 테스팅 라이브러리.
    • Mocha: Node.js 기반의 테스트 프레임워크.

7. 추가 학습이 필요한 주제

  1. Mocking과 Stub:
    • Mock 객체를 활용해 단위 테스트에서 외부 의존성을 제거하는 방법.
  2. 테스트 자동화:
    • CI/CD 파이프라인에서 단위 테스트를 자동화.
  3. 테스트 커버리지 도구:
    • 코드 커버리지 분석 도구(예: JaCoCo, Coverage.py).
  4. TDD(Test-Driven Development):
    • 테스트를 먼저 작성하는 방법론.
  5. Parameterized Test:
    • 동일한 테스트를 다양한 입력값으로 실행.

8. 정리

항목 설명
단위 테스트란? 애플리케이션의 가장 작은 구성 요소(메서드, 클래스 등)의 동작을 검증하는 테스트.
특징 독립성, 빠른 실행, 작은 범위의 검증, Mocking 활용.
장점 버그 감소, 코드 품질 향상, 리팩토링 안전성 보장, 빠른 피드백 제공.
도구 JUnit, MockK, Mockito, Jest, pytest 등.
추가 학습 주제 Mocking, 테스트 커버리지 분석, TDD, 테스트 자동화.

 

 

통합 테스트 (Integration Test)

 

1. 통합 테스트란?

  • 정의:
    • 애플리케이션의 **여러 구성 요소(모듈, 서비스)**가 함께 동작할 때, 이들이 올바르게 상호작용하는지 검증하는 테스트.
    • 단위 테스트가 개별 구성 요소를 테스트한다면, 통합 테스트는 그 구성 요소들 간의 통합 상태를 테스트.

2. 통합 테스트의 목적

  1. 구성 요소 간의 상호작용 검증:
    • 데이터베이스, API, 파일 시스템, 외부 서비스와 같은 외부 의존성과의 통합 동작 확인.
  2. 통합 시 발생할 수 있는 문제 탐지:
    • 개별적으로는 정상 작동하지만, 통합 시 발생하는 버그(데이터 불일치, 의존성 문제 등)를 식별.
  3. 시스템 신뢰성 확보:
    • 모듈 간의 연결이 올바르게 동작하여, 전체 시스템이 요구사항을 충족하는지 보장.

3. 통합 테스트의 특징

항목 설명
테스트 범위 시스템의 여러 구성 요소 또는 모듈 간의 상호작용 검증.
의존성 포함 데이터베이스, API, 메시지 큐 등 외부 시스템과의 통합 테스트를 포함.
테스트 비용 단위 테스트보다 설정과 실행에 더 많은 리소스와 시간이 필요.
실행 속도 단위 테스트보다 느리지만, E2E 테스트보다는 빠름.
사용 사례 데이터베이스 CRUD 테스트, 외부 API 호출 검증, 서비스 간 데이터 흐름 테스트.

4. 통합 테스트의 장단점

장점 단점
구성 요소 간의 호환성 검증 가능. 테스트 환경 설정 및 관리가 복잡.
외부 시스템과의 연동 문제를 사전에 발견. 단위 테스트보다 실행 속도가 느림.
시스템 전체의 신뢰성을 높임. 외부 의존성(네트워크, 데이터베이스)에 따라 테스트 결과가 달라질 수 있음.
실제 환경과 비슷한 테스트로 사용자 경험을 개선. 특정 문제의 원인을 개별 구성 요소로 좁히는 데 어려움이 있을 수 있음.

5. 통합 테스트 작성 방법

5.1 주요 단계

  1. 테스트 환경 설정:
    • 실제 데이터베이스나 외부 API와의 상호작용을 위한 환경 구성.
    • 테스트 전용 데이터베이스, Mock API 서버 설정.
  2. 데이터 초기화:
    • 테스트 시작 전, 필요한 초기 데이터 설정.
    • 테스트 종료 후, 데이터 정리(Clean-up).
  3. 테스트 실행:
    • 구성 요소 간의 데이터 흐름과 상호작용 테스트.
  4. 결과 검증:
    • 기대 결과와 실제 결과 비교.

5.2 통합 테스트 예제 Java + Spring Boot 통합 테스트

@SpringBootTest
@AutoConfigureMockMvc
class UserServiceIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
        userRepository.save(new User("Alice", "alice@example.com"));
    }

    @AfterEach
    void tearDown() {
        userRepository.deleteAll();
    }

    @Test
    void testGetUser() throws Exception {
        mockMvc.perform(get("/users/1"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.name").value("Alice"))
                .andExpect(jsonPath("$.email").value("alice@example.com"));
    }
}

Kotlin + Spring Boot 통합 테스트

@SpringBootTest
@AutoConfigureMockMvc
class UserServiceIntegrationTest {

    @Autowired
    private lateinit var mockMvc: MockMvc

    @Autowired
    private lateinit var userRepository: UserRepository

    @BeforeEach
    fun setUp() {
        userRepository.save(User("Alice", "alice@example.com"))
    }

    @AfterEach
    fun tearDown() {
        userRepository.deleteAll()
    }

    @Test
    fun `should return user details`() {
        mockMvc.perform(get("/users/1"))
            .andExpect(status().isOk)
            .andExpect(jsonPath("$.name").value("Alice"))
            .andExpect(jsonPath("$.email").value("alice@example.com"))
    }
}

6. 통합 테스트 시 Mock과 실제 객체의 사용

  1. Mock 사용:
    • 외부 API나 비효율적인 작업(Mock API, Mock 데이터베이스 등) 테스트.
    • 네트워크 장애, 예외 처리 시뮬레이션.
  2. 실제 객체 사용:
    • 데이터베이스와의 CRUD 테스트.
    • 실제 파일 시스템이나 외부 API와의 연동 확인.

7. 통합 테스트 시 주의사항

  1. 테스트 환경 설정:
    • 테스트용 데이터베이스와 실제 데이터베이스를 분리.
    • 환경 변수나 설정 파일로 테스트 환경 구성.
  2. 테스트 격리:
    • 각 테스트가 독립적으로 실행되도록 설정.
    • 테스트 실행 전후에 데이터를 초기화(Clean-up).
  3. 실행 시간 최적화:
    • 불필요한 작업(Mock 사용) 줄이고, 실행 시간을 최소화.
  4. 실제 환경과 유사성:
    • 가능한 실제 환경과 비슷한 조건에서 테스트 수행.

8. 통합 테스트와 단위 테스트의 차이

  단위 테스트 (Unit Test) 통합 테스트 (Integration Test)
테스트 범위 개별 구성 요소(메서드, 클래스 등). 여러 구성 요소 또는 시스템 전체.
실행 속도 빠름. 느림.
의존성 제거 Mock 객체를 사용해 외부 의존성을 제거. 실제 객체 또는 Mock을 혼합 사용.
사용 목적 개별 구성 요소의 동작을 검증. 구성 요소 간의 상호작용과 통합 검증.
테스트 복잡도 상대적으로 단순. 환경 설정 및 데이터 초기화가 필요하므로 복잡.

9. 추가 학습이 필요한 주제

  1. Mock Server 활용:
    • 외부 API 연동 테스트 시 Mock 서버 구현.
  2. TestContainers:
    • Docker 컨테이너를 사용해 실제 데이터베이스, 메시지 큐 테스트.
  3. CI/CD 파이프라인 통합:
    • 통합 테스트를 자동화하여 지속적인 배포 환경 구축.
  4. 테스트 격리 전략:
    • 데이터 충돌을 방지하기 위한 테스트 데이터 격리 기법.

 

End-to-End 테스트 (E2E Test)

 

1. E2E 테스트란?

  • End-to-End 테스트:
    • 애플리케이션의 전체 플로우를 검증하는 테스트.
    • 사용자가 애플리케이션을 실제로 사용하는 것과 동일한 환경에서 테스트를 진행.
    • 프론트엔드(웹, 모바일) → 백엔드 → 데이터베이스 → 외부 서비스 등 모든 구성 요소의 통합 동작 확인.

2. E2E 테스트의 목적

  1. 사용자 관점의 테스트:
    • 사용자가 애플리케이션을 사용하면서 마주칠 수 있는 모든 시나리오를 검증.
  2. 시스템 전체의 신뢰성 확인:
    • 프론트엔드, 백엔드, 데이터베이스 등 시스템 구성 요소 간의 통합 상태 확인.
  3. 버그 사전 탐지:
    • 단위 및 통합 테스트에서 발견되지 않은 전체 시스템의 결합 문제를 조기에 탐지.
  4. 운영 환경과 유사한 테스트:
    • 실제 배포 환경과 동일한 조건에서 테스트를 수행하여 배포 후 발생할 수 있는 문제를 예방.

3. E2E 테스트의 특징

특징 설명
테스트 범위 시스템의 전체 플로우 검증. 사용자와 애플리케이션의 모든 상호작용 확인.
실행 속도 단위 테스트와 통합 테스트보다 느림.
의존성 실제 시스템(데이터베이스, API, 네트워크 등)과의 상호작용 필요.
사용 사례 로그인, 회원가입, 결제 프로세스, 검색 기능 등 전체 플로우를 포함하는 기능 테스트.
자동화 도구 사용 Selenium, Cypress, Puppeteer, Playwright 등을 활용.

4. E2E 테스트의 장단점

장점 단점
실제 사용 시나리오를 기반으로 테스트하므로 사용자 관점에서 신뢰성을 높임. 테스트 속도가 느림.
시스템 전체의 결합 문제를 조기에 발견 가능. 설정 및 유지보수 비용이 높음.
운영 환경과 유사한 조건에서 실행 가능. 외부 의존성(네트워크, API)으로 인해 테스트 불안정성 증가.
주요 사용자 플로우를 검증하므로 배포 후 심각한 결함 발생 가능성을 줄임. 모든 경로를 테스트하려면 과도한 리소스가 필요.

5. E2E 테스트 작성 방법

5.1 주요 단계

  1. 테스트 환경 구성:
    • 운영 환경과 동일한 테스트 환경 준비.
    • 데이터베이스, API 서버 등 모든 구성 요소 설정.
  2. 사용자 플로우 정의:
    • 사용자가 실제로 수행할 시나리오 정의(예: 로그인 → 상품 검색 → 결제).
  3. 자동화 테스트 작성:
    • Selenium, Cypress 등의 자동화 도구를 사용해 테스트 작성.
  4. 테스트 실행 및 결과 검증:
    • 실행 후, 예상 결과와 실제 결과를 비교하여 테스트 통과 여부 확인.

5.2 E2E 테스트 예제 Cypress 예제

describe('User Login and Dashboard', () => {
    it('should allow the user to log in and view the dashboard', () => {
        // Visit the login page
        cy.visit('https://example.com/login');

        // Enter login credentials
        cy.get('#username').type('testuser');
        cy.get('#password').type('password123');
        cy.get('#login-button').click();

        // Verify redirection to dashboard
        cy.url().should('include', '/dashboard');

        // Check for welcome message
        cy.contains('Welcome, testuser');
    });
});

Selenium (Java) 예제

import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.By;
import org.openqa.selenium.chrome.ChromeDriver;

public class LoginTest {
    public static void main(String[] args) {
        System.setProperty("webdriver.chrome.driver", "/path/to/chromedriver");
        WebDriver driver = new ChromeDriver();

        try {
            // Visit login page
            driver.get("https://example.com/login");

            // Enter login credentials
            WebElement username = driver.findElement(By.id("username"));
            WebElement password = driver.findElement(By.id("password"));
            WebElement loginButton = driver.findElement(By.id("login-button"));

            username.sendKeys("testuser");
            password.sendKeys("password123");
            loginButton.click();

            // Verify redirection
            String currentUrl = driver.getCurrentUrl();
            if (currentUrl.contains("/dashboard")) {
                System.out.println("Test Passed: Dashboard loaded successfully.");
            } else {
                System.out.println("Test Failed: Dashboard not loaded.");
            }
        } finally {
            driver.quit();
        }
    }
}

6. E2E 테스트를 위한 도구

도구 특징
Cypress 빠르고 간단한 설정으로 프론트엔드 테스트 가능. JavaScript 기반.
Selenium 가장 널리 사용되는 브라우저 자동화 도구. 여러 언어(Java, Python 등) 지원.
Playwright Microsoft가 개발한 강력한 브라우저 자동화 도구. 크로스 브라우저 테스트 가능.
Puppeteer Node.js 기반, Chrome/Edge 브라우저 자동화 도구.

7. E2E 테스트 작성 시 주의사항

  1. 테스트 데이터 관리:
    • 테스트 실행 전, 초기 데이터를 설정하고, 테스트 후 정리(Clean-up).
  2. 운영 환경과 유사한 환경 사용:
    • 테스트 환경이 실제 환경과 다르면, 결과가 일관되지 않을 수 있음.
  3. 테스트 속도 최적화:
    • 불필요한 대기 시간 제거.
    • Mock 서버 사용을 통해 외부 의존성 제거.
  4. 결정적(Deterministic) 테스트:
    • 테스트 실행 결과가 항상 동일하도록 환경을 제어.
  5. 우선순위 정의:
    • 모든 사용자 플로우를 테스트하기 어렵기 때문에 핵심 플로우를 우선적으로 검증.

8. 단위 테스트, 통합 테스트와의 비교

  단위 테스트 (Unit Test) 통합 테스트 (Integration Test)  E2E 테스트 (End-to-End Test)
테스트 범위 개별 메서드 또는 클래스. 모듈 간의 상호작용 및 통합 검증. 시스템 전체 플로우.
실행 속도 가장 빠름. 단위 테스트보다 느림. 가장 느림.
외부 의존성 Mock으로 제거 가능. 외부 시스템 포함 가능. 실제 시스템 환경 필요.
사용 목적 개별 구성 요소의 동작을 검증. 구성 요소 간의 상호작용 확인. 사용자 관점에서 전체 시스템의 동작 검증.
복잡도 낮음. 중간. 높음.

9. 추가 학습이 필요한 주제

  1. 테스트 자동화 도구 심화:
    • Cypress, Selenium, Playwright 등의 고급 기능 학습.
  2. 테스트 데이터 관리:
    • 테스트 환경에서의 데이터 초기화 및 격리 전략.
  3. CI/CD 파이프라인 통합:
    • E2E 테스트를 자동화하여 지속적인 배포 파이프라인에 통합.
  4. 테스트 최적화 전략:
    • 테스트 실행 속도를 높이고, 불필요한 테스트를 줄이는 방법.

2.4 테스트코드를 구조적으로 깔끔하게 짜는 꿀팁

2.4.1 @Nested

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

@DisplayName("OrderService 단위 테스트")
class OrderServiceTest {

    private OrderService orderService = new OrderService();

    @Nested
    @DisplayName("주문 생성 테스트")
    class CreateOrderTests {

        @Test
        @DisplayName("정상적으로 주문을 생성한다.")
        void should_CreateOrder_Successfully() {
            Order order = orderService.createOrder("ProductA", 3);
            assertNotNull(order);
            assertEquals("ProductA", order.getProductName());
        }
    }

    @Nested
    @DisplayName("주문 조회 테스트")
    class GetOrderTests {

        @Test
        @DisplayName("존재하는 주문을 조회한다.")
        void should_ReturnOrder_When_OrderExists() {
            Order order = orderService.createOrder("ProductA", 3);
            Order retrievedOrder = orderService.getOrderById(order.getId());
            assertEquals(order, retrievedOrder);
        }
    }
}

 

2.4.2 @DisplayName

  • @DisplayName은 테스트의 목적이나 동작을 설명하는 이름을 추가
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.*;

@DisplayName("OrderService 테스트")
class OrderServiceTest {

    private OrderService orderService = new OrderService();

    @Test
    @DisplayName("주문 생성 시 상품 이름과 수량이 올바르게 설정된다.")
    void should_CreateOrder_With_CorrectDetails() {
        Order order = orderService.createOrder("ProductA", 3);
        assertNotNull(order);
        assertEquals("ProductA", order.getProductName());
        assertEquals(3, order.getQuantity());
    }
}

 

2.4.3 @BeforeEach / @AfterEach

  • @BeforeEach: 각 테스트 실행 전 공통 작업을 수행
  • @AfterEach: 각 테스트 실행 후 정리 작업을 수행
import org.junit.jupiter.api.*;

import static org.junit.jupiter.api.Assertions.*;

class OrderServiceTest {

    private OrderService orderService;

    @BeforeEach
    void setUp() {
        orderService = new OrderService();
    }

    @Test
    @DisplayName("주문 생성 시 상품 이름이 설정된다.")
    void should_CreateOrder_With_ProductName() {
        Order order = orderService.createOrder("ProductA", 3);
        assertEquals("ProductA", order.getProductName());
    }

    @AfterEach
    void tearDown() {
        System.out.println("테스트 정리 작업 실행");
    }
}

 

2.4.4 @TestMethodOrder + @Order

import org.junit.jupiter.api.*;
import org.junit.jupiter.api.MethodOrderer.OrderAnnotation;

import static org.junit.jupiter.api.Assertions.*;

@TestMethodOrder(OrderAnnotation.class) // @Order 어노테이션 기준으로 순서 지정
@DisplayName("OrderService 순서 지정 테스트")
class OrderServiceTest {

    private static OrderService orderService;

    @BeforeAll
    static void setUp() {
        orderService = new OrderService();
    }

    @Test
    @Order(1)
    @DisplayName("1. 주문을 생성한다.")
    void testCreateOrder() {
        Order order = orderService.createOrder("ProductA", 3);
        assertNotNull(order);
        assertEquals("ProductA", order.getProductName());
    }

    @Test
    @Order(2)
    @DisplayName("2. 주문을 조회한다.")
    void testGetOrder() {
        Order order = orderService.getOrderById(1L); // 1번 ID의 주문 조회
        assertNotNull(order);
        assertEquals("ProductA", order.getProductName());
    }

    @Test
    @Order(3)
    @DisplayName("3. 주문 상태를 변경한다.")
    void testUpdateOrderStatus() {
        Order updatedOrder = orderService.updateOrderStatus(1L, "COMPLETED");
        assertEquals("COMPLETED", updatedOrder.getStatus());
    }
}

 

2.4.5 @TestMethodOrder(MethodName.class)

import org.junit.jupiter.api.*;
import org.junit.jupiter.api.MethodOrderer.MethodName;

import static org.junit.jupiter.api.Assertions.*;

@TestMethodOrder(MethodName.class) // 메서드 이름 순서대로 실행
class OrderServiceTest {

    @Test
    void a_testCreateOrder() {
        System.out.println("1. 주문 생성");
    }

    @Test
    void b_testGetOrder() {
        System.out.println("2. 주문 조회");
    }

    @Test
    void c_testUpdateOrderStatus() {
        System.out.println("3. 주문 상태 변경");
    }
}

'기본기' 카테고리의 다른 글

클린 아키텍처 (Clean Architecture)  (1) 2025.02.23
리팩토링  (0) 2025.02.15
백엔드 로드맵  (0) 2025.01.06
Clean Cord  (4) 2024.12.05
이름 짓기 규칙  (0) 2024.12.05

+ Recent posts