본문으로 바로가기

 

 

이번 글에서는 HTML 요소를 정적 컴포넌트, 동적 컴포넌트에서는 어떻게 확인할 수 있는지 간단한 예제를 통해 글을 작성해보겠습니다. 먼저, 테스트 코드를 잘 다루고 어떻게 동작하는지 알기 위해서는, React Testing Library가 뭔지 알아야 합니다.

 

React Testing Library란?

DOM Testing Library를 기반으로 만들어진 테스팅 라이브러리로, 리액트를 기반으로 한 테스트를 수행하기 위해 만들어졌습니다. React Testing Library를 이해하려면 먼저 React Testing Library가 기반으로 하는 DOM Testing Library에 대해 먼저 알아둬야 합니다. DOM Testing Library는 jsdom을 기반으로 하고 있습니다. 

 

jsdom이란 순수하게 자바스크립트로 작성된 라이브러리로, HTML이 없는 자바스크립트만 존재하는 환경, 예를 들어 Node.js 같은 환경에서 HTML과 DOM을 사용할 수 있도록 해주는 라이브러리입니다. jsdom을 사용하면 자바스크립트 환경에서도 HTML을 사용할 ㅜ 있으므로 이를 기반으로 DOM Testing Library에서 제공하는 API를 사용해 테스트를 수행할 수 있습니다.

 

jsdom을 사용해 자바스크립트 환경에서 HTML을 사용할 수 있는 DOM Testing Library를 기반으로, 동일한 원리로 리액트 깁나 환경에서 리액트 컴포넌트를 테스팅할 수 있는 라이브러리가 바로 React Testing Library입니다. React Testing Library를 활용하면 실제로 리액트 컴포넌트를 렌더링 하지 않고도, 즉 브라우저를 직접 실행해 눈으로 확인하지 않아도 리액트 컴포넌트가 원하는 대로 렌더링 되고 있는지 확인할 수 있습니다.

 

 

HTML 요소가 있는지 확인하는 3가지 방법

HTML 요소가 있는지 여부를 확인하기 위한 방법은 크게 3가지가 있습니다. getBy..., findBy..., queryBy... 예제를 통해 확인해 보겠습니다.

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

it('renders 코딩병원 link', () => {
  render(<Index />)
  const linkElement = screen.getByTest(/코딩병원/i);
  expect(linkElement).toBeInTheDocument();
})

 

위의 Index 컴포넌트는 다음과 같이 구성되어 있습니다.

export default function Page() {
  return <h1>안녕, 코딩병원!</h1>
}

 

코드 내용을 종합하면 test 코드의 테스트하는 내용은 다음과 같이 요약할 수 있습니다.

  1. <Index/>를 렌더링한다.
  2. 렌더링 하는 컴포넌트 내부에서 "코딩병원"이라는 문자열을 가진 DOM 요소를 찾는다.
  3. expect(linkElement).toBeInTheDocument()라는 어설션을 활용해 2번에서 찾은 요소가 document 내부에 있는지 확인한다.

위와 같이 리액트 컴포넌트에서 테스트하는 일반적인 시나리오는 특정한 무언가를 지닌 HTML 요소가 있는지 여부입니다. 이를 확인하는 방법은 크게 3가지가 있습니다.

  • getBy...: 인수의 조건에 맞는 요소를 반환하며, 해당 요소가 없거나 두 개 이상이면 에러를 발생시킵니다. 복수 개를 찾고 싶다면 getAllBy... 를 사용하면 됩니다.
  • findBy...: getBy... 와 거의 유사하나 한 가지 큰 차이점은 Promise를 반환한다는 것입니다. 즉, 비동기로 찾는다는 것을 의미하며, 기본값으로 1000ms의 타임아웃을 가지고 있습니다. 마찬가지로 두 개 이상이면 에러를 발생시키지만 복수 개를 찾고 싶다면 findAllBy...를 사용하면 됩니다. 이러한 특징 때문에 findBy는 비동기 액션 이후에 요소를 찾을 때 사용합니다.
  • queryBy...: 인수의 조건에 맞는 요소를 반환하는 대신, 찾지 못하면 null을 반환합니다. getBy...와 findBy... 는 찾지 못하면 에러를 발생시키기 때문에 찾지 못해도 에러를 발생시키고 싶지 않다면 queryBy... 를 사용하면 됩니다. 마찬가지로 복수 개를 찾았을 때는 에러를 발생시키며, 복수 개를 찾고 싶다면 queryAllBy... 를 사용하면 됩니다.

