도서 정보
https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=353716210&start=slayer
좋은 기회가 생겨서 도서 "단위 테스트의 기술"을 읽고 리뷰를 작성하게 되었습니다.
단위 테스트를 처음 접하거나, 좋은 단위 테스트를 작성하고 싶어하는 분에게 책을 구매하는데 앞서 참고가 되었으면 좋겠습니다.
추천 대상*
- 프론트엔드의 단위 테스트 방법을 알고 싶은 주니어 또는 백엔드 개발자
- 다른 언어로 테스트 이론 학습했거나, 직접 구현하며 적용해 본 개발자
- OOP, SOLID 원칙, DI 등 개념을 이해하고 있는 개발자
리뷰
5장까지 읽으며 책의 전반적인 구성과 흐름이 매우 탄탄하다는 인상을 받았습니다. 1부에서는 좋은 테스트의 정의와 기본 용어를 설명하며, 환경 구성과 간단한 테스트 예제를 다룹니다. 2부에서는 테스트하기 어려운 코드를 DI로 개선하는 방법을 소개하고, 직접 구현했던 가짜 객체를 격리 프레임워크(Jest, Substitute)로 자동화하는 과정을 설명합니다. 특히, 좋은 단위 테스트를 작성하는 방법에 초점을 맞추고 있으며, 저자의 경험과 생각, 습관을 자연스럽게 공유하고 있어 흥미를 유발하며 몰입할 수 있었습니다.
저는 JUnit5와 Mockito를 주로 사용하며, 테스트와 설계 역량 강화를 위해 꾸준히 자기 계발을 해왔습니다. 처음에는 여러 강의를 찾아봤지만, 대부분 레이어 아키텍처를 기준으로 통합 테스트와 레이어별 테스트를 따라 작성하는 방식이었고, 단위 테스트에 대한 설명은 거의 없었습니다. 그러다 보니 단순히 강의를 따라 코드 작성하는 것만으로는 테스트가 머릿속에 남지 않았고, 왜 필요한지도 제대로 이해하지 못했습니다.
그러던 중, 한 교육을 통해 단위 테스트를 알게 되었고, 한 강의를 통해 직접 가짜 객체를 만들고 프레임워크로 자동화하는 과정을 실천하면서 단위 테스트의 필요성과 도구의 의미를 비로소 깨닫게 되었습니다. 이때를 기점으로 테스트를 체계화할 수 있었고, 성장 속도도 훨씬 빨라졌습니다. 놀랍게도, 제가 여러 강의와 교육, 책을 통해 단편적으로 배웠던 내용을 이 책의 초반부에서 모두 정리해주고 있어, 읽는 내내 감탄하며 공감할 수 있었습니다.
평소 프론트엔드 테스트에 대해서도 궁금했었는데, 결국 테스트 도구마다 표현 방식과 지원 범위는 다르더라도 목적과 행위는 본질적으로 같다는 점을 새롭게 깨달았습니다. 5장까지만 읽고도 많은 인사이트를 얻었는데, 완독하면 테스트에 대한 시야와 습관이 한층 더 깊어질 것 같아 기대됩니다. 실제로 뵌 적은 없지만, 제게 또 한 분의 은사가 생긴 듯한 기분이 들어 기쁩니다.👨🏻💻
p108. 좋았던 부분
다른 사람의 코드를 변경할 때 당연히 문제가 발생하지 않길 원할 것이다. 많은 개발자가 오래된 레거시(legacy) 코드를 수정하는 것에 부담감을 느끼는 이유는 코드를 바꾸면 다른 코드에 어떤 형태로 영향을 주는지 분명히 알 수 없기 때문이다. 코드 안정성을 보장할 수 없는 상태로 바꿀 가능성이 있기 때문이다. (..) 단위 테스트에서 쌓아 올린 신뢰도는 하나의 울타리가 되어 낯선 코드와 마주칠 때 두려움을 덜어 줄것이다. 좋은 테스트는 누구나 제한 없이 실행할 수 있어야 한다
읽기에 앞서
"단위 테스트의 기술"을 읽기에 어려움이 있으시다면 아래 교육, 강의를 해보시는 것을 추천합니다. 넥스트스텝의 두 강의는 주어진 과제를 직접 구현해야하기 때문에 난이도가 인프런 두 강의보다 좀 더 높습니다.
가격 | 비고 | |
넥스트스텝 - TDD, 클린코드 with Java | 800,000원 (가격변동가능) | - 6.5주간 4개 과제 수행 - 실무자 코드 리뷰 포함 - 수강인원 영향으로 1년에 2번 강의 개설 - 자기주도적, 능동적 학습 - 단위 테스트와 도메인 설계 역량 강화 |
넥스트스텝 - 플레이그라운드 | 44,000원 | - 구매 후 시간 무제한 - 스스로 총 4개의 과제 수행 - 코드 리뷰 미포함 |
인프런 - Java/Spring 테스트를 추가하고 싶은 개발자들의 오답노트 | 59,400원 | - 테스트를 샀는데 설계가 따라옴 - 레이어 아키텍처에서 헥사고날 아키텍처까지 |
인프런 - Practical Testing: 실용적인 테스트 가이드 | 77,000원 | - 설명 흐름이 해당 도서와 거의 비슷 |
*테스트와 설계 역량을 깨우치는데 도움을 주었던 강의입니다.
학습 요약
1장부터 5장까지 학습 경험을 토대로 일부 작성했습니다.
- 예제는 주로 JavaScript로 되어 있고, 마지막에 TypeScript로 변환하여 설명합니다.
- 테스트 프레임워크는 주로 Jest 사용하고, substitute.js를 추가로 사용합니다.
제 1부 시작하기
- 1장. 단위 테스트의 기초
- 2장. 첫번째 단위 테스트
제 2부 핵심 기술
- 3장. 의존성 분리와 스텁
- 4장. 모의 객체를 사용한 상호 작용 테스트
- 5장. 격리 프레임워크
1장. 단위 테스트의 기초
1장에서는 단위 테스트의 정의로 시작해서 좋은 단위 테스트의 특징과 테스트 용어를 설명하고 있습니다. SUT(System Under Test), Mock, Stub, 회귀 테스트 등 익숙한 테스트 용어가 순차적으로 나오고, 마지막 챕터에는 요약 정리를 제공합니다.
단위 테스트는(unit test)
컴퓨터 프로그래밍에서 예저 코드의 특정 모듈이 의도된 대로 정확히 작동하는지 검증하는 절차다. 즉, 모든 함수와 메서드에 대한 테스트 케이스(test case)를 작성하는 절차를 말한다. 이를 통해서 언제라도 코드 변경으로 인해 문제가 발생할 경우, 단시간 내에 이를 파악하고 바로 잡을 수 있도록 해준다. 이상적으로, 각 테스트 케이스는 서로 분리되어야 한다. (위키 백과)
좋은 단위 테스트의 특징
- 빠르게 실행되어야 한다
- 테스트 환경을 일관되게 유지하고, 테스트 결과가 항상 예측 가능해야 한다
- 다른 테스트와 완전히 독립적으로 실행되어 한다
- 시스템 파일, 네트워크, 데이터베이스가 없어도 메모리 내에서 실행되어야 한다
- 가능한 동기적인 흐름으로 실행되어야 한다 (가능하다면 병렬 스레드 사용x)
진입점과 종료점
- 작업 단위에는 항상 하나의 진입점과 하나 이상의 종료점이 있다
- 진입점: 작업 단위를 시작하는 공개(public) 함수로, 기본적인 함수(로직)을 실행하는 지점이다
- 종료점: 테스트로 검사할 수 있는 지점을 의미하며, 작업 단위 결과를 나타낸다
- 종료점은 값을 반환하거나, 상태값을 바꾸거나, 서드파티를 호출하는 형태가 될 수 있다
- 종료점은 해당 함수의 작업 단위가 돌아가는 실행 컨텍스트(execution context)에서 벗어나 다시 테스트 컨텍스트(test context)로 돌아간다는 의미
예제1-3. 진입점과 종료점
'use strict';
let total = 0;
const totalSoFar = () => {
return total;
};
const logger = makeLogger();
const sum = (numbers) => {
const [a, b] = numbers.split(',');
logger.info('this is a very important log output', {
firstNumWas: a,
secondNumWas: b
});
const result = Number.parseInt(a, 10) + Number.parseInt(b, 10);
total += result;
return result;
};
module.exports = {
sum,
totalSoFar,
};
예제 코드에서 sum함수의 종료점은 세 가지입니다
- 로깅 (logger.info(...)) → 외부 의존성(로거)에 의존
- 상태 변경 (total += result) → total이라는 전역 상태 변경
- 반환값 (return result) → sum 함수 결과로 합을 반환
종료점이 여러 개이면 테스트가 복잡해지고, 디버깅이 어렵다는 단점이 있습니다.
예로 sum('1,2') 호출 후 total 값이 예상과 다른 결과가 나왔을 때 문제가 어디서 발생했는지 한 눈에 파악하기 어려울 수 있습니다.
- 합계 연산이 잘못된 건지
- 상태값 갱신(total += result)이 잘못된 건지
- 외부 의존성 logger.info(...)에서 예외가 발생한건지
(한 함수에) 종료점이 여러 개인 경우 종료점마다 테스트를 만들어 분리하면 각 테스트끼리 영향을 주지 않고, 더 읽기 쉬우며, 디버깅하기도 쉽다
참고. 간단하게 합계 연산하는 부분을 메서드 추출했습니다
'use strict';
let total = 0;
const totalSoFar = () => total;
// 메서드 추출
const sumCore = (numbers) => {
const [a, b] = numbers.split(',');
return Number.parseInt(a, 10) + Number.parseInt(b, 10);
};
const logger = makeLogger();
const sum = (numbers) => {
const result = sumCore(numbers);
total += result; // 상태값 변경
// 로깅 호출
logger.info('this is a very important log output', {
firstNumWas: numbers.split(',')[0],
secondNumWas: numbers.split(',')[1],
});
return result;
};
module.exports = {
sum,
sumCore, // 순수 함수로 테스트 가능
totalSoFar,
};
+ sumCore로 핵심 로직(합계 연산)을 분리함으로써 합계에 대한 테스트와 디버깅이 전보다 개선되었습니다
+ logger의 경우 어답터 패턴을 적용하여 fake 객체(stub) 주입하는 방법으로 순수 코드만 사용해서 테스트 가능해 보입니다.
+ total 상태값 변경 또한 메서드 추출해서 외부에서 매개변수 전달하는 형태로 분리할 수 있어 보입니다
+ 이렇게 되면 sum()에서는 3개의 메서드가 올바르게 협력하는지만 확인하면 됩니다
+ 결과적으로 종료점 3개를 책임 분리함으로써 테스트와 디버깅이 용이해질 것으로 생각됩니다
통합 테스트
- 모듈, 외부 API 서비스, 네트워크, 데이터베이스 등 실제 의존성을 완전히 제어할 수 없는 상태에서 작업 단위를 테스트하는 것이다.
- 즉, 단일 기능이나 모듈이 아니라 여러 구성 요소가 함께 작동하여 최종 결과를 만들어 내는 방식을 테스트하는 것이다
- 통합 테스트는 하나라도 실패하면 제대로 된 결과를 얻을 수 없다.
- 여러 기능이 연쇄적으로 적용하기 떄문에 버그 원인을 찾는 것도 쉽지 않다
- 통합 테스트는 실제 의존성을 사용하기 때문에 단위 테스트의 비해 상대적으로 느리다
- 반면, 단위 테스트는 작업 단위를 의존성에 격리시켜 항상 일관된 결과를 받을 수 있도록 하여 작업 단위의 모든 측면을 쉽게 조작할 수 있게 한다.
p97
모든 테스트가 좋은 단위 테스트의 특성을 전부 만족하는 것은 사실 불가능에 가깝다. 그렇기에 항상 모든 조건을 만족할 필요는 없다. 단위 테스트 조건을 만족하기 까다로운 테스트는 적당한 리팩토링을 거쳐 보다 많은 조건을 충족하도록 만들 수도 있지만, 통합 테스트로 만드는 것도 하나의 방법이다
TDD (Test Driven Development)
- 실제 환경에서 사용할 코드를 작성하기 전에 테스트부터 작성하는 테스트 방법론 (테스트 주도 개발)
- 테스트 우선 접근법(test-first approach)이라고도 함
- red-green-refactoring cycle을 반복
- red: 실패하는 테스트를 작성한다
- green: 빠르게 기능 구현하여 테스트를 통과한다
- blue(refactoring): 리팩터링을 한다
TDD를 올바르게 사용할 경우
- 코드 품질 크게 향상
- 디버깅 시간이 줄어듦
- 버그가 줄어듦
- 코드에 대한 자신감 향상
- 코드 설계가 개선되면, 관리자 만족도 높아짐
하지만 TDD를 잘못 사용할 경우
- 프로젝트 일정이 밀림
- 시간 낭비 발생
- 동기 부여 떨어짐
- 코드 품질 저하
1장에서는 좋은 단위 테스트를 작성하는 방법에 대해서 중심을 두고 설명하고 있습니다. 그래서 좋은 단위 테스트를 작성하는 방법이나 리팩토링을 단계별로 하나씩 습득하고, TDD, 헥사고날 아키텍처 등을 학습해보는 것을 권장하고 있습니다.
2장. 첫번째 단위 테스트
2장에서는 테스트 프레임워크 Jest에 대해 살펴보고, 개발 환경 구성과 간단한 테스트를 작성합니다. JUnit5, Mockito와 같은 다른 테스트 프레임워크를 사용해 보신 분이라면 Jest 함수도 쉽게 이해할 수 있을 것 입니다.
Jest
- 페이스북에서 만든 오픈 소스 테스트 프레임워크
- 백엔드와 프런트엔드 프로젝트 테스트 모두에 널리 사용되고 있다
- 두 가지 주요 테스트 구문을 지원한다
Jest의 역할
- 테스트를 작성할 때 사용하는 테스트 라이브러리 역할
- 테스트 내에서 expect 함수를 사용하는 검증(assertion) 라이브러리 역할
- 테스트 러너(runner) 역할
- 테스트 실행 결과를 보여주는 테스트 리포터(reporter) 역할
(..) 하지만 자바 스크립트에서는 이러한 기능 중 일부만 제공하는 테스트 프레임워크가 많다. 이는 '한 가지 기능에 충실할 것'이라는 철학 때문일 수도 있고, 다른 이유가 있을 수도 있다. 어쨋든 제스트는 이 모든 기능을 하나로 통합한 몇 안되는 테스트 프레임워크 중 하나이다 (2.2 라이브러리, 검증, 러너, 리포터 내용 중)
Jest 설치 및 실행
# 예제 디렉토리 생성 및 초기화
$ mkdri ch2-first-test
$ cd ch2-first-test
$ npm init --yes 또는 yarn init --yes
# jest 글로벌 설치
$ npm install -g jest
# jest 실행
$ npx jest
- 2장 예제 디렉토리에서 초기화하지만, 3장부터는 프로젝트 루트 디렉토리에 초기화하여 관리합니다.
- 3장부터는 패키지 설치 및 설정 내용이 생략되어 있습니다. 그래서 직접 찾다보니 도서의 디테일적인 부분이 아쉬웠습니다
테스트 파일 위치
Jest는 테스트 파일을 찾을 때 기본적으로 다음 규칙을 따릅니다
- __tests__ 폴더가 있으면 그 안의 모든 파일을 이름과 상관없이 테스트 파일로 간주하고 불러온다
- 프로젝트 최상위 하위에 모든 폴더를 대상으로 *.spec.js 또는 *.test.js 파일을 재귀적으로 찾는다
2장에서는 우선 첫 번째 규칙을 따르고, 3장부터는 두 번째 규칙에 따라 실제 파일과 테스트 파일을 같은 위치에 둡니다
- 책의 저자는 테스트 폴더에 배치하는 쪽을 선호합니다
- 테스트에 필요한 헬퍼(helper) 파일을 테스트 폴더 근처에 둘 수 있어 편리하고, 탐색 측면에서도 이점이 있기 때문입니다
Jest를 사용할 때 파일 위에 require()를 쓸 필요가 없습니다. Jest는 자동으로 글로벌 함수를 불러옵니다 (import)
참고. 주로 많이 사용하는 Jest 함수
- test, it : 테스트 작성 (JUnit5의 @Test 애노테이션이 있는 메서드에 해당)
- describe : 테스트 구조를 좀 더 체계적으로 나누고 관리할 때 사용 (JUnit5의 @Nested에 해당)
- expect : 결과값과 기대값을 검증 (AssertJ나 Assertions에 해당)
① Jest로 작성하는 경우
describe('test description', () => {
test('hello', () => {
expect('hello').toEqual('goodbye');
});
it('jest', () => {
expect('jest').toEqual('jest');
});
});
② JUnit5로 작성하는 경우
import static org.assertj.core.api.Assertions.assertThat;
class Sample {
@Nested
@DisplayName("description")
class NestedClass {
@Test
@DisplayName("hello")
void fail() {
assertThat("hello").isEqualTo("goodbye");
}
@Test
@DisplayName("jest")
void success() {
assertThat("jest").isEqualTo("jest");
}
}
}
- 표현하는 방법만 다르지 JUnit5, Jest 둘 다 목적과 행위는 닮아 보였습니다
- Jest에서도 테스트 전후로 실행할 수 있는 before*, after* 콜백 메서드를 지원합니다
- Jest는 목(mock), 스텁(stub), 스파이(spy) 등을 생성할 수 있는 격리(isolation) 기능도 지원합니다
- Jest는 비동기 테스트와 콜백도 지원합니다 (책의 후반부 또는 Jest 문서 참고)
Jest, 테스트 프레임워크를 사용했을 때 달라진 점
- 테스트 코드와 일관된 형식
- 반복성
- 신뢰성과 시간 절약
- 공동의 이해
요약하자면
테스트 프레임워크는 테스트 작성부터 실행, 검증까지의 과정을 효율적으로 만들어 개발자의 생산성을 높여줍니다. 따라서 시간을 투자해서 학습할 가치가 충분합니다. 다만, 테스트 프레임워크를 사용한다고 해서 자동으로 가독성, 유지보수성, 신뢰성이 확보되는 것은 아닙니다. 또한, 모든 로직을 테스트할 수 있는 것도 아니므로 적절한 테스트 전략이 필요합니다.
개인적으로 이번 장에서 아래 주제가 흥미로웠습니다. (내용 생략*)
- (p190) 왜 test()보다 it() 함수가 더 명료하다고 할까?
- (p200) 검증 룰렛(assertion rouletter) 안티 패턴
- (p208) 스크롤 피로감 현상
이후 PasswordVerifier 기본 예제에 대한 테스트를 작성하고 개선 과정을 다룹니다. 이때, beforeEach()와 팩토리 함수를 점진적으로 도입하며 어떻게 테스트 코드 중복을 줄이고 가독성 향상을 하는지 설명합니다. 사수가 옆에서 설명하는 듯한 느낌을 주기 때문에 테스트를 처음 접한 분도 쉽게 이해할 수 있을 것입니다.
3장. 의존성 분리와 스텁
3장에서는 의존성 유형과 Mock, Stub 비교하여 살펴보고, 테스트 하기 힘든 코드를 테스트 하기 쉽게 리팩터링하는 예제를 다룹니다. 자바 스크립트의 함수형 프로그래밍과 객체 지향 프로그래밍 두 가지 스타일로 예제를 다루기 때문에 문법을 익히는데 도움이 되었습니다.
의존성 유형
- 외부로 나가는 의존성: 작업 단위의 종료점을 나타내는 의존성
- ex. 로거 함수 호출, 데이터베이스 저장, 이메일 발송, API 호출이나 웹훅 알림 보내는 작업 등
- 내부로 들어오는 의존성: 종료점을 나타내지 않는 의존성
- ex. 데이터베이스 쿼리 결과, 파일 시스템의 파일 내용, 네트워크 응답 결과 등
- 모두 이전 작업의 결과로 작업 단위로 들어오는 수동적인 데이터 조각이라 할 수 있다
stub, mock, fake의 이해
실무(또는 강의)에서 mock이라는 단어를 stub과 mock을 모두 아우르는 용어로 사용할 때가 많았습니다. 이에 저자는 stub과 mock은 큰 차이가 있으며 올바른 용어를 사용하여 상대방이 무엇을 의미하는지 명확히 하는 것이 중요하다고 설명합니다.
stub(스텁)
- 테스트에서 요청한 것에 대해 미리 준비한 결과를 제공하는 객체 (그외 응답 x)
- 결과값을 반환하도록 하여 데이터베이스 상태에 무관하게 검색 기능을 테스트 가능
- 테스트의 독립성을 높여주지만, 호출 여부는 검증하지 않는다
mock(목)
- 행위에 대한 기대를 명세하고, 그에 따라 동작하도록 만들어진 객체
- 외부 시스템과 상호 작용을 시뮬레이션하고, 호출여부나 호출된 인수를 검증하는데 사용
- 예로 실제로 이메일을 보내지 않으면서, 이메일 발송 함수가 호출되었는지 등을 확인할 때 사용
- 목은 하나의 테스트에서 하나만 사용하는 것이 좋다
fake(페이크)
- 실제 구현을 대체하는 가벼운 버전의 구성 요소이다
- 예로 실제 운영 데이터베이스 대신 인메모리 데이터베이스를 사용하여 테스트를 수행할 수 있다
“Mock ≠ Stub 이다”
- Mock 은 행위 검증(Behavior Verification)이 목적
- Stub 은 상태 검증(State Verification)이 목적
요악하자면
- stub: 미리 정의된 가짜 데이터를 제공하여 테스트 대상 코드의 입력을 시뮬레이션합니다.
- mock: 테스트 대상 코드가 외부 시스템과 상호 작용할 때 호출 여부와 인수를 검증합니다.
- fake: 실제 구현을 대체하는 가벼운 버전의 구성 요소를 제공하여 테스트를 수행합니다.
테스트 하기 힘든 코드
비밀번호 검증 프로젝트에 새로운 의존성을 추가했다고 가정합니다.
- 문제1. moment.js, 외부 모듈에 직접 의존하고 있다
- 문제2. 테스트하는 요일에 따라 테스트 성공, 실패 여부가 결정된다
예제3-1
const moment = require('moment');
const SUNDAY = 0;
const SATURDAY = 6;
const verifyPassword = (input, rules) => {
const dayOfWeek = moment().day(); // *문제, 강결합하고 있어서 제어 힘듦
if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
throw Error("It's the weekend!");
}
// do something..
return [];
};
export.modules = {
verifyPassword,
SUNDAY,
SATURDAY
}
테스트 작성
const moment = require('moment');
const { verifyPassword } = require('./password-verifier-time00');
const SUNDAY = 0;
const SATURDAY = 6;
const MONDAY = 2;
describe('verifier', () => {
const TODAY = moment().day();
// 주말에만 실행되고, 통과하는 테스트💩
test('on weekends, throws exceptions', () => {
if ([SATURDAY, SUNDAY].includes(TODAY)) {
expect(() => verifyPassword('anything', []))
.toThrowError("It's the weekend!");
}
});
});
좋은 테스트 기준 중 하나인 일관성을 다시 살펴보면
- 테스트는 언제 실행하든 이전 실행과 같은 결과를 보장해야 한다
- 테스트 내에서 사용된 같은 변하지 않아야 하고 검증도 매번 동일해야 한다
예제 함수 내부에서 날짜 모듈에 강결합을 하고 있는 상태라 테스트할 때 제어가 힘든 상태입니다. 만약에 날짜 모듈을 다른 걸로 바꾸게 된다면 어떻게 될까요? 지금 구조라면 기존 코드 수정은 불가피할 것이고, 이는 코드가 변화에 취약하고 확장에 유연하지 않다는 것을 의미하게 됩니다. 이에 도서에서는 테스트하기 쉽고, 변경에 유연하게 만드는 4가지 방식에 대해 설명합니다.
테스트 하기 힘든 코드를 개선하는 방법
① 매개변수 주입 방식
② 함수 주입 방식
③ 모듈 주입 방식
④ 생성자 주입 방식
1. 매개변수 주입
- 가장 기본적인 방식
- 함수 파라미터를 추가하여 외부에서 값을 주입하여 제어
// currentDay 추가
const verifyPassword2 = (input, rules, currentDay) => {
if ([SATURDAY, SUNDAY].includes(currentDay)) {
throw Error("It's the weekend!");
}
// do something..
return [];
};
// 외부에서 날짜 주입, 제어 할 수 있게 됨✨
describe('verifier2 - dummy object', () => {
test('on weekends, throws exceptions', () => {
expect(() => verifyPassword2('anything', [], SUNDAY))
.toThrowError("It's the weekend!");
});
});
+ currentDay는 '더미' 값인데, 저자는 이것이 '스텁' 범주에 포함되어 있다고 봅니다
+ 함수에 전달되는 특정 입력 값이나 동작을 모방하는데 이 값을 사용하므로 역시나 스텁이라 할 수 있다고 설명합니다
2. 함수 주입
- 이전 테스트와 큰 차이는 없지만, 함수를 매개변수로 전달하는 방법을 설명합니다
- 함수를 인수로 전달하는 방법은 특정 상황에서 예외를 만들어 내거나 테스트 내에서 특정한 동작을 하도록 만들 수 있어 유용하게 사용될 수 있습니다 (*Java에서는 Functional Interface가 생각나네요)
// 함수 파라미터에 getDayFn 추가
const verifyPassword3 = (input, rules, getDayFn) => {
const dayOfWeek = getDayFn(); // 필요할 때까지 호출 지연 가능
if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
throw Error("It's the weekend!");
}
// do something..
return [];
};
// ✨
describe('verifier3 - dummy function', () => {
test('on weekends, throws exceptions', () => {
const alwaysSunday = () => SUNDAY;
expect(() => verifyPassword3('anything', [], alwaysSunday))
.toThrowError("It's the weekend!");
});
test('on week days, works fine', () => {
const alwaysMonday = () => MONDAY;
const result = verifyPassword3('anything', [], alwaysMonday);
expect(result.length).toBe(0);
});
});
3. 모듈 주입
- 직접 모듈을 참조하지 않고, originalDependencies와 같이 한번 감싼 다음 사용하도록 합니다
- 도서에서는 의존성을 대체할 수 있는 심을 만든다고 표현합니다
예제
const originalDependencies = {
moment: require('moment'),
};
let dependencies = { ...originalDependencies };
const inject = (fakes) => {
Object.assign(dependencies, fakes); // fakes로 대체
return function reset() {
dependencies = { ...originalDependencies }; // 테스트 종료 후 원래 의존성 복구
};
};
const SUNDAY = 0;
const SATURDAY = 6;
const verifyPassword = (input, rules) => {
const dayOfWeek = dependencies.moment().day();
if ([SATURDAY, SUNDAY].includes(dayOfWeek)) {
throw Error("It's the weekend!");
}
// do something
return [];
};
module.exports = {
SATURDAY,
verifyPassword,
inject,
};
테스트 코드
const {
inject,
verifyPassword,
SATURDAY
} = require('./password-verifier-time00-modular');
const injectDate = (newDay) => {
const reset = inject({
moment: function () {
return {
day: () => newDay
};
}
});
return reset; // 함수를 반환
};
describe('verifyPassword', () => {
describe('when its the weekend', () => {
it('throws an error', () => {
const reset = injectDate(SATURDAY); // (1)
expect(() => verifyPassword('any input')) // (2)
.toThrowError("It's the weekend!");
reset(); // (3)
});
});
});
(1) injectDate() 함수는 테스트에서 반복되는 코드를 보일러 플레이트 코드, 헬퍼 함수라고 합니다
injectDate()에서 inject()를 호출하여 원래 의존하고 있던 moment 대신 선언한 함수로 변경하게 됩니다
(2) verifyPassword(..) 실행하면 moment().day() 함수에서 SATURDAY를 반환합니다
(3) reset() 함수 실행해서 기존 모듈로 의존성을 복구시킵니다
제어할 수 없는 서드 파티 의존성을 코드에 직접 가져오지 말고, 항상 제어할 수 있는 중간 추상화를 사용해야 한다.
포트와 어댑터 아키텍처(헥사고날 아키텍처, 어니언 아키텍처라고도 한다)가 좋은 예시이다.
4. 생성자 주입
- 클래스의 생성자를 이용하여 의존성을 주입하는 설계 방식
- 함수형 방식과 클래스 방식 두 가지를 살펴봅니다
- 상태를 가지는(stateful) 클래스는 한번만 설정하면 다음부터는 재사용할 수 있어 반복 작업을 줄일 수 있다는 장점이 있습니다
4-1. 함수형 방식
const SUNDAY = 0;
const SATURDAY = 0;
// new 키워드와 함께 호출되면 생성자 함수라고 한다
// this는 생성된 객체를 참조하여 초기화 작업을 수행한다
const Verifier = function(rules, dayOfWeekFn) {
this.verify = function(input) {
if([SATURDAY, SUNDAY].includes(dayOfWeekFn())) {
throw new Error("It's the weekend!");
}
// do something
};
};
module.exports = {
SUNDAY,
SATURDAY,
Verifier
}
4-1. 테스트
const {SUNDAY, Verifier} = require('./password-verifier-time02-modular');
test('constructor function : on weekends, throws exception', () => {
const alwaysSunday = () => SUNDAY;
const verifier = new Verifier([], alwaysSunday); // 생성자 함수
expect(() => verifier.verify('anything'))
.toThrow("It's the weekend!");
});
4-2. 클래스 방식 (ES6 문법 + 클래스 문법)
class Verifier {
constructor(rules, dayOfWeekFn) {
this.rules = rules;
this.dayOfWeekFn = dayOfWeekFn;
}
verify(input) {
if ([SATURDAY, SUNDAY].includes(this.dayOfWeekFn())) {
throw new Error("It's the weekend!");
}
// do something
}
}
4-2. 테스트
test('constructor function: on weekends, throws exceptions, ctor function', () => {
const alwaysSunday = () => SUNDAY;
const verifier = new Verifier([], alwaysSunday);
expect(() => verifier.verify('anything')).toThrow("It's the weekend!");
});
객체 지향적으로 만들수록 코드가 점점 더 장황해 지는 것을 볼 수 있는데, 이것이 객체 지향 프로그래밍의 특징입니다. 반면에 함수형 스타일의 코드는 더 간결한 경우가 많으며, 이러한 이유로 많은 사람이 함수형 스타일을 선택하기도 합니다
(➡️ 팀 컨벤션 따르는 것을 권장)
이후에 매개변수에 함수 대신 객체를 주입하는 방식과 TypeScript 변환한 예제를 다루는 데 크게 차이가 없기 때문에 생략합니다. 이번 3장에서는 내부 의존성을 외부에서 주입(DI) 할 수 있도록 변경해보면서 테스트 하기 쉬워지는 것을 확인할 수 있었습니다.
4장. 모의 객체를 사용한 상호 작용 테스트
4장에서는 세 번째 종료점에 해당하는 서드 파티 함수나 모듈, 객체를 호출하여 테스트하는 방법을 다룹니다 ( 종료점 첫번째는 '반환값', 두 번째는 '상태 값 변경')
처음 예제 학습할 때 mock을 사용해서 호출 여부를 검증하지 않기 때문에 stub 역할만 하는 mock으로 보였습니다. 지금 다시 생각해본다면 결국에 mock이 호출이 되었기 때문에 주입한 매개변수가 올바르게 반환된 것을 검증 가능하므로, mock의 역할을 올바르게 수행한 것으로 생각됩니다. (틀릴시 댓글 부탁드립니다🙇🏻♂️)
상호 작용 테스트
- 작업 단위가 외부 의존성과 어떻게 상호 작용하는지(어떤 호출이 실행되고, 어떤 매개변수로 호출되었는지) 확인하는 방법이다
- 상호 작용 테스트는 '서드 파티 모듈 및 객체, 시스템' 등을 의미하는 세 번째 종료점과 관련 있다
- 상호 작용 테스트를 위해서는 목(mock)을 사용해야 한다. 이때 목은 외부로 나가는 의존성을 대체하는 테스트 더블이다.
- 테스트에서는 목의 상호 작용을 검증해야 하지만 스텁과 상호 작용은 검증하지 않아야 한다
- 하나의 테스트에 스텁을 여러 개 사용하는 것은 괜찮지만, 목은 한 개만 사용하는 것이 좋다
- 하나의 테스트에 목을 여러 개 사용한다는 것은 여러 요구사항을 테스트한다는 의미
목과 스텁 비교
- 목(mock)
- 외부로 나가는 의존성과 연결 고리를 끊는데 사용
- 가짜로 만든 모듈, 객체, 함수를 의미
- 단위 테스트에서 종료점을 나타냄
- 외부 의존성을 제대로 호출했는지 검증할 수 있음
- 하나의 테스트에 하나의 목만 사용
- 스텁(stub)
- 내부로 들어오는 의존성과 연결 고리를 끊는데 사용
- 테스트 코드에 가짜 동작이나 데이터를 제공하는 가짜 모듈, 객체, 함수를 의미
- 목과 달리 스텁은 호출을 검증할 필요가 없음
- 하나의 테스트에 여러 개 사용할 수 있음
기본 예제
- PasswordVerifier 함수가 더욱 복잡해져서 함수 내부에서 로거 함수 추가
- PasswordVerifier 함수의 추가 요구사항 중 하나는 비밀번호 검증 작업 이후에 로거 함수를 호출하는 것
const _ = require('lodash');
const log = require('./complicated-logger'); // 외부 모듈 가정
const verifyPassword = (input, rules) => {
const failed = rules
.map((rule) => rule(input))
.filter((result) => result == false);
if(failed.length === 0) {
log.info('PASSED');
return true;
}
log.info('FAIL');
return false;
}
module.exports = {
verifyPassword
};
PasswordVerifier의 진입점은 verifyPassword 함수이고, 종료점은 두 개(반환 값, 로거 함수 호출)에 해당합니다
지금 단계에서 테스트 통해 logger 함수가 실제로 호출되었는지 확인할 수 없습니다. 여기서 코드를 추상화하는 방식으로 해결할 수 있는데, 심(seam)을 사용하는 방식을 설명합니다. 심은 두 코드 조각이 만나는 지점으로, 심을 사용해서 가짜 객체를 주입할 수 있습니다.
의존성을 추상화하는 방법은 3장과 동일합니다
① 매개변수 주입 방식
② 모듈 의존성 추상화 방식 (모듈형)
③ 고차함수 방식 (함수형)
④ 객체 주입 방식
1. 기본 스타일: 매개변수 주입 방식
매개변수를 추가하는 간단한 리팩터링으로 두가지 이점을 얻을 수 있습니다
① 테스트 코드에 import, require 구문이 사용하여 로거 함수를 불러올 필요가 없어짐
- 로거 함수 의존성 변경이 되도 영향 받지 않음
② 원하는 형태로 로거 함수를 정의하여 테스트에서 주입할 수 있음
- 이때 임의로 만든 로거 함수(mockLog)는 테스트할 때 함수가 제대로 호출되었는지 학인용도로 사용할 수 있음
예제 코드
const verifyPassword2 = (input, rules, logger) => {
const failed = rules
.map((rule) => rule(input))
.filter((result) => result == false);
if(failed.length === 0) {
logger.info('PASSED'); // 외부에서 logger 주입받아 테스트 가능
return true;
}
logger.info('FAIL');
return false;
}
module.exports = {
verifyPassword2
};
테스트 코드
const {verifyPassword2} = require('./password-verifier01');
describe('password verifier', () => {
describe('given logger, and passing scenario', () => {
it('calls the logger with PASSED', () => {
let written = '';
const mockLog = { info: (text) => (written = text)};
verifyPassword2('anything', [], mockLog);
expect(written).toMatch(/PASSED/);
});
});
});
2. 모듈 주입 방식
3장에서 했던 예제와 동일합니다. 직접 모듈을 참조하지 않고 한번 감싸서 참조하도록 변경합니다. 그리고 테스트시 모듈 의존성을 전달받은 fake로 변경하고, 테스트 종료시 원래 의존성으로 복구합니다.
예제 코드
// 모듈 주입 방식으로 변경, 참고로 require(..) 경우 complicaated-logger.js에서 export한 객체 전체가 반환된다 (info, debug)
const originalDependencies = {
log: require('./complicated-logger')
};
let dependencies = { ...originalDependencies };
const injectDependencies = (fakes) => {
Object.assign(dependencies, fakes); // 의존성을 fakes로 대체
}
const resetDependencies = () => {
dependencies = { ...originalDependencies }; // 원래 의존성으로 복구
};
const verifyPassword = (input, rules) => {
const failed = rules
.map((rule) => rule(input))
.filter((result) => result === false);
if(failed.length === 0) {
dependencies.log.info('PASSED');
return true;
}
dependencies.log.info('FAIL');
return false;
};
module.exports = {
verifyPassword,
injectDependencies,
resetDependencies
}
테스트 코드
const {
verifyPassword,
injectDependencies,
resetDependencies
} = require('./password-verifier-injectable01');
describe('password verifier', () => {
afterEach(resetDependencies); // 테스트 후 의존성 초기화
describe('given logger and passing scenario', () => {
it('calls the logger with PASS', () => {
let logged = '';
const mockLog = { info: (text) => (logged = text)}; // log.info 호출했을때 stub
injectDependencies({log: mockLog}); // 가짜 의존성 주입
verifyPassword('anything', []);
expect(logged).toMatch(/PASSED/);
});
});
});
3. 함수형 스타일 (고차 함수 방식)
lodash의 curry 함수를 사용하는 예제는 생략합니다.
예제 코드
const makeVerifier = (rules, logger) => {
return (input) => {
const failed = rules
.map((rule) => rule(input))
.filter((result) => result === false);
if(failed.length === 0) {
logger.info('PASSED');
return true;
}
logger.info('FAIL');
return false;
}
};
module.exports = {
makeVerifier
};
테스트 코드
const { makeVerifier } = require('./01-password-verifier');
describe('password verifier', () => {
describe('given logger and passing scenario', () => {
it('calls the logger with PASS', () => {
let logged = '';
const mockLog = { info: (text) => (logged = text)}; // log.info 호출했을때 stub
const passVerify = makeVerifier([], mockLog);
passVerify('any input');
expect(logged).toMatch(/PASSED/);
});
});
});
4. 객체 주입 방식
생성자 초기화를 통해 불변성을 유지할 수 있고, 테스트에서 객체를 재활용 가능하다는 장점이 있습니다. 마찬가지로 3장과 거의 동일합니다.
class PasswordVerifier {
_rules;
_logger;
constructor(rules, logger) {
this._rules = rules;
this._logger = logger;
}
verify(input) {
const failed = this._rules
.map((rule) => rule(input))
.filter((result) => result === false);
if(failed.length === 0) {
this._logger.info('PASSED');
return true;
}
this._logger.info('FAIL');
return false;
}
}
class FakeLogger {
logged = '';
info(text) {
this.logged = text;
}
}
module.exports = {
PasswordVerifier,
FakeLogger
}
테스트 코드
const { PasswordVerifier, FakeLogger } = require('./00-password-verifier');
describe('duck typing with function constructor injection', () => {
describe('password verifier', () => {
it('logger & passing scenario, calls logger with PASSED', () => {
let logged = '';
const mockLog = new FakeLogger(); // 또는 함수형으로 주입, ex. const mockLog = { info: (text) => (logged = text)};
const verifier = new PasswordVerifier([], mockLog);
verifier.verify('any input');
expect(mockLog.logged).toMatch(/PASSED/);
});
});
});
이후 타입스크립트로 변환하는 예제에서 ISP 원칙과 spy(부분 모의 객체)에 대한 내용을 다룹니다(내용 생략). spy를 다루는 예제에서 실제 메서드와 오버라이딩한 메서드가 공존하는 형태를 보여주는데 이 방식을 추출 및 오버라이드(extract and override)라고 설명합니다.
JUnit5, Mockito를 사용할 때는 애노테이션 기반으로 spy 사용했었는데, 타입 스크립트에서는 상속으로 spy 구현하는 방식이 개인적으로 신기했습니다.
5장. 격리 프레임워크
앞서 3장과 4장에서 스텁과 목을 수동으로 직접 작성하는 방법을 살펴보았습니다.
5장에서는 격리 프레임워크를 사용하여 수작업으로 작성했던 코드량을 줄이고, 더 간단하고 빠르게 테스트를 작성해봅니다.
격리 프레임워크
- 런타임에 가짜 객체를 생성하고 설정할 수 있는 재사용 가능한 라이브러리를 의미
- 이러한 객체는 동적 스텁, 동적 목이라고 한다
- 격리 프레임워크를 적절하게 사용할 경우 (장점)
- 객체의 상호 작용을 검증하거나 테스트할 때 반복적인 코드를 줄여 준다
- 테스트 지속성을 높여 오랜시간 개발자가 프로덕션 코드 변경될 때마다 테스트 수정하지 않아도 된다
- 격리 프레임워크를 잘못 오남용할 경우 가독성 저하, 신뢰할 수 없는 테스트, 의미없는 테스트를 작성하게 된다
선택하기
- 모듈 의존성(import, require) : 제스트 같은 느슨한 타입의 프레임워크가 좋다
- 함수형 의존성: 모듈 의존성과 마찬가지로, 제스트와 같은 느슨한 타입의 프레임워크가 잘 어울린다
- 객체 지향, 인터페이스: substitute.js 같은 객체 지향적인 프레임워크가 어울린다
제스트에서 제공하는 함수
- mockReturnValue() : 호출시 동일한 스텁 결과값 반환
- mockReturnValueOnce() : 한번씩만 결과값을 반환, 반환 값이 없으면 undefinded
- 오류를 테스트해야 하거나 더 복잡한 작업을 하는 경우 : mockImplementation(), mockImplementationOnce()
동적으로 가짜 모듈 만들기
- PasswordVerifier 예제를 계속해서 사용합니다.
- configuration-service.js 모듈에 선언된 로그 레벨에 따라 complicated-logger.js 에 info, debug 함수가 호출됩니다.
- jest.mock()을 선언하여 동적으로 생성할 가짜 모듈을 지정합니다.
- 그리고 테스트 내용은 AAA 패턴으로 작성합니다
jest.mock('./complicated-logger');
jest.mock('./configuration-service');
const { stringMatching } = expect; // jest 유틸리티 함수, 함수에 전달되는 매개변수 값을 검증
const { verifyPassword } = require('./password-verifier');
const mockLoggerModule = require('./complicated-logger');
const stubConfigModule = require('./configuration-service');
describe('password verifier, 로그 레벨을 stub 했을때, logger 모듈의 특정 함수가 호출되었는가', () => {
afterEach(jest.resetAllMocks);
it('with info log level and no rules, ' +
'it calls the logger with PASSED', () => {
stubConfigModule.getLogLevel.mockReturnValue('info');
verifyPassword('any input', []);
expect(mockLoggerModule.info)
.toHaveBeenCalledWith(stringMatching(/PASSED/));
});
it('with debug log level and no rules, ' +
'it calls the logger with PASSED', () => {
stubConfigModule.getLogLevel.mockReturnValue('debug');
verifyPassword('any input', []);
expect(mockLoggerModule.debug)
.toHaveBeenCalledWith(stringMatching(/PASSED/));
});
});
AAA 패턴
- 테스트 코드를 `준비(Arrange) - 실행(Act) - 검증(Assert)` 순으로 작성
- 격리 프레임워크를 사용하는 방식은 AAA 패턴과 잘 맞아 떨어집니다
- JUnit5 사용하셨다면 BDD (given - when - then) 패턴을 생각하시면 됩니다
함수형 스타일의 동적 목과 스텁
- 함수 매개변수로 logger를 주입받아 호출하는 형태로 가짜 logger를 주입하여 검증합니다
- 초기화 단계에서 jest.fn() 선언만 하고, 검증단계에서 메소드 체인을 통해 확인하는 형태로 좀 더 심플합니다
예제 코드
const makeVerifier = (rules, logger) => {
return (input) => {
const failed = rules
.map((rule) => rule(input))
.filter((result) => result === false);
if (failed.length === 0) {
logger.info('PASSED');
return true;
}
logger.info('FAIL');
return false;
};
};
module.exports = {
makeVerifier,
};
테스트 코드
const { makeVerifier } = require('./00-password-verifier');
it('given logger and passing scenario', () => {
const mockLog = { info: jest.fn() };
const verify = makeVerifier([], mockLog);
verify('any input');
expect(mockLog.info)
.toHaveBeenCalledWith(expect.stringMatching(/PASSED/));
});
객체 지향 스타일의 동적 목과 스텁
- PasswordVerifier 예제 코드 사용 ( TypeScript )
- PasswordVerifier 생성자 초기화시 logger: IComplictedLogger 주입받습니다
export interface IComplicatedLogger {
info(text: string, method: string);
debug(text: string, method: string);
warn(text: string, method: string);
error(text: string, method: string);
}
격리 프레임워크 사용 전)
예제 5-6에서 스텁을 직접 만드는 경우 보일러 플레이트 코드가 길어지는 걸 보여줍니다
import { PasswordVerifier } from "./00-password-verifier";
import {IComplicatedLogger} from "./interfaces/complicated-logger";
describe('working with log interfaces', () => {
describe('password verifier', () => {
class FakeLogger implements IComplicatedLogger {
debugText = '';
debugMethod = '';
errorText = '';
errorMethod = '';
infoText = '';
infoMethod = '';
warnText = '';
warnMethod = '';
debug(text: string, method: string) {
this.debugText = text;
this.debugMethod = method;
}
error(text: string, method: string) {
this.errorText = text;
this.errorMethod = method;
}
info(text: string, method: string) {
this.infoText = text;
this.infoMethod = method;
}
warn(text: string, method: string) {
this.warnText = text;
this.warnMethod = method;
}
}
it('verify, w logger & passing, calls logger with PASS', () => {
const mockLog = new FakeLogger();
const verifier = new PasswordVerifier([], mockLog);
verifier.verify('anything');
expect(mockLog.infoText).toMatch(/PASSED/);
});
});
});
격리 프레임워크 사용 후)
예제 5-7에서는 jest.fn() 함수로 인터페이스 구현함으로써 전보다 코드량이 줄어든 것을 확인할 수 있습니다. 추가로 인터페이스 함수의 매개변수가 변경되더라도 테스트는 영향을 받지 않습니다.
import { PasswordVerifier } from "./00-password-verifier";
import {IComplicatedLogger} from "./interfaces/complicated-logger";
describe('working with log interfaces', () => {
describe('password verifier', () => {
it('verify, w logger & passing, calls logger with PASS', () => {
const mockLog: IComplicatedLogger = {
info: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
error: jest.fn()
}
const verifier = new PasswordVerifier([], mockLog);
verifier.verify('anything');
expect(mockLog.info).toHaveBeenCalledWith(
expect.stringMatching(/PASSED/),
expect.stringMatching(/verify/)
);
});
});
});
예제 5-8에서는 substitute.js(정적 타입 격리 프레임워크)를 사용하여 가짜 인터페이스 만드는 방법을 설명합니다. 검증 단계에 메서드 체인 방식과 유틸 함수를 사용하는 부분이 인상 깊습니다.
import { PasswordVerifier } from "./00-password-verifier";
import { IComplicatedLogger } from "./interfaces/complicated-logger";
import { Substitute, Arg } from '@fluffy-spoon/substitute';
describe('working with log interfaces', () => {
describe('password verifier', () => {
it('verify, w logger & passing, calls logger with PASS', () => {
const mockLog = Substitute.for<IComplicatedLogger>();
const verifier = new PasswordVerifier([], mockLog);
verifier.verify('anything');
mockLog.received().info(
Arg.is((x) => x.includes('PASSED')),
'verify'
);
});
});
});
목과 스텁을 사용한 객체 지향 예제
- 마찬가지로 PasswordVerifier 예제 사용 ( TypeScript )
- s/w가 업데이트 되는 유지보수기간 동안 비밀번호 검증기가 비활성화 된다고 가정해보자
- 유지보수 기간 중에 verify() 호출하면 "Under Maintenance" 메시지를 logger.info()에 전달한다
- 유지보수 기간이 아닐 때 기존과 동일하게 logger.info()에 "PASSED", "FAILED" 문자열을 전달한다
예제 코드
export interface MaintenanceWindow {
isUnderMaintenance(): boolean
}
import { IComplicatedLogger } from './interfaces/complicated-logger';
import { MaintenanceWindow } from './maintenance-window';
export class PasswordVerifier3 {
private _rules: any[];
private _logger: IComplicatedLogger;
private _maintenanceWindow: MaintenanceWindow; // 추가
constructor(
rules: any[],
logger: IComplicatedLogger,
maintenanceWindow: MaintenanceWindow
) {
this._rules = rules;
this._logger = logger;
this._maintenanceWindow = maintenanceWindow; // 추가
}
verify(input: string): boolean {
if (this._maintenanceWindow.isUnderMaintenance()) { // 추가
this._logger.info('Under Maintenance', 'verify');
return false;
}
const failed = this._rules
.map((rule) => rule(input))
.filter((result) => result === false);
if (failed.length === 0) {
this._logger.info('PASSED', 'verify');
return true;
}
this._logger.info('FAIL', 'verify');
return false;
}
}
테스트 코드
substitute.js(정적 타입 격리 프레임워크) 사용하여 MaintenanceWindow 인터페이스의 스텁과 IComplicatedLogger 인터페이스의 목을 생성합니다. 직접 가짜 객체를 생성할 필요는 없어졌지만, 인터페이스에 함수가 하나라서 함수형으로 작성하고 인라인 처리하는게 좀 더 가독성이 있지 않나 싶습니다.
import { Substitute } from '@fluffy-spoon/substitute';
import { PasswordVerifier3 } from './00-password-verifier';
import { MaintenanceWindow } from './maintenance-window';
import { IComplicatedLogger } from './interfaces/complicated-logger';
// 팩터리 함수
const makeVerifierWIthNoRules = (log, maint) =>
new PasswordVerifier3([], log, maint);
describe('working with substitute part 2', () => {
it('verify, during maintenance, calls logger', () => {
const stubMaintWindow = Substitute.for<MaintenanceWindow>();
stubMaintWindow.isUnderMaintenance().returns(true);
const mockLog = Substitute.for<IComplicatedLogger>();
const verifier= makeVerifierWIthNoRules(mockLog, stubMaintWindow);
verifier.verify('anything');
mockLog.received()
.info('Under Maintenance', 'verify');
});
it('verify, outside maintenance, calls logger', () => {
const stubMaintWindow = Substitute.for<MaintenanceWindow>();
stubMaintWindow.isUnderMaintenance().returns(false);
const mockLog = Substitute.for<IComplicatedLogger>();
const verifier= makeVerifierWIthNoRules(mockLog, stubMaintWindow);
verifier.verify('anything');
mockLog.received()
.info('PASSED', 'verify');
});
});
격리 프레임워크의 장점과 함정
- 장점
- 손쉬운 가짜 모듈, 가짜 객체 생성
- 값이나 오류를 만들어 내기가 더 쉬워짐
- 함정
- 대부분의 단위 테스트에서는 모의 객체가 필요하지 않다 (5.6.1)
- 모의 객체나 스텁을 사용하면 외부 의존성에 영향을 받아 테스트 난이도가 올라간다
- 제어할 수 없는 서드 파티 의존성에 대한 반환값이나 호출 여부 확인시에 모의 객체 사용 권장
- 하나의 테스트에 많은 목을 만들거나 검증 단계가 많아지면 테스트 가독성이 저하 될 수 있다 (5.6.2)
- 가독성이 떨어진다면 목이나 검증 단계를 줄이는 것도 하나의 방법이다
- 또는 테스트를 더 작은 하위 테스트로 쪼개서 전체 가독성을 끌어올리는 것도 고민해보기
- 잘못된 대상 검증 (5.6.3)
- 모의 객체를 사용하면 인터페이스의 메서드나 함수가 호출되었는지 확인 가능하다
- 테스트 입문자가 흔히 저지르는 실수는 실제로 의미있는 동작을 검증하기보다 단기 가능하기 때문에 검증을 하는 것이다
- 이를 과잉 명세 안티 패턴이라고 함
- 하나의 테스트에 여러 개의 목을 사용 (5.6.4)
- 이는 동일한 작업 단위의 여러 종료점을 한꺼번에 테스트하는 것과 같다 (유지보수 비용 ↑, 가독성 ↓)
- 이때는 각 종료점마다 별도의 테스트를 작성(분리)하는 것이 좋다
- 테스트의 과도한 명세화 (5.6.5)
- 테스트 검증 항목이 많을수록 프로덕션 코드 변경에 쉽게 깨질 수 있다
- 상호작용을 테스트하는 것은 양날의 검과 같아서, 너무 많아도 적어도 문제가 될 수 있다
- 상황에 따라 "목 대신 스텁을 사용"하거나 "스텁을 목으로 사용하지 않는 것"을 고민해본다
- 대부분의 단위 테스트에서는 모의 객체가 필요하지 않다 (5.6.1)
mock 객체 선언해 stub으로 사용하면서, 메소드 호출 여부를 검증했던 제 자신을 반성합니다 😅😂.
도서에서 설명하는 것과 같이 호출 테스트가 가능하니깐 검증을 했었던 것 같네요. 오늘도 또 하나 배우고 성장합니다 🙇🏻♂️
'독서 > 📚' 카테고리의 다른 글
[도서] 만화로 배우는 리눅스 시스템 관리1 - 요약 정리 (4) | 2025.01.21 |
---|---|
[도서] 이것이 취업을 위한 코딩테스트다 (with 파이썬) 후기 (0) | 2025.01.10 |
[개발도서]프로그래머 열정을 말하다 (채드 파울러) (2) | 2024.05.02 |
[Next Step] 12장 확장성 있는 DI 프레임워크로 개선 (0) | 2023.11.23 |
[Next Step] 11장 의존관계 주입(DI)을 통합 테스트 하기 쉬운 코드 만들기 (0) | 2023.11.21 |
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!