본문으로 건너뛰기

vitest로 통신 mocking하기

· 약 16분
arch-spatula

테스트 코드를 작성하면서 자주 느낀 것이지만 mocking이 어렵습니다. 물론 개인적으로 jest보다 vitest를 더 많이 사용해봤습니다.

저는 오늘 제가 빼빼로를 받을지 테스트해보겠습니다.

warning

이글은 나중에 마이그레이션 대상입니다. 타입스크립트, 자바스크립트 탭으로 언제 이동할지 모릅니다.

이번에도 QDD 방식으로 작업을 진행할 것입니다. 질문을 만들고 질문에 답을 찾고 적용하고 커밋할 것입니다.

하지만 해당 레포는 볼 수 없을 것입니다. 제가 안 했습니다.

동작하는 코드 작성

프론트엔드는 TDD까지는 안 하는 경우도 많습니다. 개발 후 테스트 코드 작성으로 나중에 의존하는 부분에 변경이 안정적으로 이루어졌는지 확인하기 유용합니다.

저는 mocking할 때 현실에서 자주 발생할 만한 통신을 mocking할 것입니다. 그리고 그것도 아주 단순하게 만들 것입니다.

axios 설치

pnpm i axios

저는 pnpm으로 설치할 것입니다. 패키지 매니저는 취향것 알아서 하세요.

main.js
import axios from 'axios';

export async function callJSONPlaceholder() {
const res = await axios.get('https://jsonplaceholder.typicode.com/todos/1');
return res.data;
}

callJSONPlaceholder().then((res) => console.log(res));

위처럼 코드를 작성했습니다.

package.json
{
"dependencies": {
"axios": "^1.6.1"
},
"type": "module"
}

위처럼 package.json처럼 설정해주시기 바랍니다. 저는 import 문을 선호하는 취향이 있습니다. 존중하세요.

node main.js
# { userId: 1, id: 1, title: 'delectus aut autem', completed: false }

아마 위처럼 출력합니다.

TDD로 테스트를 먼저 작성하는 방식이 아닙니다. 하지만 코드자체가 단순하면 사후 테스트도 가치는 있습니다.

단순해서 테스트 가치가 없는 것은 아닙니다. 하지만 테스트를 쉽게 작성하기 위해 코드를 단순하게 작성하려는 습성이 생깁니다.

지금 예시는 매번 입출력의 멱등성을 보장받습니다. 하지만 현실에서는 매 요청마다 다른 결과가 나올 것입니다. 실제 요청의 결과가 매번 다를 때 어떻게 처리할지 테스트 코드가 필요합니다.

사실 레코드의 키는 같아도 받아올 엔티티의 수량은 가변적입니다. 이것을 어떻게 처리하는지 파악하는 것이 중요합니다.

테스트 코드 작성

브라우저 혹은 postman 같은 통신 클라이언트로 검증하는 행위는 처음에 자주 합니다.

하지만 수동으로 테스트하고 검증할 부분이 많을 때 지금 작업들이 필요합니다.

vitest 설치

pnpm i vitest

설치하고 난뒤에 명령 스크립트 설정합시다.

package.json
{
"dependencies": {
"axios": "^1.6.1",
"vitest": "^0.34.6"
},
"scripts": {
"test": "vitest"
},
"type": "module"
}
main.test.js
import { describe, expect, it } from 'vitest';

describe('callJSONPlaceholder', () => {
it('should work just fine', () => {
expect(false).toBe(true);
});
});

실패하는 테스트 코드를 작성합니다.

pnpm test

위코드는 성공적으로 실패할 것입니다.

여기까지 작성하면 테스트를 설정만 한 것입니다.

우리는 다양한 대상을 mocking 해야합니다. 우리가 직접 작성한 함수, 메서드부터 다른 라이브러리 혹은 패키지도 mocking해야 합니다.

본인의 코드 mocking

주의사항이 있습니다. 실제 API 요청을 시도하는 경우도 있습니다. MSW를 설정하면 실제 요청을 안 보내고 내부에서 테스트 서버가 가로채고 응답하는 방식으로 동작해서 상관 없습니다. MSW의 문제는 백엔드 전체를 mocking합니다. code gen으로 자동 해결할 수 있는 것이 아니면 좋은 라이브러리 아이디어입니다. 나중에 적용하는 것이 좋을 것 같습니다.

먼저 실제 라이브러리의 코드를 mocking하기 전의 제가 작성한 코드부터 mocking하겠습니다. 우리의 mocking 대상은 callJSONPlaceholder입니다.

그래서 저는 공식 문서의 일부를 발췌하고 비슷한 시도를 해봤습니다.

increment.js
export function increment(number) {
return number + 1;
}
increment.test.js
import { beforeEach, test } from 'vitest';
import { increment } from './increment.js';

// the module is not mocked, because vi.doMock is not called yet
increment(1) === 2;

let mockedIncrement = 100;

beforeEach(() => {
// you can access variables inside a factory
vi.doMock('./increment.js', () => ({ increment: () => ++mockedIncrement }));
});

