Etc

리액트와 함께하는 테스트 격리(번역)

리액트와 함께하는 테스트 격리

이 글은, Kent C. DoddsTest Isolation with React 원문을 번역한 글입니다.

이 블로그 게시물을 위한 영감은 다음과 같이 보이는 리액트 테스트에서 비롯되었습니다.

const utils = render(<Foo />)

test('test 1', () => {
  // use utils here
})

test('test 2', () => {
  // use utils here too
})

그래서 저는 테스트 격리의 중요성을 얘기하고, 테스트의 신뢰성을 개선하고 코드를 단순화하며 당신의 테스트에 대한 자신감을 높이기 위해 더 나은 방법을 제공하고자 안내하고 싶습니다.

간단한 컴포넌트로 이루어진 예제를 살펴봅시다.

import React, {useRef} from 'react'

function Counter(props) {
  const initialProps = useRef(props).current
  const {initialCount = 0, maxClicks = 3} = props

  const [count, setCount] = React.useState(initialCount)
  const tooMany = count >= maxClicks

  const handleReset = () => setCount(initialProps.initialCount)
  const handleClick = () => setCount(currentCount => currentCount + 1)

  return (
    <div>
      <button onClick={handleClick} disabled={tooMany}>
        Count: {count}
      </button>
      {tooMany ? <button onClick={handleReset}>reset</button> : null}
    </div>
  )
}

export {Counter}

이것은 렌더링된 버전의 컴포넌트입니다.

rendered version component

첫번째 테스트 묶음

이 게시물에 영감을 준 형태의 테스트 묶음과 함께 시작해봅시다.