그리고 컴포넌트를 테스트하는 파일은 같은 디렉터리상에 위치하는 것이 일반적입니다. 이름 규칙인 *. test. {t|s} jsx만 준수한다면 디렉터리 내부에서 명확하게 구별되고, 대부분의 프레임워크가 이러한 이름으로 된 파일은 번들링에서 제외하므로 유용하게 사용할 수 있습니다.

 

 

정적 컴포넌트 테스트

정적 컴포넌트, 즉 별도의 상태가 존재하지 않아 항상 같은 결과를 반환하는 컴포넌트를 테스트하는 방법은 크게 어렵지 않습니다. 테스트를 원하는 컴포넌트를 렌더링 한 다음, 테스트를 원하는 요소를 찾아 테스트를 수행하면 됩니다. 아래 예제를 통해 확인해 보겠습니다.

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

import StaticComponent from './index'

beforeEach(() => {
  render(<StaticComponent />)
})

describe('링크 확인', () => {
  it('링크가 3개 존재한다.', () => {
    const ul = screen.getByTestId('ul')
    expect(ul.children.length).toBe(3)
  })

  it('링크 목록의 스타일이 square다.', () => {
    const ul = screen.getByTestId('ul')
    expect(ul).toHaveStyle('list-style-type: square;')
  })
})

describe('리액트 링크 테스트', () => {
  it('리액트 링크가 존재한다.', () => {
    const reactLink = screen.getByText('리액트')
    expect(reactLink).toBeVisible()
  })

  it('리액트 링크가 올바른 주소로 존재한다.', () => {
    const reactLink = screen.getByText('리액트')

    expect(reactLink.tagName).toEqual('A')
    expect(reactLink).toHaveAttribute('href', 'https://reactjs.org')
  })
})

describe('네이버 링크 테스트', () => {
  it('네이버 링크가 존재한다.', () => {
    const naverLink = screen.getByText('네이버')
    expect(naverLink).toBeVisible()
  })

  it('네이버 링크가 올바른 주소로 존재한다.', () => {
    const naverLink = screen.getByText('네이버')

    expect(naverLink.tagName).toEqual('A')
    expect(naverLink).toHaveAttribute('href', 'https://www.naver.com')
  })
})

describe('블로그 링크 테스트', () => {
  it('블로그 링크가 존재한다.', () => {
    const blogLink = screen.getByText('블로그')
    expect(blogLink).toBeVisible()
  })

  it('블로그 링크가 올바른 주소로 존재한다.', () => {
    const blogLink = screen.getByText('블로그')

    expect(blogLink.tagName).toEqual('A')
    expect(blogLink).toHaveAttribute('href', 'https://yceffort.kr')
  })

  it('블로그는 같은 창에서 열려야 한다.', () => {
    const blogLink = screen.getByText('블로그')
    expect(blogLink).not.toHaveAttribute('target')
  })
})

 

위의 코드에서 사용된 jest의 메서드를 살펴보겠습니다.

  • beforeEach: 각 테스트(it)를 수행하기 전에 실행하는 함수
  • describe: 비슷한 속성을 가진 테스트를 하나의 그룹으로 묶는 역할
  • it: test와 완전히 동일하며, test의 축약어. it이라는 축약어를 제공하는 이유는 테스트 코드를 좀 더 사람이 읽기 쉽게 하기 위해서.
  • testId: testId는 리액트 테스팅 라이브러리의 예약어로, get 등의 선택자로 선택하기 어렵거나 곤란한 요소를 선택하기 위해 사용 가능. HTML의 DOM 요소에 testId 데이터셋을 선언해 두면 이후 테스트 시에 getByTestId, findByTestId 등으로 선택 가능. (= querySelector([data-testId="${yourId}"]))

