📝 개요
이번 프로젝트는 Spirng Boot 기반의 REST API 서버를 구축하고 기본을 익히는 과정이다. 초반에는 각 도메인에 대한 CRUD 를 개발하고 기능을 추가하는데 집중했지만, 점점 기능이 많아지고 API 요청/ 응답 케이스가 다양해지면서, 직접 서버를 띄워 일일이 Postman 으로 요청을 날리는 방식으로는 한계를 느꼈다.
API 마다 다양한 예외 상황과 비즈니스 로직을 수동으로 검증하다보니 작업 속도도 느려지고, 코드 수정 시 기존 기능이 깨지는 경우도 있었다. 그래서 REST API 서버를 구축하면서 테스트 코드라는 것을 알게 되었고, 이를 적용하여 개발 속도를 높이고 각 예외에 대해서 더 견고하게 확인하고 설계하기 위해 도입했다.
다음은 프로젝트에 적용된 Spirng Boot 기반의 백엔드 서버에서의 REST API 에 대한 테스트 코드를 설명한다.
🚀 테스트 코드
1️⃣ 개념
테스트 코드는 소프트웨어가 의도한 대로 동작하는지 자동으로 검증하는 코드이다. 테스트를 직접 수동으로 하는게 아니라 코드를 통해 자동으로 실행하여 결과를 체크하기 때문에 실수를 줄이고 서비스 품질을 높일 수 있다. 테스트 코드를 작성함으로써 자동화와 효율성을 챙길 수 있고 요구사항에 대한 명확한 정리도 자연스럽게 이루어진다.
Spirng 기반 백엔드 프로젝트에서의 테스트 코드는 단순히 “함수가 제대로 동작하나?” 의 수준이 아니고 Spring 의 다양한 컴포넌트/레이어 (Controller, Serivce, Repository 등), 그리고 실제 웹 요청, DB 연동, 인증/권한, 트랜잭션 등 “실서비스 환경” 과 유사한 다양한 조건까지 자동으로 검증할 수 있는 엔터프라이즈용 품질 보증 도구라고 할 수 있다.
[ Spirng 백엔드에서의 테스트 코드 ]
- 계층별(레이어별) 독립 테스트 가능
- 단순 자바 메서드 단위(unit test) 테스트
- Controller(HTTP 요청 / 응답) 테스트
- Service(비즈니스 로직) 테스트
- Repository / Mapper (DB 연동) 테스트
- 통합(Integration) 등 레이어별 동작과 상호작용 테스트
- 스프링 DI, 빈 주입, 시큐리티, 트랜잭션까지 포함
- 단순 객체가 아니라 스프링의 IoC 컨테이너가 실제로 빈을 주입/관리하는 상황에서 각종 AOP(트랜잭션), 시큐리지(권한), 검증, 실제로 돌아가는 환경과 거의 동일하게 테스트 가능
- 자동화된 검증 & CI/CD 연동
- 모든 테스트는
@Test등 어노테이션 기반으로 실행되어Build, 배포, PR, merge단계에서 자동 실행됨 - 코드 퀄리티와 배포 안정성 확보 가능
- 모든 테스트는
2️⃣ 종류
실제로는 테스트라는 큰 틀안에서 정말 많은 테스트들이 분류되고, 테스트 코드들이 정의 되는데 지금 이 부분에서는 Spring 기반 백엔드 기준으로 설명한다.
- 단위테스트 (Unit Test)
- 가장 작은 코드 단위(메서드, 클래스 등) 외부 환경과 분리해서 검증
Service에서Mapper, 외부 API 호출 등은 모두Mock으로 대체하고 로직 자체만 테스트- 비즈니스 로직이 정확히 동작하는지 확인, 버그가 발생해도 어디서 발생한지 빠른 추적 가능
- 테스트가 빠르고, 코드 변경 시 원인 추적에 용이하며 외부 영향이 없음 (실제 DB, 네트워크 미사용)
- 너무 세분화 하거나
Mock으로 대체된 부분이 많을수록 진짜 서비스 환경과 차이 발생 - TDD/리팩토링 등 빈번한 코드 수정 구간에서 적극 사용
- 통합 테스트 (Integration Test)
- 여러 컴포넌트(
Controller-Service-Repository) 또는 실제 DB/외부 시스템 등 전체 흐름 검증 @SpringBootTest등으로 스플이 전체 환경을 띄우고 진짜 DB/트랜잭션/빈주입/시큐리티 등 실제 서비스와 유사하게 테스트- 각 레이어/의존성이 제대로 연동되는지, 실제 DB/외부 API 연동, 트랜잭션 롤백 등 서비스 전체 품질 체크
- Mock 없이 실제 환경 테스트 가능, 실서비스와 매우 유사, 배포 전 장애/문제를 조기 발견 가능
- 테스트 속도가 느리고 데이터 세팅, 환경 셋업이 복잡할 수 있음
- 배포 전 전체 품질/연동 테스트, 비즈니스 흐름 (회원가입-로그인-조회 등) 자동 검증 시 사용
- 여러 컴포넌트(
- 컨트롤러(웹 레이어) 테스트 (Web/Controller Test)
- HTTP 요청/ 응답, 파라미터/유효성, 에러처리, 인증 등 컨트롤러 계층의 API 가 정상 동작하는지 테스트
@WebMvcTest + MockMvc등으로 컨트롤러만 단독 실행, 나머지는 Mock 처리- API 스펙, 입력값 검증, 응답 형태, 인증/권한 체크, 에러 핸들링 등 API 품질 보증
- 컨트롤러 로직만 집중, 빠른 실행 가능
MockMvc로 실제 HTTP 요청/응답과 유사하게 검증 가능- 필드/파라미터 유효성, 예외처리, 시큐리티 핸들링까지 커버
DB Service로직은 Mock 으로 대체함 (실제 연동은 하지 않음)- API 개발/변경 시 외부 계약(API 스펙) 보장, 입력값 유효성, 에러 포맷 등 UI/FE 연동 테스트 시 사용
- 엔드투엔드 (E2E, 인수 테스트, 시나리오 테스트)
- 실제 유저 관점에서, 시스템 전체(프론트백 ~ DB ~ 외부 API) 시나리오대로 동작하는지 자동화 검증
- Spirng 단독보다는
Cypress, Selenium, RestAssured등 도구화 병행 가능 - 실서비스 관점의 완전한 시나리오 품질 보장 / 진짜 배포 환경과 유사하고 복잡한 인수/사용자 시나리오 까지 검증
- 테스트 속도가 느리고 환경/데이터/의존성이 복잡함, 운영 환경과 동기화 문제
- 전체 시나리오 QA, 주요 기능 릴리즈 전 최종 품질 점검
🚀 프로젝트 적용
1️⃣ 테스트 코드 작성 기준/패턴
Groupware 프로젝트에서는 서비스 코드에 대한 단위 테스트(비즈니스 로직 검증) 와 컨트롤러 코드에 대한 컨트롤러 테스트(API 요청 및 응답, 유효성 검증)을 진행한다.
[ 네이밍 규칙 ]
- 테스트 클래스명 :
{테스트 대상 도메인 서비스/컨트롤러}Test로 작성 - 클래스명 :
@DisplayName(”도메인 기능”) {도메인_기능} - 메서드명 :
@DisplayNmae(”도메인 기능” : 성공/실패 - 케이스)성공/실패_실패케이스
[ 테스트 결과 DisplayName 표시 화면 ]

[ 정리 ]
- 기능별 그룹핑 (
@Nested) 사용, 성공/실패 케이스 분리, 독립적인 예외 조건으로 테스트 - Mock/BDD 스타일 테스트 코드 패턴
- Service 테스트 : 외부 의존성 (Mock) 으로 대체, DB, 외부 API는 전부 Mocking
- Controller 테스트 : MockMvc로 HTPP 요청/응답을 실제처럼 검증 / 서비스는
MockitoBean으로 대체
BDD(Behavior-Driven Development) 란?
BDD는 행위 주도 개발이라고 번역하며 시스템(코드)이 어떻게 동작(행위/시나리오) 해야 하는지 자연어로 명확하게 표현하고, 그에 맞춰 테스트 코드를 먼저 작성해서 개발/테스트/협업의 기준점을 만드는 개발 방법론이다.”이 기능이 이런 상황에서 이런 결과가 나와야 한다” 를 사람(기획/개발/테스트) 모두가 이해할 수 있게 명확하게 적고, 이를 그대로 테스트 코드(특히 Given-When-Then 구조)로 녹여낸다.
[ Spring/JUnit 에서의 BDD ]
Given : “어떤 상황/데이터가 주어졌을 때”
When : “어떤 행위(함수/API 호출)를 했을 때”
Then : “이런 결과/상태가 나와야 한다”기존의 when(…).thenReturn(…) 대신 BDD 스타일의 given(…).willReturn(…), then(…).should() 로 작성한다.
2️⃣ Service, Controller 계층별 테스트
[ Service Test ]
- BDDMokito 패턴 (given / then / willReturn / should /never)
given(…).willReturn(…): Mock 객체의 동작 정의given()의 인자로는 동작에 대한 메서드가 들어감willReturn()의 인자로는 동작에 대한 예상 결과 값이 들어감- 내부적으로 Proxy 객체의 “호출 기록/리턴값/예외 등 스텁 동작” 을 지정
- 예시 :
given(userMapper.isUserExist(userId)).willReturn(false)
- when 단계
- Mock 객체의 호출 (검증하고자 하는 메서드 혹은 서비스 동작을 하는 메서드)
- 내부에서 의존성으로 주입된 Mock 객체의 메서드를 실제로 호출하게 됨
- 미리
given에서 정의해둔 리턴값/예외가 실행됨
then(…).should()- Mock 객체가 실제로 호출되었는지 검증
should()에never()를 인자로 주어 호출되지 않음을 검증- 예시 :
then(userMapper).should().insertUser(any(UserVO.class));
- any(), eq(), ArgumentMatchers 계열
any, any(클래스명.class)- 어떤 값이든 허용, 타입만 맞으면 됨
- 파라미터 값이 매번 달라지거나, 테스트 대상과 직접적인 상관이 없을 때 사용
- 예시 :
any(), any(Long.class), any(UserVO.class)
eq(value)- 정확하게 이 값이 들어왔는지(동등성) 검증, 파라미터가 특정 값으로 호출되었는지 체크 시 사용
- 예시 :
eq(USER_ID)
- Mock 객체 메서드 호출 시 모든 인자(파라미터는 전부
ArgumentMathcer로 통일해야함
- assertThat, assertThatThrownBy (AssertJ 라이브러리)
assertThat(value).isEqualTo(expectValue)- 결과 값이 기대값과 같은지 검증
assertThatThrwonBy(() → …).isinstanceOf(…).hasMessage(…)- 특정 코드 실행 시, 예외가 발생하는지(타입/메세지까지) 검증
# ArgumentMatcher 로 통일되지 않은 경우 에러 발생
This exception may occur if matchers are combined with raw values:
//incorrect: someMethod(any(), "raw String");
When using matchers, all arguments have to be provided by matchers. For example:
//correct: someMethod(any(), eq("String by matcher"));
[ 실제 적용 코드 ]
@Nested
@DisplayName("사용자 등록")
class 사용자_등록 {
// ... BeforeEach
@Test
@DisplayName("사용자 등록 : 성공")
void 성공() {
// given
setAuthentication(USER_ID, "ROLE_ADMIN");
given(userMapper.isUserExist(defaultRegisterReqDTO.getUserId())).willReturn(0);
// ...
// when
userService.registerUser(defaultRegisterReqDTO);
// then
then(userMapper).should().insertUser(any(UserVO.class));
}
@Test
@DisplayName("사용자 등록 : 실패 - 권한 없음")
void 실패_권한_없음() {
// given
setAuthentication(USER_ID, "ROLE_USER"); // 일반 사용자 권한
// when & then
assertThatThrownBy(() -> userService.registerUser(defaultRegisterReqDTO))
.isInstanceOf(CustomException.class)
.hasMessage(ErrorCode.FORBIDDEN.getMessage()); // CustomException -> FORBIDDEN
then(userMapper).should(never()).insertUser(any()); // 사용자 등록 호출 검증
}
}
3️⃣ 어노테이션 및 적용 라이브러리
[ Class 단위 설정 어노테이션 ]
- @ExtendWith(MockitoExtension.class)
JUnit5에서Mockito를 사용할 때,Mockito의 확장(Extension) 기능을 활성화 하는 어노테이션- 테스트 클래스에 이 어노테이션이 있으면 내부의
@Mock, @InjectMocks등Mockito관련 어노테이션이 자동으로 동작함 - Mock 객체를 자동으로 생성/주입해서 테스트 환경을 쉽게 만들어줌
- @WebMvcTest
- Spring MVC(
Controller) 계층만 슬라이스해서 테스트할 때 사용하는 어노테이션 - 실제로 스프링 전체 애플리케이션 컨텍스트를 띄우지 않고, 컨트롤러/관련된 MVC 컴포넌트(
Controller, @ControllerAdvice, Filter, Interceptor등) 만 최소한으로 로드해서 테스트 환경을 가볍게 만듦 Service, Repository등은MockBean등으로 주입해서 비즈니스 로직이나 DB는 실제로 동작하지 않고, HTTP 요청/응답, 파라미터, 유효성, 예외처리 등 API 스펙을 검증하는데 최적화 되어 있음- 테스트 클래스 선언부에 붙이고 어떤 컨트롤러를 테스트할지 명시적으로 지정함
@WebMvcTest(AuthController.class) class AuthControllerTest { ... }
- Spring MVC(
- @WithMockUser
- Spring Security 테스트 지원 어노테이션
- 테스트실행 시 가짜 로그인 사용자를 만들어서 컨트롤러(혹은 서비스)에서 인증/권한이 필요한 API도 로그인 상태로 테스트 가능
- 기본값은 username : “user”, ROLE : “USER” →
@WithMockUser(username="admin", roles={"ADMIN"})커스텀 가능
@WithMockUser(username = "admin", roles = {"ADMIN"}) @Test void 인증_필요_API_테스트() { ... }
[ 의존성 관련 어노테이션 ]
- @Mock (Mockito)
- 테스트 대상 클래스가 의존하는 객체(Service, Mapper, 외부 API 등)를 가짜(Mock) 객체로 만듦
- 진짜 객체의 작/DB/네트워크 호출없이, 원하는 동작/결과를 미리 세팅해서 단위 테스트, 로직 검증에만 집중할 수 있게 해줌
@Mock은 테스트 클래스 필드에 선언함Controller테스트의 경우Service를@MockBean으로 주입하는게 더 일반적임
- @InjectMocks (Mockito)
- @Mock 으로 만든 Mock 객체들을 자동으로 주입해서 테스트 대상 클래스를 실제 인스턴스로 만듦
- 테스트 대상 객체(Service 등) 가 내부적으로 의존성을 주입받아야 할 때 Mock 객체를 알아서 필드/생성자에 할당해줌
@InjectMocks는 테스트 클래스(테스트 대상)에 선언함
- @MockitoBean (Spring Core)
- Spring Test 공식 제공 → Spring 6.x 공식 지원
- 테스트 컨텍스트에 등록된 실제 빈을
Mock객체로 대체 @MockBean(Spring boot)대신 사용하는 최신방식- 주로 통합 테스트, 컨트롤러 테스트 등에서 Spring 컨테이너의 진짜 빈을
Mock으로 바꿔서 주입
- @TempDir (JUnit)
- 테스트 실행 중에 임시 디렉터리/파일 이 필요할 때 JUnit 이 자동으로 임시 폴더를 생성, 테스트가 종료되면 자동으로 정리
- 파일 업로드/다운로드 등 파일 시스템 관련 테스트에서 사용
- @Autowired ObjectMapper, MockMvc
ObjectMapper: JSON 변환(직렬화/역직렬화) 지원MockMvc: Spring MVC 환경에서 HTTP 요청/응답 테스트 지원
@Test @DisplayName("공지사항 리스트 조회 : 실패 - 잘못된 페이지 요청")
void 실패_잘못된_페이지_요청() throws Exception {
// when & then
mockMvc.perform(get(NOTICE_LIST_API_URL)
.param("page", "0")
.param("size", "1000") // 잘못된 페이지 사이즈
.param("keyword", "")
.with(csrf().asHeader()))
.andExpect(status().isBadRequest()) // 400 에러 검증
.andExpect(jsonPath("$.message")
.value(ErrorCode.INVALID_DATA_REQUEST.getMessage())); // CustomException -> INVALID_DATA_REQUEST
then(noticeService).should(never()).getNoticeList(any()); // 공지사항 리스트 조회 메서드 호출 검증
}
[ 네이밍 및 그룹핑에 사용된 어노테이션 ]
- @Nested
Junit5에서 지원하는 기능으로 한 클래스 안에서 관련된 테스트 케이스끼리 내부 클래스로 논리적 그룹으로 묶어줌- 여러 기능/상황 등을 각각 내부 클래스로 구분함 IDE 트리에서도 그룹핑 됨
- @DisplayName
- 각 테스트(
@Test, @Nested, @TestClass) 에 사람이 읽기 쉬운 제목/설명을 붙임 - 한글로 써도 되고
@Nested에@DisPlayName지정 시 하위 테스트 클래스에서 해당 이름 인식 - IDE, CI/CD 테스트 리포트에 트리뷰로 정리되어 노출
- 각 테스트(
- @Test
JUnit이 테스트 메서드로 인식하도록 함- 자동으로 빌드/테스트 툴에서의 실행과 성공/실패 여부가 CI/CD 리포트. IDE 에서 자동 체크 됨
'Project > Backend' 카테고리의 다른 글
| [Groupware] Spring Security + Exception (1) | 2025.06.18 |
|---|---|
| [Groupware] Spring-Boot : Spring Security + JWT (2) | 2025.06.16 |
| [Groupware] Spring-Boot : API 공통 응답 객체 (1) | 2025.05.30 |
| [Groupware] Spring-Boot : Swagger 연동 (0) | 2025.05.29 |
