UI 컴포넌트 테스트

UI 컴포넌트 테스트

웹 프론트엔드 주요 개발 대상은 UI 컴포넌트, 컴포넌트에는 렌더링 뿐만 아니라 복잡한 로직이 포함될 때가 많다.

컴포넌트를 테스트할 때 어떤 부분에 중점을 두는 것이 좋은지 살펴보자.

  • UI 컴포넌트의 최소 단위는 버튼과 같은 개별 UI

  • 작은 UI 컴포넌트를 조합하여 중간 크기 컴포넌트를 만들며, 작은 단위부터 하나씩 조합해 화면 단위의 UI를 완성한다.

  • 만약 고려해야할 사항을 빠뜨려서 중간 크기의 UI 컴포넌트에 문제가 생기면 페이지 전체에 문제가 생겨 앱을 사용하지 못하게 될 수도 있다.

    • UI 컴포넌트에 테스트를 작성해야 하는 이유

UI 컴포넌트 기능

"의도한 대로 작동하고 있는가", "문제가 생긴 부분은 없는가"를 확인해야 한다.

  • 데이터를 렌더링하는 기능

  • 사용자의 입력을 전달하는 기능

  • 웹 API와 연동하는 기능

  • 데이터를 동적으로 변경하는 기능

웹 접근성 테스트

신체적, 정신적 특성에 따른 차이 없이 정보에 접근할 수 있는 정도를 웹 접근성이라 한다.

  • 웹 접근성은 홤녀에 보이는 문제가 아니라서 의식적으로 신경써야만 알 수 있다.

    • 디자인에 문제 없고 정상적으로 동작한다면 품질에 문제가 없다고 생각하기 때문

  • 웹 접근성을 신경쓰지 않으면 사용자 특성에 따라 접근조차 못하는 기능이 생기고 만다.

  • 마우스를 쓰는 사용자와 보조 기기를 쓰는 사용자가 동일하게 요소들을 인식할 수 있는 쿼리로 테스트를 작성해야하기 때문에 UI 컴포넌트는 기본적인 기능의 테스트 뿐만 아니라 웹 접근성 품질 향상에도 도움이 된다.

테스트 환경 구축

UI 컴포넌트 테스트는 렌더링된 UI를 조작하고, 조작 때문에 발생한 결과를 검증하는 방식으로 진행된다.

라이브러리 설치

  • jest-environment-jsdom

  • @testing-library/react

  • @testing-library/jest-dom

  • @testing-library/user-event

jsdom

  • jsdom이 필요한 이유: UI를 렌더링하고 조작하려면 DOM API가 필요하지만 제스트가 테스트를 실행하는 환경인 Node.js에서는 공식적으로 DOM API를 지원하지 않기 때문

    • jest-envrionment-jsom: jsdom을 개선한 버전

// jest.config.ts
module.exports = {
  testEnvironment: "jest-environment-jsdom"
}
  • Next.js 처럼 서버와 클라이언트 코드가 공존하는 경우는 테스트 파일 첫 줄에 다음과 같은 주석을 작성해 파일별로 다른 테스트 환 경을 사용하도록 설정할 수 있다.

/**
* @jest-environment jest-environment-jsdom
*/

테스팅 라이브러리

UI 컴포넌트를 테스트하는 라이브러리

테스팅 라이브러리의 역할

  • UI 컴포넌트를 렌더링한다

  • 렌더링된 요소에서 임의의 자식 요소를 취득한다

  • 렌더링된 요소에 인터렉션을 일으킨다.

"테스트는 소프트웨어의 사용법과 유사해야 한다." - 테스팅 라이브러리의 기본 원칙

  • 클릭, 마우스오버, 키보드 입력 같은 기능을 사용하여 실제 웹 앱을 조작할 때와 유사하게 테스트를 작성할 것을 권장한다.

  • 리엑트를 사용한다면 @testing-library/react 를 사용하면 된다.

    • 공통적으로 @testing-library/dom 을 코어로 사용하므로 뷰나 다른 컴포넌트 라이브러리를 사용하더라도 유사한 테스트 코드를 작성하게 된다.

  • DOM 상태를 검증할 때는 제스트 매처만으로 부족하기 때문에 @testing-library/jest-dom 을 사용하여 매처를 확장한다.

    • 커스텀 매처라는 제스트의 확장 기능을 제공, UI 컴포넌트를 쉽게 테스트할 수 있는 여러 매처를 사용 가능