* 데이터셋이란?

HTML의 특정 요소와 관련된 임의 정보를 추가할 수 있는 HTML 속성입니다. HTML의 특정 요소에 data-로 시작하는 속성은 무엇이든 사용할 수 있습니다. 앞의 예제에서는 HTML에 data-testid를 추가해 getByTestId를 사용했습니다.

 

 

동적 컴포넌트 테스트

사실, 아무런 상태값이 없는(stateless) 컴포넌트는 테스트하기가 매우 간편합니다. 하지만 상태값이 있는 컴포넌트, 예를 들어 useState를 사용해 선택값을 관리하는 컴포넌트는 어떨까요? 아래 간단한 예제를 통해 확인해 보겠습니다.

import { fireEvent, render } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

import { InputComponent } from '.'

describe('InputComponent 테스트', () => {
  const setup = () => {
    const screen = render(<InputComponent />)
    const input = screen.getByLabelText('input') as HTMLInputElement
    const button = screen.getByText(/제출하기/i) as HTMLButtonElement
    return {
      input,
      button,
      ...screen,
    }
  }

  it('input의 초기값은 빈 문자열이다.', () => {
    const { input } = setup()
    expect(input.value).toEqual('')
  })

  it('input의 최대길이가 20자로 설정되어 있다.', () => {
    const { input } = setup()
    expect(input).toHaveAttribute('maxlength', '20')
  })

  it('영문과 숫자만 입력된다.', () => {
    const { input } = setup()
    const inputValue = '안녕하세요123'
    userEvent.type(input, inputValue)
    expect(input.value).toEqual('123')
  })

  it('아이디를 입력하지 않으면 버튼이 활성화 되지 않는다.', () => {
    const { button } = setup()
    expect(button).toBeDisabled()
  })

  it('아이디를 입력하면 버튼이 활성화 된다.', () => {
    const { button, input } = setup()

    const inputValue = 'helloworld'
    userEvent.type(input, inputValue)

    expect(input.value).toEqual(inputValue)
    expect(button).toBeEnabled()
  })

  it('버튼을 클릭하면 alert가 해당 아이디로 뜬다.', () => {
    const alertMock = jest
      .spyOn(window, 'alert')
      .mockImplementation((_: string) => undefined)

    const { button, input } = setup()
    const inputValue = 'helloworld'

    userEvent.type(input, inputValue)
    fireEvent.click(button)

    expect(alertMock).toHaveBeenCalledTimes(1)
    expect(alertMock).toHaveBeenCalledWith(inputValue)
  })
})

 

