끄적끄적

Chai 예외 검증 커스터마이징 (with Mocha) 본문

개발/js & ts & node.js

Chai 예외 검증 커스터마이징 (with Mocha)

코리이 2024. 1. 21. 20:18

테스트를 하다보면 예외사항을 테스트해야 하는 경우가 자주 생긴다. 이 때 필자의 경우 어떻게 예외 테스트를 하고 있는지에 대해 이야기를 해보고자 한다.

 

이 포스팅에서는 커스텀한 에러를 던지고 있으며 그 형태는 아래와 같다고 가정하자.

export class CustomError extends Error {
    name: string;
    message: string;
    code: string;
    stack?: string;
    
    constructor(code: string, message: string, name?: string, stack?: string) {
        super(message);
        this.name = name || code;
        this.message = message;
        this.code = code;
        if (stack) {
            this.stack = stack;
        } else {
            Error.captureStackTrace(this, this.constructor);
        }
    }
}

export const isCustomError = (error: any): error is CustomError => error && error.name && error.message && error.code;

Chai 예외 테스트

Chai 에는 기본적인 예외케이스를 테스트 할 수 있도록 throw() 메서드를 제공하고 있으며 추가적으로 에러를 커스텀 했으므로 내부에 code 가 존재하는지 확인할 수 있다.

it('출금 금액이 보유한 금액보다 많다면 에러를 던진다.', () => {
    // given
    const sut = Wallet.create(/* ... */);
    
    // when
    // then
    expect(() => sut.withdraw(amount))
        .throw()
        .which.has.deep.include({
            code: 'ERROR_023',
            message: 'withdrawal amount is more than total amount',
        });
});

 

하지만 Promise 예외인 경우에는 어떻게 테스트 할 수 있을까 고민해볼 필요가 있다. 실제로 typescript 테스트를 하면 동기 테스트도 많이 하지만 비동기 테스트할 일이 정말 많이 생긴다. 다만 아쉽지만 Promise 예외의 경우 chai 에서 기본적으로는 제공하지 않아 플러그인을 활용해야 한다. 필자의 경우 처음에는 chai-as-promised 라는 chai 플러그인을 활용해 비동기 함수의 예외케이스를 테스트해 왔다.

 

비동기 함수 예외 또한 에러를 커스텀 했으므로 내부에 code 가 존재하는지 직접 확인해야 할 필요가 있다.

import * as chai from 'chai';
import * as chaiPromise from 'chai-as-promised';
chai.use(chaiPromise);

it('출금 금액이 보유한 금액보다 많다면 에러를 던진다.', async () => {
    // given
    const sut = WithdrawCommandHandler(/* constructor */);
    
    // when
    // then
    await expect(sut.handle(command)).to.eventually.be.rejected.which.has.deep.include({
    	code: 'ERROR_023',
        message: 'withdrawal amount is more than total amount',
    });
});

 

최근까지는 위와 같은 방식으로 커스텀한 에러를 테스트해 왔다. 하지만 chai-as-promised 라이브러리는 마지막 커밋이 2017 년으로 7년 이상 방치된 플러그인이라는 문제가 존재했다. 그 뿐만 아니라 개인적으로 위의 코드를 보면 커스텀한 에러를 파악하기 위해서 여러가지 메서드 체이닝을 써야 해서 가독성이 부족한 부분이 존재한다고 생각했다.  그래서 이 부분들을 리펙터링했다.

예외 테스트 헬퍼를 이용한 예외 테스트

헬퍼 함수를 만들기 전 chai plugin 형태로 만들지, 단순 함수로 만들지 고민했었다. 하지만 plugin 형태로 만들고 관리하는게 더 어려울 것이라고 생각이 들어 헬퍼 함수를 만들어서 사용했다.  초반에 만든 헬퍼 함수는 아래와 같다. 

// 함수 실행 시 catch 에서 에러를 잡아 리턴하는 형태
export function actCustomErrorThrow(action: () => any): CustomError {
    try {
        action();
    } catch (error: any) {
        if (isCustomError(error)) {
            return error;
        }
    }

    throw new Error('Unknown Error');
}

 

이를 활용하면 테스트 코드는 아래와 같이 리펙터링 되어질 수 있다.

