개요
🔹 이 장에서 배우는 것
- ✅ 왜 테스트가 중요한가
- ✅ 테스트가 어떻게 동작하는가
- ✅ 단위 테스트(Unit Test) 구현
- ✅ 통합 테스트(Integration Test) 구현
🔹 테스트란?
👉 특정 기능이 예상대로 작동하는지 검증하는 작은 로직 조각
- 단위 테스트: 작은 로직을 고립시켜 검증
- 통합 테스트: 여러 컴포넌트가 올바르게 상호작용하는지 검증
⚠️ "테스트"라고만 하면 두 범주 모두 포함한다.
🔹 왜 테스트가 필수인가?
- 🛡 회귀 방지: 코드 변경으로 기존 기능이 깨지는 것을 막는다
- 📄 문서 역할: 테스트를 읽으면 해당 유스케이스 의도가 드러난다
- 🔁 자동 반복: 수동 검증이 아니라, 언제든지 재실행 가능
- 🚨 빠른 피드백: 개발 중 새 버그를 조기에 잡는다
🔹 수동 테스트 대신 자동 테스트를 쓰는 이유
- 수동: 한 번 검증하고 끝
- 자동: 무한 반복 검증 가능 + 코드 변경 시 즉시 확인
🔹 "왜 처음엔 잘 됐던 기능이 나중에 깨질까?"
- 우리는 코드에 지속적으로 변경을 가한다 (버그 수정, 기능 추가)
- 이때, 기존 기능이 깨질 수 있다
- → 테스트가 있으면 즉시 감지 가능
- → 배포 전에 문제 발견 = 재앙 예방
👉 이것이 바로 회귀 테스트(Regression Testing)
🔹 좋은 테스트 습관
- 각 기능마다 모든 관련 시나리오를 테스트로 확보
- 코드 변경 시마다 테스트 실행 → 기존 기능 영향 확인
🔹 현대 개발팀에서의 실행 방식
- 오늘날, 테스트는 개발자가 직접 돌리는 것에 그치지 않는다
- CI(지속적 통합) 파이프라인에 포함된다
⚙️ CI 도구 (예: Jenkins, TeamCity)
- 개발자가 코드를 변경하면 빌드 실행
- 테스트 자동 실행
- 실패 시 → 개발자에게 알림

🔹 이 장의 흐름
- 15.1: 단위 테스트의 큰 그림과 동작 방식
- 15.2: 스프링에서 가장 많이 쓰이는 단위·통합 테스트 구현 예시
🔹 마지막 당부
- 테스트는 복잡하고 방대한 주제
- 이 장에서는 스프링 앱 테스트에 꼭 필요한 핵심 지식만 다룬다
- 더 깊이 배우고 싶다면 📖 JUnit in Action (Manning, 2020) 참고
15.1 올바르게 구현된 테스트 작성하기
테스트란 무엇인가?
이 절에서는 테스트가 어떻게 동작하는지, 그리고 올바르게 구현된 테스트란 무엇인지 다룬다.
- 앱의 코드를 테스트하기 쉽게 작성하는 법을 배운다.
- 테스트 용이성(Testability)과 유지보수성(Maintainability) 사이의 강한 연관성을 확인한다.
- 테스트하기 좋은 앱은 곧 변경·확장이 쉬운 앱이다.
- 이 두 품질은 서로를 강화한다.
테스트의 목적
- 특정 메서드 로직이 원하는 방식대로 동작하는지 검증한다.
- 하나의 메서드를 테스트할 때도 보통 여러 가지 시나리오를 확인해야 한다.
- 각 시나리오는 테스트 클래스 안의 개별 테스트 메서드로 작성한다.
📂 Maven 프로젝트 구조
- 테스트 클래스는 src/test/java 폴더 안에 작성한다.

