Unit Test | 3.2.모듈 모킹(Mocking)

Kate Jung·2024년 2월 7일
0

Front-end Test

목록 보기
9/17
post-thumbnail

📌 독립적인 컴포넌트 단위 테스트에서 의존성이 있는 경우 검증 해야 vs 안해도 될 것

  • 검증하지 않아도 되는 것

    의존성의 동작 같은 것

    범용적인 라이브러리(ex. react, react-router-dom)는 이미 내부적으로 상세히 핵심 기능을 단위 통합 테스트로 검증한 상태

    → 우리가 useNavigate를 사용하는 컴포넌트의 단위 테스트에서 useNavigate의 동작까지 검증할 필요 無

  • 검증해야 할 것

    (홈으로 이동 링크와 같은 버튼을 눌렀을 때) UseNavigate에서 제공하는 API가 제대로 호출되었는지

    • (외부 모듈에 대한) 호출 여부를 판단하기 위해 mocking 하기

📌 모킹(Mocking)의 정의 & 장단점


📌 모킹하는 방법 (예시)

🔹 예시들의 공통점

  • 단위 테스트로 검증하기 좋은 이유

    상위에서 다른 컴포넌트들과 조합되어 사용 X && 독립적으로 동작

    (여러 컴포넌트가 조합되어 있어도 기능 or 자식 컴포넌트가 단순함)

  • 모두 react-router에 의존성을 가지고 있고 useNavigate란 훅을 사용

🔹 예시 1 | EmptyNotice

  • EmptyNotice.jsx

    ...생략
    import { useNavigate } from 'react-router-dom';
    
    const EmptyNotice = () => {
      const navigate = useNavigate();
    
      const handleClickBack = () => {
        navigate(pageRoutes.main);
      };
    
      return (
        <Box>
          <Typography>~
          </Typography>
          <MuiLink
            onClick={handleClickBack}
          >
            홈으로 가기
          </MuiLink>
        </Box>
      );
    };
    
    export default EmptyNotice;
  • EmptyNotice.spec.jsx

    import { screen } from '@testing-library/react';
    import React from 'react';
    
    import EmptyNotice from '@/pages/cart/components/EmptyNotice';
    import render from '@/utils/test/render';
    
    // 실제 모듈을 모킹한 모듈로 대체하여 테스트 실행
    // useNavigate 훅으로 반환받은 navigate함수가 올바르게 호출되었는가 -> 스파이 함수
    const navigateFn = vi.fn(); // 스파이 함수
    
    vi.mock('react-router-dom', async () => {
      const original = await vi.importActual('react-router-dom');
    
      return { ...original, useNavigate: () => navigateFn };
    });
    
    it('"홈으로 가기" 링크를 클릭할경우 "/"경로로 navigate함수가 호출된다', async () => {
      const { user } = await render(<EmptyNotice />); // EmptyNotice 컴포넌트 렌더링
    
      await user.click(screen.getByText('홈으로 가기')); // 홈으로 가기 링크 버튼 클릭
    
      expect(navigateFn).toHaveBeenNthCalledWith(1, '/'); 
    	// useNavigate에서 반환하는 navigateFn이 우리가 원하는 인자와 호출되는지 단언
    });
    • vi.mock

      실제 모듈을 모킹한 모듈로 대체하여 테스트를 실행하게 하는 것

      • 특정 모듈을 원하는 형태로 대체 가능

      • 테스트를 실행 시, import된 모듈을 가져오기 전에 실행됨

    • react-router-dom 라이브러리 모킹

      vi.mock('react-router-dom', async () => {
      	// 실제 react-router-dom에 있는 모듈을 그대로 가져옴
        const original = await vi.importActual('react-router-dom');
      
        return { ...original, useNavigate: () => navigateFn };
      	// useNavigate 자체를 spy함수로 모킹하여 react-router-dom 모듈의 기존 구현을 대체 가능
      });
      1. 모킹할 모듈의 이름 작성

      2. 원하는 대체 구현을 함수 형태로 작성

        • vi.importActual

          일부 모듈만 모킹 (나머지는 기존 모듈의 기능을 그대로 사용)

    • const navigateFn = vi.fn();

      스파이 함수

      • 목적 : navigate함수가 올바르게 호출되었는지 확인
    • 효과

      react-router-dom의 다른 모듈에 대한 불필요한 모의구현 없이 편리하게 모킹하여 테스트 실행 가능

    • 결론

      외부 모듈과의 의존성이 있는 경우, spy 함수를 사용해서 특정 구현을 모킹하고 우리가 원하는 인자와 함께 올바르게 호출되었는지만 검증하면 독립적으로 단위 테스트 작성 가능

