TDD (Test-Driven Development)
1.1 TDD란 무엇인가?
- **TDD(Test-Driven Development)**란:
- 테스트 코드를 먼저 작성하고, 그 테스트를 통과하기 위한 구현 코드를 작성하는 개발 방법론.
- 코드 작성 전에 테스트를 통해 문제 정의와 요구사항을 명확히 이해.
- 핵심 개념:
- 테스트 작성 → 테스트 통과 → 코드 리팩토링.
- 코드 품질을 높이고, 안정성을 확보.
- TDD의 슬로건:
- “테스트부터 작성하자. 그리고 통과시키자!”
1.2 TDD의 기본 사이클: Red-Green-Refactor
- 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); // 실패! 왜? 구현 코드가 없으니까. } }
- Green (테스트 통과는 최소 노력으로):
- 테스트를 통과시키는 코드를 작성.
- 복잡하게 고민하지 않고, 테스트 요구사항만 충족.
public class Calculator { public int add(int a, int b) { return a + b; // 통과! } }
- 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.4 TDD의 장단점
장점 단점
코드 신뢰성과 품질 향상. | 초기 학습 곡선이 높음. |
요구사항 변경에도 코드 구조를 쉽게 유지 가능. | 테스트 작성에 시간이 추가적으로 소요. |
디버깅 시간 절약 및 문제 사전 예방. | 잘못된 테스트 설계 시 오히려 개발 속도 저하. |
자동화된 테스트로 코드 변경 시 빠른 피드백 가능. | 작은 프로젝트에서는 과도한 테스트 작성 가능성. |
1.5 DDD (Domain-Driven Design)와의 관계
- TDD:
- 코드를 테스트하면서 어떻게 구현할지에 초점.
- 코드 안정성과 품질을 높이기 위해 활용.
- DDD:
- 비즈니스 도메인을 설계하여 무엇을 구현할지에 초점.
- 요구사항을 분석하고, 도메인 모델을 설계하여 복잡한 비즈니스 로직을 제대로 반영.
- TDD와 DDD의 차이:
TDD | DDD | |
목적 | 코드의 동작 보장 | 비즈니스 요구사항을 시스템에 반영 |
초점 | 테스트 작성 → 코드 작성 → 리팩토링 | 요구사항 분석 → 도메인 설계 → 코드 작성 |
중요성 | 코드 안정성과 품질 확보 | 비즈니스 로직의 정확한 반영 |
1.6 TDD 실습 시 추가 학습이 필요한 주제
- 테스트 작성 기법:
- 단위 테스트(Unit Test), 통합 테스트(Integration Test)의 차이와 작성 방법.
- Mocking 라이브러리(예: Mockito, MockK) 활용.
- 테스트 자동화 도구:
- CI/CD 파이프라인에서 TDD를 통합하기 위한 Jenkins, GitHub Actions.
- 테스트 피라미드:
- 테스트 종류(단위 테스트, 통합 테스트, UI 테스트)와 비중.
- 테스트 커버리지:
- 코드의 어느 부분이 테스트되고, 어느 부분이 테스트되지 않는지 분석.
- 리팩토링 패턴:
- 코드 리팩토링 시 자주 사용하는 디자인 패턴과 사례.
결론
TDD는 코드 안정성과 품질을 높이는 데 중요한 역할을 하는 개발 방법론입니다. 코드를 작성하기 전에 테스트를 먼저 작성함으로써 요구사항을 명확히 하고, 불필요한 작업을 줄일 수 있습니다. 또한, TDD는 DDD와 함께 사용하면 비즈니스 로직 설계와 구현을 더욱 효율적으로 진행할 수 있습니다.
TDD를 실무에서 성공적으로 적용하기 위해서는 단위 테스트 작성 기법, Mocking, 테스트 피라미드와 같은 추가 개념을 학습하는 것이 중요합니다. 😊
테스트 코드
📌 “내 코드, 정말 제대로 작동할까?”라는 질문에 대한 답을 미리 준비하는 개발자의 무기
- 프로그램의 기능이 의도대로 동작 하는지 자동으로 확인
- 안만든다는 선택지도 분명 존재는 하나, 서버를 주력으로 다루는 백엔드에서 오류가 안나도록 테스트를 진행하는 것은 사실상 필수라고 봐야한다.
Mock
1. Mock이란?
- Mock(모의 객체):
- 테스트 환경에서 실제 객체를 대신하여 동작하는 가짜 객체. [실제 객체는 DB,네트워크와 같은 외부 의존성에 의해 움직일 수 있는데, 테스트 시에는 이런게 없으면 편하니 유사 객체를 만든 것]
- 외부 의존성을 제거하고, 테스트 대상 코드에만 집중할 수 있도록 도와줌.
- 객체의 특정 동작을 미리 정의하거나 호출 여부를 검증하는 데 사용.
2. Mock의 필요성
- 외부 의존성 제거:
- 데이터베이스, 네트워크, 파일 시스템 등 외부 시스템과 상호작용 없이 독립적인 테스트 가능.
- 테스트 성능 향상:
- 실제 객체 대신 Mock 객체를 사용하여 빠르고 가벼운 테스트 환경 구현.
- 예외 및 경계 조건 테스트:
- 실제 객체가 처리하지 못하는 특정 조건(예: 네트워크 장애, DB 연결 실패)을 Mock으로 시뮬레이션.
- 상태 검증:
- 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의 주요 활용 사례
- 데이터베이스(Mock DB):
- 실제 DB 연결 없이 테스트 수행.
- 데이터 삽입/조회/삭제를 Mock으로 처리.
- 네트워크 호출(Mock API):
- 외부 API의 응답을 Mock으로 설정하여 테스트.
- 예외 처리 테스트:
- 특정 메서드 호출 시 예외를 발생하도록 설정.
- 의존성 많은 서비스(Mock Dependency):
- 복잡한 의존성을 가진 클래스 테스트 시 간소화.
6. Mock 작성 시 주의사항
- 과도한 Mock 사용:
- Mock을 너무 많이 사용하면 테스트의 신뢰성이 떨어질 수 있음.
- 필요한 부분만 Mock하고, 가능한 실제 객체와 함께 테스트.
- 복잡한 동작 정의:
- Mock 객체의 동작이 지나치게 복잡하면 테스트 가독성 저하.
- 간단한 동작 정의를 선호.
- Mock 객체와 실제 객체의 혼용:
- 동일한 테스트에서 Mock 객체와 실제 객체를 혼합 사용 시 결과를 이해하기 어려워질 수 있음.
- 잘못된 동작 정의:
- 테스트 시 실제와 다르게 동작하도록 Mock을 정의하면 테스트 결과가 부정확해질 수 있음.
7. Mock 관련 추가 학습 주제
- 테스트 대역(Test Double):
- Mock, Stub, Spy, Fake, Dummy의 차이와 사용법.
- Mock 라이브러리 심화:
- Mockito(Java), MockK(Kotlin), Jest(JavaScript) 등 주요 라이브러리 활용.
- Mock을 활용한 통합 테스트:
- 단위 테스트와 통합 테스트에서 Mock의 적절한 사용 시점 학습.
- CI/CD와 Mock:
- CI/CD 파이프라인에서 Mock을 활용한 자동화 테스트.
- 비동기 및 동시성 테스트:
- 비동기 작업(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. 단위 테스트의 특징
- 독립성:
- 테스트는 서로 독립적으로 실행되어야 함.
- 하나의 테스트가 실패해도 다른 테스트에 영향을 주지 않아야 함.
- 빠른 실행:
- 단위 테스트는 작은 단위의 코드만 검증하므로 빠르게 실행.
- Mocking 활용:
- 외부 의존성을 제거하기 위해 Mock 객체를 사용.
- 예: 데이터베이스, 네트워크 요청 등을 실제로 호출하지 않음.
- 작은 범위:
- 클래스 또는 메서드 단위로 테스트 작성.
- 외부 시스템이나 다른 모듈과 상호작용하지 않음.
3. 단위 테스트의 장점
장점 | 설명 |
빠른 피드백 | 코드 변경 후 테스트를 통해 즉시 결과 확인 가능. |
버그 감소 | 개발 초기에 코드의 정확성을 검증하여 버그 발생 가능성을 줄임. |
리팩토링 안전성 보장 | 테스트가 작성된 코드는 리팩토링 시에도 기능이 보장됨. |
개발 속도 향상 | 초기에는 시간이 더 들지만, 유지보수 시간 단축으로 장기적으로 개발 속도가 향상됨. |
문서화 역할 | 테스트 코드 자체가 해당 코드의 사용 방법과 요구사항을 문서화하는 역할을 함. |
4. 단위 테스트 작성 방법
1) 단위 테스트 구성 요소
- Given (준비):
- 테스트 환경 및 필요한 데이터를 설정.
- When (실행):
- 테스트 대상 메서드 또는 기능을 호출.
- 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. 단위 테스트 작성 시 고려사항
- 단일 책임 원칙:
- 하나의 테스트는 하나의 기능만 검증해야 함.
- 테스트 케이스 명확화:
- 테스트 이름은 무엇을 테스트하는지 명확히 나타내야 함.
- 예: testAddition_ShouldReturnSum_WhenGivenTwoIntegers.
- Mock 객체 사용:
- 외부 의존성(데이터베이스, API 등)을 실제로 호출하지 않기 위해 Mocking 라이브러리 활용.
- 예: Mockito(Java), MockK(Kotlin).
- 테스트 독립성 보장:
- 각 테스트는 독립적으로 실행되어야 함.
- 테스트 순서에 따라 결과가 달라지지 않도록 작성.
- 실패 케이스 테스트:
- 정상 동작뿐 아니라, 예외 상황(Invalid Input, Null 등)도 검증.
6. 단위 테스트 도구 및 프레임워크
- Java:
- JUnit: 가장 널리 사용되는 테스트 프레임워크.
- Mockito: Mock 객체 생성 및 관리.
- Kotlin:
- JUnit5: Kotlin에서도 기본적으로 사용 가능.
- MockK: Kotlin용 Mocking 라이브러리.
- Python:
- unittest: 표준 라이브러리로 제공되는 테스트 프레임워크.
- pytest: Python의 강력한 테스트 도구.
- JavaScript:
- Jest: Facebook에서 개발한 JavaScript 테스팅 라이브러리.
- Mocha: Node.js 기반의 테스트 프레임워크.
7. 추가 학습이 필요한 주제
- Mocking과 Stub:
- Mock 객체를 활용해 단위 테스트에서 외부 의존성을 제거하는 방법.
- 테스트 자동화:
- CI/CD 파이프라인에서 단위 테스트를 자동화.
- 테스트 커버리지 도구:
- 코드 커버리지 분석 도구(예: JaCoCo, Coverage.py).
- TDD(Test-Driven Development):
- 테스트를 먼저 작성하는 방법론.
- Parameterized Test:
- 동일한 테스트를 다양한 입력값으로 실행.
8. 정리
항목 | 설명 |
단위 테스트란? | 애플리케이션의 가장 작은 구성 요소(메서드, 클래스 등)의 동작을 검증하는 테스트. |
특징 | 독립성, 빠른 실행, 작은 범위의 검증, Mocking 활용. |
장점 | 버그 감소, 코드 품질 향상, 리팩토링 안전성 보장, 빠른 피드백 제공. |
도구 | JUnit, MockK, Mockito, Jest, pytest 등. |
추가 학습 주제 | Mocking, 테스트 커버리지 분석, TDD, 테스트 자동화. |
통합 테스트 (Integration Test)
1. 통합 테스트란?
- 정의:
- 애플리케이션의 **여러 구성 요소(모듈, 서비스)**가 함께 동작할 때, 이들이 올바르게 상호작용하는지 검증하는 테스트.
- 단위 테스트가 개별 구성 요소를 테스트한다면, 통합 테스트는 그 구성 요소들 간의 통합 상태를 테스트.
2. 통합 테스트의 목적
- 구성 요소 간의 상호작용 검증:
- 데이터베이스, API, 파일 시스템, 외부 서비스와 같은 외부 의존성과의 통합 동작 확인.
- 통합 시 발생할 수 있는 문제 탐지:
- 개별적으로는 정상 작동하지만, 통합 시 발생하는 버그(데이터 불일치, 의존성 문제 등)를 식별.
- 시스템 신뢰성 확보:
- 모듈 간의 연결이 올바르게 동작하여, 전체 시스템이 요구사항을 충족하는지 보장.
3. 통합 테스트의 특징
항목 | 설명 |
테스트 범위 | 시스템의 여러 구성 요소 또는 모듈 간의 상호작용 검증. |
의존성 포함 | 데이터베이스, API, 메시지 큐 등 외부 시스템과의 통합 테스트를 포함. |
테스트 비용 | 단위 테스트보다 설정과 실행에 더 많은 리소스와 시간이 필요. |
실행 속도 | 단위 테스트보다 느리지만, E2E 테스트보다는 빠름. |
사용 사례 | 데이터베이스 CRUD 테스트, 외부 API 호출 검증, 서비스 간 데이터 흐름 테스트. |
4. 통합 테스트의 장단점
장점 | 단점 |
구성 요소 간의 호환성 검증 가능. | 테스트 환경 설정 및 관리가 복잡. |
외부 시스템과의 연동 문제를 사전에 발견. | 단위 테스트보다 실행 속도가 느림. |
시스템 전체의 신뢰성을 높임. | 외부 의존성(네트워크, 데이터베이스)에 따라 테스트 결과가 달라질 수 있음. |
실제 환경과 비슷한 테스트로 사용자 경험을 개선. | 특정 문제의 원인을 개별 구성 요소로 좁히는 데 어려움이 있을 수 있음. |
5. 통합 테스트 작성 방법
5.1 주요 단계
- 테스트 환경 설정:
- 실제 데이터베이스나 외부 API와의 상호작용을 위한 환경 구성.
- 테스트 전용 데이터베이스, Mock API 서버 설정.
- 데이터 초기화:
- 테스트 시작 전, 필요한 초기 데이터 설정.
- 테스트 종료 후, 데이터 정리(Clean-up).
- 테스트 실행:
- 구성 요소 간의 데이터 흐름과 상호작용 테스트.
- 결과 검증:
- 기대 결과와 실제 결과 비교.
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과 실제 객체의 사용
- Mock 사용:
- 외부 API나 비효율적인 작업(Mock API, Mock 데이터베이스 등) 테스트.
- 네트워크 장애, 예외 처리 시뮬레이션.
- 실제 객체 사용:
- 데이터베이스와의 CRUD 테스트.
- 실제 파일 시스템이나 외부 API와의 연동 확인.
7. 통합 테스트 시 주의사항
- 테스트 환경 설정:
- 테스트용 데이터베이스와 실제 데이터베이스를 분리.
- 환경 변수나 설정 파일로 테스트 환경 구성.
- 테스트 격리:
- 각 테스트가 독립적으로 실행되도록 설정.
- 테스트 실행 전후에 데이터를 초기화(Clean-up).
- 실행 시간 최적화:
- 불필요한 작업(Mock 사용) 줄이고, 실행 시간을 최소화.
- 실제 환경과 유사성:
- 가능한 실제 환경과 비슷한 조건에서 테스트 수행.
8. 통합 테스트와 단위 테스트의 차이
단위 테스트 (Unit Test) | 통합 테스트 (Integration Test) | |
테스트 범위 | 개별 구성 요소(메서드, 클래스 등). | 여러 구성 요소 또는 시스템 전체. |
실행 속도 | 빠름. | 느림. |
의존성 제거 | Mock 객체를 사용해 외부 의존성을 제거. | 실제 객체 또는 Mock을 혼합 사용. |
사용 목적 | 개별 구성 요소의 동작을 검증. | 구성 요소 간의 상호작용과 통합 검증. |
테스트 복잡도 | 상대적으로 단순. | 환경 설정 및 데이터 초기화가 필요하므로 복잡. |
9. 추가 학습이 필요한 주제
- Mock Server 활용:
- 외부 API 연동 테스트 시 Mock 서버 구현.
- TestContainers:
- Docker 컨테이너를 사용해 실제 데이터베이스, 메시지 큐 테스트.
- CI/CD 파이프라인 통합:
- 통합 테스트를 자동화하여 지속적인 배포 환경 구축.
- 테스트 격리 전략:
- 데이터 충돌을 방지하기 위한 테스트 데이터 격리 기법.
End-to-End 테스트 (E2E Test)
1. E2E 테스트란?
- End-to-End 테스트:
- 애플리케이션의 전체 플로우를 검증하는 테스트.
- 사용자가 애플리케이션을 실제로 사용하는 것과 동일한 환경에서 테스트를 진행.
- 프론트엔드(웹, 모바일) → 백엔드 → 데이터베이스 → 외부 서비스 등 모든 구성 요소의 통합 동작 확인.
2. E2E 테스트의 목적
- 사용자 관점의 테스트:
- 사용자가 애플리케이션을 사용하면서 마주칠 수 있는 모든 시나리오를 검증.
- 시스템 전체의 신뢰성 확인:
- 프론트엔드, 백엔드, 데이터베이스 등 시스템 구성 요소 간의 통합 상태 확인.
- 버그 사전 탐지:
- 단위 및 통합 테스트에서 발견되지 않은 전체 시스템의 결합 문제를 조기에 탐지.
- 운영 환경과 유사한 테스트:
- 실제 배포 환경과 동일한 조건에서 테스트를 수행하여 배포 후 발생할 수 있는 문제를 예방.
3. E2E 테스트의 특징
특징 | 설명 |
테스트 범위 | 시스템의 전체 플로우 검증. 사용자와 애플리케이션의 모든 상호작용 확인. |
실행 속도 | 단위 테스트와 통합 테스트보다 느림. |
의존성 | 실제 시스템(데이터베이스, API, 네트워크 등)과의 상호작용 필요. |
사용 사례 | 로그인, 회원가입, 결제 프로세스, 검색 기능 등 전체 플로우를 포함하는 기능 테스트. |
자동화 도구 사용 | Selenium, Cypress, Puppeteer, Playwright 등을 활용. |
4. E2E 테스트의 장단점
장점 | 단점 |
실제 사용 시나리오를 기반으로 테스트하므로 사용자 관점에서 신뢰성을 높임. | 테스트 속도가 느림. |
시스템 전체의 결합 문제를 조기에 발견 가능. | 설정 및 유지보수 비용이 높음. |
운영 환경과 유사한 조건에서 실행 가능. | 외부 의존성(네트워크, API)으로 인해 테스트 불안정성 증가. |
주요 사용자 플로우를 검증하므로 배포 후 심각한 결함 발생 가능성을 줄임. | 모든 경로를 테스트하려면 과도한 리소스가 필요. |
5. E2E 테스트 작성 방법
5.1 주요 단계
- 테스트 환경 구성:
- 운영 환경과 동일한 테스트 환경 준비.
- 데이터베이스, API 서버 등 모든 구성 요소 설정.
- 사용자 플로우 정의:
- 사용자가 실제로 수행할 시나리오 정의(예: 로그인 → 상품 검색 → 결제).
- 자동화 테스트 작성:
- Selenium, Cypress 등의 자동화 도구를 사용해 테스트 작성.
- 테스트 실행 및 결과 검증:
- 실행 후, 예상 결과와 실제 결과를 비교하여 테스트 통과 여부 확인.
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 테스트 작성 시 주의사항
- 테스트 데이터 관리:
- 테스트 실행 전, 초기 데이터를 설정하고, 테스트 후 정리(Clean-up).
- 운영 환경과 유사한 환경 사용:
- 테스트 환경이 실제 환경과 다르면, 결과가 일관되지 않을 수 있음.
- 테스트 속도 최적화:
- 불필요한 대기 시간 제거.
- Mock 서버 사용을 통해 외부 의존성 제거.
- 결정적(Deterministic) 테스트:
- 테스트 실행 결과가 항상 동일하도록 환경을 제어.
- 우선순위 정의:
- 모든 사용자 플로우를 테스트하기 어렵기 때문에 핵심 플로우를 우선적으로 검증.
8. 단위 테스트, 통합 테스트와의 비교
단위 테스트 (Unit Test) | 통합 테스트 (Integration Test) | E2E 테스트 (End-to-End Test) | |
테스트 범위 | 개별 메서드 또는 클래스. | 모듈 간의 상호작용 및 통합 검증. | 시스템 전체 플로우. |
실행 속도 | 가장 빠름. | 단위 테스트보다 느림. | 가장 느림. |
외부 의존성 | Mock으로 제거 가능. | 외부 시스템 포함 가능. | 실제 시스템 환경 필요. |
사용 목적 | 개별 구성 요소의 동작을 검증. | 구성 요소 간의 상호작용 확인. | 사용자 관점에서 전체 시스템의 동작 검증. |
복잡도 | 낮음. | 중간. | 높음. |
9. 추가 학습이 필요한 주제
- 테스트 자동화 도구 심화:
- Cypress, Selenium, Playwright 등의 고급 기능 학습.
- 테스트 데이터 관리:
- 테스트 환경에서의 데이터 초기화 및 격리 전략.
- CI/CD 파이프라인 통합:
- E2E 테스트를 자동화하여 지속적인 배포 파이프라인에 통합.
- 테스트 최적화 전략:
- 테스트 실행 속도를 높이고, 불필요한 테스트를 줄이는 방법.
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 |