테스트 클래스 작성 원칙
- 테스트 클래스는 하나의 메서드 로직만 집중적으로 검증해야 한다.
- 단순한 로직이라도 다양한 시나리오가 존재한다.
- 각 시나리오는 별도의 테스트 메서드로 작성한다.
예시: 계좌 이체 (Money Transfer)
13장, 14장에서 다룬 계좌 이체 유스케이스를 떠올려 보자.
💡 이체 과정(4단계):
- 출금 계좌 정보를 DB에서 조회한다.
- 입금 계좌 정보를 DB에서 조회한다.
- 이체 후 두 계좌의 금액을 계산한다.
- DB에 계좌 잔액을 갱신한다.
가능한 테스트 시나리오
- 출금 계좌 정보를 찾지 못했을 때
- 입금 계좌 정보를 찾지 못했을 때
- 출금 계좌 잔액이 부족할 때
- 금액 갱신(update)이 실패할 때
- 모든 단계가 정상적으로 동작할 때
테스트 시나리오 구현
- 각 시나리오에 대해 앱이 어떻게 동작해야 하는지 이해한 뒤, 테스트 메서드로 검증한다.
- 예시: 시나리오 3
- 출금 계좌에 돈이 부족하다면?
- 이체가 발생하지 않아야 하며, 특정 예외(Exception)가 발생해야 한다.
- 하지만 앱 요구사항이 신용 한도(credit limit)를 허용한다면?
- 이 경우, 테스트는 신용 한도를 고려한 검증을 포함해야 한다.
- 출금 계좌에 돈이 부족하다면?
👉 즉, 테스트 시나리오는 앱의 요구사항과 밀접하게 연결된다.
그러나 기술적으로는 동일하다:
- 시나리오를 식별하고
- 각각을 테스트 메서드로 작성한다.

중요한 교훈
- 작은 메서드에도 수많은 시나리오가 존재할 수 있다.
- 따라서 메서드를 작게 유지하는 것이 필수다.
- 코드 라인 수가 많고, 매개변수가 많고, 여러 책임을 동시에 처리하는 거대한 메서드는
→ 시나리오를 식별하기 어렵다.
→ 테스트 용이성(Testability)이 크게 떨어진다.
- 코드 라인 수가 많고, 매개변수가 많고, 여러 책임을 동시에 처리하는 거대한 메서드는
- 작고 읽기 쉬운 메서드는 곧 테스트하기 좋은 메서드다.
15.2 스프링 앱에서 테스트 구현하기
이 절에서 다루는 것
스프링 애플리케이션에서 실제 프로젝트에서 자주 접하게 되는 두 가지 테스트 기법을 다룬다.
우리는 앞 장에서 구현했던 유스케이스를 기반으로 테스트를 작성하며, 두 기법을 시연한다.
이 두 가지는 어떤 개발자라도 반드시 알아야 할 기본기다:
- 단위 테스트(Unit Tests)
- 특정 메서드 로직을 검증
- 짧고, 빠르고, 하나의 흐름만 집중
- 모든 의존성을 제거하고 작은 로직만 검증
- 스프링 통합 테스트(Spring Integration Tests)
- 메서드 로직 + 스프링 프레임워크 기능과의 통합을 검증
- 스프링이 제공하는 특정 기능(예: DB 연동, DI, AOP 등)과 올바르게 동작하는지 확인
- 라이브러리/프레임워크 버전 업그레이드 후에도 기능이 정상적으로 유지되는지 보장
세부 내용
- 15.2.1 단위 테스트
- 단위 테스트가 왜 중요한지
- 단위 테스트 작성 시 고려해야 할 단계
- 앞서 구현한 유스케이스 몇 가지에 대해 실제 단위 테스트 작성
- 15.2.2 통합 테스트
- 통합 테스트를 구현하는 방법
- 단위 테스트와 무엇이 다른지
- 스프링 앱에서 단위 테스트를 보완하는 역할
15.2.1 단위 테스트 구현하기
단위 테스트란?
- 특정 조건에서 특정 유스케이스를 호출하여 동작을 검증하는 메서드.
- 전제 조건(Assumptions)을 정의하고, 메서드를 호출한 후, 결과를 검증(Validation)한다.
- 모든 의존성을 제거하고, 고립된 작은 로직만 다룬다.
👉 가치:
- 실패 시, 문제가 정확히 어느 코드에 있는지 알려줌.
- 자동차의 계기판과 같다.
- 차가 시동이 안 걸리면 원인을 알기 힘들다. (기름 부족? 배터리 문제?)
- 하지만 계기판이 “기름 없음”을 알려주면 즉시 문제 파악 가능.
- 단위 테스트도 마찬가지로, 복잡한 앱 속에서 특정 컴포넌트의 문제를 가리킨다.
예시: 돈 이체 유스케이스
14장에서 작성한 계좌 이체 로직:
- 송금 계좌 정보 조회
- 수취 계좌 정보 조회
- 새로운 금액 계산
- 송금 계좌 업데이트
- 수취 계좌 업데이트
@Transactional
public void transferMoney(long idSender, long idReceiver, BigDecimal amount) {
Account sender = accountRepository.findById(idSender) ❶
.orElseThrow(() -> new AccountNotFoundException());
Account receiver = accountRepository.findById(idReceiver) ❷
.orElseThrow(() -> new AccountNotFoundException());
BigDecimal senderNewAmount = sender.getAmount().subtract(amount); ❸
BigDecimal receiverNewAmount = receiver.getAmount().add(amount); ❸
accountRepository.changeAmount(idSender, senderNewAmount); ❹
accountRepository.changeAmount(idReceiver, receiverNewAmount); ❺
}

