기능
쇼핑몰 앱은 크게 세 단계로 구분된다.
Step 1 - 주문 페이
Step 2 - 확인 페이
Step 3 - 주문 히스토리 페이지(주문 완료)
컴포넌트 구조
Client - pages ┭ OrderPage - OrderPage
│ ┣ SummaryPage - SummaryPage
│ ┖ CompletePage - CompletePage
┖ components
Server
SummaryPage
Form 구현
"주문 하려는 것을 확인 하셨나요?"를 체크하면 확인 버튼이 활성화됨을 테스트한다.
test("checkbox and button", () => {
render(<SummaryPage />);
const checkbox = screen.getByRole<HTMLInputElement>("checkbox", {
name: "주문하려는 것을 확인하셨나요?",
});
// HTMLInputElement로 타입을 지정해야 checked 프로퍼티가 인식됨
expect(checkbox.checked).toEqual(false);
const confirmButton = screen.getByRole<HTMLInputElement>("button", {name: '주문 확인'});
expect(confirmButton.disabled).toBeTruthy()
});
제너릭 타입
위 코드에서
screen.getByRole("checkbox", {
name: "주문하려는 것을 확인하셨나요?",
})
해당 부분은 라벨이 "주문하려는 것을 확인하셨나요?"인 체크박스를 불러온다.
이때 타입스크립트는 HTMLElement로 해당 요소의 타입을 지정하기에 <HTMLInputElement>
를 통해 제너릭으로 지정한다.
동등 비교 (toBe, toEqual)
expect(checkbox.checked).toEqual(false);
expect(confirmButton.disabled).toBeTruthy()
toBe와 toEqual은 값이 같은지를 비교하는 Matcher이다.
원시값을 비교할 때에는 작동 방식에 차이가 없으나,
범객체를 비교할 때에는 차이가 난다.
toBe는 얕은 동등성을 비교한다.
toEqual은 깊은 동등성을 비교한다.
가령 테스트는 아래와 같이 진행된다.
const arr = [10, 20, 30]
expect(arr).toBe([10, 20, 30]) // Fails
expect(arr).toEqual([10, 20, 30]) // Passes
arr과 toBe는 다른 인스턴스이기 때문에 얕은 비교시에 fail이 발생한다.
하지만 toEqual로 비교할 시에, 다른 인스턴스이지만 그 요소들의 원시값이 같기 때문에 pass가 된다. 객체 불변성을 위해 새로운 객체가 빈번하게 생성되는 리액트의 특성상, 이처럼 깊은 비교를 하는 toEqual의 역할이 중요하다.
동등 비교 (toBe(true), toBeTruthy())
toBe
Matcher의 내부를 살펴보면,
반환된 값이 파라미터로 입력받은 값과 동일한지(===
)를 비교한다.
한편 toBeFalsy(), toBeTruthy()는 연산자에 !
혹은 !!
를 붙여 동일한지를 비교한다.
!!값 === true
위의 예제에서 checkbox의 체크 여부는 클릭에 따라 true 혹은 false가 되므로 toEqual(true)혹은 toBe(true)를 사용함은 적절하다.
반면 disabled 프로퍼티는 개발자의 성향에 따라 truthy, falsy한 값을 입력하는 경우가 있으므로 toBeTruethy()로 테스트를 진행한다.
위의 테스트를 통과하기 위한 코드는 아래와 같다
return (
<div>
<form>
<input
type="checkbox"
checked={checked}
onChange={(e) => setChecked(e.target.checked)}
id="confirm-checkbox"
/>
<label htmlFor="confirm-checkbox">주문하려는 것을 확인하셨나요?</label>
<button disabled={!checked} type="submit">
주문 확인
</button>
</form>
</div>
);
getByRole의 name이 어느 곳에서 인식되는지 확인해보자. Accessible Name and Description Computation에서 확인할 수 있듯,
input태그는 label 태그로부터 name을 인식한다.
그리고 button 태그는 text로부터 name을 인식한다.
MSW 구현
Mock Service Worker란 Service Worker API를 이용하여 백엔드 API를 Mocking하는 라이브러리이다.
Service Worker는 클라이언트 사이드에 위치하여 프록시 서버의 역할을 하는 표준 API이다. 네트워크의 요청을 가로채고 처리하는 작업을 하며, HTTPS 상에서만 작동한다.
Mock Service Worker는 이러한 Service Worker를 이용하여 요청을 가로챈 후,
자바스크립트로 작성한 로직(Handler)에 따라 REST API를 Mocking하거나 GraphQL API를 Mocking한다.
MSW 설치
환경 설정 참조
공식문서
MSW를 설치한다. yarn add -D msw
public폴더에 서비스 워커 등록에 필요한 코드를 넣어준다. 이는 msw의 cli로 설치 가능하다.
npx msw init public/ --save
public\mockServiceWorker.js 파일이 생성됨을 확인할 수 있다.
handlers 작성
이제 src\mocks\handlers.ts 파일을 생성한다.
"rest"는 "msw"의 REST API의 요청을 모아둔 Namespace이다.
import { rest } from "msw";
export const handlers = [
rest.get("http://localhost:5000/products", (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json([
{
name: "America",
imagePath: "/images/america.jpeg",
},
])
);
}),
rest.get("http://localhost:5000/options", (req, res, ctx) => {
return res(ctx.status(200), ctx.json([{ name: "Insurance" }]));
}),
];
handlers라는 배열 안에 요청 목록들을 넣게 된다.
setupServer로 서버 기본설정
msw/node를 통해 setupServer로 해당 handler들을 불러온다.
src\mocks\server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
(setupServer가 intelliSense로 자동완성이 되지 않기에 주의)
setupTests에 server를 실행
src\setupTests.ts에 아래의 코드를 추가한다
// src/setupTests.js
import { server } from './mocks/server.js'
// Establish API mocking before all tests.
beforeAll(() => server.listen())
// Reset any request handlers that we may add during the tests,
// so they don't affect other tests.
// 매 테스트마다 handler를 초기화하여 영향을 미치지 않도록 합니다.
afterEach(() => server.resetHandlers())
// Clean up after the tests are finished.
// 테스트가 끝난 후 서버를 종료합니다.
afterAll(() => server.close())
이미지 렌더링 테스트
src\pages\OrderPage\tests\Type.test.ts
test("displays product images from server", async () => {
render(<Type orderType="products" />);
// 이미지 찾기
const productImages = await screen.findAllByRole("img", {
name: /product$/i,
});
expect(productImages).toHaveLength(1);
const altText = productImages.map((element) => element.alt);
expect(altText).toEqual(["America product"]);
});
name에 사용된 정규 표현식 /product$/i
는 이름이 product
로 끝나는지를 검사한다.
그리고 img태그의 alt 어트리뷰트는 accesible name에 해당한다.
따라서 위의 코드는 .map으로 렌더링 했을 때에 'product'라는 alt로 끝나는 이미지의 갯수가 1개인지를 테스트한다. 'findBy' 쿼리에 await를 걸어 렌더링이 끝날 때까지 대기하게 된다.
아래의 코드는 모든 altText가 올바르게 들어있는지를 확인한다. toEqual을 이용해 깊은 비교를 수행한다.
에러 상황 테스트
에러가 발생했을 시
test("when fetching product datas, face an error", async () => {
server.resetHandlers(
rest.get("http://localhost:5000/products", (req, res, ctx) => {
return res(ctx.status(500));
})
);
render(<Type orderType="products" />);
const errorBanner = await screen.findByTestId("error-banner");
expect(errorBanner).toHaveTextContent("에러가 발생했습니다.");
});
server.resetHandlers(
rest.get("http://localhost:5000/products", (req, res, ctx) => {
return res(ctx.status(500));
})
);
resetHandlers() 함수를 활용하면 함수의 초기값을 변경할 수 있다.
해당 테스트가 실행되면, 기존의 server가 리셋되고, 파라미터로 넘겨준 핸들러가 실행된다.
input 태그 테스트
test("update product's total when products change", async () => {
render(<Type orderType="products" />);
const productsTotal = screen.getByText("상품 총 가격:", { exact: false });
expect(productsTotal).toHaveTextContent("0");
// 아메리카 여행 상품 한개 올리기
const americaInput = await screen.findByRole("spinbutton", {
name: "America",
});
// userEvent.type(americaInput, "3");
// userEvent.type(americaInput, "2");
// userEvent.clear(americaInput);
userEvent.clear(americaInput);
userEvent.type(americaInput, "1");
expect(productsTotal).toHaveTextContent("1000");
});
input 태그를 선택하고 userEvent로 input을 입력한다.
이때 userEvent.clear()로 값을 초기화한 후,
userEvent.type()를 통해 입력하는 것이 에러를 줄일 수 있다.
이때 input 태그를 포함한 컴포넌트가 Context API를 통해 다른 컴포넌트로 상태를 전달하고 있다.
src\contexts\OrderContext.js
export const OrderContext = createContext();
export function OrderContextProvider(props) {
...
return <OrderContext.Provider value={value} {...props} />;
이를 테스트하기 위해선 Context API의 Provider를 똑같게 Render에도 감싸주어야 한다.
test("update product's total when products change", async () => {
render(
<OrderContextProvider>
<Type orderType="products" />
</OrderContextProvider>
);
const productsTotal = screen.getByText("상품 총 가격:", { exact: false });
expect(productsTotal).toHaveTextContent("0");
// 아메리카 여행 상품 한개 올리기
const americaInput = await screen.findByRole("spinbutton", {
name: "America",
});
userEvent.clear(americaInput);
userEvent.type(americaInput, "1");
expect(productsTotal).toHaveTextContent("1000");
});
Custom Render 만들기
Provider와 Wrapper를 활용할 때에 유용한 render API의 옵션이 바로 wrapper이다. 테스팅 라이브러리 공식문서
src\test-utils.js
import { render } from "@testing-library/react";
import { OrderContextProvider } from "./contexts/OrderContext";
const customRender = (ui, options) =>
render(ui, {wrapper: OrderContextProvider, ...options})
export * from '@testing-library/react'
export {customRender as render}
만일 OrderContextProvider 뿐 아니라 여러개의 wrapper를 써야 한다면, chlidren을 props로 받는 새로운 wrapper를 만들면 된다.
import React from 'react'
import {render} from '@testing-library/react'
import {ThemeProvider} from 'my-ui-lib'
import {TranslationProvider} from 'my-i18n-lib'
import { OrderContextProvider } from "./contexts/OrderContext";
import defaultStrings from 'i18n/en-x-default'
const AllTheProviders = ({children}) => {
return (
<ThemeProvider theme="light">
<TranslationProvider messages={defaultStrings}>
<OrderContextProvider>
{children}
</OrderContextProvider>
</TranslationProvider>
</ThemeProvider>
)
}
const customRender = (ui, options) =>
render(ui, {wrapper: AllTheProviders, ...options})
// re-export everything
export * from '@testing-library/react'
// override render method
export {customRender as render}
이렇게 export 된 customRender는 아래와 같은 방식으로 import하게 된다.
// - import { render, screen } from "@testing-library/react";
import { render, screen } from "../../../test-utils";