기존에 해왔던 사이드 프로젝트에서는 Service 로직과 일부 커스텀 클래스에 대한 테스트 코드 정도만 작성해 둔 상태였다.
'어차피 Controller는 Service의 메서드를 호출하는 정도의 역할만 하는데 굳이 테스트가 필요할까?' 등의 이유로 나머지 부분들에 대한 테스트는 작성하지 않고 있었다.

하지만 최근에 토스의 이응준 개발자님께서 올리신 테스트 커버리지 100% 영상을 보면서 테스트 코드 작성에 대한 생각을 바로잡게 되었다.
특히 모든 파일, 메서드, 로직에 대해서 테스트를 작성하시면서 '테스트 커버리지는 얼마든지 높일 수 있다'는 생각을 굳히게 되신 점이 인상깊었다.
그래서 이번 사이드 프로젝트에서는 테스트 커버리지 100%를 목표로 두고 프로젝트에 테스트 코드를 추가해 나가기로 결심했다.
테스트 커버리지를 높이려면 모든 파일의 모든 선언/브랜치/함수/라인을 커버할 수 있어야 한다.
처음에는 이 부분이 꽤나 까다롭게 느껴졌지만, 그 번거로움을 넘어설 만큼의 이점이 있다는 것을 느꼈다.
특히 테스트 커버리지의 부족한 부분을 메꾸는 과정에서 본 로직의 잘못되거나 부족한 부분을 찾게 되는 경우도 있었다.
아래의 예시에서 validateKeyAndLock은 lockName, key를 받아서 유효한지 검증하는 메서드이다.
메서드에서는 lock의 verifyKey 메서드를 이용해서 key에 대한 검증을 수행한다.
async validateKeyAndLock(
lockName: string,
key: string,
): Promise<void> {
const lock = await this.lockRepository.findOneByName(lockName);
if (!lock?.verifyKey(key)) {
throw new InvalidKeyException();
}
if (lock?.isExpired()) {
throw new ExpiredLockException();
}
}
얼핏 보면 해당 메서드에 대한 테스트 케이스는 3개면 충분하다고 생각할 수 있다.
it('매칭되는 lockName과 key가 주어졌을 때 에러가 발생하지 않음', async () => {
// given
const lock = Lock.create('테스트 자물쇠');
await lockRepository.save(lock);
const lockName = '테스트 자물쇠';
const key = lock.getKey();
// when
// then
await expect(lockService.validateKeyAndLock(lockName, key))
.resolves.not.toThrow();
});
it('lock과 매칭되지 않은 key를 제공하면 InvalidKeyException이 발생', async () => {
// given
const lock = Lock.create('테스트 자물쇠');
await lockRepository.save(lock);
const lockName = '테스트 자물쇠';
const key = 'invalid_key';
// when
// then
await expect(keyService.validateKeyAndLock(lockName, key))
.rejects.toThrow(InvalidKeyException);
});
it('lock이 만료되었다면 ExpiredLockException이 발생', async () => {
// given
const lock = Lock.create('테스트 자물쇠', new Date(new Date().getTime() - 10000));
await lockRepository.save(lock);
const lockName = '테스트 자물쇠';
const key = lock.getKey();
// when
// then
await expect(keyService.validateKeyAndLock(lockName, key))
.rejects.toThrow(ExpiredLockException);
});
하지만 위와 같이 작성하면 테스트 커버리지가 100%를 달성하지 못한다.
위의 테스트에서 커버리지가 부족한 부분은 branch 부분이다.
잘 살펴보면 key를 검증하거나 lock의 만료 상태를 검증하는 부분에서 optional chaining을 사용하고 있다.
const lock = await this.lockRepository.findOneByName(lockName);
if (!lock?.verifyKey(key)) {
throw new InvalidKeyException();
}
따라서 해당 제어문에 대한 모든 케이스를 검증하기 위해서는 두가지 케이스를 모두 검사해야 한다.
기존에 작성한 테스트 코드의 경우 lock이 null인 경우에 대한 테스트를 누락했다.
존재하지 않는 lock name인 경우에 대한 테스트를 추가해야 한다.
하지만 이를 추가하다보면 이상한 점을 발견하게 된다.
it('존재하지 않는 lock name이면 InvalidKeyException이 발생', async () => {
// given
const lockName = 'invalid_lock_name';
const key = 'key';
// when
// then
await expect(keyService.validateKeyAndLock(lockName, key))
.rejects.toThrow(InvalidKeyException);
});
InvalidKeyException은 lock에 매칭되지 않는 key를 제공했을 때 발생하는 게 적절하다.
위와 같이 존재하지 않는 lock name을 제공했을 때 InvalidKeyException을 발생시키면 명확성이 떨어진다.
존재하지 않는 lock 이름을 사용했을 때 발생시킬 별도의 에러를 정의할 필요가 있다.
이렇게 정의한 에러를 lock 조회에 실패했을 때 발생하도록 구성하면 된다.
이렇게 별도로 if문을 나눠서 구성하게 되면 optional chaining을 제거할 수 있다.
async validateKeyAndLock(
lockName: string,
key: string,
): Promise<void> {
const lock = await this.lockRepository.findOneByName(lockName);
if (!lock) {
throw new NotExistsLockNameException();
}
if (!lock.verifyKey(key)) {
throw new InvalidKeyException();
}
if (lock.isExpired()) {
throw new ExpiredLockException();
}
}
수정한 내용에 맞춰서 방금 작성한 테스트 코드도 함께 수정해보자.
it('존재하지 않는 lock name이면 NotExistsLockException이 발생', async () => {
// given
const lockName = 'invalid_lock_name';
const key = 'key';
// when
// then
await expect(keyService.validateKeyAndLock(lockName, key))
.rejects.toThrow(NotExistsLockException);
이렇게 작성하면 테스트 커버리지 100%를 달성할 수 있다.
위 영상에서 이응준 개발자님께서는 테스트 작성에 필요한 것은 테스트가 필요하다는 믿음과 테스트를 작성할 시간 이라고 말씀하셨다.
테스트 코드를 작성할수록 테스트가 필요하다는 믿음을 더 굳게 가지게 되는 것 같다.
테스트 코드는 본 로직의 안정성을 검증하는 것을 넘어서, 그것을 작성하는 과정에서 본 로직을 더 깊이 이해하고 개선할 기회를 제공해준다.
앞으로도 테스트 코드를 보다 적극적으로 작성하면서 테스트의 이점을 더 많이 느껴보고 싶다.