신입 사원의 사내 프론트엔드 테스트 도입기 - 테스트 코드

정동환·2024년 5월 1일

티맥스 업무일지

목록 보기
2/8
post-thumbnail

단위 테스트 및 통합 테스트 코드 작성 기록

이제부터 테스트 코드에 대해서 이야기 해보겠습니다. 테스트 코드는 AAA 패턴으로 작성했습니다. Arrange(준비), Act(실행), Assert(검증) 순서이며 테스트들의 구조들을 일관되게 작성함으로서 가독성과 유지보수성을 늘렸습니다.

00. 단위 테스트

먼저 테스트 코드가 정상작동하는지 확인하기 위해 util 함수들의 단위 테스트코드를 작성했습니다.

단위 테스트란 가장 작은 소프트웨어를 실행하여 예상대로 동작하는지 확인하는 테스트이며 보통 함수 단위로 테스트를 합니다.

00.1. dateFormat.ts

export const createFormattedTime = (
  date: Date,
  format = 'YYYY.MM.DD',
): string => {
  const formatReplacements: Record<string, string> = {
    YYYY: String(date.getFullYear()),
    YY: String(date.getFullYear()).substring(2, 4),
    MM: String(date.getMonth() + 1).padStart(2, '0'),
    DD: String(date.getDate()).padStart(2, '0'),
    hh: String(date.getHours()).padStart(2, '0'),
    mm: String(date.getMinutes()).padStart(2, '0'),
  };

  const formattedTime = format.replace(
    /YYYY|YY|MM|DD|hh|mm/g,
    match => formatReplacements[match],
  );

  return formattedTime;
};

createFormattedTime함수는 date객체와 원하는 포맷의 문자열을 입력받아 포맷대로 날짜 문자열을 반환하는 함수입니다.

00.2. dateFormat.spec.ts

import { createFormattedTime } from './dateFormat';

describe('createFormattedTime 함수', () => {
  test('첫번째 매개변수로 날짜 입력했을 때 YYYY.MM.DD 형식으로 된 문자열 반환', () => {
    const date = new Date('2022-01-01T12:34:56');
    const formattedTime = createFormattedTime(date);

    expect(formattedTime).toBe('2022.01.01');
  });

  test('두번째 매개변수로 사용자 형식을 입력했을 때 사용자 정의 형식 정상적으로 포맷팅되는지 확인', () => {
    type TestCase = { format: string; result: string };

    const date = new Date('2022-01-01T12:34:56');
    const testCases: TestCase[] = [
      {
        format: 'YYYY년 MM월 DD일 hh:mm',
        result: '2022년 01월 01일 12:34',
      },
	//...
      {
        format: 'hh:mm',
        result: '12:34',
      },
    ];

    testCases.forEach(({ format, result }) => {
      const formattedTime = createFormattedTime(date, format);
      expect(formattedTime).toBe(result);
    });
  });
});
  • formattedTime의 결과값을 formattedTime에 저장한 후 expect를 이용해 검증합니다.
  • expect함수와 chaining되어 있는 toBe는 expect의 입력과 toBe의 입력이 일치하는지 검증합니다.
  • vitest는 toBe이외에도 객체간 비교를 위한 toEqual등을 포함한 다양한 매처들을 제공합니다.

01. 통합 테스트

단위테스트를 통과한다고 해서 프로그램이 잘 돌아간다고 확신할 수 없습니다. 바퀴가 잘 굴러간다고 자동차가 잘 동작한다고 확신할 수 없는 것처럼요.

마찬가지로 input들이 정상작동한다고 form이 정상적으로 작동한다는 보장할 수는 없었습니다. 따라서 각각의 form의 통합테스트가 필요하다고 판단했습니다.

통합 테스트는 단위 테스트보다 더 큰 동작을 달성하기 위해 여러 모듈들을 모아 이들이 의도대로 협력하는지 확인하는 테스트입니다.

작성한 테스트 코드 중 이름 변경 폼 테스트를 일부 변형해서 가져왔습니다.

01.1 mocking

import { screen } from '@testing-library/react';

import { SettingName } from '../SettingName';

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,
  };
});

...

vi.mock(회사 공통 모듈, async () => {
  ...
});

vi.mock(전역 스토어, async () => ({
  ...
}));

테스트가 진행되기 전 먼저 mocking 했습니다. 외부 라이브러리들이나 스토어들은 mocking한 후 함수의 호출만을 테스트했습니다.

...
describe('전역 window 객체의 env.ENV1가 A일 경우', () => {
  beforeAll(() => {
    vi.stubGlobal('env', {
      ENV1: 'A',
    });
    afterAll(() => {
    	vi.clearAllMocks();
  	});
  });
  ...
})
...

또한 저희 회사에서는 전역 window 객체에 입력한 환경변수 값에 따라 다른 UI를 보여주었습니다.
이를 위해서 window 객체의 mocking이 필요했는데 이는 Vitest에서 제공하는 StubGlobal을 이용해서 진행했습니다.

01.2. 렌더링

