객체를 구현할 때는 항상 테스트하기 쉽게 설계 되었는지 점검해야 한다. 개발자가 실행 결과를 직접 눈으로 확인하는 것이 아니라 테스트 실행 및 결과 확인까지 자동화된 테스트가 가능해야 한다. 테스트 가능한 객체를 만들기 위해 코드를 작성할 때 어떤 부분을 고려하는 것이 좋은지 점검 포인트를 알아보자.
테스트 가능한 객체 설계 원칙

(우) 테스트 대역을 주입하여 테스트할 수 있는 느슨한 결합
(Gemini가 생성한 이미지)
1. 의존성 주입
다른 객체와 협력해야 한다면 객체 생성 시 주입 받아야 한다. 객체가 내부에서 직접 다른 객체를 생성(new)하면 테스트 시 제어가 불가능해진다. 따라서 생성자를 통해 외부에서 의존성을 주입 받는 방식을 사용해야 테스트 코드에서 테스트 대역을 자유롭게 갈아 끼울 수 있다. 자바에서는 주입 받을 필드에 final 키워드를 붙이고 생성자를 통해 객체를 주입하면 객체의 불변성을 보장하고 의존성 주입을 강제할 수 있다.
// Bad: 내부에서 직접 생성 (제어 불가능)
public class OrderService {
private final OrderRepository repository = new MySqlOrderRepository();
}
// Good: 생성자 주입 (테스트 시 가짜 객체 주입 가능)
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
2. 테스트 대역 활용
외부 API를 호출하는 객체는 외부 환경에 의존하게 된다. 외부 환경에 의존하는 코드는 테스트하기 어렵다. 이때 실제 객체 대신 원하는 반환 값을 정의한 Stub 객체를 만들어서 테스트 대역으로 사용하면 외부 의존성 없이도 원하는 시나리오를 쉽고 빠르게 테스트할 수 있다.
DB 접근이 필요한 Repository 객체는 가능하다면 실제 객체를 사용하는 것이 가장 좋다. 테스트 코드에서는 H2와 같은 인메모리 DB를 사용하고 테이블과 데이터를 초기화해서 테스트하면 된다. 만약 인메모리 DB 사용이 어려운 상황이거나 쿼리에 비즈니스 로직이 없고 매우 단순한 경우에는 Fake 객체(예: 메모리 내 Map을 사용하는 저장소)로 대체하는 것도 고려해볼 수 있다.
| 자주 사용되는 테스트 대역 유형 | 설명 | 주요 사용 사례 |
| Fake | 실제 로직을 단순화하여 구현한 객체 | 인메모리 데이터베이스, 가짜 파일 시스템 |
| Stub | 미리 준비된 답만 반환하는 객체 | 외부 API의 성공/실패 응답 시뮬레이션 |
| Mock | 행위를 검증하기 위해 사용되는 객체 | 특정 메서드가 몇 번 호출되었는지 확인 |
// Interface
public interface OrderRepository {
void save(Order order);
}
// Fake Object: DB 대신 Map을 사용하여 메모리에서 동작
public class FakeOrderRepository implements OrderRepository {
private final Map<Long, Order> store = new HashMap<>();
@Override
public void save(Order order) {
store.put(order.getId(), order);
}
}
3. 실행 시점에 따라 변하는 값은 인자로 전달
LocalDateTime.now()와 같이 실행 시점에 따라 결과가 달라지는 코드가 내부에 있으면 테스트 결과가 매번 달라질 수 있다. 이런 불확실한 데이터는 메서드의 파라미터로 전달 받거나, Clock 객체를 주입받아 제어 가능하도록 해야 한다. 이렇게 설계하면 특정 날짜나 시간을 강제로 설정하여 엣지 케이스(Edge Case)를 자유롭게 검증할 수 있다.
// Bad: 내부에서 시간을 결정 (테스트 시점에 따라 결과가 달라짐)
public void validateOrder() {
LocalDateTime now = LocalDateTime.now();
if (now.isAfter(EXPIRATION_TIME)) { ... }
}
// Good: 시간을 외부에서 입력 (특정 시간을 강제로 주입하여 테스트 가능)
public void validateOrder(LocalDateTime now) {
if (now.isAfter(EXPIRATION_TIME)) { ... }
}
4. 동작에 집중한 테스트
프로덕션 코드를 리팩터링 한 뒤에 테스트 코드가 깨진다면, 테스트 코드가 내부 구현에 너무 의존적인 것은 아닌지 점검해봐야 한다. 테스트는 객체가 “어떻게 일을 하는지”가 아니라 “어떤 결과를 내놓는지” 즉, 어떤 메서드가 몇 번 호출되었는지 확인하는 것처럼 내부 구현을 검증하는 것이 아니라 공개된 인터페이스를 검증해야 한다. 예를 들어, 주문이 완료되면 주문의 상태가 “COMPLETED”로 변경 되거나, 비밀번호를 5 번 이상 틀리면 계정의 상태가 “LOCKED”가 되는 등 약속된 동작들(비즈니스 로직)이 있을 수 있다. 내부 구현이 아닌 동작에 집중한 테스트를 작성함으로써 리팩터링에 의해 깨지지 않는 테스트를 작성할 수 있다. 만약에 프로덕션 코드를 리팩터링 할 때마다 테스트 코드가 깨진다면 아무도 프로덕션 코드를 리팩터링 하고 싶어 하지 않을 것이므로, 리팩터링에 깨지지 않는 테스트 코드를 작성하는 것은 중요하다.
void 주문_완료_시_COMPLETED_상태로_변경된다() {
Order order = new Order();
// public 메서드 호출 후의 '결과(상태)'를 검증한다.
order.complete();
// 리팩터링으로 complete 내부 로직이 바뀌어도 이 테스트는 깨지지 않는다.
assertThat(order.getStatus()).isEqualTo(OrderStatus.COMPLETED);
}
참고 자료
- Martin Fowler – Mocks Aren’t Stubs
- Baeldung – Constructor Injection in Spring
- BrowserStack – Mock vs Stub vs Fake
