테스트를 진행할때는 보통 소프트웨어의 한 요소에 집중한다. 이를 일반적으로 단위테스트(Unit Test)라 부른다.
그러나 단위테스트가 제대로 작동하려면, 종종 다른 단위(Unit, module, funtion...)이 필요하다.
예를들어 저장소같은 것을 대체할 수단이 필요하게 된다.
저장소를 대체할때 Stub, MOck, Fake, Dummy같은 용어가 혼용되어 용어의 혼란이 생기기 쉽다.
여기서는 Gerard Meszaros의 책에 나온 용어를 따른다.
Meszaros는 "Test Double"이라는 용어를 실제 객체 대신 사용하는 모든 가짜 객체의 포괄적인 용어로 정의한다. 이는 영화의 스턴트 더블(Stunt Double) 개념에서 유래했다. 또한 그는 Test Double을 다섯 가지로 분류했다.
Mock은 행동 검증(Behavior Verification)을 고집하지만, 다른 Double들은 상태 검증(State Verification)을 사용한다. Mock도 실행 단계에서는 다른 Double처럼 동작하지만, 설정(Setup)과 검증(Verification) 단계에서 차이가 있다.
=> 일단 이해하기로는 Mock은 결과를 검증, 다른 가짜객체들은 상태를 검증한다. 정확히 와닿지 않으니 예제로 살펴보자.
실제 객체 대신 Test Double을 사용하는 일반적인 경우는 테스트 시 실제 객체가 다루기 어려울 때다. 예를 들어, 주문이 처리되지 않았을 경우 이메일 메시지를 보내야 한다고 가정해보자. 이 경우 테스트 중에 실제 고객에게 이메일을 보내고 싶지 않으므로, 이메일 시스템의 Test Double을 만든다.
즉, 이메일 시스템의 가짜객체를 생성한다.
원본 예시는 java코드이나 js로 변환해보았음!
// emailService.js
class EmailService {
send(message) {
throw new Error('Method not implemented');
}
}
class EmailServiceStub extends EmailService {
constructor() {
super();
this.messages = [];
}
send(message) {
this.messages.push(message);
}
numberSent() {
return this.messages.length;
}
}
module.exports = { EmailService, EmailServiceStub };
// order.js
class Order {
constructor(product, quantity) {
this.product = product;
this.quantity = quantity;
this.mailer = null;
this.isFilled = false;
}
setMailer(mailer) {
this.mailer = mailer;
}
fill(warehouse) {
...
}
}
module.exports = Order;
const { EmailServiceStub } = require(...);
test('이메일 전송 여부를 확인한다', () => {
const order = new Order('Product1', 10); // 주문 생성
const emailServiceStub = new EmailServiceStub(); // Stub 객체 생성
order.setMailer(emailServiceStub); // 이메일 서비스로 Stub 설정
order.fill(warehouse); // 주문 처리. warehouse는 가짜객체가 아니라 진짜 객체임
expect(emailService.numberSent()).toBe(1); // Stub의 상태(전송된 메시지 개수) 검증
});
stub사용시 상태를검증한다. 즉, 전송된 메시지의 개수가 내가 기대한 값과 같은지를 검증한다.
const emailServiceMock = {
send: jest.fn(), // Mock 함수로 선언
};
const warehouseMock = {
hasInventory:() => false;
};
// 테스트 코드
test('이메일 전송 함수가 호출되었는지 확인한다', () => {
// 주문 객체 생성 및 Mock 설정
const order = new Order('TALISKER', 51);
order.setMailer(emailServiceMock);
order.fill(warehouseMock);
// 행동 검증: 'send' 메서드가 한 번 호출되었는지 확인
expect(emailServiceMock.send).toHaveBeenCalledTimes(1);
// 행동 검증: 'hasInventory' 메서드가 한 번 호출되었는지 확인
expect(warehouseMock.hasInventory).toHaveBeenCalledTimes(1);
// 행동검증: 'hasInventory' 메서드가 적절한 인자로 호출되었는지 확인
expect(warehouseMock.hasInventory).toHaveBeenCalledWith('TALISKER', 51);
});
Mock사용시 내가 기대한 행동이 일어났는지를 검증한다. 즉, send
메서드가 몇 번 호출되었는지, 어떤 인자로 호출되었는지 를 검증한다. Stub와 Mock은 상태,행동 검증이라는 차이가 있다.또한 stub 사용시 테스트를 위해 추가적인 메서드 구현이 필요할 수도 있다.
Meszaros는 Stub 중에서 동작 검증을 사용하는 것을 Test Spy라고 부르기도 한다.
출처 : https://martinfowler.com/articles/mocksArentStubs.html#TheDifferenceBetweenMocksAndStubs
단위테스트는 모든 부분을 분리하여 테스트한다. 즉, 하나의 모듈만 테스트한다.
describe("get todady!", () => {
it("should return today like YYYY-MM-DD-HH:MM:SS", () => {
const getToday = require("./getToday");
const today = getToday();
const regex = /^\d{4}-\d{2}-\d{2}-\d{2}:\d{2}:\d{2}$/;
expect(today).toMatch(regex);
});
});
그림으로 나타내면 이렇다.
이미지 출처 : https://dariuszwozniak.net/posts/kurs-tdd-2-testy-jednostkowe-a-testy-integracyjne
단일메서드를 테스트하기에 단위테스트는 나머지 소프트웨어가 올바르게 동작한다는 가정(종종 잘못된)에 의존한다.
이는 모든 종속성을 명시적으로 모의하기 때문이다.
따라서 특정 기능의 단위테스트가 올바르게 작동한다 해도, 해당 기능이 제대로 작동한다는 것을 의미하지 않는다.
굉장히 아이러니한 일이 아닐 수 없다. 왜 그런건지는 조금 이따 살펴보자.
통합테스트에서는 시스템의 여러 모듈을 테스트한다.
이미지 출처 : https://dariuszwozniak.net/posts/kurs-tdd-2-testy-jednostkowe-a-testy-integracyjne
왜 통합테스트가 필요한걸까?
만약 단위테스트만 사용하면 아래와 같은 참사가 일어난다.
이미지 출처 : http://softwaretestingfundamentals.com/integration-testing/
따라서 단위테스트와 통합테스트를 적절히 행해야 한다.
Unit Test, Integration Test단락의 출처 : https://stackoverflow.com/questions/10752/what-is-the-difference-between-integration-and-unit-tests
대충 이런 메서드가 있다고 가정해보자.
class Human {
//...
DoBreath: (air) => {
console.log('스읍!');
const carbonDioxide = purificate(air);
console.log('휴~!');
return carbonDioxide;
}
}
누가봐도 아주 중요한 메서드다!🤣
이 메서드의 단위테스트를 작성해보자.
describe('breath unit test', () =>{
it('Inhale oxygen and exhale carbon dioxide', () => {
const result = DoBreath(cleanAir);
expect(result).toBe(carbonDixoide);
})
})
테스트에 통과하면, 작동하는 기능을 제공한다고 단언할 수 있다.
단위테스트를 작성하려면 나머지 클래스와 메서드가 작동하는 것을 가정하고 기능이 작동하는지 확인해야한다.
위 메서드에서는 purificate()
가 무조건 올바르게 작동한다고 가정해야한다. 즉, 철저하게 다른 모듈에서 격리시켜야한다.
그러나 purificate()
에 버그가 있다고 가정해보자. 다행히도 개발자가 통합테스트코드를 작성해놓아서, 버그를 찾을수 있었다.
만약 purificate()
가 100군데서 사용되고 있다면 100개의 기능이 실패할 것이다. 하지만 통합테스트 코드를 작성해놓았기에 문제를 드러낼 수 있게되었다.
하지만 DoBreath()
에 대한 단위테스트는 성공한다.
만약 B
라는 클래스에 버그가 있다고 가정해보자.
해당 클래스에 의존하는 다른 클래스들의 통합 테스트는 실패할 것이다.
그러나 단위테스트는 단 하나의 테스트(B
클래스에 대한)만 실패하게 된다.
단위 테스트는 Test Dobule위에서 진행된다. 따라서 다른 모듈이 실패할 경우를 고려하지 않는다.
통합테스트는 무엇이 작동하지 않는지알려준다. 그러나 문제가 어디있는지추측하는데는 아무 소용이 없다.
단위테스트는 버그가 정확히 어디있는지 알려준다. 그러나 무엇이 작동하지 않는지알려주지는 않는다.
따라서 두가지 테스트를 적절히 행할 필요가 있다.
출처 : https://stackoverflow.com/a/7876055/23726403
https://arialdomartini.wordpress.com/2011/10/21/unit-tests-lie-thats-why-i-love-them/
단위테스트는 하나의 모듈만을 테스트하지만, 통합 테스트는 여러 모듈을 테스트한다.
프로젝트의 규모가 커질 수록 모든 코드를 테스트하기란 어려운 법이다.
따라서 복잡한 테스트일수록 더 적게, 간단한 테스트일수록 더 많이 작성한다. 이것이 테스팅 피라미드의 정의다.
일반적으로 단위테스트는 자주실행한다.
통합테스트는 여러 모듈을 합한 테스트다. 따라서 브랜치 병합, 기능 변경사항 통합이후에 실행해야한다.
E2E 테스트는 End-to-End테스트라 하며 사용자 인터페이스 부터 데이터베이스에 이르기까지 전체 서비스를 시작부터 끝까지 테스트한다.
따라서 제일 적게 실행한다.