test('importing the next module imports mocked one', async () => {
// original import WAS NOT MOCKED, because vi.doMock is evaluated AFTER imports
expect(increment(1)).toBe(2);
const { increment: mockedIncrement } = await import('./increment.js');
// new dynamic import returns mocked module
expect(mockedIncrement(1)).toBe(101);
expect(mockedIncrement(1)).toBe(102);
expect(mockedIncrement(1)).toBe(103);
});

위는 공식 문서의 일부입니다. 간단해보여서 시도해봤습니다.

main.test.js
beforeEach(() => {
vi.doMock('./main.js', async () => ({
userId: 1,
id: 1,
title: 'delectus aut autem',
completed: false,
}));
});

describe('callJSONPlaceholder', () => {
it('should work just fine', async () => {
const { callJSONPlaceholder: mockedCallJSONPlaceholder } = await import(
'./main.js'
);

const result = await mockedCallJSONPlaceholder();

expect(result).toBe(null);
});
});
 FAIL  main.test.js > callJSONPlaceholder > should work just fine
Error: [vitest] No "callJSONPlaceholder" export is defined on the "./main.js" mock. Did you forget to return it from "vi.mock"?
If you need to partially mock a module, you can use "vi.importActual" inside:

vi.mock("./main.js", async () => {
const actual = await vi.importActual("./main.js")
return {
...actual,
// your mocked methods
},
})

돌려준 에러입니다. 에러메시지를 자세히 보니까 생각보다 친절합니다. 저처럼 영어 독해력이 부족한 사람들을 위해 설명해주자면 importActual 메서드를 활용하라고 합니다.

main.test.js
import { describe, expect, it, vi } from 'vitest';
import { callJSONPlaceholder } from './main.js';

vi.mock('./main.js', async () => {
const actual = await vi.importActual('./main.js');
return {
...actual,
async callJSONPlaceholder() {
return {
userId: 1,
id: 1,
title: 'delectus aut autem',
completed: false,
};
},
};
});

describe('callJSONPlaceholder', () => {
it('should work just fine', async () => {
const result = await callJSONPlaceholder();

expect(result).toEqual({
userId: 1,
id: 1,
title: 'delectus aut autem',
completed: false,
});
});
});

위처럼 작성하니까 통과합니다. 테스트는 외부 요청을 안합니다. /todo/2으로 변경해도 테스트는 독립적으로 동작합니다. 테스트가 실제 외부 서버를 접근하는 행위를 안합니다.

외부 통신

저는 테스트 코드가 외부 통신을 시도하는 경험을 했습니다.

import axios from 'axios';

export async function callJSONPlaceholder() {
const res = await axios.get('https://jsonplaceholder.typicode.com/todos/1');
return res.data;
}

callJSONPlaceholder().then((res) => console.log(res));

아까 작성한 코드와 동일합니다.

main.test.js
import { describe, expect, it, vi } from 'vitest';
import { callJSONPlaceholder } from './main.js';

vi.mock('axios', async (importOriginal) => {
const mod = await importOriginal();

return {
...mod,
get(str) {
return {
userId: 1,
id: 1,
title: 'delectus aut autem',
completed: false,
};
},
};
});

describe('callJSONPlaceholder', () => {
it('should call axios', async () => {
const res2 = await callJSONPlaceholder();

expect(res2).toEqual({
userId: 1,
id: 1,
title: 'delectus aut autem',
completed: false,
});
});
});

위처럼 작성하면 테스트를 통과합니다.

하지만 uri의 값 1개를 올리(/todos/2로 변경하)면 테스트는 실패합니다. 즉 이 테스트는 신뢰할 수 없는 테스트 코드입니다. 위 테스트 코드를 신뢰할 수 있게 올바른 mocking을 해야 합니다.

만약에 테스트 코드에 시간(예: 400 ms)이 출력되면 실제 API를 주고 받는데 걸린 시간을 표시한 것입니다. E2E 테스트에서는 실제로 필요하지만 우리의 의도랑 다르게 동작한 경우입니다.

다른 점은 importOriginal를 매개변수를 통해서 접근했다는 점입니다. 실제 코드를 mocking해서 외부 요청을 차단하려면 viimportActual를 사용하기 바랍니다.

라이브러리 코드 mocking

테스트 코드를 작성하는 방식은 사람마다 다릅니다. axios 라이브러리를 직접 호출하는 사람들도 있고 wrapper 함수에 axios를 감싸는 사람도 있습니다. 가끔은 wrapper를 실험해야 하는 경우도 있습니다. 호출자의 관심사와 무관하게 요청을 차단해야 하는 경우도 존재하기 때문입니다. 즉 요청 차단이 성공적으로 동작하는지 유효성 검증을 확인하고자 테스트 코드를 작성하는 경우도 존재합니다.

이런 상황에서는 결국 라이브러리 코드의 동작을 mocking해야 합니다.

