기능 개발을 하다 보면 패키지 매니저를 통해 다양한 외부 라이브러리를 설치해 사용할 때가 많다. 일반적으로는 서비스 클래스의 비즈니스 로직안에서 필요한 기능을 외부에서 가져와 구현하는 경우가 많을텐데, 이런 경우 어떻게 단위 테스트를 작성해야 하는지에 대해 적어보려한다.
기본적인 흐름은 이전과 크게 다르지 않으며, 최대한 이전에 다룬 FIRST 규칙과 논리적 흐름에 대한 파악을 중점적으로 다뤄본다.
CRUD 메소드들에 대한 단위 테스트를 작성하다보면 TypeORM 과 같은 ORM 라이브러리에 대한 단위 테스트는 외부 라이브러리의 모듈보다 익숙할 수도 있다. TypeORM 을 사용해 데이터 베이스 쿼리를 사용하는 메소드들에 대한 단위 테스트를 어떻게 했었는지 다시 떠올려보자. repository
의 find
메소드를 1회 user_id 를 통해 실행했는지, 이후에 save 메소드를 1회 dto 를 통해 수행했는지를 검사했었다. 외부 라이브러리를 사용하는 목적은 다르지만 논리적인 구조는 동일하다.
먼저 어떤 값을 받아서 어떤 기능(절차)를 수행하고 어떤 값을 반환할지에 초점을 맞춘다. 받아오는 값은 함수의 인자가 될 것이고 기능은 크기가 크다면 메소드가 되고 아니라면 간단한 익명 함수일수도 있다. 마지막으로 기능들이 수행되고 함수는 정해진 반환값을 리턴할 것이다.
우리가 집중하는 부분은 어떤 기능 즉, 절차이다. 인자가 주어졌을때 우리가 원한 절차대로 기능들이 실행되는 것을 테스트해야 한다.
예시를 위해 User Service
클래스의 비밀번호 암호화 함수를 가져와보았다.
import * as cryptoModule from 'crypto';
export class UserService implements IUserService {
...
async **hashPassword**(password: string){
const salt = cryptoModule.**randomBytes**(32).toString('hex'); // 1) **randomBytes 를 통해 원문에 덧붙일 임의의 문자열을 생성한다.**
const encryptedPassword = crypto.**pbkdf2Sync**( // 2) pbkdf2Sync 를 통해 동기식 암호 기반 키 파생 함수를 실행시켜 digest(암호화한 결과값)을 획득한다.
password, salt, 1, 32, 'sha512'
).toString('hex');
return encryptedPassword
}
...
}
hashPassword
라는 함수는 비밀번호를 암호화한다. 레인보우 테이블 등을 통한 무차별 공격에 의해 타인의 비밀번호가 유출되는 것을 막기 위해 암호화에 단순 해시가 아닌 salt 문자열을 적용해 보안을 강화하였다.
따라서 위의 함수는 주석에 적힌대로
cryptoModule.randomBytes
를 통해 원문에 덧붙일 임의의 문자열을 생성한다. crypto.pbkdf2Sync
를 통해 암호화된 결과값 획득 및 반환위와 같이 논리적 구성이 이루어진다. 따라서 우리는 1, 2번이 순차적으로 적절한 값으로 정해진 만큼 실행되고, 올바른 값을 반환하는지를 테스트할 것이다.
우리는 단위 테스트를 작성하는데 있어서 어떻게 암호화 알고리즘이 작동되는지를 테스트하지 않는다. 위에서 다룬 절차에 대해서 테스트한다. 따라서 외부 모듈(여기서는 cryptoModule
)의 randomByte
, pbkdf2Sync
를 가짜로 대체하거나, 구현 자체를 가짜로 대체하지 않고 실행 과정을 spying 하는 방법을 취해야 한다.
혹은, 두 방법을 적절히 섞어 사용할 수도 잇을 것이다.
이번에는 spyOn
을 사용해서 실제 구현을 가짜로 대체하지 않고 적절한 인자로 실행되었는지를 확인해본다. 전체 코드는 아래와 같고 번호별로 조금 더 살펴보자.
import * as CryptoModule from 'crypto';
describe('hashPassword', () => {
const password = 'kimbeobwoo'; // 함수의 인자
const saltBuffer = Buffer.from(Array<string>(32).fill('t').toString()); // 실제 구현 1) 에서 반환할 salt Buffer (dummy)
// 테스트 항목 정의
it('randomBytes 와 pbkdf2Sync 를 실행해 암호화된 문자열을 반환한다.', async () => {
// (1) CryptoModule 의 randomBytes 함수를 spying 하고 반환값은 위에서 정의한 salt Buffer 로 한다.
const randomBytes = jest
**.spyOn(CryptoModule, 'randomBytes')
.mockImplementation(() => saltBuffer);**
// (2) CryptoModule 의 pbkdf2Sync 함수를 spying 한다.
**** const pbkdf2Sync = **jest.spyOn(CryptoModule, 'pbkdf2Sync');**
const executeResult = await service.hashPassword(password);
// (3) randomBytes 함수가 32 의 값으로 1회 호출되었는지 테스트
expect(randomBytes).**toBeCalledTimes**(1);
expect(randomBytes).**toBeCalledWith**(32);
// (4) pbkdf2Sync 함수가 password, salt string, 1, 32, 'sha512' 의 값으로 1회 호출되었는지 테스트
expect(pbkdf2Sync).**toBeCalledTimes**(1);
expect(pbkdf2Sync).**toBeCalledWith**(
password,
saltBuffer.toString('hex'),
1,
32,
'sha512',
);
...
});
});
const saltBuffer = Buffer.from(Array<string>(32).fill('t').toString());
...
const randomBytes = jest
**.spyOn(CryptoModule, 'randomBytes')
.mockImplementation(() => saltBuffer);**
spyOn
을 통해 CryptoModule
의 randomBytes
함수에 spy 를 붙였다. 굳이 mockImplementation
을 사용해 구현을 가짜로 대체할 필요는 없지만, 실제 코드를 생각해보면 randomBytes
함수로 만들어진 salt 문자열이 pbkdf2Sync
함수의 인자로 전달된다. 이를 적절히 테스트하기 위해 saltBuffer 를 반환하도록 처리하였다.
이렇게 되면, it
(테스트항목)의 콜백에서 우리의 hassPassword
함수가 실행될때 처음으로 실행되는 randomBytes
함수의 호출 관련 정보를 spy 할 수 있다.
const pbkdf2Sync = **jest.spyOn(CryptoModule, 'pbkdf2Sync');**
spyOn
을 통해 CryptoModule
의 pbkdf2Sync
함수에 spy 를 붙였다. 위와 마찬가지로 pbkdf2Sync
함수의 호출 관련 정보를 spy 할 수 있다.
expect(randomBytes).**toBeCalledTimes**(1);
expect(randomBytes).**toBeCalledWith**(32);
드디어 우리가 함수를 잘 배치하고 적절한 값을 넘겼는지 테스트 할 수 있게 되었다. expect 함수의 인자로 jest.fn 혹은 jest.spyInstance 를 넘기게 되면 toBeCalltedTimes 와 같은 검증 메소드를 실행 하여 테스팅을 할 수 있다.
우리는 위의 (1), (2) 를 통해 spyInstance 를 정의해놓았으므로 위의 코드 처럼 적절한 검증 메소드를 수행하면 된다. 처음에 이야기했던 것 처럼 우리는 randomBytes 의 인자값과 호출 횟수를 검증하였다.
expect(pbkdf2Sync).**toBeCalledTimes**(1);
expect(pbkdf2Sync).**toBeCalledWith**(
password,
saltBuffer.toString('hex'),
1,
32,
'sha512',
);
대부분의 내용은 (3) 과 같은데, (4) 이 다른점은 saltBuffer
를 인자로 넘기는 부분에 있다. 나는 이렇게 연속적인 함수의 호출이 있고, 각 반환값이 다음 함수의 인자로 넘어가야하는 경우에는 이런식으로 테스트를 구성한다.
pbkdf2Sync
는 mocking 을 통해 가짜로 대체되지않아 실제 구현 그대로의 함수가 호출된다. 이때 우리가 위에서 randomBytes
가 saltBuffer
라는 값을 반환하도록 가짜 구현으로 대체해놓았으므로 정상적인 상황이라면 pbkdf2Sync
는 반드시 salt 문자열 자리에 saltBuffer.toString('hex')
가 와야한다.
위처럼 spyOn
을 사용하면 가짜로 대체하기 매우 애매한 (특히 외부 라이브러리의 경우 더 그런 경우가 많다 … ) 상황에서 테스트를 쉽게 수행 할 수 있다.
하지만, 만약 테스트 항목이 1개가 아니라 여러개로 늘어나게되면 이상한 상황을 보게 되는데,
it('randomBytes 와 pbkdf2Sync 를 실행해 암호화된 문자열을 반환한다.', async () => {
const randomBytes = jest
.spyOn(CryptoModule, 'randomBytes')
.mockImplementation(() => saltBuffer);
...
expect(pbkdf2Sync).toBeCalledWith(
password,
saltBuffer.toString('hex'),
1,
32,
'sha512',
);
});
it('pbkdf2Sync 의 오류 발생시 "비밀번호 암호화 실패" 에러를 반환한다.', async () => {
const randomBytes = jest
.spyOn(CryptoModule, 'randomBytes')
.mockImplementation(() => saltBuffer);
...
const executeResult = () => service.hashPassword(password);
await expect(async () => await executeResult())
.rejects.toThrow(new Error('비밀번호 암호화 실패'));
**expect(randomBytes).toBeCalledTimes(1); <-- 테스트 실패**
expect(randomBytes).toBeCalledWith(32);
});
위의 코드는 무조건 테스트 실패를 반환한다.
expect(jest.fn()).toBeCalledTimes(expected)
Expected number of calls: 1
Received number of calls: 2
53 | await service.hashPassword(password);
54 |
> 55 | expect(randomBytes).toBeCalledTimes(1);
| ^
56 | expect(randomBytes).toBeCalledWith(32);
57 | });
58 | });
왜냐하면 우리의 mockFn.mock.calls
와 mockFn.mock.instances
에는 이미 첫번째 it 콜백에서 수행된 테스트의 정보가 남아있기 때문이다. 이렇게 이전의 정보가 남아있는 경우 독립적인 테스트를 할 수 없게 되므로 단위 테스트의 의미가 많이 흐려지게된다.
따라서 반드시 mock
을 정리해주어야 한다.
describe('hashPassword', () => {
const password = 'kimbeobwoo';
const saltBuffer = Buffer.from(Array<string>(32).fill('t').toString());
const randomBytes = jest
.spyOn(CryptoModule, 'randomBytes')
.mockImplementation(() => saltBuffer);
const pbkdf2Sync = jest.spyOn(CryptoModule, 'pbkdf2Sync');
**beforeEach(() => {
randomBytes.mockClear();
pbkdf2Sync.mockClear();
});**
...
mock
을 정리하는 방법은 jest
객체를 사용해도되고 jest.fn,
jest.spyInstance
를 사용해 정리해도 된다. 해당 부분에 대해 자세한 내용은 다음에 다루도록 해본다.