describe('이름 변경 페이지 통합 테스트', () => {  
  ...
  it('페이지가 렌더링된 후 이름 변경 input이 포커스 된다.', async () => {
    await render(<SettingName />);
    expect(screen.getByPlaceholderText(MOCK_NAME)).toHaveFocus();
  });

  it('페이지가 렌더링된 후 이름 변경 input에서 원래 유저의 이름을 보여준다.', async () => {
    await render(<SettingName />);
    expect(screen.getByDisplayValue(MOCK_NAME)).toBeInTheDocument();
  });

  it('이름 변경 input값이 비어있다면 placeholder로 원래 유저의 이름을 보여준다.', async () => {
    await render(<SettingName />);
    expect(screen.getByPlaceholderText(MOCK_NAME)).toBeInTheDocument();
  });

  it('페이지가 렌더링 됐을 때 현재 이름과 변경할 이름이 같으므로 저장 버튼이 비활성화된다', async () => {
    await render(<SettingName />);
    const submitButton = screen.getByRole('button', { name: BUTTON_TEXT });

    expect(submitButton).toBeDisabled();
  });
  //...
});
  • 폼이 렌더링된 이후의 상황을 테스트한 부분입니다.
  • 렌더링 후 원하는 요소에 포커스 되었는지, 원하는 정보를 보여주고 있는지를 테스트하고 있습니다.

01.3. 유효성 검사 테스트

describe('이름 변경 페이지 통합 테스트', () => {  
  //...
  it('이름이 비어있는 채로 저장 버튼을 누르면 'EMPTY_NAME' 경고문구가 노출된다.', async () => {
    const { user } = await render(<SettingName />);
    const updateNameTextField = screen.getByPlaceholderText(MOCK_NAME);
    const submitButton = screen.getByRole('button', { name: BUTTOM_TEXT });

    await user.clear(updateNameTextField);
    await user.click(submitButton);

    expect(
      screen.getByText(EMPTY_NAME),
    ).toBeInTheDocument();
  });

  it('이름은 10글자 이상 입력 시 초과되는 글자는 입력되지 않는다', async () => {
    const { user } = await render(<SettingName />);
    const updateNameTextField = screen.getByPlaceholderText(MOCK_NAME);

    await user.clear(updateNameTextField);
    await user.type(updateNameTextField, '01234567890');

    expect(screen.getByDisplayValue('0123456789')).toBeInTheDocument();
  });

  it('현재 이름과 변경할 이름이 같은 경우 저장 버튼이 비활성화된다', async () => {
    const { user } = await render(<SettingName />);
    const updateNameTextField = screen.getByPlaceholderText(MOCK_NAME);
    const submitButton = screen.getByRole('button', { name: BUTTON_TEXT });

    await user.clear(updateNameTextField);
    await user.type(updateNameTextField, MOCK_NAME);

    expect(submitButton).toBeDisabled();
  });
  //..
 })
  • 이름의 유효성검사를 테스트하는 코드입니다.
  • 이전 게시글에서 설명한대로 userEvent의 clear, click, type 등을 사용해서 사용자와의 상호작용을 테스트하고 있습니다.
  • 또한 textField(input)과 버튼을 찾을 때에도 getByPlaceholderText, getByDisplayValue, getByRole 등 화면에 보이는 정보들로 요소를 찾고 있습니다.
  • 세부 구현이 아닌 UI 및 사용자 위주의 테스트를 진행함으로 써 테스트가 쉽게 깨지는 것을 방지했습니다.

01.4. 폼 제출 테스트

describe('이름 변경 페이지 통합 테스트', () => {  
  //...
  it('유효성에 맞는 이름을 입력하고 제출하면 updateUser 함수가 호출된다.', async () => {
    const { user } = await render(<SettingName />);
    const updateNameTextField = screen.getByPlaceholderText(MOCK_NAME);
    const submitButton = screen.getByRole('button', { name: BUTTON_TEXT });

    await user.clear(updateNameTextField);
    await user.type(updateNameTextField, MOCK_UPDATE_NAME);
    await user.click(submitButton);

    expect(updateUserFn).toHaveBeenNthCalledWith(1, {
      id: MOCK_ID,
      name: MOCK_UPDATE_NAME,
    });
  });

  it('이름 변경이 성공하면 -1 경로로 navigate함수를 호출한다.', async () => {
    const { user } = await render(<SettingName />);
    const updateNameTextField = screen.getByPlaceholderText(MOCK_NAME);
    const submitButton = screen.getByRole('button', { name: BUTTON_TEXT });

    await user.clear(updateNameTextField);
    await user.type(updateNameTextField, MOCK_UPDATE_NAME);
    await user.click(submitButton);

    expect(navigateFn).toHaveBeenNthCalledWith(1, -1);
  });
});
  • 유효성 검사를 진행한 이후 폼 제출 동작을 테스트하고 있습니다.
  • updateUser는 공통모듈, navigate는 react-router-dom의 함수이므로 mocking 후 함수의 호출만 테스트했습니다.

02. 다음 게시글

여기까지 테스트 코드를 작성한 경험에 대해 이야기 해보았습니다. 다음 게시글에서는 테스트 코드를 작성 한 후 react-hook-form + zod를 이용해서 유효성 검사를 진행했던 경험을 공유하려고 합니다.

profile
Software developer

0개의 댓글