Skip to content

Commit

Permalink
Merge pull request #1 from Wannabe-Woowa-Article/lurgi
Browse files Browse the repository at this point in the history
stop mocking fetch (24.05.26)
  • Loading branch information
Jaymyong66 authored Jun 3, 2024
2 parents dd933da + 335fd03 commit 1fb7029
Show file tree
Hide file tree
Showing 4 changed files with 335 additions and 2 deletions.
1 change: 0 additions & 1 deletion May/article/article.md

This file was deleted.

312 changes: 312 additions & 0 deletions May/article/stop-mocking-fetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,312 @@
## 🔗 [Stop mocking fetch](https://kentcdodds.com/blog/stop-mocking-fetch)

### 🗓️ 번역 날짜: 2024.05.26

### 🧚 번역한 크루: 러기(박정우)

---

## “fetch” 모킹은 그만!

```jsx
// __tests__/checkout.js
import * as React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { client } from "~/utils/api-client";

jest.mock("~/utils/api-client");

test('clicking "confirm" submits payment', async () => {
const shoppingCart = buildShoppingCart();
render(<Checkout shoppingCart={shoppingCart} />);

client.mockResolvedValueOnce(() => ({ success: true }));

userEvent.click(screen.getByRole("button", { name: /confirm/i }));

expect(client).toHaveBeenCalledWith("checkout", { data: shoppingCart });
expect(client).toHaveBeenCalledTimes(1);
expect(await screen.findByText(/success/i)).toBeInTheDocument();
});
```

이 텍스트는 당신이 **‘Checkout’****‘/checkout’** 엔드포인트의 실제 API와 요구 사항을 알지 못한다면 정말로 답할 수 없다는 것을 전제로 하고 있습니다. 이 점을 양해해 주십시오.

이 경우 문제점 중 하나는, 클라이언트를 모킹하고 있기 때문에 클라이언트가 제대로 사용되고 있는지 어떻게 알 수 없다는 것입니다.

클라이언트가 window.fetch를 제대로 호출하는지 단위 테스트를 할 수 있지만, 클라이언트의 API가 최근에 데이터 대신 본문을 받도록 변경되지 않았다는 것을 어떻게 알 수 있을까요?

