Skip to content

테스트 코드 작성

jw0097 edited this page Aug 25, 2024 · 10 revisions

React Test에서 테스팅 하는 주요 항목

  • UI가 잘 렌더링이 되는지
import React from 'react';
import { render, screen } from '@testing-library/react';
import MyComponent from './MyComponent';

test('컴포넌트가 올바르게 렌더링되는지 확인', () => {
  render(<MyComponent />);
  expect(screen.getByText('Hello World')).toBeInTheDocument();
});
  • User Interaction이 잘 되는지
    • 클릭, 입력폼, route 등
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ButtonComponent from './ButtonComponent';

test('버튼 클릭 시 콜백이 호출되는지 확인', async () => {
  const handleClick = jest.fn();
  render(<ButtonComponent onClick={handleClick} />);
  
  const button = screen.getByRole('button');
  await userEvent.click(button);

  expect(handleClick).toHaveBeenCalledTimes(1);
});
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import FormComponent from './FormComponent';

test('입력 폼에 텍스트 입력이 잘 되는지 확인', async () => {
  render(<FormComponent />);
  
  const input = screen.getByPlaceholderText('이름을 입력하세요');
  await userEvent.type(input, '홍길동');

  expect(input).toHaveValue('홍길동');
});
import React from 'react';
import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import App from './App';

test('라우팅이 정상적으로 되는지 확인', () => {
  render(
    <MemoryRouter initialEntries={['/']}>
      <App />
    </MemoryRouter>
  );

  expect(screen.getByText('홈페이지')).toBeInTheDocument();
});
  • state 변경이 잘 되는지
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import ButtonComponent from './ButtonComponent';