it('출금 금액이 보유한 금액보다 많다면 에러를 던진다.', () => {
    // given
    const sut = Wallet.create(/* ... */);
    
    // when
    const actual = actCustomErrorThrow(
    	() => sut.withdraw(amount)
    );
    
    // then
    expect(actual.code).to.equal('ERROR_023');
    expect(actual.message).to.equal('withdrawal amount is more than total amount');
});

 

확실히 위처럼 테스트 코드를 작성하면 Given-When-Then 패턴에 맞도록 테스트 코드 가독성이 올라갈 수 있다. 현재는 동기 함수 테스트 코드를 리펙터링 했지만 비동기 함수 리펙터링을 한 코드를 보면 더 깔끔하고 가독성 있는 코드가 되었음을 파악할 수 있다. 헬퍼함수의 경우 Promise 를 받도록 수정만 하고 Async 라는 네이밍만 붙혔기 때문에 생략한다.

it('출금 금액이 보유한 금액보다 많다면 에러를 던진다.', async () => {
    // given
    const sut = WithdrawCommandHandler(/* constructor */);
    
    // when
    const actual = await actAsyncCustomErrorThrow(
    	() => sut.handle(command)
    );
    
    // then
    expect(actual.code).to.equal('ERROR_023');
    expect(actual.message).to.equal('withdrawal amount is more than total amount');
});

 

위 코드는 ".to.eventually.be.rejected.which.has.deep.include" 의 미친듯한 메서드 체이닝을 하지 않고 깔끔하게 equal 로만 상태 테스트를 진행할 수 있다는 점, chai-as-promised 라는 오래되고 커밋도 없는 라이브러리 의존성이 사라져서 마음 한 편의 짐을 덜을 수 있다는 점, Given-When-Then 패턴을 깔끔하게 사용할 수 있다는 점 등 이전 코드보다 훨씬 가독성 있는 테스트 코드가 만들어 졋다.

Try Catch 를 활용한 헬퍼 사용시 주의점

사실 Custom Error 헬퍼에는 한가지 문제점이 존재한다. 이에 대한 설명은 아래 블로그로 대신한다.

 

Jest로 Error 검증시 catch 보다는 expect

Jest를 통한 테스트를 작성하다보면 Exception에 대한 검증을 작성해야할 때가 있다. 이럴때 보통 2가지 방법 중 하나를 선택한다. try ~ catch expect.rejects.toThrowError 실제 코드로는 다음과 같다. // try ~ c

jojoldu.tistory.com

Mocha 대신 Jest 를 사용했지만 위의 에러 헬퍼 함수를 활용하면 동일한 문제가 발생할 수 있다. 가장 큰 문제는 아마 커스텀 에러가 아니라 다른 에러 발생시 제대로 된 에러를 판단하기 어렵다는게 아닐까 싶다. 그래서 에러 헬퍼 함수를 테스트 코드에서만 사용하는 에러를 하나 만들어서 stack 과 에러가 제대로 찍히도록 만들었다.

class UnknownTestError extends Error {
    constructor(
        name: string,
        message: string,
        stack?: string,
    ) {
        super(message);
        this.name = name;
        this.message = message;
        if (stack) {
            this.stack = stack;
        } else {
            Error.captureStackTrace(this, this.constructor);
        }
    }
}

export function actCustomErrorThrow(action: () => any): CustomError {
    try {
        action();
    } catch (error: any) {
        if (isCustomError(error)) {
            return error;
        }
        
        // 예상한 에러가 아니라면 에러를 던지되 name, message, stack 을 덮어씌운다.
        throw new UnknownTestError(
            error.name,
            error.message,
            error.stack,
        );
    }
    
    // action 이 제대로 실행되었다면 에러를 예상했지만 잘 실행된 것이므로 에러를 다시 던진다.
    throw new Error('error was not thrown');
}

 

이제 에러 추적이 어려운 문제를 해결했으므로 헬퍼 함수를 잘 활용해도 좋을 것 같다. 이 때 헬퍼함수만 변경한 것으로 테스트하는 코드 자체는 변한 것이 없으므로 그대로 두면 된다.

결론

테스트 코드 자체를 리펙터링 하는 일이 사실 많지는 않을지도 모른다. 하지만 반복적으로 테스트 코드를 작성하다보니 이 부분을 고치면 좋지 않을까 하는 생각들이 정말 많이 드는 부분이 있다. 개인적으로는 개발자라면 비즈니스 로직 코드 뿐 아니라 테스트 코드를 리펙터링 하는 일도 중요하게 생각해야 하지 않나 싶다.