🔹 예시 2 | ErrorPage

  • 검증할 것

    뒤로 이동 버튼을 클릭했을 때 navigate로 -1인자가 올바르게 전달되어 호출되는지 확인

  • ErrorPage.jsx

    import { useNavigate } from 'react-router-dom';
    
    export const ErrorPage = () => {
      const navigate = useNavigate();
      const handleClickBackButton = () => {
        navigate(-1); // navigate로 마이너스 1 인자를 전달하여 호출
      };
    
      return (
        <div>
          <h1>!</h1>
          <p>예상치 못한 에러가 발생했습니다.</p>
          <button onClick={handleClickBackButton}>뒤로 이동</button>
        </div>
      );
    };
  • ErrorPage.spec.jsx

    import { screen } from '@testing-library/react';
    
    import ErrorPage from '@/pages/error/components/ErrorPage';
    import render from '@/utils/test/render';
    
    // 4. 페이지 이동이 정상적으로 실행되는지 확인하기 위해 react-router-dom의
    // useNavigate를 spy함수로 모킹
    const navigateFn = vi.fn();
    
    vi.mock('react-router-dom', async () => {
      const original = await vi.importActual('react-router-dom');
    
      return { ...original, useNavigate: () => navigateFn };
    });
    
    it('"뒤로 이동" 버튼 클릭시 뒤로 이동하는 navigate(-1) 함수가 호출된다', async () => {
      const { user } = await render(<ErrorPage />); 
    	// 1. 에러 페이지 컴포넌트를 렌더링
      const button = await screen.getByRole('button', { name: '뒤로 이동' }); 
    	// 2. 뒤로 이동 버튼을 조회 
    	// (name 지정 시, 버튼 중에서도 뒤로 이동이란 텍스트를 가진 요소를 명확하게 지정 가능)
    
      await user.click(button); // 3. 해당 버튼을 클릭
    
      expect(navigateFn).toHaveBeenNthCalledWith(1, -1); 
    	// 5. 스파이 함수가 원하는 인자와 함께 호출되는지만 단언
    });

🔹 예시 3 | NotFoundPage

  • 검증할 것

    react-router-dom에 useNavigate를 spy함수로 모킹한 후 특정 인자와 함께 올바르게 호출되었는지 검증

  • NotFoundPage.jsx

    import { useNavigate } from 'react-router-dom';
    
    import { pageRoutes } from '@/apiRoutes';
    
    export const NotFoundPage = () => {
      const navigate = useNavigate();
      const handleClickNavigateHomeButton = () => {
        navigate(pageRoutes.main, { replace: true }); // 👈
      };
    
      return (
        <div id="error-page">
          <h1>404</h1>
          <p>페이지 경로가 잘못 되었습니다!</p>
          <button onClick={handleClickNavigateHomeButton}>Home으로 이동</button>
        </div>
      );
    };
    
    export default NotFoundPage;
  • NotFoundPage.spec.jsx

    import { screen } from '@testing-library/react';
    
    import NotFoundPage from '@/pages/error/components/NotFoundPage';
    import render from '@/utils/test/render';
    
    const navigateFn = vi.fn();
    
    vi.mock('react-router-dom', async () => {
      const original = await vi.importActual('react-router-dom');
    
      return { ...original, useNavigate: () => navigateFn };
    });
    
    it('Home으로 이동 버튼 클릭시 홈 경로로 이동하는 navigate가 실행된다', async () => {
      const { user } = await render(<NotFoundPage />);
    	// 1. Not Found 페이지를 렌더링
    
      const button = await screen.getByRole('button', { name: 'Home으로 이동' });
    	// 2. 홈으로 이동 버튼을 찾음
    
      await user.click(button);
    	// 3. 유저 이벤트를 사용하여 클릭 이벤트를 시뮬레이션
    
      expect(navigateFn).toHaveBeenNthCalledWith(1, '/', { replace: true });
    	// 4. 모킹한 navigateFn의 메인 루트 경로와 { replace: true } 라는 옵션이 명확하게 전달되었는지 단언
    });

