비동기 코드 테스트하기
비동기 코드 테스트하기 (Testing Asynchronous Code)
자바스크립트에서는 코드가 비동기로 실행되는 일이 흔해요. 비동기로 실행되는 코드를 테스트할 때 Jest는 다음 테스트로 넘어가기 전에 지금 테스트 중인 코드가 완료됐다는 걸 알아야 해요. Jest에는 이런 상황을 처리하는 방법이 여럿 준비돼 있죠. 이 글에서는 Promise · async/await · 콜백 · .resolves/.rejects 네 가지 방식을 차례로 살펴볼게요.
Promise
테스트에서 Promise를 반환하면 Jest가 그 Promise가 resolve될 때까지 기다려 줘요. 만약 Promise가 reject되면 테스트는 실패하죠.
fetchData가 'peanut butter' 문자열로 resolve되는 Promise를 반환한다고 가정해 볼게요. 이렇게 테스트할 수 있어요.
test('the data is peanut butter', () => {
return fetchData().then(data => {
expect(data).toBe('peanut butter');
});
});
Async/Await
또는 테스트 안에서 async와 await를 사용할 수도 있어요. test에 전달하는 함수 앞에 async 키워드를 붙이면 되죠. 같은 fetchData 시나리오를 이렇게 테스트해 볼게요.
test('the data is peanut butter', async () => {
const data = await fetchData();
expect(data).toBe('peanut butter');
});
test('the fetch fails with an error', async () => {
expect.assertions(1);
try {
await fetchData();
} catch (error) {
expect(error).toMatch('error');
}
});
async/await는 .resolves나 .rejects와도 조합할 수 있어요.
test('the data is peanut butter', async () => {
await expect(fetchData()).resolves.toBe('peanut butter');
});
test('the fetch fails with an error', async () => {
await expect(fetchData()).rejects.toMatch('error');
});
이 경우 async와 await는 사실상 앞선 Promise 예제와 같은 로직을 위한 문법적 설탕(syntactic sugar)일 뿐이에요.
:::caution
Promise를 반환하거나 await하는 걸 잊지 마세요. return/await 문을 빼먹으면, fetchData가 반환한 Promise가 resolve나 reject되기 전에 테스트가 끝나 버려요.
:::
Promise가 reject될 거라고 예상한다면 .catch 메서드를 쓰세요. 이때 반드시 expect.assertions를 추가해 특정 개수의 단언이 호출되는지 확인해야 해요. 그렇지 않으면 fulfilled된 Promise는 테스트를 실패시키지 않으니까요.
test('the fetch fails with an error', () => {
expect.assertions(1);
return fetchData().catch(error => expect(error).toMatch('error'));
});
콜백 (Callbacks)
Promise를 쓰지 않는다면 콜백을 사용할 수 있어요. 예를 들어 fetchData가 Promise를 반환하는 대신, 데이터를 가져와 완료되면 callback(null, data)를 호출한다고 해 볼게요. 반환되는 데이터가 'peanut butter' 문자열인지 테스트하고 싶죠.
기본적으로 Jest 테스트는 실행이 끝에 도달하면 완료돼요. 즉 아래 테스트는 의도대로 동작하지 않아요.
// Don't do this!
test('the data is peanut butter', () => {
function callback(error, data) {
if (error) {
throw error;
}
expect(data).toBe('peanut butter');
}
fetchData(callback);
});
문제는 fetchData가 완료되자마자(콜백이 호출되기 전에) 테스트가 끝나 버린다는 거예요.
이를 해결하는 test의 또 다른 형태가 있어요. 빈 인자를 가진 함수에 테스트를 넣는 대신, done이라는 인자 하나를 받는 함수를 쓰는 거죠. Jest는 테스트를 끝내기 전에 done 콜백이 호출될 때까지 기다려요.
test('the data is peanut butter', done => {
function callback(error, data) {
if (error) {
done(error);
return;
}
try {
expect(data).toBe('peanut butter');
done();
} catch (error) {
done(error);
}
}
fetchData(callback);
});
done()이 호출되지 않으면 테스트는 (타임아웃 에러와 함께) 실패해요. 이건 우리가 원하는 동작이죠. 만약 expect 문이 실패하면 에러를 던지고 done()은 호출되지 않아요. 테스트 로그에서 왜 실패했는지 보고 싶다면 expect를 try 블록으로 감싸고, catch 블록에서 그 에러를 done에 전달해야 해요. 그렇지 않으면 expect(data)가 받은 값이 무엇인지 보여주지 않는 불투명한 타임아웃 에러만 남게 되죠.
:::caution
같은 테스트 함수에 done() 콜백을 전달하면서 Promise도 반환하면 Jest가 에러를 던져요. 테스트에서 메모리 누수를 막기 위한 안전장치예요.
:::
.resolves / .rejects
expect 문에 .resolves 매처를 사용할 수도 있어요. Jest가 그 Promise가 resolve될 때까지 기다리고, Promise가 reject되면 테스트가 자동으로 실패하죠.
test('the data is peanut butter', () => {
return expect(fetchData()).resolves.toBe('peanut butter');
});
단언을 반드시 return 하세요. 이 return 문을 빼먹으면, fetchData가 반환한 Promise가 resolve되고 then이 콜백을 실행할 기회를 얻기 전에 테스트가 끝나 버려요.
Promise가 reject될 거라고 예상한다면 .rejects 매처를 사용해요. .resolves와 똑같은 방식으로 동작하고, Promise가 fulfilled되면 테스트가 자동으로 실패하죠.
test('the fetch fails with an error', () => {
return expect(fetchData()).rejects.toMatch('error');
});
어떤 형태가 다른 형태보다 뛰어나다고 말할 수는 없어요. 코드베이스 안에서, 심지어 한 파일 안에서도 섞어 쓸 수 있죠. 단지 어떤 스타일이 테스트를 더 단순하게 만드는지에 달렸어요.
더 알아보기
비동기 코드 테스트에서 타이머를 제어해야 한다면 Timer Mocks를, 목 함수로 비동기 의존성을 대체하는 법은 Mock Functions을 참고하세요.