📌 Happy Flow 테스트 (정상 시나리오)
테스트 작성 기본 3단계 (Arrange / Act / Assert):
- Assumptions – 입력값과 의존성(mock) 준비
- Call – 테스트할 메서드 호출
- Validations – 기대한 동작 검증



🛠️ 첫 번째 단위 테스트
public class TransferServiceUnitTests {
@Test
public void moneyTransferHappyFlow() {
AccountRepository accountRepository = mock(AccountRepository.class); ❶
TransferService transferService = new TransferService(accountRepository); ❷
}
}
- ❶ AccountRepository를 Mockito mock 객체로 생성
- ❷ 진짜 AccountRepository 대신 mock을 주입 → 제어 가능한 환경
🛠️ Mock 동작 제어 + 실행
@Test
@DisplayName("Test transfer when no exception occurs")
public void moneyTransferHappyFlow() {
AccountRepository accountRepository = mock(AccountRepository.class);
TransferService transferService = new TransferService(accountRepository);
Account sender = new Account(); sender.setId(1); sender.setAmount(new BigDecimal(1000));
Account destination = new Account(); destination.setId(2); destination.setAmount(new BigDecimal(1000));
given(accountRepository.findById(sender.getId())) // sender 조회
.willReturn(Optional.of(sender));
given(accountRepository.findById(destination.getId())) // destination 조회
.willReturn(Optional.of(destination));
transferService.transferMoney(1, 2, new BigDecimal(100)); // 메서드 호출
}
🛠️ 검증 (Verify)
verify(accountRepository).changeAmount(1, new BigDecimal(900)); ❶
verify(accountRepository).changeAmount(2, new BigDecimal(1100)); ❶
❶ Repository 메서드가 기대한 파라미터로 호출되었는지 검증
📌 Annotation 활용 (@Mock, @InjectMocks)
더 깔끔한 방법 → Mockito 어노테이션 사용
@ExtendWith(MockitoExtension.class) ❶
public class TransferServiceWithAnnotationsUnitTests {
@Mock ❷
private AccountRepository accountRepository;
@InjectMocks ❸
private TransferService transferService;
@Test
public void moneyTransferHappyFlow() {
Account sender = new Account(); sender.setId(1); sender.setAmount(new BigDecimal(1000));
Account destination = new Account(); destination.setId(2); destination.setAmount(new BigDecimal(1000));
given(accountRepository.findById(sender.getId())).willReturn(Optional.of(sender));
given(accountRepository.findById(destination.getId())).willReturn(Optional.of(destination));
transferService.transferMoney(1, 2, new BigDecimal(100));
verify(accountRepository).changeAmount(1, new BigDecimal(900));
verify(accountRepository).changeAmount(2, new BigDecimal(1100));
}
}
- ❶ Mockito 확장을 활성화
- ❷ @Mock: 가짜 객체 생성
- ❸ @InjectMocks: mock들을 주입받은 실제 테스트 대상 생성