test('버튼 클릭 시 상태가 증가하는지 확인', async () => {
  render(<ButtonComponent />);
  
  const button = screen.getByRole('button', { name: //i });
  const counter = screen.getByText('0');

  await userEvent.click(button);

  expect(counter).toHaveTextContent('1');
});
  • API 호출이 잘 되는지
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import axios from 'axios';
import FetchComponent from './FetchComponent';

jest.mock('axios');

test('API 호출이 정상적으로 이루어지는지 확인', async () => {
  const data = { data: { message: '성공' } };
  axios.get.mockResolvedValueOnce(data);

  render(<FetchComponent />);
  
  const button = screen.getByRole('button', { name: / /i });
  await userEvent.click(button);

  await waitFor(() => {
    expect(screen.getByText('성공')).toBeInTheDocument();
  });

  expect(axios.get).toHaveBeenCalledTimes(1);
});
  • 반응형이 제대로 동작하는지

이러한 사항들에 대해 블랙박스 테스트를 수행하여, 내부 로직보다는 사용자의 관점에서 테스트 해야 한다.

jest 사용 시 유의 사항

  • AAA 패턴으로 작성하여 읽고 이해하기 쉽게 작성한다.
  • 하나의 테스트에 하나의 동작을 테스트한다.
  • 예상치 못한 UI 변경에 대비해 스냅샷 테스트를 사용한다.

고립되고 재사용가능한 테스트 작성 시 유의 사항

  • cleanup-after-each등을 사용해서 공유되는 state가 없도록 한다.
  • 실제 구현 코드를 import 하지 않고 jest.spyon을 사용한다.
  • custom wrapper를 사용한다.

기타 유의 사항

  • toBeTruthy() 보다 toBeVisible, toBeInTheDocument를 사용한다.
  • toContain보다는 toHaveTextContent를 사용한다.
  • toBeDisabled, toBeEnabled를 사용한다.

getByRole

<form className="form" onSubmit={onSubmit}>
  <div className="field">
    <label>Name</label>
    <input
      id="name"
      onChange={onFieldChange}
      placeholder="Enter your name"
    />
  </div>

다음과 같은 예제에서 input을 가져오기 위해 다음과 같이 작성할 수 있다.

describe("Form", () => {
  it("should submit correct data", async () => {
    ...
      const inputElement = screen.getByPlaceholderText("Enter your name");
    ... 
  });
});

하지만 접근성, 유지보수성(텍스트가 바뀌어도 input을 가져올 수 있음) 같은 이유로 getByRole로 가져오는 것이 더 권장된다. 다음과 같이 코드를 수정하면 getByRole을 사용할 수 있다.

<form className="form" onSubmit={onSubmit}>
  <div className="field">
    <label htmlFor="name">Name</label>. // <-- htmlFor 추가
    <input
      id="name" // <-- id 와 맞춰준다.
      onChange={onFieldChange}
      placeholder="Enter your name"
    />
  </div>
describe("Form", () => {
  it("should submit correct data", async () => {
    ...
      const inputElement = screen.getByRole("textbox", { name: "Name" });
    ... 
  });
});

이같은 수정으로 label을 클릭하면 해당 입력 필드로 포커스가 이동하고, 스크린 리더에 더 좋은 접근성을 제공할 수 있다.

요소가 사라지는지 테스트하는 방법

  • 어떤 비동기 작업 후, 요소가 사라지는 것을 테스트하고 싶을 때, waitForElementToBeRemoved를 사용한다.
it("renders without breaking", async () => {
  render(<ListPage />);
  await waitForElementToBeRemoved(() => screen.queryByText("Loading..."));
});
it("renders without breaking", async () => {
  render(<ListPage />);
  await waitForElementToBeRemoved(screen.queryByText("Loading..."));
});

코드 작성 예시

애니메이션 테스트 처리

기존 애니매이션 테스트를 하기 위해서는 클래스 이름이 있는지를 확인했었다.

describe("ColorToggleButton", () => {
  it("toggles the 'toggled' class on click", () => {
    const { getByText } = render(<ColorToggleButton />);
    const button = getByText("Toggle Color");

    expect(button).not.toHaveClass("toggled");

    fireEvent.click(button);

    expect(button).toHaveClass("toggled");
  });
});

하지만 이러한 방식은 애니메이션 세부 사항이 바뀐다면 테스트 코드도 하나하나 수정해야 하는 단점이 존재하고, 클래스 이름이 존재하는지가 애니메이션이 수행되었는지 테스트 한다는 것이 조금 이상하게 다가왔다. 따라서 Web Animation API 를 사용하여 테스트 하는 방법을 생각해보았다.

Element.prototype.animate 함수를 mock 하여 이 함수가 올바른 keyframe, option과 실행되었는지를 확인하는 방식으로 코드를 작성하였다.

import React from "react";
import { render, screen } from "../../utils/test-utils";
import FCFSEventPage from "./FCFSEventPage";
import { userEvent } from "@storybook/test";
import { fadeInUp, fadeOutDown } from "../../styles/keyframes";
import { FCFSHintOptions } from "../../styles/options";

Element.prototype.animate = jest.fn();

describe("FCFSEventPage Component", () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  test("아래 힌트 화면을 호버하면 퀴즈 힌트가 올라와야 한다.", async () => {
    render(<FCFSEventPage />);

    const hintContainer = screen.getByRole("dialog");
    await userEvent.hover(hintContainer);

    expect(hintContainer.animate).toHaveBeenCalledWith(
      fadeInUp,
      FCFSHintOptions,
    );
  });
});

이렇게 함으로써, styles폴더에 있는 keyframe과 option가 변할 때 마다 테스트 코드를 변경할 필요가 없어지도록 하였다.

IntersectionObserver

  • Jest는 기본적으로 node 환경이기 때문에 DOM API를 사용할 수 없다. 따라서 IntersectionObserver도 mocking 해주어야 한다.
 IntersectionObserver = jest.fn().mockImplementation((callback) => {
  return {
    observe: jest.fn((element) => {
      callback([
        {
          isIntersecting: false,
          target: element,
          intersectionRect: { top: 0, left: 0 },
        },
      ]);
    }),
    unobserve: jest.fn(),
    disconnect: jest.fn(),
  };
});

await 이후 실행되는 함수 테스트

  • Glow에서 애니메이션 1개가 실행된 후(finished가 true가 된 후) 다른 애니메이션이 실행되는지를 검사해야 했다. 따라서 다음과 같이 Promise.all로 첫 번째 애니메이션이 모두 실행되는 것을 기다린 후, 두 번째 애니메이션을 검사해주었다.
 test("글로우 밝기가 +- 10%에서 랜덤으로 밝아졌다 어두워졌다 해야 한다.", async () => {
    render(<Glow />);

    const glowElemntArray = screen.getAllByRole("img", {
      name: /glow-effect/i,
    });

    await Promise.all( // <--- 모든 애니메이션 실행될 때 까지 대기
      glowElemntArray.map((glowElemnt, index) => {
        expect(glowElemnt.animate).toHaveBeenCalledWith(fadeIn, {
          ...glowFadeOptions,
          delay: (index + 1) * 1000,
        });
      }),
    );

    glowElemntArray.map((glowElemnt) => {
      expect(glowElemnt.animate).toHaveBeenCalledWith(randomGlow, glowOptions);
    });
  });

Provider Mock 하기

  • AppProvider를 사용하는 컴포넌트에 대해 MockProvider를 작성해주어야 했다. 현재 우리의 프로젝트에서는 Provider를 사용하는 곳에서 useAppProvider 를 사용하고 있으므로 이 함수를 mockImplementation으로 작성해주고, MockProvider도 직접 생성해주었다. 이때 내가 원하는 상태 값을 넣을 수 있도록 MockProvider에서 initialState를 매개변수로 받을 수 있게 하였다.
jest.mock("../../providers/AppProvider", () => ({
  useAppContext: jest.fn().mockImplementation(() => {
    return useContext(MockContext);  // <--- MockContext를 반환
  }),
}));
interface MockState {
  isAuth: boolean;
  isFCFSEnd: boolean;
  isRandomEnd: boolean;
  setIsAuth: (isAuth: boolean) => void;
  setIsFCFSEnd: (isFCFSEnd: boolean) => void;
  setIsRandomEnd: (isRandomEnd: boolean) => void;
}

interface MockProviderInterface {
  children: ReactNode;
  initialState?: Partial<MockState>;
}

export const MockContext = createContext<MockState | undefined>(undefined);

export const MockProvider = ({
  children,
  initialState,
}: MockProviderInterface) => {
  const [isAuth, setIsAuth] = useState(initialState?.isAuth || false);
  const [isFCFSEnd, setIsFCFSEnd] = useState(initialState?.isFCFSEnd || false);
  const [isRandomEnd, setIsRandomEnd] = useState(
    initialState?.isRandomEnd || false,
  );

  return (
    <MockContext.Provider
      value={{
        isAuth,
        setIsAuth,
        isFCFSEnd,
        setIsFCFSEnd,
        isRandomEnd,
        setIsRandomEnd,
      }}
    >
      {children}
    </MockContext.Provider>
  );
};

const Providers = ({
  children,
  initialState,
}: {
  children: ReactNode;
  initialState?: Partial<MockState>;
}) => {
  return (
    <BrowserRouter>
      <MockProvider initialState={initialState}>{children}</MockProvider>
    </BrowserRouter>
  );
};

const customRender = (
  ui: ReactElement,
  options?: RenderOptions,
  initialState?: Partial<MockState>,
) =>
  render(ui, {
    wrapper: (props) => <Providers initialState={initialState} {...props} />,
    ...options,
  });

export * from "@testing-library/react";
export { customRender as render };

jest.fn(), mockImplementation 시 유의 사항

  • jest.fn(), 또는 mockImplementation으로 빈 함수를 만들어 테스트 할 때, 내부 로직에 의해 테스트가 막히는 상황이 자주 발생하였다.
const stopAnimation = () => {
    const element = elementRef.current;
    const animation = animationRef.current; // <-- animate가 아무것도 반환하지 않아서 다음 로직이 실행되지 않음
    if (!element || !animation) return;

    animationRef.current = element.animate(
      cancelKeyframes,
      cancelOptions ?? startOptions,
    );
  };
Element.prototype.animate = jest.fn().mockImplementation(() => {
  return 1; // <-- id를 반환하여 해결
});
 const observer = new IntersectionObserver(
    (entries) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) {
          setIsInView(true);
        } else if (entry.intersectionRect.top !== 0) {. // <-- intersectionRect.top 값이 없어서 오류 발생
          setIsInView(false);
          if (onViewEscape) onViewEscape();
        }
      });
    },
    { threshold },
  );
