본문 바로가기
개발

프론트엔드 단위테스트 작성

by dev-joy 2024. 5. 31.

테스트란?

  • 제품(함수, 기능, UI, 성능, api 등)이 예상하는대로 동작하는지 검증하기 위한 절차
  • 크게 유닛테스트, 통합테스트, 사용자테스트(end-to-end)가 있는데 시간과 비용은 물론이고 개발적인 효율도 유닛테스트가 가장 저렴하다. 유닛테스트는 작은 한 개 단위의 테스트라서 오류가 발생했을 때 빠른 파악이 가능하며 이러한 이점들이 있어서 유닛테스트를 가장 많이 작성한다고한다.

테스트를  작성하는 이유와 장점

  • 구현된 기능이 요구사항에 맞게 정상 작동하는지 확인 할 수 있다.
  • 이슈나 예외에대한 예측이 가능하다.
  • 코드간의 의존성을 고려하게되어 코드 품질을 향상 시킬 수 있다.
  • 문서화, 테스트코드 자체가 기능에대한 명세가 되어 의사소통 비용을 낮출 수 있다.
  • 테스트 커버리지를 가질경우 리팩터링이나 코드 수정 시 QA 비용을 낮출 수 있다.

무엇으로 테스트하나? (Tools)

  • Jest, Jest는 자바스크립트 테스트러너로 작성한 테스트를 실행하고 콘솔에 성공, 실패여부를 표시하는 일을 담당한다. 기본적인 테스트 실행 외에도 테스트 전후에 반복되는 로직을 한곳에 작성할 수 있도록하거나 함수나 모듈을 Mocking해서 다른 함수나 코드의 영향을 받지않고 테스트가 실행되도록 만드는 방법도 제공한다.
  • React Testing Library, 리액트 컴포넌트를 테스트하기위해 만들어진 라이브러리로 내부 구현사항을 테스트하지않고 렌더링했을 때 외부에 보여지는 실제 사용자관점에서의 테스트 작성이 가능하다는 철학을 가지고있다. 기존에 사용되던 Enzyme은 클래스 이름과같은 내부 구현사항을 가지고 테스트하는데 보여지는 인터페이스가 아니라 전달되는 props나 상태를 테스트하는건 실제 제품의 동작과 일치하지않을 가능성이 크고 테스트의 신뢰도가 높지않다.

컴포넌트 유닛테스트

서버로부터 아이템리스트를 받아와서 각 아이템의 이미지, 제목 등을 표시하고 클릭 했을 때 해당 아이템의 상세페이지로 이동하는 간단한 컴포넌트가 있고 각컴포넌트에서 테스트하고자하는 기능은 다음과 같다. 가장 작은 단위에서부터 순서대로 테스트를 작성해보쟈!


Item

1) item props를 받아서 텍스트와 이미지를 표시한다.

2) 클릭하면 item의 Id로 라우트된다.

 

Items

items, isLoading을 받아서 isLoading일 경우 Skeleton UI를 표시하고 아닐 경우 Item을 표시한다.

 

ItemsSeciton

1) Context에서 받아온 텍스트를 표시한다.

2) useQuery를 리턴하는 커스텀훅에서 쿼리의 데이터와 isLoading을 받아와서 Items 컴포넌트에 넘긴다.

정적테스트 - 스냅샷(Snapshot) 테스트

컴포넌트에서 props로전달 받은 데이터를 표시하는 것과 같이 정적인 화면을 테스트할 때 일일이 어떤값이 표시돼야하는지를 작성할 수도 있지만 스냅샷 테스트를 사용해서 간편하게 정적 화면을 테스트할 수 있다. 스냅샷 테스트는 테스트에 기준이되는 화면을 스냅샷으로 남겨놓고 추후 변경된 화면의 스냅샷과 비교하여 다른곳이 있으면 테스트가 실패한다