📌 예외 흐름 테스트 (Exception Flow)
예: 수취 계좌를 찾을 수 없는 경우
@Test
public void moneyTransferDestinationAccountNotFoundFlow() {
Account sender = new Account();
sender.setId(1); sender.setAmount(new BigDecimal(1000));
given(accountRepository.findById(1L)).willReturn(Optional.of(sender));
given(accountRepository.findById(2L)).willReturn(Optional.empty()); ❶
assertThrows(AccountNotFoundException.class, ❷
() -> transferService.transferMoney(1, 2, new BigDecimal(100)));
verify(accountRepository, never()) ❸
.changeAmount(anyLong(), any());
}
- ❶ destination 계좌 조회 시 빈 값 반환
- ❷ AccountNotFoundException 발생 검증
- ❸ changeAmount()가 호출되지 않았음을 검증
📌 반환값 검증 (Return Value Testing)
로그인 컨트롤러 예시:
@PostMapping("/")
public String loginPost(@RequestParam String username,
@RequestParam String password,
Model model) {
loginProcessor.setUsername(username);
loginProcessor.setPassword(password);
boolean loggedIn = loginProcessor.login();
if (loggedIn) model.addAttribute("message", "You are now logged in.");
else model.addAttribute("message", "Login failed!");
return "login.html";
}
테스트 (로그인 성공 시)
@ExtendWith(MockitoExtension.class)
class LoginControllerUnitTests {
@Mock private Model model;
@Mock private LoginProcessor loginProcessor;
@InjectMocks private LoginController loginController;
@Test
public void loginPostLoginSucceedsTest() {
given(loginProcessor.login()).willReturn(true); ❶
String result = loginController.loginPost("username", "password", model); ❷
assertEquals("login.html", result); ❸
verify(model).addAttribute("message", "You are now logged in."); ❹
}
}
테스트 (로그인 실패 시)
@Test
public void loginPostLoginFailsTest() {
given(loginProcessor.login()).willReturn(false);
String result = loginController.loginPost("username", "password", model);
assertEquals("login.html", result);
verify(model).addAttribute("message", "Login failed!");
}


15.2.2 통합 테스트 구현하기
📌 통합 테스트란?
- 단위 테스트와 매우 비슷하다. (JUnit으로 작성 가능)
- 하지만 초점이 다르다.
- 단위 테스트: 개별 컴포넌트의 동작 검증
- 통합 테스트: 두 개 이상의 컴포넌트가 상호작용하는 방식 검증
👉 자동차 비유
- 연료 탱크가 가득 차 있어도, 탱크 → 엔진 사이 분배가 고장나면 차가 시동되지 않는다.
- 계기판은 연료가 충분하다고만 알려줄 뿐, “분배 문제”는 못 잡아낸다.
- 앱도 마찬가지다: 개별 컴포넌트는 잘 동작하지만, 서로 통신이 안 될 수 있다.
- 이를 막기 위해 통합 테스트가 필요하다
📌 테스트 가능한 통합 시나리오
- 앱 내부 객체 간 통합
- 두 객체가 올바르게 협력하는지 검증
- 한쪽 변경 시 협업 문제가 생길 수 있음
- 앱 객체 + 프레임워크 기능 통합
- 예: 트랜잭션, 의존성 주입, 캐싱
- 프레임워크 업그레이드 시 동작이 바뀔 수 있음 → 조기 감지 가능
- 앱 + 데이터베이스 통합
- Repository ↔ DB 동작 검증
- JDBC 드라이버 변경 등으로 인한 문제를 빨리 발견 가능
📌 단위 테스트와의 차이
- 절차는 동일 (Assumptions → Call → Validation)
- 하지만:
- 단위 테스트: 모든 의존성 Mocking 필수
- 통합 테스트: 실제 객체 호출 허용 가능 (상호작용 검증 목적)
⚠️ 주의:
- DB 연동 시 **실제 DB 대신 인메모리 DB(H2 등)**를 사용하라.
- 네트워크/인프라 문제로 테스트 실패하는 것을 막는다.
- 우리는 “인프라”가 아니라 “앱”을 테스트하는 것이 목적이다.
📌 스프링 통합 테스트
- 스프링이 직접 빈(Bean)을 생성하고 컨텍스트를 구성하도록 한다.
- 단위 테스트와 달리, 실행 환경이 실제 앱 실행과 유사하다.
- 이 덕분에:
- Spring DI(의존성 주입), 트랜잭션, 시큐리티, 캐싱 등 스프링 기능과의 통합 검증 가능

