1. Introduction
빠른 호흡으로 애자일하게 에픽을 진행하며 개발을 하다보면, 테스트 코드에 대하여 타협하는 경우가 종종 있다. Test Driven하게 작업을 진행하다보면 개발 시간이 더 오래걸리고, 이를 지켜나가는 과정이 상당히 고통스럽기 때문이다.
그러나 개인적으로는 테스트 코드를 작성하며 Test Driven Development를 준수하는 것이야 말로, 코드를 가장 빠르면서 안전하게 만드는 올바른 방법이라고 생각한다. 특히, Core 로직 (service layer, repository layer)은 특별한 이유가 없고서는 단위 테스트를 꼼꼼히 작성하는 편이다.
이때 외부 의존성을 제거하기 위해 의존하는 외부 객체를 모킹하는 방법을 자주 활용해왔다. 그럼에도 불구하고 Test Double에 대한 개념과 내용을 제대로 공부한 적이 없는 것 같아 해당 내용을 학습하고 정리하고자 한다.
•
모든 테스트는 섬이어야한다.
•
단위 테스트는 독립적이어야 한다.
•
독립적이라는 말은 어떠한 테스트도 다른 테스트에 의존하지 않음을 의미
•
테스트 대상을 의존하는 것으로부터 격리하는것
2. Glossary
•
Test Double
◦
실제로 동작하는 것처럼 보이는 별개의 객체를 만드는 것
◦
이때 별개의 객체를 Test Double 이라고 함
•
SUT (System Under Test)
◦
테스트를 하는 대상 (테스트 대상 객체)
◦
주요 객체 (primary object)
•
Collaborator
◦
협력 객체
◦
부차적 객체 (secondary object)
◦
SUT가 의존하는 객체
•
Solitary vs Sociable
◦
Solitary Test: 의존성을 제거하여 테스트 대상만 테스트
◦
Sociable Test: 테스트 대상과 의존하는 대상을 함께 테스트
3. Test Double type
3.1. Dummy
objects are passed around but never actually used. Usually they are just used to fill parameter lists.
•
아무런 동작도 하지 않음 (정상 동작 보장 X)
•
인스턴스화된 객체만 필요하여 구현한 Dummy 객체
•
기능이 필요없는 경우에 사용
•
주로 파라미터를 전달하기 위한 용도
•
예시
interface Logger {
log();
}
class DummyLogger implements Logger {
log() {
// dummy
}
}
TypeScript
복사
3.2. Fake
objects actually have working implementations, but usually take some shortcut which makes them not suitable for production (an InMemoryTestDatabase is a good example).
•
실제 동작하는 구현은 갖고 있지만 운영환경에는 적합하지 않은 객체
•
Database에 연관된 객체를 테스트 하는 경우
◦
기존 객체를 상속받아, Database처럼 동작하는것처럼 보이는 객체를 만듦
3.3. Stub
provide canned answers (준비된 답변) to calls made during the test, usually not responding at all to anything outside what's programmed in for the test.
•
상태(결과) 기반 테스트
•
Dummy 객체가 실제로 동작하는 것처럼 만들어 놓은 객체
◦
기존 객체를 상속받아 Mock 객체를 만들어 결과값을 검증
•
테스트에서 호출된 요청에 대해 미리 준비해둔 결과를 제공
•
테스트를 위해 프로그래밍된 내용에 대해서만 준비된 결과를 제공하는 객체
3.4. Spy
are stubs that also record some information based on how they were called. One form of this might be an email service that records how many messages it was sent.
•
stub이 확장된 개념
•
실제 객체를 stubbing하면서 정보를 기록하는 객체
◦
호출 횟수
3.5. Mock
are pre-programmed with expectations which form a specification of the calls they are expected to receive. They can throw an exception if they receive a call they don't expect and are checked during verification to ensure they got all the calls they were expecting.
•
행위 기반 테스트
•
호출 될 것이라고 예상되는 specification(사양)을 형성한 기대값으로 미리 프로그램화 된 객체
4. Mocks Aren’t Stubs
4.1. Stub vs Mock
•
Stub
◦
상태(결과) 기반 테스트
◦
테스트 중에 만들어진 호출에 미리 준비된 답변을 제공
◦
일반적으로 테스트를 위해 프로그래밍된 것외에 전혀 응답하지 않음
•
Mock
◦
행위 기반 테스트
◦
호출될 것으로 예상되는 사양을 형성하는 기대값을 미리 프로그래밍 함
4.2. 상태 검증 vs 행위 검증
•
상태 검증
◦
Classicist가 주로 쓰는 방법
◦
테스트의 결과나 객체 내부 상태를 주로 검증
◦
테스트를 위해 상태를 드러내는 메서드가 생길 수도 있음
◦
상태를 직접적으로 검증하므로 안정적임
// Given
Order order = new Order('식빵', '딸기잼', '우유');
WareHouse wareHouse = new WareHouse('식빵', '딸기잼', '우유', '사과');
// When
order.check(wareHouse);
// Then
assertThat(order.isPossible).isTrue();
assertThat(wareHouse.size()).isEqualTo(1);
Java
복사
•
행위 검증
◦
협력 객체가 호출되었는지(행위) 검증
◦
특정 행동이 이루어졌는지를 확인 & 검증
// Given
Order order = new Order('식빵', '딸기잼', '우유');
WareHouse mockWareHouse = mock(WareHouse.class);
given(mockWareHouse.hasInventory('식빵', '딸기잼', '우유'))
.willReturn(true);
// When
order.check(wareHouse);
// Then
verify(mockWareHouse).hasInventory('식빵', '딸기잼', '우유');
verify(mockWareHouse).remove('식빵', '딸기잼', '우유');
Java
복사
4.3. 생각 정리
상태 검증이 상태와 결과를 검증하고, 행위 검증은 특정 행동이 이루어졌는지를 검증한다는 것의 차이는 명확히 이해가 되었다.
다만 SUT 내부에 collaborator(협력 객체, 의존성)가 존재하는 경우 test double을 이용하여 collaborator를 대체해줘야하는 경우가 있는데, 이 때 mock과 stub의 mocking 방식에 어떤 차이가 있는지 아직까지 명확히 이해가 되지 않았다.
Stub 객체를 따로 생성하여, 환경변수나 DI를 통해 주입하여 동작하게끔 한다면, Stub과 Mock을 구분할 수 있겠지만, 꼭 Stub 클래스를 별로도 생성해야만 Stub 이라고 간주 할 수 없다고 생각한다. 테스트에서 호출된 요청에 대해 미리 준비해둔 결과를 제공한다면, 일종의 Stub이라고 볼 수 있다고 생각한다.
특히 서비스 코어 로직이 순수함수로 구성되어있는 현재 회사에서는 stub 객체를 생성하는 방식이 적합하지 않다고 생각이 들었고, Stub과 Mock 둘다 아래와 같은 형태로 mocking이 진행될 것이라고 짐작되었다.
given(mockWareHouse.hasInventory('식빵', '딸기잼', '우유'))
.willReturn(true);
Java
복사
개인적으로는, mocking을 하는 방식에는 큰차이가 없고, 무엇을 검증하느냐에 따라 이걸 Stub과 Mock으로 분류 할 수 있지않을까 라고 생각된다.
•
상태나 결과를 검증할 경우 → Stub (상태 검증)
•
특정 행동이 이루어졌는지를 검증할 경우 → Mock (행동 검증)
위에서 정리한 생각을 바탕으로 작성한 단위 테스트 내용은 다음과 같다.
// Given
const spyGets3Object = jest.spyOn(s3ClientModule, 'getS3Object');
spyGets3Object.mockResolvedValue(new GetS3ObjectDto(config, '2'));
// When
const res = await executeSampleMethod();
// Then
expect(someData.value).toEqual(true); // SUT의 결과를 검증 -> Stub
expect(spyGets3Object).toBeCalledTimes(2); // SUT의 특정 행동 검증 -> Mock
TypeScript
복사
또한, 마이크로소프트에서 작성한 Unit Testing: Exploring The Continuum Of Test Doubles 에 따르면, Test Double의 분류는 이론적으로 구분된것처럼 보이나, 실제로는 그 경계가 상당히 모호하며 이를 일련의 연속체 또는 스펙트럼으로 보는게 더 적합하다고 한다. 따라서 완전히 구분하는것이 크게 의미가 없다고 판단되며 나 또한 상당히 공감되는 바이다.
•
Spectrum of Test Doubles
•
classical TDD
◦
use real objects if possible and a double if it's awkward to use the real thing. So a classical TDDer would use a real warehouse and a double for the mail service. The kind of double doesn't really matter that much.
•
mockist TDD
◦
practitioner, however, will always use a mock for any object with interesting behavior. In this case for both the warehouse and the mail service.