이 테스트 코드는 앞서 작성한 코드와 다른 점이 몇 가지 있습니다. 하나씩 자세히 살펴보겠습니다.

  • setup 함수: setup 함수는 내부에서 컴포넌트를 렌더링 하고, 또 테스트에 필요한 button과 input을 반환합니다. 이 파일에서 수행하는 모든 테스트는 렌더링과 button, input을 필요로 하므로 이를 하난의 함수로 묶어두었습니다.
  • userEvent.type: userEvent.type은 사용자가 타이핑하는 것을 흉내 내는 메서드입니다. userEvent.type을 사용하면 사용자가 키보드로 타이핑하는 것과 동일한 작동을 만들 수 있습니다. userEvent는 @testing-library/react에서 제공하는 fireEvent와 차이가 있습니다. 기본적으로 userEvent는 fireEvent의 여러 이벤트를 순차적으로 실행해 좀 더 자세하게 사용자의 작동을 흉내 냅니다. 예를 들어, userEvent.click을 수행하면 내부적으로 다음과 같은 fireEvent가 실행됩니다.
    • fireEvent.mouseOver
    • fireEvent.mouseMove
    • fireEvent.mouseDown
    • fireEvent.mouseUp
    • fireEvent.click
    • userEvent.click은 사용자가 마우스를 움직이고, 요소에 올리고, 마우스를 클릭하는 등의 모든 작동을 수행합니다. 따라서 userEvent는 사용자의 작동을 여러 fireEvent를 통해 좀 더 자세하게 흉내 내는 모듈이라고 볼 수 있습니다. 
    • maxLength는 사용자가 하나씩 입력하는 경우에만 막히고, 코드로 한 번에 입력하는 경우에는 작동하지 않습니다. fireEvent.type으로는 이 maxLength 작동을 확인할 수 없으므로 userEvent.type을 사용해야 합니다.
    • 요약하자면, 대부분의 이벤트를 테스트할 때는 fireEvent로 충분하고 훨씬 더 빠릅니다. 단, 특별히 사용자의 이벤트를 흉내 내야 할 때만 userEvent를 사용하면 됩니다.
  • jest.spyOn(window, 'alert').mockImplementation(): 이 구문을 이해하려면 jest.spyOn과 mockImplementation에 대해 알아야 합니다.
  • jest.spyOn: Jest가 제공하는 spyOn은 어떠한 특정 메서드를 오염시키지 않고 실행이 됐는지, 또 어떤 인수로 실행됐는지 등 실행과 관련된 정보만 얻고 싶을 때 사용합니다. 여기서는 (window, 'alert')라는 인수와 함께 사용됐는데, 이는 window 객체의 메서드 alert을 구현하지 않고 해당 메서드가 실행됐는지만 관찰한다는 뜻입니다.
  • mockImplementation: 해당 메서드에 대한 모킹(mocking) 구현을 도와줍니다. 현재 Jest를 실행하는 Node.js 환경에서는 window.alert가 존재하지 않으므로 해당 메서드를 모의 함수 (mock)로 구현해야 하는데, 이것이 바로 mockImplementation의 역할입니다. 비록 모의 함수로 구현된 함수이지만 함수가 실행됐는지 등의 정보는 확인할 수 있도록 도와줍니다. 즉, 여기서는 Node.js가 존재하지 않는 widnow.alert을 테스트하기 위해 jest.spyOn을 사용해 window.alert를 관찰하게끔 하고, mockImplementation을 통해 window.alert가 실행됐는지 등의 정보를 확인할 수 있도록 처리한 것입니다. 이렇게 먼저 Node.js 환경에서 실행될 수 없는 window.alert를 처리해 주면 실제 alert가 발생할 때 해당 모의 함수가 실행되어 함수가 몇 번 실행됐는지, 어떤 인수와 함께 실행됐는지 알 수 있습니다.

 

jest.spyOn에 대한 예제를 추가로 보겠습니다.

const calc = {
  add: (a, b) => a+b
}

const spyFn = jest.spyOn(calc, 'add');
const result = calc.add(1, 2)

expect(spyFn).toBeCalledTimes(1);
expect(spyFn).toBeCalledWith(1, 2);
expect(result).toBe(3)

 

위 코드에서는 jest.spyOn으로 calc 객체의 add 메서드를 관찰한 것을 볼 수 있습니다. spyOn으로 관찰한 덕분에 한 번 호출됐는지(toBeCalledTimes(1)), 원하는 인수와 함께 호출됐는지(toBeCalledWith(1, 2))를 확인할 수 있습니다. 그리고 spyOn으로 관찰은 했지만 calc.add의 작동 자체에는 영향을 미치지 않은 것을 확인할 수 있습니다.

이처럼 jest.spyOn은 특정 객체의 메서드를 오염시키지 않고 단순히 관찰하는 용도로 사용할 수 있습니다.

 

동적인 컴포넌트가 확실히, 여러 이벤트들이 있다 보니 정적인 컴포너트에 비해 테스트 코드가 복잡했습니다. 하지만, 액션이 수행된 이후에 DOM에 기댓값이 반영됐는지 확인하는 방법은 동일해서 다행입니다.

 

 

 

References

https://www.daleseo.com/jest-fn-spy-on/

https://junheedot.tistory.com/entry/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EC%BD%94%EB%93%9C-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0-%EA%B0%9C%EB%85%90%ED%8E%B8

React Deep Dive

 

 

 

  

'1. 웹개발 > 1_1_7 Jest' 카테고리의 다른 글

[Jest] MSW로 비동기 이벤트 테스트하기  (0) 2024.05.06
Jest를 사용하는 이유  (0) 2023.12.17