🛠️ 예시: 돈 이체 유스케이스 (Spring Integration Test)
@SpringBootTest
class TransferServiceSpringIntegrationTests {
@MockBean ❶
private AccountRepository accountRepository;
@Autowired ❷
private TransferService transferService;
@Test
void transferServiceTransferAmountTest() {
Account sender = new Account(); sender.setId(1); sender.setAmount(new BigDecimal(1000));
Account receiver = new Account(); receiver.setId(2); receiver.setAmount(new BigDecimal(1000));
when(accountRepository.findById(1L)).thenReturn(Optional.of(sender)); ❸
when(accountRepository.findById(2L)).thenReturn(Optional.of(receiver)); ❸
transferService.transferMoney(1, 2, new BigDecimal(100)); ❹
verify(accountRepository).changeAmount(1, new BigDecimal(900)); ❺
verify(accountRepository).changeAmount(2, new BigDecimal(1100)); ❺
}
}
해설
- ❶ @MockBean: Mock 객체를 스프링 컨텍스트에 등록
- ❷ @Autowired: 스프링 컨텍스트에서 실제 빈 주입
- ❸ 가정(Assumptions): mock 동작 정의
- ❹ 실행(Call): transferMoney() 호출
- ❺ 검증(Validation): Repository 메서드가 기대한 파라미터로 호출되었는지 확인
📌 단위 테스트 vs 통합 테스트 비교
- 단위 테스트:
- 빠름, 독립적
- 모든 시나리오 검증에 사용
- 통합 테스트:
- 느림 (스프링 컨텍스트 로딩 필요)
- 스프링과의 통합 포인트만 검증 (DI, 트랜잭션 등)
👉 최적 전략
- 단위 테스트로 컴포넌트 로직을 철저히 검증
- 통합 테스트로 필요한 통합 시나리오만 검증
요약 (Summary)
테스트(Test)란?
→ 앱에 구현된 특정 로직의 동작을 검증하기 위해 작성하는 작은 코드 조각
🔹 테스트가 필요한 이유
- 미래에 앱을 발전시킬 때 기존 기능이 깨지지 않도록 보장
- 문서화 역할도 수행 (테스트 코드를 읽으면 유스케이스 이해 가능)
🔹 테스트의 두 가지 종류
- 단위 테스트(Unit Test)
- 고립된 작은 로직만 검증
- 다른 기능과의 통합은 고려하지 않음
- 장점: 빠르게 실행되고, 특정 컴포넌트의 문제를 정확히 가리킴
- 통합 테스트(Integration Test)
- 두 개 이상의 컴포넌트가 서로 상호작용하는 방식 검증
- 개별적으로는 정상 동작하지만, 서로 통신이 어긋나는 문제를 발견하는 데 유용
🔹 Mock의 역할
- 때로는 특정 의존성을 제거하고 싶을 때가 있다.
- 이때 사용하는 것이 Mock 객체 (가짜 객체)
- Mock을 통해:
- 불필요한 의존성을 제거
- 테스트가 특정 상호작용에만 집중하도록 함
🔹 테스트의 3단계 구조
- Assumptions (가정/준비)
- 입력값 정의
- Mock 객체의 동작 정의
- Call / Execution (호출/실행)
- 검증할 메서드 실행
- Validations (검증)
- 실행 결과가 기대한 대로 동작했는지 확인
'책 > 스프링교과서' 카테고리의 다른 글
| [부록 B] 컨텍스트 설정에 XML 사용하기 (0) | 2025.10.03 |
|---|---|
| 부록 A. 아키텍처적 접근법 (1) | 2025.10.02 |
| [14] Spring Data를 이용한 데이터 영속성 구현 (1) | 2025.10.01 |
| [13] 스프링 앱에서 트랜잭션 사용 (0) | 2025.09.30 |
| [12] 스프링 앱에서 데이터 소스 사용 (0) | 2025.09.30 |