https://product.kyobobook.co.kr/detail/S000001248962
테스트 주도 개발 시작하기 | 최범균 - 교보문고
테스트 주도 개발 시작하기 | 작동하는 깔끔한 코드를 만드는 데 필요한 습관 - JUnit 5를 이용한 테스트 주도 개발 안내 - 테스트 작성과 설계를 위한 대역 - 테스트 가능한 설계 방법 안내 - 유지
product.kyobobook.co.kr
1. 테스트 범위
1-1. “테스트 범위”
테스트 범위 = 테스트를 할 때 실제 시스템 구성요소를 어디까지 묶어서 하나의 시나리오로 돌릴 것인가
일반적인 웹 서비스 기준으로 테스트에 등장할 수 있는 구성요소
- 브라우저 / 모바일 앱 (프런트)
- HTTP 서버, 웹 프레임워크 (Spring MVC 등)
- 서비스, 도메인, 리포지토리 코드
- DB, 캐시, 메시지 큐, 외부 API 서버
- 설정 파일, 스키마, HTML/JS/CSS 등 프런트 리소스
하나의 “회원가입 기능”이 제대로 동작하려면 실제로는 이 모든 게 맞물려야 한다.
근데 테스트는 항상 이 모든 걸 한 번에 다 묶어서 테스트할 필요는 없다
그래서 “테스트 범위”라는 말을 쓰는 거고,
대표적으로
- 기능(E2E) 테스트
- 통합 테스트
- 단위 테스트
2. 기능 테스트 / E2E 테스트
2-1. 기능 테스트(Functional Test) 정의
기능 테스트는 간단히 사용자 입장에서, 시스템이 제공하는 기능이 올바르게 동작하는지 확인하는 테스트.
예를 들어 회원가입 기능을 기능 테스트로 검증하려면:
- 브라우저나 모바일 앱에서
- 회원가입 화면을 열고
- 필수 필드를 채우고
- “가입” 버튼을 누른 다음
- DB에 회원이 잘 들어갔는지, 화면에 성공 메시지가 나오는지 등을 확인
즉, 끝에서 끝까지(End to End) 실제 사용 플로우를 따라가기 때문에 기능 테스트는 곧 E2E 테스트라고도 불림
2-2. 특징
- 범위가 가장 넓다
- 브라우저/앱 ~ 서버 ~ DB ~ 외부 시스템까지 실제로 얽힘
- 사용자 시나리오 그대로 테스트한다
- QA가 수동으로 하는 시나리오 테스트도 여기에 포함 가능
- 준비 비용이 크다
- 테스트 데이터를 준비하고,
- 웹 서버/앱을 띄우고,
- 프론트, 백엔드, DB 설정을 다 맞춰야 함
- 실행 시간이 길다
- UI 오픈, HTTP 왕복, DB IO 등등
그래서 E2E 테스트는 “다양한 예외 케이스를 빡빡하게 커버”하는 용도라기보다,
- 주요 시나리오 (해피 패스, 대표 실패 케이스 등)
- 릴리즈 전 최종 검증 에 가깝게 가져가야 한다
3. 통합 테스트
3-1. 통합 테스트
시스템의 여러 구성 요소들이 서로 잘 연동되는지 확인하는 테스트.
예를 들어 웹 앱에서
- 서비스 클래스 + 리포지토리 + DB
- REST 클라이언트 + 외부 서버 (대역 포함)
- 스프링 컨테이너 + 설정 + Bean 초기화
이런 정도의 범위를 묶어 한 번에 테스트하는 걸 의미
- 기능 테스트
- 사용자 관점, 브라우저/앱까지 포함
- 통합 테스트
- 주로 서버-side 코드 관점
- 컨트롤러~DB 또는 하나의 모듈~외부 시스템 정도 범위를 테스트
3-2. 예시: 서비스 레벨 통합 테스트
“회원가입 서비스(UserRegister)” 같은 걸 테스트할 때
- 스프링 부트 컨테이너를 띄우고 (실제 빈 등록)
- 진짜 DB(H2, MySQL 등)에 연결한 뒤
- UserRegister.register()를 호출하고
- DB에 데이터가 잘 들어갔는지 JdbcTemplate 로 직접 확인
→ 스프링 + DB 통합 테스트
4. 단위 테스트
한 클래스, 한 메서드 같은 작은 코드 단위가 기대대로 동작하는지 확인하는 테스트.
특징:
- 일반적으로 외부 의존성은 대역(Stub/Fake/Mock) 으로 대체
- 테스트 대상 메서드의 입력/출력(또는 상태 변화)를 중심으로 검증
- 테스트 실행 속도가 매우 빠름(서버/컨테이너/DB 안 띄워도 되기 때문)
5. 테스트 범위 간 차이
5-1. 준비 비용
- 기능 테스트
- 웹 서버/앱 구동
- 브라우저/디바이스 준비
- 실 DB/외부 시스템까지 세팅 필요
- → 준비 비용 가장 큼
- 통합 테스트
- DB, 캐시, 메시지 브로커, 스프링 컨테이너 등 필요
- 웹 UI까지는 안 건드릴 수도 있지만, 그래도 환경 세팅이 많음
- 단위 테스트
- JUnit + 코드만 있으면 됨
- 외부 의존성은 대역으로 처리
- → 준비 비용 최소
5-2. 실행 속도
- 기능 테스트: 제일 느림
- 브라우저 구동/렌더링, 전체 HTTP, DB 등
- 통합 테스트: 그 다음
- 스프링 컨테이너 초기화, DB IO
- 단위 테스트: 압도적으로 빠름
- 순수 자바 코드 실행 + 메모리 상 대역만 사용
가능하면 많은 상황을 단위 테스트에서 다루고, 통합/기능 테스트는 “핵심 시나리오” 위주로 가져가라.
5-3. 표현할 수 있는 상황
- 외부 시스템과 연동하는 기능은 통합/기능 테스트 만으로는, 특정 예외 상황(타임아웃, 에러 코드 등)을 만들기 어렵다.
- 예를 들어 “외부 서버가 계속 타임아웃 나는 상황”을 실제 서버에게 매번 부탁할 수는 없음.
- 이런 경우엔 단위 테스트 + 대역(Stub/Mock/WireMock) 을 조합해서 상황을 마음대로 구성해야함.
6. 테스트 범위 vs 테스트 개수 및 시간 전략
6-1. 모든 걸 통합/기능 테스트로 커버하면?
- 예외 상황까지 전부 통합/기능 테스트로만 커버하면, 테스트 개수는 상대적으로 적게 보일 수 있다.
- 한 통합 테스트가 많은 로직을 한 번에 커버하기 때문
- 하지만:
- 테스트 한 개당 실행 시간이 길다.
- 설정/데이터 정리도 많이 필요합니다.
- 전체 테스트 수행 시간이 길어져서 “귀찮아서 테스트 안 돌리게 되는” 상황이 생기기 쉽다.
6-2. 단위 테스트 중심 전략
- 다양한 상황(예외, 경계값, 복잡한 조합) 은 가급적 단위 테스트로 커버
- 통합/기능 테스트는
- 핵심 플로우(해피 패스 + 대표 실패 케이스) 위주
- 프레임워크 설정, DB 연동, 실제 API JSON 포맷 같은 것 위주 검증
이렇게 하면 전체 테스트 수는 많아질 수 있지만, 대부분이 빠른 단위 테스트이기 때문에 피드백 속도는 오히려 빨라진다.
7. 외부 연동이 필요한 테스트 예
7-1. 스프링 부트와 DB 통합 테스트
7-1-1. 구성
프링 부트 애플리케이션에서 아래 Bean 들이 등록되어 있다고 가정
- UserRegister (회원 가입 서비스)
- SimpleWeakPasswordChecker (약한 비밀번호 체크 구현)
- UserRepository (Spring Data JPA)
- VirtualEmailNotifier (테스트용 이메일 알림 구현)
UserRegisterIntTest 라는 통합 테스트 클래스에서:
- @SpringBootTest 로 스프링 컨테이너를 띄우고
- @Autowired 로 UserRegister 와 JdbcTemplate 를 주입받아요
7-1-2. 시나리오 1 – 중복 ID 존재 시 예외
통합 테스트에서 “이미 같은 ID가 있을 때 가입하면 예외가 발생” 하는지 확인하기 위해:
- 상황 만들기
- JdbcTemplate.update() 로 user 테이블에 ID가 "cbk"인 행을 강제로 insert
- 실제로 이미 데이터가 있어도 에러 안 나게 ON DUPLICATE KEY UPDATE 같이 처리
- 실행
- register.register("cbk", "strongpw", "email@...") 호출
- 결과 확인
- DupIdException 이 발생하는지 assertThrows() 로 검증
- 통합 테스트에서는 실제 DB를 조작해서 상황을 만들고 다시 DB 상태를 조회하거나 예외를 확인해 결과를 검증
7-1-3. 시나리오 2 – 없는 ID면 정상 저장
두 번째 테스트는 “ID가 없으면 잘 저장되는지” 확인
- 상황 만들기
- delete from user where id = 'cbk' 같은 쿼리로 해당 ID 레코드 삭제
- 실행
- register.register("cbk", "strongpw", "email@...")
- 결과 확인
- select * from user where id='cbk' 로 조회해
- email 컬럼 값이 기대한 값인지 assertEquals
여기서 강조하는 점 통합 테스트는 실제 DB를 사용하므로, 같은 테스트를 여러 번 돌려도 결과가 같도록 DB 상태를 테스트 안에서 직접 통제해야 한다.
7-1-4. 단위 테스트와 비교
같은 요구사항을 단위 테스트로 하면 UserRepository 를 진짜 DB가 아닌 MemoryUserRepository (Fake)로 사용
- 중복 ID 상황:
- Fake repo에 fakeRepository.save(new User("id", ...)) 한 번 넣어두고,
- userRegister.register("id", ...) 에서 예외 발생 여부만 확인
- ID가 없는 상황:
- Fake repo가 비어 있는 상태에서 register() 호출 후
- Fake repo 안 데이터만 검사하면 됨
- 통합 테스트
- DB insert/delete 쿼리 직접 실행 + 컨테이너 구동
- 상대적으로 느림, 설정 부담 큼
- 단위 테스트
- 메모리에서 객체만 만지면 됨
- 매우 빠르고 가볍다
7-2. WireMock을 이용한 REST 클라이언트 테스트
7장에서 만들었던 CardNumberValidator 를 실제 HTTP 통신까지 포함한 통합 테스트로 검증
7-2-1. CardNumberValidator 코드 요약
CardNumberValidator 는 외부 카드사 API에 HTTP POST를 보내서 카드번호 유효성을 확인하는 역할
- 생성자에서 서버 주소를 받는다.
- validate(cardNumber) 에서 POST {server}/card 로 카드번호를 text/plain body로 전송
- 응답 body 문자열이
- "ok" → VALID
- "bad" → INVALID
- "expired" → EXPIRED
- "theft" → THEFT
- 그 외 → UNKNOWN
- 3초 타임아웃 설정, 타임아웃 시 TIMEOUT, 기타 예외는 ERROR 등으로 매핑
- 응답 body 문자열이
이 코드는 실제 HttpClient 를 사용해 요청, 응답을 처리합니다.
7-2-2. 문제의식
이걸 통합 테스트하려면 실제 카드사 서버가 있어야 하고, 응답을 "ok", "expired", "bad", "theft", 타임아웃 등으로 원하는 대로 제어할 수 있어야 한다.
하지만 현실적으로 외부 업체에 “테스트마다 5초씩 늦게 응답해주세요” 라고 부탁할 수 없고, 운영 환경과 테스트 환경을 구분하기도 쉽지 않을 수 있음.
그래서 WireMock 을 사용해 HTTP 서버 자체를 대역으로 띄움
7-2-3. WireMockServer 테스트 구조
CardNumberValidatorTest의 구조
- @BeforeEach
- wireMockServer = new WireMockServer(port) 로 서버 생성
- wireMockServer.start() 로 실제 HTTP 서버 시작
- 각 @Test 메서드 안에서
- stubFor(...) 로 요청 → 응답 규칙 정의
- CardNumberValidator 를 new CardNumberValidator("<http://localhost:8089>") 처럼 생성
- validate("1234567890") 호출
- 결과 enum 이 기대값인지 assertEquals 로 검증
- @AfterEach
- wireMockServer.stop() 으로 서버 정리
7-2-4. 시나리오 예시 – 유효 카드 / 타임아웃
- 유효한 카드번호 테스트
- WireMock 설정:
- URL /card
- POST 요청
- Body "1234567890" 이 들어오면
- Header Content-Type: text/plain, Body "ok" 으로 응답하도록 stub
- CardNumberValidator가 이 서버로 요청을 보내면 응답 body "ok" → CardValidity.VALID 가 되어야 함
- 테스트에서 assertEquals(CardValidity.VALID, validity) 확인
- 타임아웃 상황 테스트
- WireMock 설정:
- URL /card, POST 요청에 대해 응답 지연 withFixedDelay(5000) (5초) 같은 식으로 설정
- CardNumberValidator는 3초 타임아웃으로 설정되어 있으므로
- HttpTimeoutException 발생 → 내부에서 CardValidity.TIMEOUT 반환해야 함
- 테스트에서 assertEquals(CardValidity.TIMEOUT, validity) 로 검증
WireMock으로 HTTP 서버 자체를 대역으로 띄우면,실제 네트워크 IO + HTTP 클라이언트 코드까지 포함한 테스트를 원하는 상황(응답/지연/에러) 별로 마음대로 구성할 수 있다.
7-3. 스프링 부트 내장 서버를 이용한 API 기능 테스트
“스프링 부트 내장 톰캣 + TestRestTemplate”를 사용해 실제 HTTP API 레벨에서 기능(E2E) 테스트
7-3-1. 시나리오: 약한 비밀번호일 때 회원가입 API 응답 검증
- 모바일 앱이 POST /users API를 통해 회원가입을 요청
- 비밀번호가 약하면 서버가 400(BAD_REQUEST)와 함께 에러 정보를 JSON으로 응답
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT
)
public class UserApiE2ETest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void weakPwResponse() {
// 1) JSON 요청 바디 작성
// 2) RequestEntity.post(URI.create("/users")) 로 요청 객체 생성
// 3) restTemplate.exchange(...) 로 실제 HTTP 호출
// 4) 상태코드가 400인지, 응답 body에 예외명이 포함되는지 검증
}
}
- @SpringBootTest(webEnvironment = RANDOM_PORT)
- 내장 서버(예: 내장 톰캣)를 랜덤 포트로 띄워서 실제 웹 애플리케이션 구동
- TestRestTemplate
- 스프링 부트가 테스트용으로 제공하는 HTTP 클라이언트
- 위 랜덤 포트에 자동으로 붙어서 요청을 날림
- 테스트는 실제로 HTTP 요청을 보내고 응답을 받기 때문에 컨트롤러, 서비스, 예외 핸들러, JSON 직렬화 등 웹 API 레이어 전체를 한 번에 검증하는 기능(E2E) 테스트가 됨
핵심정리
- 테스트 범위는 세 가지
- 기능(E2E) 테스트: 브라우저/앱~서버~DB~외부 시스템까지 “사용자 플로우” 전체 검증
- 통합 테스트: 여러 서버-side 구성 요소(프레임워크, 서비스, DB, 외부 연동 등)가 제대로 연동되는지 테스트
- 단위 테스트: 한 클래스/메서드 단위의 동작을 대역을 이용해 빠르게 검증
- 범위가 넓을수록
- 준비 비용커지, 실행 속도낮아짐
- 하지만 실제 환경에 더 가깝게 검증 가능
- 예외 상황/복잡한 조합은 오히려 단위 테스트+대역으로 더 잘 다룰 수 있음
- 테스트 전략
- 다양한 경우의 수와 예외 상황은 단위 테스트에서 최대한 커버
- 통합/기능(E2E) 테스트는 주요 플로우, 설정, 실제 연동 동작 위주
- 그래야 전체 테스트 시간이 감당 가능한 수준으로 유지되고, TDD 피드백 루프도 빠르게 유지할 수 있다.
- 외부 연동 테스트 실전 예
- 스프링 부트 + DB 통합 테스트
- 실제 DB를 쓰되, 테스트 코드에서 insert/delete 로 상황을 제어
- WireMock으로 REST 클라이언트 테스트
- HTTP 서버 자체를 대역으로 띄워 응답/지연을 마음대로 조작
- 스프링 부트 내장 서버 + TestRestTemplate
- 실제 HTTP API 레벨에서 E2E 테스트를 JUnit 안에서 수행
- 스프링 부트 + DB 통합 테스트
'외부활동 > 우아한테크코스 [프리코스]' 카테고리의 다른 글
| TDD 핵심 정리 (0) | 2025.11.08 |
|---|---|
| 테스트주도 개발 시작하기 - 챕터 10~11. 테스트 코드와 유지 보수 & 마무리 (0) | 2025.11.08 |
| 테스트주도 개발 시작하기 - 챕터 8. 테스트 가능한 설계 (0) | 2025.11.06 |
| 테스트주도 개발 시작하기 - 챕터 7. 대역 (0) | 2025.11.06 |
| 테스트주도 개발 시작하기 - 챕터 6. 테스트 코드 구성 (0) | 2025.11.05 |