IntersectionObserver = jest.fn().mockImplementation((callback) => {
  return {
    observe: jest.fn((element) => {
      callback([
        {
          isIntersecting: false,
          target: element,
          intersectionRect: { top: 0, left: 0 },  // <-- callback의 매개변수에 intersectionRect을 추가하여 해결
        },
      ]);
    }),
    unobserve: jest.fn(),
    disconnect: jest.fn(),
  };
});

.mock

  • .mock을 사용하면 Mock 함수를 함수가 호출된 방법과 반환된 함수에 대한 데이터가 담긴 객체를 컨트롤 할 수 있다.
  • 처음에는 intersectionObserver의 is intersecting이 false 였다가, 나중에 true가 될 때를 시뮬레이션 하기 위해 .mock을 사용하였다.
    1. 처음의 isIntersecting을 false로 설정한다.
    2. IntersectionObserver의 mock.calls에는 함수를 실행할 때 넘겨진 매개변수들이 저장된다. mocks.calls[0]은 처음 함수가 호출될 때 넘겨진 매개변수를 의미하고 mocks.calls[0][0]은 첫번째 호출시 넘겨진 첫번째 매개변수인 callback을 의미한다. 여기서 callback을 isIntersecting이 true인 배열과 함께 넘겨주어서 콜백함수를 수동으로 호출한다.
    3. 콜백 함수가 호출되면 useInView 훅의 setIsInView(true)가 실행되어 isInView 상태가 변경되어 클래스도 변경된다.