📌 모킹 초기화

🔹 하는 이유

  1. 다른 테스트에서는 react-router-dom의 내부 구현을 모킹할 필요가 없다면?
  1. 특정 테스트에서 useNavigate를 다른 형태로 모킹하였다면 다른 테스트의 결과에 영향을 주진 않을까요?
  • 결과적으로 이러한 영향은 테스트의 신뢰성을 떨어뜨릴 수 있음

    → 테스트 실행 후, 특정 모드를 모킹했을 때 반드시 항상 모킹 작업을 초기화하는 것이 좋음

🔹 setup.js 파일(예제 프로젝트의 공통 설정 파일)에 작성된 Teardown

...생략

afterEach(() => {
  server.resetHandlers();

  // 모킹된 모의 객체 호출에 대한 히스토리를 초기화
  vi.clearAllMocks(); // 👈 1
});

afterAll(() => {
  // 모킹 모듈에 대한 모든 구현을 초기화
  vi.resetAllMocks(); // 👈 2

  server.close();
});

...생략
  1. vi.clearAllMocks()

    모킹된 모의 객체 호출에 대한 히스토리를 초기화

    • 먼저 각 테스트가 종료된 후에 afterEach teardown에서 vi.clearAllMocks함수를 호출하여 모킹된 모의객체 호출에 대한 히스토리를 초기화함.

    • 모킹된 모의객체의 구현 자체는 제거되지 않음.

      • 기존 모듈이 모킹된 상태 그대로 유지

        유지 이유 : 사전에 작성한 테스트가 올바르게 실행되기 때문

      • 모킹 히스토리를 계속 쌓아두면

        spy 함수의 호출 횟수/인자가 계속 바뀌어 다른 테스트에 영향 줄 가능성 有

        → 그래서 테스트 실행이 끝날 때마다 clearAllMocks 함수를 호출하여 히스토리를 항상 초기화함.

  2. vi.resetAllMocks()

    모킹 모듈에 대한 모든 구현을 초기화

    모든 테스트가 종료된 후, afterAll teardown에서 mocking 모듈이 더 이상 의미가 없기 때문에 vr.resetAllMocks 함수를 호출하여 spy함수에 대한 호출 횟수/결과뿐만 아니라 mocking 모듈에 대한 모든 구현을 초기화함.

  3. setup.js 파일

    • setup.js 파일에 작성한 이유

      모든 테스트가 끝날 때마다 mocking 초기화를 하기 위해 teardown 작업을 전역에서 동작하도록 함.

    • 호과

      모든 테스트의 독립성 & 안정성을 보장 가능 (다른 테스트에서 실행한 모킹 작업에 영향받지 않도록)

  • 공식 문서를 참고하여 적절한 API 확인하기

    • vi.restoreAllMocks와 같은 API도 있음

      모의객체 호출에 대한 히스토리는 초기화하지만 모킹 모듈의 구현을 원래 모듈의 로직으로 되돌림

📌 정리


참고

  • 실무에 바로 적용하는 프런트엔드 테스트
profile
복습 목적 블로그 입니다.

0개의 댓글