🧐 Test Double이란?
테스트 더블이란 실제 구현체로 테스트를 진행하기 어려운 경우, 이를 대신해서 테스트를 진행할 수 있도록 만들어지는 객체이다.
Test Double 이란 명칭의 유래 💭
영화를 촬영하는 경우, 위험한 장면 촬영할 때 실제 배우를 대신해서 촬영하는 스턴트 더블에서 유래된 단어이다.
사용 이유 💭
이전에 '테스트 주도개발 시작하기' 라는 책을 읽은 적이 있는데, 해당 책 저자의 말을 빌리자면
테스트 더블은 외부 요인에 의존하는 객체에 대한 테스트를 작성할 때 "실패하는 테스트는 항상 실패하고, 성공하는 테스트는 항상 성공한다" 라는 테스트의 일관성을 지키기 위해 사용한다고 했다.
예를 들어 DB, 외부 API 등을 사용할 때, 네트워크 연결 상태 등을 포함한 다양한 변칙적 이유에 의해서 테스트가 실패하는 상황이 발생할 수 있을 것이다.
이러한 문제에 대해 실제 구현체의 대역인 테스트 더블을 사용하여 테스트가 어떤 상황이든 간에 동작할 수 있도록 만들어주는 것이다.
'외부 요인' 의 범위
코드 리뷰를 주고받으며 현업자인 리뷰어는 테스트 더블로 처리해야하는 외부 요인의 범위를 지정하는 것이 가장 중요하다고 얘기해주셨다.
이에 대해서 본인의 기준을 한 줄 적어보자면
API 서버 개발에 사용하는 스프링부트 애플리케이션이 아닌 모든 외부 의존성들을 외부 요인이라고는 생각하지는 않는다.
예를 들어 H2 DB를 In-Memory & Embedded 모드로 사용한다면, 이는 스프링부트 애플리케이션 내부에서 작동하며 네트워크가 없이도 애플리케이션 내부에서 정상적으로 작동할 수 없으니 외부 의존성인 DB라고 해도 외부 요인으로 생각하지 않는다.
외부 API, 데이터베이스 서버, 그리고 다른 포트에서 동작하는 다른 애플리케이션 등
애플리케이션의 동작에 영향을 미칠 수 있는 외부 의존성을 외부 요인이라고 생각했고
이에 대해 타당한 의견인 것 같다는 리뷰도 받아 합리적인 생각에 가깝다고 판단할 수 있었다.
하지만 모든 상황마다 범위는 다를 수 있고 개발에 정답은 없으니 참고만 하길 바란다.
가짜를 사용하면 의미가 없지 않을까? 💭
본인은 가짜 객체인 테스트 더블의 테스트 결과는 실제 구현체의 객체의 결과와 다르기 때문에 별 의미가 없는 것 아닐까? 라고 생각했었다.
하지만 실제로 애플리케이션에 대해 테스트를 작성하면서 테스트 더블의 필요성을 느끼게 되었다.
예를 들어 0~10 사이의 난수를 발생시키는 generate() 메서드를 가진 RandomNumberGenerator가 있고 외부에서는 해당 난수 값이 0~4면 false, 5~10이면 true를 return하는 로직이 존재한다고 가정하자.
이러한 로직을 테스트해야하는 상황에서, 난수는 우리가 조작할 수 없는 값이기 때문에 테스트를 작성하기 꽤나 곤란해진다.
이런 경우 우리는 테스트 더블을 통해 간단히 문제를 해결할 수 있다.
NumberGeneratorStub을 만들고 generate() 메서드의 return값을 0~10 중 테스트에 필요한 값으로 직접 설정하여 우리가 원하는 특정값을 얻도록 할 수 있다.
물론 해당 결과는 우리가 조작한 가짜이지만, 이렇게 통제가 100% 가능한 상황이 아닌 때에만 적절히 활용한다면 테스트를 통해 기능에 대해 쉽게 파악할 수 있고, 내부적으로 어떻게 동작하는 지 이해하는 것에 큰 도움을 줄 수 있는 훌륭한 테스트를 작성에 큰 도움을 줄 것이다.
이렇듯 우리가 통제할 수 없는 요인에 대해서 테스트 더블을 사용할 때, 효용을 얻을 수 있을 것이다.
📃 Test Double의 종류
출처: https://tecoble.techcourse.co.kr/post/2020-09-19-what-is-test-double/
테스트 더블의 종류는 위와 같이 꽤나 다양하다.
🔑 Dummy
더미는 어떠한 동작도 필요하지 않지만, 어떠한 기능을 사용할 때 특정 객체의 인스턴스만이 필요할 때 사용된다.
다시 말해, 더미는 동작하지 않아도 테스트에 어떠한 영향도 미치지 않는 객체를 뜻한다.
예제를 통해 정확한 사용 상황을 살펴보자
Accelerator.java
public interface Accelerator {
int push();
}
자동차를 앞으로 가게하기 위한 자동차의 힘(int)을 return하는 push()라는 행위에 책임을 가진 Accelerator 가 존재한다.
RandomMoveAccelerator.java
public class RandomMoveAccelerator implements Accelerator {
public static final int MIN_ACCEL_POWER = 0;
public static final int MAX_ACCEL_POWER = 9;
public static final int MIN_MOVABLE_POWER = 4;
public int push() {
return RandomNumberUtils.generate(MIN_ACCEL_POWER, MAX_ACCEL_POWER);
}
}
애플리케이션의 프로덕션 코드는 push() 메서드 실행 시에, 0~9 사이의 난수를 생성하여 return해야한다.
Car.java
public class Car {
private final CarName name;
private int position;
private final Accelerator accelerator;
public Car(String name, Accelerator accelerator) {
this.name = new CarName(name);
this.position = 0;
this.accelerator = accelerator;
}
public void pushAccelerator() {
moveForward(accelerator.push());
}
private void moveForward(int power) {
if (power >= RandomMoveAccelerator.MIN_MOVABLE_POWER) {
position++;
}
}
Car 클래스는 이러한 RandomMoveAccelerator를 생성자 주입받아, 애플리케이션 내부에서 랜덤값을 활용하여 position을 증가시킨다.
하지만 테스트에서 이러한 Car 객체를 생성할 때, RandomMoveAccelerator는 사용하지 않지만, Car 객체를 생성하기 위해 Accelerator는 주입해야주어야 하는 경우에는 어떻게 할까?
이때 사용하는 것이 더미이다.
BridgeGeneratorDummy.java
public class AcceleratorDummy implements Accelerator {
@Override
public int push() {
return 0;
}
}
이러한 상황에 빗대어 보았을 때, 위와 같이 아무런 동작도 하지 않는 AcceleratorDummy 클래스를 통해 ”어떠한 동작도 하지 않고 인스턴스 자체만을 제공한다” 라는 Dummy의 역할에 대해 직관적으로 이해할 수 있다.
- 물론 return type이 int형이기 때문에 0을 return하긴 하지만, 어떠한 로직처리도 하지 않으며 그저 0을 리턴한다.
- Dummy로 사용될 것이기 때문에 push 라는 메서드가 사용될 일이 없는 경우에만 사용한다.
어짜피 실행시키지 않을 거면 프로덕션 코드에 구현해놓은 구현체를 사용하면 되지 않을까?
물론 가능하다고 생각한다. 하지만 이 기준은 상황에 따라 다를 것 같다.
하지만 만약 프로덕션 코드에 구현해놓은 구현체가 싱글톤으로 사용되고 있다면, 테스트에서도 프로덕션 코드에서 사용중인 인스턴스와 같은 인스턴스를 사용하게 된다.
이때 프로덕션 코드의 구현체 내부에 정적으로 상태를 가지고 있는 클래스 변수가 있다고 하면? 테스트의 동작이 실제 애플리케이션 작동에 영향을 끼칠 수도 있다.
이러한 경우에 더미를 사용하면 적절하지 않나 싶다.
🔑 Fake
페이크 객체는 복잡한 로직이나, 객체에서 필요로하는 다른 외부 객체들의 동작을 단순화하여 구현한 객체이다.
페이크 객체에서 단순화된 동작은 실제 플로우와 유사하게 구현은 되어 있지만, 실제 프로덕션 코드에는 적합하지 않는 코드이다.
예제를 통해 자세히 살펴보자
예제 코드
MemberRepository.java
public interface MemberRepository {
Member save(Member member);
}
Member 객체를 입력받아서 저장소에 저장한다는 행위의 책임을 가진 MemberRepository 인터페이스의 save() 메서드가 존재한다고 가정하자.
MemberRepositoryImpl.java
public class MemberRepositoryImpl {
Member save(Member member) {
// DB 저장 로직..
}
}
실제 프로덕션 코드에선 MemberRepositoryImpl이라는 구현체가 Member 객체를 DB에 저장하는 로직을 수행할 것이다.
그리고 저장되는 Member 엔티티의 PK는 1번부터 자동으로 증가하는 Auto Increment 설정이 되어있다.
우리는 이제 이 로직에 대해 테스트를 작성해보려고 했지만 문제를 발견하게 된다.
DB는 애플리케이션 서버의 외부로 분리되어 있다. 때문에 네트워크에 문제가 발생하면, DB에 접근해서 데이터를 추가하는 로직을 테스트하는 과정에서 에러가 발생한다. 네트워크에 따라 테스트가 실패할 수도 있게 된다는 것이다.
이러한 네트워크 영향을 없애면서 똑같은 동작을 수행하여 테스트 코드를 통해 기능이 어떻게 작동하는 지 직관적으로 개발자에게 인식시켜주기 위한 방법으로 무엇이 있을까?
이때 사용되는 것이 바로 Fake 객체이다.
FakeMemberRepository.java
public class FakeMemberRepository implements MemberRepository {
private static final Map<Long, Member> members = new ConcurrentHashMap<>();
private static Long idSequence = 1L;
@Override
public Member save(Member member) {
members.put(idSequence++, member);
return member;
}
}
FakeMemberRepository는 실제 DB가 아닌 Map에 데이터를 저장함으로써 데이터가 저장소에 저장되는 것과, idSequence를 사용함으로써 Member의 PK가 Auto Increment 되는 것을 정확히 구현한 Fake 객체이다.
이러한 내부 구현 단순화를 통해 테스트를 하는 개발자에게 DB에 수행되는 것과 동일한 결과를 전달하며, 외부 요인에 영향을 받지 않고 항상 성공하는 테스트를 작성할 수 있도록 돕는 것이 바로 Fake 객체의 역할이다.
🔑 Stub
스텁은 위에서 소개한 더미 객체가 실제 동작하는 것처럼 보이게 만들어놓은 객체이다.
인터페이스 또는 기본 클래스가 최소한으로만 구현된 상태이며, 테스트에서 호출된 요청에 대해 미리 준비된 결과를 제공한다.
쉽게 말해 스텁은, 상태 검증을 위한 테스트 더블이다.
정리하자면 내부 동작에 대한 구현은 모두 제외하고, 동작에 대한 결과만을 얻고 싶을 때 사용한다.
아래 예제는 초반에 가자 객체를 사용하면 의미가 없지 않을까? 에서 설명한 내용과 동일한 예제이다.
예제를 통해 스텁을 이해해보자
예제 코드
Accelerator.java
public interface Accelerator {
int push();
}
자동차를 앞으로 가게하기 위한 자동차의 힘(int)을 return하는 push()라는 행위에 책임을 가진 Accelerator 가 존재한다.
RandomMoveAccelerator.java
public class RandomMoveAccelerator implements Accelerator {
public static final int MIN_ACCEL_POWER = 0;
public static final int MAX_ACCEL_POWER = 9;
public static final int MIN_MOVABLE_POWER = 4;
public int push() {
return RandomNumberUtils.generate(MIN_ACCEL_POWER, MAX_ACCEL_POWER);
}
}
애플리케이션의 프로덕션 코드는 push() 메서드 실행 시에, 0~9 사이의 난수를 생성하여 return해야한다.
Car.java
public class Car {
private final CarName name;
private int position;
private final Accelerator accelerator;
public Car(String name, Accelerator accelerator) {
this.name = new CarName(name);
this.position = 0;
this.accelerator = accelerator;
}
public void pushAccelerator() {
moveForward(accelerator.push());
}
private void moveForward(int power) {
if (power >= RandomMoveAccelerator.MIN_MOVABLE_POWER) {
position++;
}
}
Car 클래스는 Accelerator 인터페이스를 통해 이러한 RandomMoveAccelerator를 생성자 주입받고, moveForward(int power) 메서드를 통해 내부에서 랜덤값을 활용하여 position을 증가시킨다.
position 증가로직의 분기처리는 아래와 같다.
- 난수가 0~4이면 position을 그대로
- 5~10이면 position을 1 증가
하지만 moveForward(int power)는 난수에 의존하기 때문에 moveForward의 position 증가 테스트를 작성할 때, 우리가 원하는 결과를 만들기 어려워보인다.
테스트를 하겠다고 private인 moveForward의 접근제어자를 public으로 변경할 수도 없는 노릇이다.
이럴 때 Stub 객체를 사용하여, 우리가 원하는 값을 return하도록 구현하면 moveForward에 대해 원하는 결과를 만들어낼 수 있다.
StubAccelerator.java
public class StubAccelerator implements Accelerator {
private int power;
public StubAccelerator() {
this.power = 0; // NPE 방지를 위해 기본값 주입
}
public void setPower(final int power) {
this.power = power;
}
public int push() {
return power;
}
}
위와 같이 Accelerator에 대한 스텁을 생성하면 push() 메서드가 랜덤 값이 아닌 우리가 설정한 값을 return하도록 만들 수 있다.
보통 위 코드와 같이 원하는 설정 값을 필드로 만들어두고 setter를 통해 값을 설정하도록 구현한다.
- 생성자를 통해 power의 값을 지정하는 방법도 있겠지만,
프로덕션 코드의 Accelerator 구현체는 power를 인자로 입력받는 생성자가 존재하지 않는다.
때문에 테스트를 위해 새로운 인스턴스 생성 규칙(생성자)을 만들어주는 것은 테스트 코드를 통해 프로덕션 코드의 동작 로직을 파악하는 것에 혼란을 야기할 수 있다.
때문에 Stub 사용에 있어서는 생성자보단 Setter 사용을 지향하자!
🔑 Spy
스파이 객체는 스텁의 역할을 가지면서 객체의 상태를 관찰하고 기록하는 데 사용되며 주로 메소드 호출과 관련된 정보를 기록하고 반환한다. 이는 특히 테스트 중에 객체가 어떻게 상호작용하는지 추적하고 확인하는 데 유용하다.
스텁의 역할을 가진다고 해서 완전히 스텁과 동일한 것은 아니다.
특정 부분은 실제 객체와 동일하게 구현할 수 있고, 특정 부분은 스텁으로 구현할 수 있다. 그리고 스텁은 어떠한 행위에 대한 결과를 얻기 위해 사용하지만, 스파이 객체는 내부 로직에 숨어서 실행 횟수 등의 객체의 상태를 관찰하고 기록하는 역할을 한다.
실제 객체와 동일하게 로직을 구현하는 이유는 로직의 실행 속도 등을 체크해야하는 상황일 듯 싶다. 물론 실제 객체와 동일하게 동작하도록 구현할 필요가 없거나, 구현할 수 없는 부분에 대해서는 스텁으로 구현해도 된다.
결론적으로 구현은 실제 객체와 스텁 사이에서 필요한 것을 취해서 만들면 되는 것이고, 객체의 내부에서 어떠한 정보를 측정하거나 기록하기 위해 사용되는 객체인 것이다.
예제 코드
SpyRandomMoveAccelerator.java
public class SpyRandomMoveAccelerator implements Accelerator {
public static final int MIN_ACCEL_POWER = 0;
public static final int MAX_ACCEL_POWER = 9;
public static final int MIN_MOVABLE_POWER = 4;
private int calledCount = 0;
public int push() {
calledCount++;
return RandomNumberUtils.generate(MIN_ACCEL_POWER, MAX_ACCEL_POWER);
}
public int getCalledCount() {
return calledCount;
}
}
위 코드는 RandomMoveAccelerator의 Spy 객체이다.
위에서 소개한 RandomMoveAccelerator 클래스와 똑같이 작동하지만, 내부적으로 calledCount 라는 정수 자료형이 push() 메서드의 호출 횟수를 기록한다.
또한 getCalledCount() 메서드를 통해 push() 메서드가 얼마나 호출 되었는 지 확인할 수 있다.
이렇게 메서드의 호출 횟수 등과 같은 객체의 행위 또는 상태 등을 관찰하고 기록하는 역할을 하는 객체가 바로 스파이 객체이다.
🔑 Mock
Mock이란 한글로 모의의, 가짜의 라는 뜻을 가지는 테스트 더블 객체로, 실제 객체와 동일한 모의 객체를 만들어 테스트의 효용성 높이기 위해 사용한다.
상태를 검증하는 Stub과는 다르게, 어떠한 행위를 잘 수행하는 지에 대해 검증하는 행위 검증 을 위한 테스트 더블 객체이다.
Java환경에선 주로 Mockito를 통해 테스트를 수행하게 된다.
Mockito란?
mock을 쉽게 만들고 mock의 행동을 정하는 stubbing, 정상적으로 작동하는 지 검증하는 verify 등 다양한 기능을 제공해주는 프레임워크이다.
DB연동 작업등과 같은 테스트 자동화가 어렵거나, 요청에 시간이 오래 걸리는 내용들은 Mockito를 통해 가짜 객체를 생성하여 행위에 대한 검증을 신속하게 수행한다.
예제 코드
(적용해보고 작성)
결함 내성
(학습해서 채우기)
'부트캠프 > 우아한테크코스 6기' 카테고리의 다른 글
[DB] 트랜잭션(Transaction) 이란? (with. ACID) (1) | 2024.04.07 |
---|---|
[Java] Java 반복 작업 수행 시, For-Loop와 Stream 사이 성능 비교 (0) | 2024.04.06 |
[Java] Fluent API란? (feat. JDBC에 적용해보기) (0) | 2024.04.04 |
[Java] 생성자 체이닝(Constructor Chaining) 기법 (0) | 2024.04.02 |
[Java] 자바에서 라인을 ‘잘’ 개행하는 방법 (2) | 2024.03.01 |