사용자 입력 시뮬레이션 라이브러리

  • 테스팅 라이브러리는 사용자 인터렉션 이벤트를 발생시키기 위해 fireEvent API를 제공한다

  • 다만 fireEvent API는 DOM 이벤트를 발생시킬 뿐만 아니라 실제 사용자라면 불가능한 입력 패턴을 만들기도 한다

  • 실제 사용자의 입력에 가깝게 시뮬레이션하기 위해서는 @testing-library/user-event 를 추가로 사용하는 것이 좋다.

테스트할 컴포넌트
type Props = {
  name: string
  onSubmit?: (evt: React.FormEvent<HTMLFormElement>) => void;
}
export const Form = ({ name, onSubmit }) => {
  return (
    <form
      onSubmit={(evt) => {
        evt.preventDefault();
        onSubmit?.(evt)
      }}
    >
      <h2>계정정보</h2>
      <p>{name}</p>
      <div>
        <button>수정</button>
      </div>
    </form>
  )
}

UI 컴포넌트 렌더링

  • 테스팅 라이브러리의 render 함수를 사용해서 렌더링

// name이 제데로 표시됐는가를 테스트
import { render, screen } from '@testing-library/react';
import { Form } from './Form';

test("이름을 표시한다.", () => {
  render(<Form name="woong" />);
})

특정 DOM 요소 취득하기

  • 렌더링된 요소 중 특정 DOM 요소를 취득하려면 screen.getByText 를 사용

    • 일치하는 문자열을 가진 한 개의 텍스트 요소를 찾는 API

    • 요소를 발견하면 해당 요소의 참조를 취득

    • 만약 요소를 찾지 못하면 오류 발생 후 테스트는 실패

import { render, screen } from '@testing-library/react';
import { Form } from './Form';

test("이름을 표시한다.", () => {
  render(<Form name="woong" />);
  console.log(screen.getByText("woong"))
})

단언문 작성

  • 단언문은 @testing-library/jest-dom 으로 확장한 커스텀 매처를 사용

  • toBeInTheDocument() 는 '해당 요소가 DOM에 마운트 됐는가?'를 검증하는 커스텀 매처

import { render, screen } from '@testing-library/react';
import { Form } from './Form';

test("이름을 표시한다.", () => {
  render(<Form name="woong" />);
  expect(screen.getByText('woong')).toBeInTheDocument();
})

@testing-library/jest-dom 을 명시적으로 import 하지 않아도 사용 가능하다

  • jest-setup.ts (모든 테스트에 적용할 설정 파일)에서 import 하고 있기 때문

특정 DOM 요소를 역할로 취득하기

  • screen.getByRole: 특정 DOM 요소를 역할로 취득

  • <button> 요소는 명시적으로 button 이라는 역할을 하진 않는다.

    • 그럼에도 button으로 취득할 수 있는 것은 암묵적 역할이라는 식별자를 테스팅 라이브러리가 지원하기 때문

  • 테스팅 라이브러리는 암묵적 역할을 활용한 쿼리를 우선적으로 사용하도록 권장한다

    • 역할은 웹 접근성 면에서 필수 개념

이벤트 핸들러 호출 테스트

  • 이벤트 핸들러: 어떤 입력이 발생했을 때 호출되는 함수

  • 이벤트 핸들러 호출은 목 함수로 검증한다

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