// gives us the toHaveTextContent/toHaveAttribute matchers
import '@testing-library/jest-dom/extend-expect'
import {render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import {Counter} from '../counter'

const {getByText} = render(<Counter maxClicks={4} initialCount={3} />)
const counterButton = getByText(/^count/i)

test('the counter is initialized to the initialCount', () => {
  expect(counterButton).toHaveTextContent('3')
})

test('when clicked, the counter increments the click', () => {
  userEvent.click(counterButton)
  expect(counterButton).toHaveTextContent('4')
})

test(`the counter button is disabled when it's hit the maxClicks`, () => {
  userEvent.click(counterButton)
  expect(counterButton).toHaveAttribute('disabled')
})

test(`the counter button does not increment the count when clicked when it's hit the maxClicks`, () => {
  expect(counterButton).toHaveTextContent('4')
})

test(`the reset button has been rendered and resets the count when it's hit the maxClicks`, () => {
  userEvent.click(getByText(/reset/i))
  expect(counterButton).toHaveTextContent('3')
})

우선, @testing-library/react@9.0.0 기준으로 이 테스트의 스타일은 제대로 작동하지 않을 것이지만, 그렇다고 상상해봅시다.

이 케이스들은 우리에게 컴포넌트의 100% 커버리지를 제공하고 테스트들이 확인하고자 하는 것들을 정확하게 확인합니다. 문제는 테스트들이 변하는 상태를 공유하고 있다는 것입니다. 그들이 공유하고 있는 상태는 무엇일까요? 바로 컴포넌트입니다! 하나의 테스트는 카운터 버튼을 클릭하고 다른 테스트들은 그 사실에 따라 통과합니다. 만약 우리가 "클릭하면, 카운터는 클릭을 증가시킵니다." 테스트를 삭제(또는 .skip)한다면, 그 뒤에 따라오는 모든 테스트들은 멈출 것입니다.

테스트:

break tests

이것은 문제가 있습니다. 그 이유는 어떤 테스트가 다른 테스트의 기능에 영향을 미치는지 모르기 때문에 이러한 테스트를 안정적으로 리팩터링하거나 디버깅 목적으로 다른 테스트와 격리된 단일 테스트를 실행할 수 없기 때문입니다. 누군가가 한 테스트를 변경하기 위해 들어왔을 때, 다른 테스트가 갑자기 중단되기 시작하면 정말 혼란스러울 수 있습니다.

나은 방법

이제 다른 것들을 시도해보고 어떻게 테스트들을 변경하는지 살펴봅시다.

import '@testing-library/jest-dom/extend-expect'
import {render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import {Counter} from '../counter'

let getByText, counterButton

beforeEach(() => {
  const utils = render(<Counter maxClicks={4} initialCount={3} />)
  getByText = utils.getByText
  counterButton = utils.getByText(/^count/i)
})

test('the counter is initialized to the initialCount', () => {
  expect(counterButton).toHaveTextContent('3')
})

test('when clicked, the counter increments the click', () => {
  userEvent.click(counterButton)
  expect(counterButton).toHaveTextContent('4')
})

test(`the counter button is disabled when it's hit the maxClicks`, () => {
  userEvent.click(counterButton)
  expect(counterButton).toHaveAttribute('disabled')
})

test(`the counter button does not increment the count when clicked when it's hit the maxClicks`, () => {
  userEvent.click(counterButton)
  userEvent.click(counterButton)
  expect(counterButton).toHaveTextContent('4')
})

test(`the reset button has been rendered and resets the count when it's hit the maxClicks`, () => {
  userEvent.click(counterButton)
  userEvent.click(getByText(/reset/i))
  expect(counterButton).toHaveTextContent('3')
})

위처럼, 각 테스트들은 서로 완벽하게 격리되어 있습니다. 우리는 어떠한 테스트들을 삭제하거나 건너뛸 수 있으며, 테스트들의 나머지들은 통과를 계속할 수 있습니다. 가장 큰 근본적인 차이는 각각의 테스트들은 작동하는 고유의 카운트 인스턴스를 갖고 있으며, 그것은 각각의 테스트 후에 언마운트가 됩니다. (이것은 React Testing Library 덕분에 자동으로 일어납니다.) 이것은 사소한 변화로 함께 우리의 테스트들의 복잡성을 상당히 줄여줍니다.

이러한 접근에 대해 사람들이 종종 말하는 것은, 이전의 접근보다 느리지 않냐는 것입니다. 저는 그것에 대해서 어떻게 반응해야 할지 확실하게 모르겠습니다. 예를 들어 얼마나 느리나요?, 몇 밀리초가 느릴까요? 이러한 경우는 어떻게 해야할 까요? 몇 초일까요? 그렇다면 당신의 컴포넌트는 그것이 끔찍하기 때문에 최적화가 되어야 할 것입니다. 저는 그것이 시간이 지남에 따라 합쳐지는것을 알고 있습니다. 그러나 신뢰성을 얻고 이러한 접근 방법의 유지보수성이 개선된다면, 저는 이러한 방법으로 렌더링이 되기 위해 기꺼이 몇초를 더 기다릴 것입니다. 게다가 당신은 jest에서 제공하는 훌륭한 감시모드 덕분에, 모든 테스트들을 실행시킬 필요는 없습니다.

더 나은 방법

이제 저는 위에 있는 테스트들에 대해 매우 만족하지 않습니다.
저는 beaforeEach 와 테스트들간의 변수를 공유하는 것을 좋아하지 않습니다. 저는 그것들이 테스트들을 이해하기 어렵게 만들기 때문입니다. 다시 시도해봅시다.

import '@testing-library/jest-dom/extend-expect'
import {render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import {Counter} from '../counter'

function renderCounter(props) {
  const utils = render(<Counter maxClicks={4} initialCount={3} {...props} />)
  const counterButton = utils.getByText(/^count/i)
  return {...utils, counterButton}
}

test('the counter is initialized to the initialCount', () => {
  const {counterButton} = renderCounter()
  expect(counterButton).toHaveTextContent('3')
})

test('when clicked, the counter increments the click', () => {
  const {counterButton} = renderCounter()
  userEvent.click(counterButton)
  expect(counterButton).toHaveTextContent('4')
})

test(`the counter button is disabled when it's hit the maxClicks`, () => {
  const {counterButton} = renderCounter({
    maxClicks: 4,
    initialCount: 4,
  })
  expect(counterButton).toHaveAttribute('disabled')
})

test(`the counter button does not increment the count when clicked when it's hit the maxClicks`, () => {
  const {counterButton} = renderCounter({
    maxClicks: 4,
    initialCount: 4,
  })
  userEvent.click(counterButton)
  expect(counterButton).toHaveTextContent('4')
})

test(`the reset button has been rendered and resets the count when it's hit the maxClicks`, () => {
  const {getByText, counterButton} = renderCounter()
  userEvent.click(counterButton)
  userEvent.click(getByText(/reset/i))
  expect(counterButton).toHaveTextContent('3')
})

여기서 우리는 보일러 플레이트를 늘렸습니다. 하지만 모든 테스트들일 기술적뿐만 아니라 시각적으로 격리되었습니다. 당신은 테스트 안에서 발생하는 hook에 대해 걱정할 필요 없이 테스트가 무엇을 하는지 정확히 볼 수 있습니다. 이것은 당신을 위한 테스트들이 리팩터링이 되거나 삭제 또는 추가가 되는 능력에서의 큰 승리입니다.

더 더 나은 방법

저는 지금 우리가 갖고 있는 것을 좋아합니다. 하지만 제가 테스트들을 정말로 좋아하기 전에 한단계 나아갈 필요가 있다고 생각합니다. 우리는 테스트들을 기능적으로 분리하였지만, 정말로 우리가 원하는 것은 컴포넌트가 만족하는 사용 사례입니다. 그것은 maxClick에 도달할 때까지 클릭을 허용하며, 그 후에 리셋을 요구합니다. 그것이 우리가 확인하고
신뢰를 얻기 위해 시도하고자 하는 것입니다. 저는 특정 기능보다 사용 사례에 더욱 흥미가 있습니다. 개별 기능보다 사용사례에 조금 더 관심을 가진다면 테스트들은 어떻게 보일까요?

import '@testing-library/jest-dom/extend-expect'
import {render} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'

import {Counter} from '../counter'

test('allows clicks until the maxClicks is reached, then requires a reset', () => {
  const {getByText} = render(<Counter maxClicks={4} initialCount={3} />)
  const counterButton = getByText(/^count/i)

  // the counter is initialized to the initialCount
  expect(counterButton).toHaveTextContent('3')

  // when clicked, the counter increments the click
  userEvent.click(counterButton)
  expect(counterButton).toHaveTextContent('4')

  // the counter button is disabled when it's hit the maxClicks
  expect(counterButton).toHaveAttribute('disabled')
  // the counter button no longer increments the count when clicked.
  userEvent.click(counterButton)
  expect(counterButton).toHaveTextContent('4')

  // the reset button has been rendered and is clickable
  userEvent.click(getByText(/reset/i))

  // the counter is reset to the initialCount
  expect(counterButton).toHaveTextContent('3')

  // the counter can be clicked and increment the count again
  userEvent.click(counterButton)
  expect(counterButton).toHaveTextContent('4')
})

저는 이러한 종류의 테스트를 매우 좋아합니다. 이것은 기능성에 대해 생각하는 것을 피하고 컴포넌트로 달성하고자 하는 것에 더욱 집중할 수 있습니다. 이것은 다른 테스트들보다 더 나은 컴포넌트의 문서를 제공합니다.

예전에는, 이러한 방식을 하지 않은 이유는(하나의 테스트에서 여러개의 단언) 어떤 테스트가 중단되었는지 확인하기 어렵기 때문이었습니다. 그러나 지금은 더 나은 에러 결과가 있으며 어떤 테스트가 중단되었는지 구별하기 쉽습니다. 예를들어

test error part

코드 프레임은 특히 도움이 됩니다. 그것은 줄 번호 뿐만아니라 이전 테스트에서도 제공하지 않은 오류메시지에 대한 컨텍스트를 제공하는데 실제로 도움이 되는 주석 및 기타 코드를 표시하는 실패한 주변 코드를 보여줍니다.

언급해야 할것은, 당신이 컴포넌트를 위해 테스트 케이스를 분리해서는 안되는다는 것을 말하는 것이 아닙니다! 당신이 그렇게 하고자 하는 데에는 여러가지 이유가 있으며 대부분이 그럴 것입니다. 기능보다 사용 사례에 집중하면, 일반적으로 다루고자 하는 코드들을 다룰 수 있습니다. 그 다음, 당신은 엣지 케이스를 다루기 위해 약간의 추가 테스트를 수행할 수 있습니다.

결론

이것이 당신에게 매우 도움이 되기를 희망합니다! 코드 예시는 여기서 찾아볼 수 있습니다. 당신의 테스트가 서로 격리되고 기능적이기보다 사용 예시에 집중해보세요. 그러면 좀 더 나은 테스트를 해볼 수 있습니다. 행운을 빕니다!

'Etc' 카테고리의 다른 글

소스맵 정리  (0) 2023.09.28
TTF, OTF 폰트별 특징  (0) 2023.07.28
TIL | Reflow, Repaint  (0) 2022.08.28
TIL | 패키지 잠금파일  (1) 2022.03.26
TIL | GraphQL Subscriptions  (0) 2022.03.13