0. 들어가기 앞서
1. 테스트 코드의 필요성
개발자는 Swagger, Postman 을 통해 직접 API를 호출하거나, QA 과정을 통해 완성된 소프트웨어를 테스트할 수도 있다.
하지만 프로젝트의 규모가 크고 새로운 기능 추가 시에는 비용이 많이 들게 된다.
- 테스트 코드를 사용하면, 새로운 기능 추가나 리팩토링 과정에서 기존 요구사항에 영향을 주는 부분을 빠르게 발견할 수 있다.
- 단기적으로 개발 속도가 느릴 수 있어도, 장기적으로는 굉장히 많은 비용을 아낄 수 있다.
2. 테스트 코드 작성 시 중요하게 생각해야 할 부분
1) 구현이 아닌 '행위 (Behavior)' 를 검증
- 테스트는 내부 코드 구조(How)가 아니라 외부로 드러나는 결과(What)를 검증해야 한다.
- 구현 방식을 검증하면, 코드 리팩토링 시 기능은 정상 동작해도 테스트가 깨지는 현상 (Fragile Test)이 발생한다.
2) 명확한 구조화 : Given - When - Then
- Given (준비) : 테스트에 필요한 상태/데이터 정의
- When (실행) : 검증할 동작/함수 호출
- Then (검증) : 예상 결과와 실제 결과 비교
3) 경계값과 예외 상황 (Edge & Negative Cases) 검증
- 성공하는 케이스보다 "실패하거나 예외가 발생하는 상황"을 잘 검증해야 테스트의 가치가 높다.
null, 빈값, 최대/최소 범위, 권한 없음 등 예외 상황 시나리오를 반드시 포함해야 한다.
4) 테스트 간의 완전한 격리 (Independence)
- 각 테스트는 독립적으로 실행 가능해야 하며, 실행 순서에 영향을 받지 않아야 한다. (메서드 단위)
- 공통 데이터베이스나 전역 변수를 공유해 다른 테스트 결과에 영향을 주는 부작용을 방지해야 한다.
5) F.I.R.S.T 원칙 체크
4. 테스트의 종류
- 단위 테스트 (Unit Test)
- 통합 테스트 (Integration Test)
- E2E 테스트 (End-to-End Test)
- 인수 테스트 (Acceptance Test)
⇒ 반드시 모든 종류의 테스트가 존재해야 하는 것은 아니다.
| 종류 | 범위 | 속도 | 주요 도구 |
|---|---|---|---|
| 단위 테스트 | 메서드 / 클래스 | ⚡ 빠름 | JUnit, Mockito |
| 통합 테스트 | 서비스 ↔ DB 레이어 | 🐢 보통 | @SpringBootTest |
| E2E 테스트 | 전체 시스템 Flow | 🐌 느림 | TestRestTemplate |
| 인수 테스트 | 비즈니스 요구사항 | — | BDD 시나리오 |
5. 단위 테스트 (with JUnit)
- 개념 : 시스템에서 가장 작은 단위 (메서드, 클래스) 가 의도대로 동작하는지 검증
- 주요 특징 : 외부 의존성 (DB, API, 네트워크 등) 을 가짜 객체 (Mock, Stub)로 대체하여 독립적으로 테스트함
- 실행 속도가 빠르며, 실시간 피드백 가능
단위 테스트의 경우는 현재 개인으로 진행 중인 프로젝트의 구현한 테스트 코드 일부를 가져옴 (완벽하지는 않을 수도 있다.)
@Test @DisplayName("예외: 존재하지 않거나 삭제된 회원이면 IllegalArgumentException 이 발생한다.") void 유저_조회_예외처리() { // given Long userId = 999L; given(userRepository.findByIdAndDeletedAtIsNull(userId)).willReturn(Optional.empty()); // when & then assertThatThrownBy(() -> userService.findUser(userId)) .isInstanceOf(IllegalArgumentException.class) .hasMessage("사용자를 찾을 수 없습니다. ID : " + userId); }
실제 없는 userId 가 입력되면 조회 시 Optional.empty() 인 빈 값이 나오도록 유도하였고, 해당 서비스의 findUser 메서드를 사용하여 제대로된 예외처리가 이루어지는지 확인하는 코드이다.
given 절에서 일반적인 when 을 사용하는 경우와 달리 given 이라는 키워드를 사용하였는데 이는 BDDMockito 를 사용한 예시이다.
참고 사항 — Mock 객체를 무조건 사용해야 하는 것은 아니다
@Test @DisplayName("성공 : 회원 정보 수정 정상 수행") void 유저_정보_수정() { // given Long userId = 1L; User realUser = User.builder() .username("박태우") .phoneNumber("010-1234-5678") .email("wootaepark@naver.com") .build(); given(userRepository.findByIdAndDeletedAtIsNull(userId)).willReturn(Optional.of(realUser)); ModifyUserReqDto reqDto = ModifyUserReqDto.builder() .phoneNumber("010-1111-2222") .build(); // when UpdateUserResDto response = userService.modifyUser(userId, reqDto); // then assertThat(realUser.getPhoneNumber()).isEqualTo("010-1111-2222"); assertThat(response).isNotNull(); }
유저 정보를 수정하고 해당 객체의 수정이 제대로 이루어졌는지 확인하기 위해서는 mock 객체보다는 실제 객체 사용이 필요하다.
6. 통합 테스트
- 개념 : 단위 모듈들이 서로 결합하여 함께 동작할 때 발생하는 상호작용 및 데이터 흐름을 검증
- 주요 특징
- 실제 DB 연동, 파일 시스템 접근, 외부 서비스와의 연결 부위에서 에러가 없는지 확인함.
- 단위 테스트보다 구성이 복잡하고 실행 시간이 상대적으로 오래 걸림
- 예시 : 회원 가입 서비스 실행 시, 실제 DB 테이블에 사용자 레코드가 올바르게 저장되는지 검증
@SpringBootTest // Spring Context 전체를 로드하여 통합 테스트 환경 구축 @Transactional // 테스트 완료 후 DB 데이터를 자동으로 Rollback class UserServiceIntegrationTest { @Autowired private UserService userService; @Autowired private UserRepository userRepository; @Test @DisplayName("회원가입 신청 시 DB에 정상 저장되고 DTO가 반환되어야 한다") void registerUser_Success() { // Given UserRegisterRequest request = new UserRegisterRequest("hong", "hong@example.com"); // When UserResponse response = userService.registerUser(request); // Then assertThat(response.getId()).isNotNull(); assertThat(response.getName()).isEqualTo("hong"); // DB 직접 조회 검증 User savedUser = userRepository.findById(response.getId()).orElse(null); assertThat(savedUser).isNotNull(); assertThat(savedUser.getEmail()).isEqualTo("hong@example.com"); } }
실제 데이터베이스와 연결하여 (Service → Repository → DB) 로 이어지는 레이어 간 연동을 검증한다.
실행 시 실제 Spring Boot 어플리케이션이 실행되며 실행 속도가 느린 편이다. (+ 실제 객체 사용)
7. E2E 테스트
- 개념 : 사용자 관점에서 시스템의 처음부터 끝까지 전체 흐름(Flow)이 올바르게 작동하는지 검증
- 주요 특징
- UI, 백엔드 API, DB 등 실제 운영 환경과 유사한 조건에서 진행
- 시스템의 전체적인 신뢰도는 높여주지만, 실행 속도가 가장 느리고 테스트 유지보수 비용이 높다.
- 예시 : 로그인 → 아이디/비번 입력 → 대시보드 이동 → 장바구니 → 결제 까지의 전체 흐름을 브라우저 자동화 도구로 검증
// RANDOM_PORT: 실제로 톰캣(Embedded Tomcat) 서버를 포트에 띄움 @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class OrderE2ETest { // 실제 HTTP 요청을 보낼 클라이언트 객체 @Autowired private TestRestTemplate restTemplate; @Autowired private OrderRepository orderRepository; @Test @DisplayName("[E2E] 실제 HTTP POST 요청을 보내 주문을 생성하고 DB 저장까지 확인한다") void createOrder_E2E_Success() { // Given OrderRequest request = new OrderRequest("ITEM-001", 2, 30000); // When: 실제 서버 엔드포인트 `/api/orders` 로 HTTP POST 요청 전송 ResponseEntity<OrderResponse> response = restTemplate.postForEntity( "/api/orders", request, OrderResponse.class ); // Then 1: HTTP 응답 상태 및 Body 검증 assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat(response.getBody()).isNotNull(); assertThat(response.getBody().getItemCode()).isEqualTo("ITEM-001"); assertThat(response.getBody().getTotalPrice()).isEqualTo(60000); Long createdOrderId = response.getBody().getOrderId(); // Then 2: 실제 DB에 데이터가 올바르게 영속화되었는지 조회하여 확인 Order savedOrder = orderRepository.findById(createdOrderId).orElse(null); assertThat(savedOrder).isNotNull(); assertThat(savedOrder.getQuantity()).isEqualTo(2); } }
통합 테스트와 같이 실제 객체들로 테스트하며, 사용자 입장에서 하나의 서비스 flow 전체를 테스트하는 것과 같다.
여러 의존성 및 라이브러리를 가져와서 사용하기 때문에 제일 무거운 테스트
8. 인수 테스트
- 개념 : 시스템이 비즈니스 요구사항을 충족했는지 사용자 또는 고객(PO/PM) 관점에서 검증
- 주요 특징
- "기술적으로 잘 작동하는가?" 보다 "사용자가 요청한 기능 명세대로 동작하는가?" 에 초점
- 시나리오 기반(BDD)으로 작성하는 경우가 많으며, 해당 테스트 통과 시 배포/수락 가능한 상태로 판단
- 예시 : "일반 회원이 쿠폰을 적용하면 최종 결제 금액에서 10% 가 할인되어야 한다" 라는 기획 요구사항을 검증
코드로 작성되기보다는 비즈니스 테스트 관점이 더 크다.
9. 마치며
기존에는 무조건 단위 테스트 위주로 테스트를 진행해야겠다고 생각했지만, 공부해보면서 무조건 테스트 코드를 작성하기보다는 꼭 필요한 곳에 대한 테스트를 적재적소에 사용해야겠다는 생각이 들었다.
앞으로도 더 많은 프로젝트를 진행하며 이번 이론을 바탕으로 실제 테스트 코드를 더 많이 경험하고 적용하고 싶다.
다음 포스트에는 단위 테스트 중에서 제일 많이 사용되는 JUnit 사용 방법에 대해서 자세히 알아 볼 것이다.
- Gemini
- https://jhzlo.tistory.com/28
'Spring boot' 카테고리의 다른 글
| [Spring] 환경 변수 관리 (with @Value, @ConfigurationProperties) (0) | 2026.07.03 |
|---|