TypeScript를 사용하므로 [일부 버그 카테고리는 제거되었습니다!](https://kentcdodds.com/blog/eliminate-an-entire-category-of-bugs-with-a-few-simple-tools) 하지만 비즈니스 로직 버그가 발생할 수 있으므로 클라이언트를 모킹하는 데 주의가 필요합니다. 물론 E2E 테스트를 통해 확신을 얻을 수 있지만, 더 빠른 피드백 루프를 가지고 있는 이 하위 레벨에서 클라이언트에 직접 호출하여 확신을 얻는 것이 더 나을 수도 있습니다. 만약 그렇게 어렵지 않다면요!

하지만 실제로 ‘**fetch’** 요청을 하고 싶지는 않겠죠? 그럼 ‘**window.fetch**’를 모킹해 봅시다.

```jsx
// __tests__/checkout.js
import * as React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

beforeAll(() => jest.spyOn(window, "fetch"));
// jest의 resetMocks가 "true"로 설정되어 있다고 가정합니다.
// 정리에 대해 걱정할 필요가 없습니다.
// 또한 `whatwg-fetch`와 같은 불러오기 폴리필을 로드했다고 가정합니다.

test('clicking "confirm" submits payment', async () => {
const shoppingCart = buildShoppingCart();
render(<Checkout shoppingCart={shoppingCart} />);

window.fetch.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
});

userEvent.click(screen.getByRole("button", { name: /confirm/i }));

expect(window.fetch).toHaveBeenCalledWith(
"/checkout",
expect.objectContaining({
method: "POST",
body: JSON.stringify(shoppingCart),
})
);
expect(window.fetch).toHaveBeenCalledTimes(1);
expect(await screen.findByText(/success/i)).toBeInTheDocument();
});
```

이렇게 하면 실제로 요청이 이루어지고 있음을 좀 더 확신할 수 있지만, 이 테스트에서 부족한 것은 '**Content-Type**'이 '**application/json**'인지 확인하는 단언이 없다는 것입니다. 그것 없이 어떻게 서버가 당신이 만든 요청을 인식할 것이라고 확신할 수 있나요? 그리고 올바른 인증 정보가 전송되고 있는지 어떻게 보장할 수 있나요?

"**우리는 클라이언트 단위 테스트에서 이미 그것을 검증했어요, Kent. 더 무엇을 원하나요? 저는 모든 곳에 단언을 복사/붙여넣기 하고 싶지 않아요!**" 분명히 당신의 입장을 이해합니다. 하지만 모든 테스트에서 그 확신을 얻으면서도 모든 곳에서 추가적인 작업을 피할 수 있는 방법이 있다면 어떨까요? 계속 읽어보세요.

저를 정말 괴롭히는 것 중 하나는 fetch와 같은 것을 모킹할 때, 당신은 테스트 곳곳에서 전체 백엔드를 재구현하게 된다는 것입니다. 종종 여러 테스트에서 이런 일이 발생합니다. 특히 "**이 테스트에서는 정상적인 백엔드 응답을 가정합니다**"라고 할 때, 그것을 곳곳에서 모킹해야만 하는 것은 정말 짜증납니다. 그런 경우에는 정말로 단지 설정 소음이 당신과 실제로 테스트하려는 것 사이에 끼어들게 됩니다.

결국 불가피하게 다음과 같은 시나리오 중 하나가 발생합니다:

1. **클라이언트를 모킹합니다** (첫 번째 테스트에서처럼) 그리고 몇 가지 E2E 테스트에 의존하여 적어도 가장 중요한 부분이 클라이언트를 올바르게 사용하고 있음을 약간 확신할 수 있습니다. 이는 백엔드를 건드리는 것들을 테스트할 때마다 백엔드를 재구현하는 결과를 초래합니다. 종종 작업이 중복됩니다.
2. **window.fetch를 모킹합니다** (두 번째 테스트에서처럼). 이 방법은 조금 나은 편이지만, #1과 같은 문제를 일부 겪습니다.
3. 모든 것을 작은 함수들로 나누고 모두를 독립적으로 단위 테스트합니다 (자체적으로 나쁘지 않은 일) 그리고 통합 테스트를 하지 않습니다 (좋은 일은 아닙니다).

결국 우리는 더 낮은 확신과 더 느린 피드백 루프, 많은 중복된 코드 또는 이들의 조합에 직면하게 됩니다.

오랫동안 나에게 잘 작동했던 한 가지 방법은 fetch를 한 함수에서 모킹하는 것인데, 이는 기본적으로 내가 테스트한 백엔드의 모든 부분을 재구현하는 것입니다. 나는 이 방식을 PayPal에서 사용했고 매우 잘 작동했습니다.

이를 다음과 같이 생각할 수 있습니다

```jsx
// add this to your setupFilesAfterEnv config in jest so it's imported for every test file
import * as users from "./users";

async function mockFetch(url, config) {
switch (url) {
case "/login": {
const user = await users.login(JSON.parse(config.body));
return {
ok: true,
status: 200,
json: async () => ({ user }),
};
}
case "/checkout": {
const isAuthorized = user.authorize(config.headers.Authorization);
if (!isAuthorized) {
return Promise.reject({
ok: false,
status: 401,
json: async () => ({ message: "Not authorized" }),
});
}
const shoppingCart = JSON.parse(config.body);
// do whatever other things you need to do with this shopping cart
return {
ok: true,
status: 200,
json: async () => ({ success: true }),
};
}
default: {
throw new Error(`Unhandled request: ${url}`);
}
}
}

beforeAll(() => jest.spyOn(window, "fetch"));
beforeEach(() => window.fetch.mockImplementation(mockFetch));
```

```jsx
// __tests__/checkout.js
import * as React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

test('clicking "confirm" submits payment', async () => {
const shoppingCart = buildShoppingCart();
render(<Checkout shoppingCart={shoppingCart} />);

userEvent.click(screen.getByRole("button", { name: /confirm/i }));

expect(await screen.findByText(/success/i)).toBeInTheDocument();
});
```

내 성공 경로 테스트는 특별히 할 것이 없습니다. 실패 사례에 대한 fetch 모킹을 추가할 수도 있지만, 이 방식에 대해 매우 만족했습니다.

이 방식의 장점은 내 확신이 증가하고 대부분의 경우에 작성해야 할 테스트 코드가 더욱 줄어든다는 것입니다.

## 그러던 중에 msw를 발견했습니다.

[msw](https://github.com/mswjs/msw)는 "Mock Service Worker"의 약자입니다. 서비스 워커는 Node에서 작동하지 않고, 브라우저 기능입니다. 하지만 msw는 테스트 목적으로 Node에서도 지원합니다.

기본 아이디어는 다음과 같습니다: 모든 요청을 가로채서 실제 서버처럼 처리할 수 있는 모의 서버를 생성합니다. 제 개인적인 구현에서 이는 json 파일을 사용하여 "데이터베이스"를 "씨딩"하거나 faker나 test-data-bot과 같은 것을 사용하는 "빌더"를 만드는 것을 의미합니다. 그런 다음 서버 핸들러(Express API와 유사)를 만들고 그 모의 데이터베이스와 상호 작용합니다. 이 방법은 내 테스트를 빠르고 쉽게 작성할 수 있게 해줍니다(일단 설정을 마치면).

이전에 nock과 같은 것을 사용하여 이러한 작업을 해본 적이 있을 수 있습니다. 그러나 msw에 대해 멋진 점 (그리고 나중에 쓸 수도 있는 것)은 개발 중에 브라우저에서도 동일한 "서버 핸들러"를 사용할 수 있다는 것입니다. 이는 몇 가지 큰 이점을 가지고 있습니다:

- 엔드포인트가 준비되지 않았을 때
- 엔드포인트가 고장 났을 때
- 인터넷 연결이 느리거나 존재하지 않을 때

[Mirage](https://miragejs.com/)에 대해 들어보셨을 수 있는데, 많은 부분에서 비슷한 일을 합니다. 그러나 (현재로서는) mirage는 클라이언트에서 서비스 워커를 사용하지 않으며, msw를 설치했는지 여부에 관계없이 네트워크 탭이 동일하게 작동한다는 점이 마음에 듭니다. 그 차이점에 대해 더 알아보세요.

```jsx
// server-handlers.js
// this is put into here so I can share these same handlers between my tests
// as well as my development in the browser. Pretty sweet!
import { rest } from "msw"; // msw supports graphql too!
import * as users from "./users";

const handlers = [
rest.get("/login", async (req, res, ctx) => {
const user = await users.login(JSON.parse(req.body));
return res(ctx.json({ user }));
}),
rest.post("/checkout", async (req, res, ctx) => {
const user = await users.login(JSON.parse(req.body));
const isAuthorized = user.authorize(req.headers.Authorization);
if (!isAuthorized) {
return res(ctx.status(401), ctx.json({ message: "Not authorized" }));
}
const shoppingCart = JSON.parse(req.body);
// do whatever other things you need to do with this shopping cart
return res(ctx.json({ success: true }));
}),
];

export { handlers };
```

```jsx
// test/server.js
import { rest } from "msw";
import { setupServer } from "msw/node";
import { handlers } from "./server-handlers";

const server = setupServer(...handlers);
export { server, rest };
```

(→ 24년 5월 기준 `import {rest} from 'msw'`대신 `import {http} from 'msw'` 사용)

```jsx
// test/setup-env.js
// add this to your setupFilesAfterEnv config in jest so it's imported for every test file
import { server } from "./server.js";

beforeAll(() => server.listen());
// if you need to add a handler after calling setupServer for some specific test
// this will remove that handler for the rest of them
// (which is important for test isolation):
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
```

테스트 코드는 다음과 같습니다.

```jsx
// __tests__/checkout.js
import * as React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

test('clicking "confirm" submits payment', async () => {
const shoppingCart = buildShoppingCart();
render(<Checkout shoppingCart={shoppingCart} />);

userEvent.click(screen.getByRole("button", { name: /confirm/i }));

expect(await screen.findByText(/success/i)).toBeInTheDocument();
});
```

fetch 모킹보다 이 방법이 더 만족스러운 이유는 다음과 같습니다.

1. 가져오기 응답 속성과 헤더의 구현 세부 사항에 대해 걱정할 필요가 없습니다.
2. **‘fetch’**를 호출하는 방법에 문제가 있으면 서버 핸들러가 호출되지 않고 내 테스트가 (정확하게) 실패하여, 결함 있는 코드를 배포하는 것을 방지할 수 있습니다.
3. 이와 동일한 서버 핸들러를 개발 중에도 재사용할 수 있습니다!

## 코로케이션과 오류/엣지 케이스 테스트에 관하여

이 접근 방식에 대한 하나의 합리적인 우려는 모든 서버 핸들러를 한 곳에 모아두고, 그 서버 핸들러에 의존하는 테스트들이 전혀 다른 파일에 위치하게 되어 코로케이션의 이점을 잃어버린다는 것입니다.

우선, 중요하고 테스트에 특유한 요소들만 코로케이션하는 것이 좋습니다. 모든 설정을 각 테스트마다 중복해서 가질 필요는 없습니다. 유일한 부분만 필요합니다. 그래서 "성공 경로"에 해당하는 것들은 일반적으로 설정 파일에 포함시켜 테스트 자체에서는 제거하는 것이 더 낫습니다. 그렇지 않으면 너무 많은 잡음이 생겨 실제로 무엇이 테스트되고 있는지 분리하기 어렵습니다.

그렇다면 엣지 케이스와 오류에 대해서는 어떨까요? 이 경우, MSW는 테스트 중에 추가적인 서버 핸들러를 런타임에 추가할 수 있는 기능을 제공하며, 서버를 원래의 핸들러로 재설정하여 (런타임 핸들러를 효과적으로 제거하여) 테스트 격리를 유지합니다. 예를 들어 다음과 같습니다:

```jsx
// __tests__/checkout.js
import * as React from "react";
import { server, rest } from "test/server";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

// happy path test, no special server stuff
test('clicking "confirm" submits payment', async () => {
const shoppingCart = buildShoppingCart();
render(<Checkout shoppingCart={shoppingCart} />);

userEvent.click(screen.getByRole("button", { name: /confirm/i }));

expect(await screen.findByText(/success/i)).toBeInTheDocument();
});

// edge/error case, special server stuff
// note that the afterEach(() => server.resetHandlers()) we have in our
// setup file will ensure that the special handler is removed for other tests
test("shows server error if the request fails", async () => {
const testErrorMessage = "THIS IS A TEST FAILURE";
server.use(
rest.post("/checkout", async (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: testErrorMessage }));
})
);
const shoppingCart = buildShoppingCart();
render(<Checkout shoppingCart={shoppingCart} />);

userEvent.click(screen.getByRole("button", { name: /confirm/i }));

expect(await screen.findByRole("alert")).toHaveTextContent(testErrorMessage);
});
```

따라서 코로케이션이 필요한 곳에는 코로케이션을, 추상화가 필요한 곳에는 추상화를 사용할 수 있습니다.

## 결론

**‘msw’**와 관련하여 할 일이 분명히 더 많지만, 일단 여기서 마무리하겠습니다. **‘msw’**를 실제로 보고 싶다면, 제가 진행하는 4부작 워크숍 "Build React Apps" (EpicReact.Dev에 포함)에서 사용되며, GitHub에서 모든 자료를 찾아볼 수 있습니다.

이 테스트 방법의 정말 멋진 측면 중 하나는 구현 세부 사항으로부터 멀리 떨어져 있기 때문에, 상당한 리팩토링을 수행할 수 있으며, 테스트가 사용자 경험을 깨뜨리지 않았다는 확신을 줄 수 있다는 것입니다. 바로 이것이 테스트가 존재하는 이유입니다! 이런 일이 일어날 때 정말 좋습니다:

Kent C. Dodds

@kentcdodds

최근에 내 앱에서 인증 방식을 완전히 변경했는데, 테스트 유틸리티에 약간의 수정만 필요했고, 모든 테스트(단위, 통합, E2E)가 통과하여 사용자 경험이 변경에 영향을 받지 않았다는 확신을 주었습니다. 바로 이것이 테스트가 존재하는 이유죠!

Dillon

@d11erh
오늘은 React 컴포넌트를 리팩토링하는 데 하루를 보냈습니다. react-testing-library로 잘 작성된 테스트(감사합니다 @kentcdodds)는 큰 확신을 주었고 미묘한 오류를 잡는 데 도움이 되었습니다.

결론: 좋은 단위 테스트는 정말 중요합니다!

---
Loading

0 comments on commit 1fb7029

Please sign in to comment.