// Item.jsx
const Item = ({
  item: { contentid, title, firstimage, firstimage2 },
  grid,
}) => {
  return ( // ...
// Item.test.js
describe('Item', () => {
  const item = {
    contentid: 123456,
    title: '에버랜드',
    firstimage: 'https://firstimage.jpg',
    firstimage2: 'https://firstimage2.jpg',
  };

it('renders correctly', () => {
  const { container } = render(<Item item={item} grid={3} />);
  expect(container).toMatchSnapshot();
});

 

작성하면 아래와 같이 스냅샷 파일이 생성된다 (찰칵!)

exports[`Item renders correctly 1`] = `
<div>
  <div>
    <button type="button">
      <a
        href="/view/123456"
      >
        <p>
          에버랜드
        </p>
        // ...

 

동적테스트

클릭했을 때 다른페이지로 이동하는 것과같이 유저의 상호작용이 발생하는 동적인 화면을 테스트할 땐 React Testing Library의 user-event를 사용한다. userEvent는 한 가지 상호작용에대해 발생하는 여러 이벤트들을 시뮬레이션 할 수 있어서 보다 정확한 테스트가 가능하다.

 

React Testing Library의 Core API인 fireEvent도 이벤트를 트리거할 수 있지만 정확한 상호작용을 시뮬레이션 하진않는다. 예로 사용자가 input의 값을 변경할 때 change 이벤트 외에 focus, blur, keydown, keyup 과 같은 여러 이벤트들이 발생하는데 fireEvent는 단순히 해당 이벤트만 전달할 뿐 실제 발생하는 연속적인 이벤트들을 시뮬레이션하지않기때문에 정확하고 현실적인 테스트를 위해 userEvent를 사용한다.

 

보통 리액트에서 페이지이동을 테스트할 땐 react-router-dom의 MemoryRouter를 사용해서 테스트하는데 난 NextJS로 만들었기때문에 next-router-mock이라는 라이브러리를 사용해서 테스트했다.

*Setting up Jest with Next.js https://nextjs.org/docs/app/building-your-application/testing/jest

*react-router testing https://v5.reactrouter.com/web/guides/testing

// Item.jsx
import Link from 'next/link';

const Item = ({
  item: { contentid, title, firstimage, firstimage2 },
  grid,
}) => {
  return ( 
    <Link href={`/view/${contentid}`}>
      <a>
        <p>{title}</p>
  		// ...
// Item.test.js
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import mockRouter from 'next-router-mock';
import { MemoryRouterProvider } from 'next-router-mock/MemoryRouterProvider';

it('navigate to detail view page when clicked', async () => {
  render(<Item item={item} grid={3} />, { wrapper: MemoryRouterProvider });
  const link = screen.getByRole('link', { name: /에버랜드/i });
  userEvent.click(link);
  await waitFor(() => {
    expect(mockRouter.asPath).toEqual('/view/123456');
  });
});

조건부 렌더링

Items는 전달받는 Props의 데이터가 없거나 로딩중일 경우 스켈레톤 UI를 표시하고 그렇지않을 경우 Item 목록을 표시하는 컴포넌트인데 이러한 조건부 렌더링 또한 스냅샷으로 테스트가 가능하다. 처음 스냅샷을 생성하게되면 실제로 렌더링되는 html과 일치하는지 확인이 필요하다.

// Items.jsx
const Items = ({ items, isLoading }) => (
  <div>
    {!isLoading && items ? (
      items.map((item, index) => (
        <Item key={item.contentid} item={item} />
      ))
    ) : (
      <Skeleton />
    )}
  </div>
);
// Items.test.js
it('renders correctly', () => {
  const { container } = render(<Items isLoading={false} items={items} />);
  expect(container).toMatchSnapshot();
});
  
it('renders correctly without items', () => {
  const { container } = render(<Items isLoading={false} items={null} />);
  expect(container).toMatchSnapshot();
});

Context, React-Query 컴포넌트 테스트

ItemsSeciton 컴포넌트는 Context에서 받아온 값을 useQuery의 queryFn의 인자로 넘겨서 데이터를 받아오고있다.

Context를 사용하는 컴포넌트도 라우트테스트와 동일하게 컴포넌트를 ContextProvider로 감싸서 테스트하면 된다. useQuery 또한 Context와 동일하게 QueryClientProvider에서 QueryClient를 제공받아서 사용하기 때문에 같은 방법으로 테스트를 작성한다.

// ItemsSection.jsx
const ItemsSection = () => {
  const {
    province: { name, code },
  } = useItemsContext();
  const { data: items, isLoading } = useItems(code);

  return ( 
  // ...
// ItemsSection.test.js
function createTestQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
      },
    },
    logger: {
      log: () => {},
      warn: () => {},
      error: () => {},
    },
  });
}

const AllTheProviders = ({ children, value }) => {
  const queryClient = createTestQueryClient();
  return (
    <QueryClientProvider client={queryClient}>
      <ItemsContext.Provider value={value}>{children}</ItemsContext.Provider>
    </QueryClientProvider>
  );
};

it('renders correctly', async () => {
  render(
    <AllTheProviders value={{ province: { name: '서울특별시', code: '11' } }}>
      <ItemsSection />
    </AllTheProviders>
  );

 

이때, queryFn의 data fetch 함수를 모킹하여 실제 네트워크 통신에 영향받지않는 테스트를 작성할 수 있다.

// useItems.js
import { fetchItems } from '../api/fetchItems';

export const useItems = code => {
  return useQuery({
    queryKey: ['items', code],
    queryFn: () => fetchItems(code),
    staleTime: 1000 * 60 * 60,
  });
};
// ItemsSection.test.js
import { fetchItems } from '../../../api/fetchItems';

jest.mock('../../../api/fetchItems');

it('renders correctly', async () => {
  fetchItems.mockImplementation(() => Promise.resolve(data));
  
  render(
    <AllTheProviders value={{ province: { name: '서울특별시', code: '11' } }}>
      <ItemsSection />
    </AllTheProviders>
  );

 

받아온 데이터를 자식 컴포넌트로 넘겨주는 부분은 toHaveBeenCalledWith 매쳐로 어떤값과 함께 호출됐는지 확인하는것으로 테스트한다. React Testing Library는 Enzyme와같이 자식 컴포넌트없이 렌더링하는 shallow를 제공해주지않지만 이 부분도 위와 동일하게 Jest로 모킹해서 테스트하면된다. 

// ItemsSection.test.js
import Items from '../Items';

jest.mock('../Items');

it('renders correctly', async () => {
  ...
  
  expect(screen.getByText('서울특별시')).toBeInTheDocument();
  await waitFor(() =>
    expect(Items).toHaveBeenCalledWith(
      {
        items: data,
        isLoading: false,
      },
      // 컴포넌트를 테스트할 땐 두번째 인자를 꼭 넣어줘야한다.
      // https://dev.to/peterlidee/mocking-react-components-jest-mocking-react-part-2-2l8j
      {}
    )
  );
});

 

모킹한 구현사항들이 서로다른 테스트에 영향을 주지않도록 각 테스트 실행 후 mockReset을 호출하여 리셋시킨다.

describe('ItemsSection', () => {
  afterEach(() => {
    fetchItems.mockReset();
    Items.mockReset();
  });

 

빠밤!

마치며

데이터를 받아와서 뿌리고 상세페이지로 이동하는 간단한 화면이지만 유의미한 테스트를 작성하기위해 많은 예제들을 찾아보고 고민하며 작성했다. 실제로 작성해보니 기존에 보이지않던 예외케이스를 고려해서 보완할 수 있었고 무엇보다 테스트를 작성하기위해 엉켜있던 로직들을 각 모듈로 분리시키는데 그냥 리팩터링을 하는것보다 어떤 기준으로 분리해야하는지가 명확하게 보여서 리팩터링의 효과가 기장 크게 와닿았다. 실제 프로젝트들에도 적용해서 더 안정성 높은 수정하기쉬운 제품을 만들고싶다.

 

*코드는 이 곳에서 확인할 수 있습니다. https://github.com/SeheeYun/travel-trends

 

도움받은 글

https://stackoverflow.com/questions/66341449/testing-library-react-vs-jest

https://velog.io/@blackberry1114/Next.js-pathname에-따라-변경되는-UI를-테스트해보자

https://tkdodo.eu/blog/testing-react-query

https://testing-library.com/docs/react-testing-library/setup/#custom-render

https://academy.dream-coding.com/courses/react-tdd

'개발' 카테고리의 다른 글

Firebase RTDB에 React-Query 적용하기  (0) 2024.07.01
D3.js 사용하기  (0) 2024.03.27