우리는 axiosget 메서드를 호출하고 어떤 문자열을 대입하고 무슨 문자열을 대입하든 무관하게 동일한 응답을 하도록 할 것입니다.

import { describe, expect, it, vi } from 'vitest';
import { callJSONPlaceholder } from './main.js';
import axios from 'axios';
import { beforeEach } from 'vitest';

vi.mock('axios');

beforeEach(() => {
axios.get.mockReset();

axios.get.mockResolvedValue({
data: {
userId: 1,
id: 1,
title: 'delectus aut autem',
completed: false,
},
});
});

describe('callJSONPlaceholder', () => {
// ... 생략
it('should work with axios', async () => {
const result = await axios.get(
'https://jsonplaceholder.typicode.com/todos/1'
);

expect(result).toEqual({
data: {
userId: 1,
id: 1,
title: 'delectus aut autem',
completed: false,
},
});
});
});

이렇게 작성하면 제가 작성했던 함수를 더이상 mocking하지 않을 수 있게 됩니다. 또 기존 코드도 그냥 통과합니다.

예외처리

하지만 문제가 있습니다. 사실 심각한 문제는 아닌데 에러처리 를 강제하고 있습니다.

Vitest caught 1 unhandled error during the test run.
This might cause false positive tests. Resolve unhandled errors to make sure your tests are not affected.

TypeError: Cannot read properties of undefined (reading 'data')
❯ callJSONPlaceholder main.js:5:14
3| export async function callJSONPlaceholder() {
4| const res = await axios.get('https://jsonplaceholder.typicode.com/todos/1');
5| return res.data;
| ^
6| }
7|

vitest는 틀렸는데 맞다고 하는 1종오류(false positive)를 범할 수 있다고 설명하고 있습니다. 다시 생각해보면 참 친절합니다.

저보고 지금 정의한 저의 코드에서 예외처리하라고 테스트러너가 알려줍니다.

import axios from 'axios';

export async function callJSONPlaceholder() {
try {
const res = await axios.get('https://jsonplaceholder.typicode.com/todos/1');
return res.data;
} catch (err) {
console.log(err);
return null;
}
}

callJSONPlaceholder().then((res) => console.log(res));

이렇게 수정하니까 에러는 사라졌습니다.

예외 테스트케이스

에번에는 throw를 mocking해보겠습니다. 실패하는 함수는 어떻게 처리하는지 확인하겠습니다.

main.test.js
vi.mock('axios');

beforeEach(() => {
axios.get.mockReset();
});

describe('callJSONPlaceholder', () => {
// ... 생략
it('should catch Error and return null', async () => {
axios.get.mockRejectedValue(new Error('oh Noo!'));

const result = await callJSONPlaceholder();

expect(result).toBeNull();
});
});

이번에는 위 코드가 테스트를 통과할 것입니다. mockRejectedValue이 에러를 던지게 만듭니다.

하지만 문제가 있습니다.

TypeError: Cannot read properties of undefined (reading 'data')
at callJSONPlaceholder
null

위와 같은 에러를 출력합니다. 그리고 여러개의 파일 디렉토리를 표시합니다.

저의 실수입니다.

import axios from 'axios';

export async function callJSONPlaceholder() {
try {
const res = await axios.get('https://jsonplaceholder.typicode.com/todos/1');
return res.data;
} catch (err) {
return null;
}
}

실행 안하게 console.log를 모두 지우면 되는 것이었습니다.

dangerouslyIgnoreUnhandledErrors으로 catch 안하기

--dangerouslyIgnoreUnhandledErrors 플래기를 CLI에 설정하면 unhandled rejection 경고를 제거할 수 있습니다. catch를 안 했다고 주는 경고인데 설정이 다행이 가능합니다.

호출자가 알아서 catch 하도록 코드를 작성하는 코드베이스도 많습니다. react-query를 사용하고 있으면 hook이 catch를 제공하고 있습니다.

import axios from 'axios';

export async function callJSONPlaceholder() {
const res = await axios.get('https://jsonplaceholder.typicode.com/todos/1');
return res.data;
}

위 처럼 작성해도 괜찮게 만들려면 CLI를 수정하면 됩니다.

vitest --dangerouslyIgnoreUnhandledErrors

공식 문서를 활용하면 위처럼 작성하면 됩니다.

하지만 더 구체적으로 다음처럼 package.json에 설정하면 됩니다.

package.json
{
"dependencies": {
"axios": "^1.6.1",
"vitest": "^0.34.6"
},
"scripts": {
"test": "vitest --dangerouslyIgnoreUnhandledErrors"
},
"type": "module"
}

위처럼 설정하면 더이상 catch 안했다고 화내지는 않습니다.

결론

라이브러리를 mocking할지 본인 코드를 mocking할지 코드의 관심사 문제로 생각됩니다. give에 mock를 설정하고 어떻게 처리하는 테스트 코드로 설정하면 될 것같습니다.

만약에 util 함수가 다른 라이브러리에 의존 하는 경우에는 라이브러리 mock으로 검증해는 것이 적절합니다.