test("버튼을 클릭하면 이벤트 핸들러가 실행된다", () => {
  const mockFn = jest.fn();
  render(<Form name="woong" onSubmit={mockFn} />;
  fireEvent.click(screen.getByRole('button'));
  expect(mockFn).toHaveBeenCalled();
})

데이터가 주어질 때 조건부 렌더링 테스트

  • 테스트는 아이템이 존재하는 경우와 존재하지 않는 경우의 분기 처리에 중점을 둬야 한다.

    • 아이템이 존재하면 목록이 표시돼야 한다.

    • 아이템이 존재하지 않으면 목록이 표시되지 않아야 한다.

    • 테스트용 데이터(픽스처, fixture)는 목록을 표시하기 위한 배열 데이터

test("아이템의 수만큼 목록을 표시한다", () => {
  render(<ArticleList items={items} />);
  const list = screen.getByRole("list");
  expect(list).toBeInTheDocument();
  expect(screen.getAllByRole("listitem")).toHaveLength(3);
})

큰 컴포넌트를 다룰 때는 '테스트 대상이 아닌 listitem'도 getByRole의 반환값에 포함될 수 있다.

  • 따라서 취득한 list 노드로 범위를 좁혀 여기에 포함된 listitem 요소의 숫자를 검증해야 한다.

  • 대상 범위를 좁히고 시다면 within 함수르 사용

  • within 함수의 반환값에는 screen과 동일한 요소 취득 API가 포함되어있음

test("아이템의 수만큼 목록을 표시한다", () => {
  render(<ArticleList items={items} />);
  const list = screen.getByRole("list");
  expect(list).toBeInTheDocument();
  expect(within(list).getAllByRole("listitem")).toHaveLength(3); //범위 좁히기
})

목록에 표시할 내요이 없는 상황에서의 테스트

  • 존재하지 않음을 테스트하려면 queryBy 접두사를 붙인 API를 사용해야 한다.

    • get- 을 사용했을 때 해당 요소가 존재하지 않은 경우 오류를 발생시켜 테스트는 실패하게 됨

  • queryBy 는 취득할 요소가 없는 경우 null을 반환한다.

    • not.toBeInTheDocument 또는 toBeNull 로 검증

    • 컨벤션을 정해서 하나의 방식으로 통일하자.

test("목록에 표시할 데이터가 없으면 '게제된 기사가 없습니다'를 표시한다.", () => {
  render(<ArticleList items={[]} />)
  const list = screen.queryByRole("list");
  expect(list).not.toBeInTheDocument();
  expect(screen.getByText("게제된 기사가 없습니다")).toBeInTheDocument();
})

개별 아이템 컴포넌트 테스트

  • 예시로 개별 아이템이 props로 받은 id를 사용해서 더 알아보기 링크에 연결할 URL을 만드는 기능이 있다고치자

const item: ItemProps = {
  id: 'howto-testing-with-typescript',
  title: "타입스크립트를 사용한 테스트 작성법",
  body: "...",
}

test("링크에 id로 만든 URL을 표시한다", () => {
  render(<ArticleListItem {...item} />);
  expect(screen.getByRole("link", { name: "더 알아보기"})).toHaveAttribute(
    "href",
    "/articles/howto-testing-with-typescript"
  );
})

인터렉티브 UI 컴포넌트 테스트

  • 체크박스를 클릭하면 onChange 이벤트 핸들러로 할당된 콜백함수가 실행됨

// 체크 박스의 초기 상태 검증
test("체크 박스가 체크되어 있지 않습니다", () => {
  render(<Agreement />);
  expect(screen.getByRole("checkbox").not.toBeChecked();
})
  • 문자열을 입력하는 테스트

userEvent 를 사용한 모든 인터렉션은 력이 완료될 때까지 기다려야 하는 비동기 처리이므로 await을 사용해 입력이 완료될 떄까지 기다린다.

import userEvent from '@testing-library/user-event';

const user = userEvent.setup(); // 테스트 파일 초기에 설정

test("메일 주소 입력란", async () => {
  // 요소 렌더링
  render(<InputAccount />);
  // 요소 취득
  const textbox = screen.getByRole('textbox', { name: '메일주소' });
  const value = "xodnd9503@gmail.com";
  // 요소 인터렉션 
  await user.type(textbox, value);
  // 검증
  expect(screen.getByDisplayValue(value)).toBeInTheDocument();
})

<input type="password" /> 는 역할을 가지지 않는다?

  • 패스워드 인풋은 textbox 역할이 아니다

  • https://github.com/w3c/aria/issues/935

  • 역할이 없는 경우에 요소를 취득하는데 대체 수단으로 placeholder 값을 참조하는 getByPlaceholderText를 사용해보자.

접근 가능한 이름

  • form에 aria-labelledby 를 사용하여 접근 가능한 이름으로 인용해보자.

  • 리엑트 18에 추가된 훅인 useId 를 사용하면 접근성에 필요한 id 값을 자동으로 생성해준다.

    • 고유한 값을 관리하는 일은 번거로움 개이득

  • form은 접근 가능한 이름을 할당하지 않으면 form이라는 역할을 가지지 않는다.

import { useId } from 'react';

export const Form = () => {
  const headingId = useId();
  return (
    <form aria-labelledby={headingId}>
      <h2 id={headingId}>신규 계정 등록</h2>
    </form>
  )
}

test("form의 접근 가능한 이름은 heading에서 인용한다", () => {
  render(<Form />);
  expect(screen.getByRole('form', {name: '신규 계정 등록'}).toBeInTheDocument();
})

유틸리티 함수를 활용한 테스트

사용자의 입력이 검증의 기점이 되는데 입력 인터렉션을 함수화해서 활용해보자.

  • 폼 입력은 여러 번 동일한 인터렉션을 작성해야 할 때가 많다.

  • 이렇게 반복적으로 호출해야 하는 인터렉션을 하나의 함수로 정리하면 여러 곳에서 재사용할 수 있다.

// 유틸리티 함수
async function inputContactNumber(
  inputValues = {
    name: "정태웅",
    phoneNumber: "000-000-0000",
  }
) {
  await user.type(
    screen.getByRole('textbox', { name: "전화번호" }),
    inputValues.phoneNumber
  );
  await user.type(
    screen.getByRole('textbox', { name: "이름" }),
    inputValues.name
  );
  return inputValues
}

// 사용 예시
describe("이전 배송지가 없는 경우", async () => {
  test("폼을 제출하면 입력 내용을 전달받는다", () => {
    const [mockFn, onSubmit] = mockHandleSubmit();
    render(<Form onSubmit={onSubmit} />);
    const contactNumber = await inputContactNumber(); // 유틸리티함수 사용
    const deliverAddress = await inputDeliveryAdrres(); // 유틸리티함수 사용
    // 인터렉션의 세부 내용을 함수에 숨기면 각 테스트에서 무엇을 검증하고 싶은지 명확해진다.
    await clickSubmit();
    // 스프레드 연산자로 합쳐서 입력 내용을 제데로 전달받았는지 검증
    expect(mockFn).toHaveBeenCalledWith(
      expect.objectContaining({ ...contactNumber, ...deliveryAddress });
    )
  })
})

이벤트 검증을 위한 목함수

function mockHandlerSubmit() {
  const mockFn = jest.fn();
  const onSubmit = (evt: React.FormEvent<HTMLFormElement>) => {
    event.preventDefault();
    const formData = new FormData(evt.currentTarget);
    const data: { [k:string]: unknown } = {};
    formData.forEach((value,key) => (data[key] = value));
    mockFn(data);
  }
  
  return [mockFn, onSubmit] as const;
}

비동기 처리가 포함된 UI 컴포넌트 테스트

  1. handleSubmit: form 에 전송된 값을 values라는 객체로 변환한다

  2. checkPhoneNumber: 전송된 값에 유효성 검사를 실시

  3. postMyAddress: 웹 API 클라이언트에 호출

입력된 값을 전송하는 인터렉션 함수

  • 입력란에 모두 입력한 후 전송하는 과정을 비동기 함수로 정리

async function fillValuesAndSubmit() {
  const contactNumber = await inputContactNumber();
  const deliveryAddress = await inputDeliveryAddress();
  const submitValues = { ...contactNumber, ...deliveryAddress };
  await clickSubmit();
  return submitValues;
}

응답 성공 테스트

test("성공하면 '등록됐습니다'가 표시된다.", () => {
  const mockFn = mockPostMyAddress(); // msw를 쓰면 스킵
  render(<RegisterAddress />);
  const submitValues = await fillValueAndSubmit();
  expect(mockFn).toHaveBeenCalledWith(expect.objectContaining(submitValues));
  expect(screen.getByText("등록됐습니다")).toBeInTheDocument();
})

유효성 검사 테스트

  • 유효성 검사를 사용하는 로직은 try-catch 를 사용해보자.. 오호

const handleSubmit = (values) => {
  try {
    checkPhoneNumber(values.phoneNumber);
    // 데이터 취득 함수 
  } catch (err) {
    if (err instanceof ValidationError) {
      setPostResult("올바르지 않은 값이 포함되어 있습니다");
      return;
    }
  }
}

export class ValidationError extends Error {}

export function checkPhoneNumber(value) {
  if (!vaue.match(/^[0-9\-]+$/)) {
    throw new Error ValidationError();
  }
}

'준비', '실행', '검증' 3단계로 정리한 테스트 코드를 AAA 패턴(arrange act assert pattern)이라고 하며, 가독성이 좋다.

test("유효성 검사 오류가 발생하면 메시지가 표시된다", async() => {
  render(<RegisterAddress />); // 준비
  await fillInvalidValuesAndSubmit(); // 실행
  expect(screen.getByText("어쩌고 저쩌고").toBeInTheDocument(); // 검증
})

비동기 처리가 있다면 오류 분기가 복잡해지는 상황이 많기 때문에 테스트를 작성하면서 누락된 부분이 없는지 확인해야 한다.

UI 컴포넌트 스냅 테스트

UI 컴포넌트가 예기치 않게 변경됐는지 검증해보고 싶다면 스냅샷 테스트를 해보자.

스냅샷 기록하기

  • UI 컴포넌트의 스냅샷 테스트를 실행하면 HTML 문자열로 해당 시점의 렌더링 결과를 외부 파일에 저장한다

  • toMatchSnapshot 단언문 실행

  • 테스트가 실행되면 테스트파일과 같은 경로에 __snapshots__ 디렉토리가 생성됨

    • 디렉터리 내부에 테스트파일명.snap 형식으로 파일이 저장됨

    • 자동으로 생성되는 .snap 파일은 깃의 추적 대상으로 두고 커밋하는 것이 일반적 (.gitignore 추가 X)

회귀 테스트 발생시키기

  • 스냅샷 테스트는 이미 커밋된 .snap 파일과 현시점의 스냅샷 파일을 비교하여 차이점이 발견되면 테스트를 실패하게 만든다

  • 테스트를 실행하면 변경된 부분에 diff 가 생기고 실패한다.

  • UI 컴포넌트가 복잡해지면 의도하지 않은 변경 사항을 발견하는 경우도 있다.

스냅샷 갱신하기

  • 실패한 테스트를 성공시키려면 커밋된 스냅샷을 갱신해야 한다.

  • 테스트를 실행할 때 --updateSnapshot 혹은 -u 옵션을 추가하면 스냅샷이 새로운 내용으로 갱신됨

  • 발견한 변경사항이 의도한 것이라면 갱신을 허가한다는 의미에서 새로 출력된 스냅샷을 커밋하자.

test("...", () => {
  const { container } = render(<Form name="woong" />);
  expect(container).toMatchSnapshot();
})

암묵적 역할과 접근 가능한 이름

  • getByRole 은 웹 기술 표준을 정하는 W3C의 WAI-ARIA라는 사양에 포함된 내용 중 하나

  • WAI-ARIA에는 마크업만으로 부족한 정보를 보강하거나 의도한 대로 의미를 전달하기 위한 내용들이 있다.

  • WAI-ARIA 기반한 테스트 코드를 작성하면 스크린 리더 등의 보조 기기를 활용하는 사용자에게도 의도한 대로 컨텐츠가 도달했는지 검증할 수 있다.

  • 보조기기 뿐만 아니라 테스팅 라이브러리에서도 동일한 암묵적 역할을 사용한다.

    • 테스팅 라이브러리는 내부적으로 aria-query 라이브러리로 암묵적 역할을 취득함

    • jsdom은 접근성에 관여하지 않음

역할과 요소는 일대일로 매칭되지 않는다.

  • 요소가 가진 암묵적 역할과 요소가 일대일로 매칭되지는 않는다.

  • 암묵적 역할은 요소에 할당한 속성에 따라 변경됨

    • 대표적인 경우가 input

    • type에 속성에 따라 암묵적 역할이 변함

    • 또한 type 속성에 지정한 값과 역할 명칭이 반드시 일치하지도 않음

aria 속성을 활용해 추출

  • h1 ~ h6getByRole("heading", { level: x }) 라는 쿼리로 특정

접근 가능한 이름을 활용하여 추출

  • 접근 가능한 이름: 보조기기가 인식하는 노드의 명칭

  • 예를 들면 버튼에 '전송'이라는 문자가 있으면 해당 요소는 '전송' 버튼으로 읽힌다.

  • 아이콘만 있는 경우 어떤 기능을 제공하는 버튼인지 사용자에게 전달하진 못한다 (aria-label 필수!!)

  • logRoles 함수를 실행하면 접근 가능한 이름을 확인할 수 있다.

    • 취득 가능한 요소가 ---- 로 구분되어 로그가 출력됨

    • 접근성을 강화하거나 테스트 코드에 반영하는데 활용 가능

import { logRoles, render } from '@testing-library/react';
import { Form } from './Form';

test("...", () => {
  const { container } = render(<Form />);
  logRoles(container);
})

암묵적 역할 목록

HTML 요소WAI-ARIA 암묵적 역할참고

<article>

article

<aside>

complementary

<nav>

navigation

<header>

banner

<footer>

contentinfo

<main>

main

<section>

region

aria-labelledby가 지정되야함

<form>

form

접근 가능한 이름을 가진 경우로 한정

<button>

button

<a href="xxxx">

link

href 속성을 가진 경우로 한정

<input type="xxx">

checkbox, radio, button, textbox, searchbox, spinbutton, slider

type="password" 는 역할 X

<select>

listbox

<optgroup>

group

<option>

option

<ul>, <ol>, <li>

list, listitem

<table>

table

<caption>

caption

<th>, <td>, <tr>

columnheader/rowheader, cell, row

<fieldset>

group

<legend>

X

쿼리(요소 취득 API)의 우선순위

  • 테스팅 라이브러리는 '사용자 입력을 제약 없이 재현한다.'는 원칙이 있다.

  • 요소 취득 API는 다음과 같은 순서로 사용할 것을 권장한다.

1. 모두가 접근 가능한 쿼리

  • 웹 접근성에 준수하여 차이 없이 접근할 수 있는 쿼리를 의미

  • 시각적으로 인지한 것과 스크린 리더등의 보조 기기로 인지한 것이 동일하다는 것을 증명 가능

  • getByRole

    • 명시적으로 role 속성이 할당된 요소뿐만 아니라 암묵적 역할을 가진 요소도 취득 가능

  • getByLabelText

  • getByPlaceholerText

  • getByText

  • getByDisplayValue

2. 시맨틱 쿼리

  • 공식 표준에 기반한 속성을 사용하는 쿼리를 의미

  • 시멘틱 쿼리는 브라우저나 보조기기에 따라 상당히 다른 결과가 나올수 있는 것에 주의

  • getByAllText

  • getByTitle

3. 테스트 ID

  • 테스트용으로 할당된 식별자를 의미

  • 역할이나 문자 컨텐츠를 활용한 쿼리를 사용할 수 없거나 의도적으로 의미 부여를 피하고 싶을 때만 사용할 것을 권장

  • getByTestId

Last updated