IntersectionObserver = jest.fn().mockImplementation((callback) => {
  return {
    observe: jest.fn((element) => {
      callback([
        {
          isIntersecting: false,  // 1.
          target: element,
          intersectionRect: { top: 0, left: 0 },
        },
      ]);
    }),
    unobserve: jest.fn(),
    disconnect: jest.fn(),
  };
});
test("ConvenienceSection에 들어오면 스크롤이 좌에서 오른쪽으로 오는 애니메이션이 발생해야 한다.", () => {
  render(<ConvenienceSection />);

  const items = screen.getAllByRole("article");

  items.forEach((item) => {
    expect(item).toHaveClass("translate-x-3/4");
    expect(item).not.toHaveClass("translate-x-0");
  });

  act(() => {
    (IntersectionObserver as jest.Mock).mock.calls[0][0]([ // 2. 
      { isIntersecting: true },
    ]);
  });

  items.forEach((item) => {
    expect(item).toHaveClass("translate-x-0");
    expect(item).not.toHaveClass("translate-x-3/4");
  });
});

GetByRole

jest에서 getByRole을 사용할 시, 다양한 role을 설정할 수 있다. aria-label등을 통해서 다양한 정보를 전달이 가능하다.

  • alert
    • 긴급 상황을 사용자에게 알리는 메시지. 예: <div role="alert">
  • button
    • 클릭 가능한 버튼 요소. 예: <button><input type="button"><div role="button">
  • checkbox
    • 체크박스 입력 요소. 예: <input type="checkbox">
  • dialog
    • 대화 상자, 모달 창. 예: <dialog><div role="dialog">
  • grid
    • 데이터 그리드, 표 형태의 데이터. 예: <table role="grid">
  • link
    • 하이퍼링크 요소. 예: <a><area><link>
  • listbox
    • 선택 가능한 목록 상자. 예: <select><ul role="listbox">
  • menu
    • 명령 메뉴. 예: <ul role="menu">
  • menubar
    • 메뉴 바, 일반적으로 파일 메뉴. 예: <ul role="menubar">
  • menuitem
    • 메뉴 항목. 예: <li role="menuitem">
  • progressbar
    • 진행률 표시기. 예: <progress><div role="progressbar">
  • radio
    • 라디오 버튼. 예: <input type="radio">
  • radiogroup
    • 라디오 버튼 그룹. 예: <fieldset role="radiogroup">
  • slider
    • 슬라이더, 범위를 조절하는 입력 요소. 예: <input type="range"><div role="slider">
  • spinbutton
    • 숫자 입력 스핀 버튼. 예: <input type="number"><div role="spinbutton">
  • textbox
    • 텍스트 입력 상자. 예: <input type="text"><textarea><div role="textbox">
  • combobox
    • 드롭다운 목록과 결합된 텍스트 입력 상자. 예: <input list="browsers"><select role="combobox">
  • form
    • 폼 요소. 예: <form><div role="form">
  • search
    • 검색 영역. 예: <input type="search"><form role="search">
  • table
    • 데이터 테이블. 예: <table><div role="table">
  • tabpanel
    • 탭 패널. 예: <div role="tabpanel">
  • tablist
    • 탭 리스트. 예: <div role="tablist">
  • tab
    • 개별 탭. 예: <button role="tab"><li role="tab">
  • tree
    • 트리 구조 목록. 예: <ul role="tree">
  • treeitem
    • 트리 구조 목록 항목. 예: <li role="treeitem">
Clone this wiki locally