스무디 한 잔 마시며 끝내는 리액트 + TDD (12)

y_cat·2022년 12월 27일
0

테스트 작성

이제 할 일 목록 앱을 Jest와 react-testing-library를 사용하여 테스트 코드를 작성해본다.
우선 앞에서 작성한 테스트 코드를 지우고 시작하기 위해 ./src/App.test.tsx 파일 내에 it 함수 내부 내용들을 삭제한다.

describe('<App />', () => {
  it('renders component correctily', () => {
  
  })
})

그리고 npm run test를 입력한 다음 a, u 키를 눌러서 테스트 수행과 스냅샷을 찍는다.


Button 컴포넌트

Button 컴포넌트는 Props만을 가지는 단순한 컴포넌트이다.
Button 컴포넌트가 화면에 잘 표시되는지, Props가 잘 적용되는 지 테스트를 해보자.

다음과 같이 ./src/Component/Button/index.test.tsx 파일을 작성한다.

import React from 'react'
import { render, screen } from '@testing-library/react'
import 'jest-styled-components'

import { Button } from './index'

describe('<Button />', () => {
    it('renders component correctly', () => {
        const { container } = render(<Button label="Button Test" />)
        
        const label = screen.getByText('Button Test')
        expect(label).toBeInTheDocument()
        
        const parent = label.parentElement
        expect(parent).toHaveStyleRule('background-color', '#304FFE')
        expect(parent).toHaveStyleRule('background-color', '#1E40FF', {
            modifier: ':hover'
        })

        expect(container).toMatchSnapshot()
    })
})

jest-styled-components는 toHaveStyleRule이라는 Matcher를 추가로 제공하여 styled-components를 좀 더 자세히 테스트할 수 있게 도와준다.

react-testing-library의 render 함수를 이용하여 Button 컴포넌트를 렌더링하였다. 렌더링한 결과를 container 변수를 할당받아 스냅샷 테스트에 사용했다.

react-testing-library의 screen.getByText를 사용하여 Button 컴포넌트의 필수 Props인 label의 설정한 값으로 Button 컴포넌트를 찾고 이렇게 찾은 Button 컴포넌트를 toBeInTheDocument를 사용하여 화면에 표시되어 있는지 확인했다.

backgroundColor와 hoverColor가 설정되어 있지 않은 상황에서 기본값이 잘 설정되는지 확인하기 위해 jest-styled-components의 새로운 Matcher인 toHaveStyleRule를 사용하여 확인했다.

backgroundColor와 hoverColor는 screen.getByText로 찾은 Label 컴포넌트가 아닌 Label 컴포넌트의 부모 요소인 Container 컴포넌트에 설정된다. label.parentEelement를 사용하여 Label 컴포넌트의 부모 요소인 Container 컴포넌트에 접근하여 값이 잘 설정되었는지 확인했다.


npm run test를 입력하여 테스트가 all passed가 되는지 확인한 다음에, Button/__snapshots__에 index.test.tsx에 대한 스냅샷 파일이 생성된다. 이는 Button 컴포넌트가 필수 Props인 label만 설정하여도 화면에 잘 표시되는 것을 확인할 수 있었다.



이제는 Button 컴포넌트의 필수가 아닌 Props인 backgroundColor와 hoverColor를 테스트하기 위해 ./src/Component/Button/index.test.tsx 파일에서 다음과 같이 describe 함수 내에서 이미 작성된 it 함수 다음에 새로운 it 함수를 추가해본다.

...
  it('changes backgroundColor and hoverColor Props', () => {
      const backgroundColor = "#FF1744"
      const hoverColor = "#F01440"
      render(<Button label="Button Test" backgroundColor={ backgroundColor } hoverColor={ hoverColor } />)

      const parent = screen.getByText('Button Test').parentElement
      expect(parent).toHaveStyleRule('background-color', backgroundColor)
      expect(parent).toHaveStyleRule('background-color', hoverColor, {
          modifier: ':hover',
      })
  })
...

Button 컴포넌트의 필수 Props가 아닌 backgroundColor와 hoverColor를 화면에 렌더링한 후 Label 컴포넌트의 부모 컴포넌트인 Container 컴포넌트에서 전달한 색상이 제대로 표시되는지 확인하기 위해 toHaveStyleRule 함수를 사용하여 검사하였다.


npm run test를 입력하여 all passed를 확인해보고 마지막으로 onClick 이벤트를 테스트해본다.



Button 컴포넌트의 onClick 이벤트에는 사실 어떤 함수가 연결될 지 알 수 없다. Jest에서는 어떤 이벤트를 통해 함수가 호출되는지를 확인하기 위해 모의 함수(Mocking functions)를 사용하여 onClick 이벤트를 테스트한다.

우선 Button 컴포넌트의 테스트에 클릭 이벤트를 사용하기 위해 테스트 파일 상단에 fireEvent를 추가하도록 한다.

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

그런 다음, 다음과 같이 Button 컴포넌트의 필수가 아닌 Props인 onClick 함수를 테스트하기 위한 테스트 명세를 추가한다.
...
  it('clicks the button', () => {
      const handleClick = jest.fn()
      render(<Button label="Button Test" onClick={handleClick} />)

      const label = screen.getByText('Button Test')
      expect(handleClick).toHaveBeenCalledTimes(0)
      fireEvent.click(label)
      expect(handleClick).toHaveBeenCalledTimes(1)
  })
...

Jest의 모의 함수(jest.fn)를 사용하여 handleClick 변수를 선언하고 Button 컴포넌트의 Props인 onClick을 통해 전달했다.
그런 다음, 화면에 표시된 Button 컴포넌트를 찾아서 아직 해당 컴포넌트를 클릭하지 않았음을 확인하기 위해 toHaveBeenCalledTimes 함수를 사용하여 모의 함수가 호출되었는지를 확인했다.
그리고 실제 fireEvent.click 함수를 통해 Button 컴포넌트에 클릭 이벤트를 발생시킨 후, Jest의 모의 함수가 호출되었는 지를 확인함으로써 Button 컴포넌트의 Props인 onClick을 테스트했다.


npm run test를 입력하여 all passed가 되었는 지 확인해본다. 이렇게 Button 컴포넌트가 화면에 잘 표시되는지 그리고 Button 컴포넌트가 가지고 있는 Props를 통해 전달한 데이터가 우리가 원하는 대로 동작하는 지를 테스트해보았다.



Input 컴포넌트

./src/Component/Input/index.test.tsx 파일을 생성하고 다음과 같이 App.test.tsx 파일 내용을 복붙하여 수정한다.

import React from 'react';
import { render, screen } from '@testing-library/react';
import 'jest-styled-components'

import { Input } from './index';

describe('<Input />', () => {
  it('renders component correctily', () => {
    const { container } = render(<Input value="default value" />)

    const input = screen.getByDisplayValue('default value')
    expect(input).toBeInTheDocument()

    expect(container).toMatchSnapshot()
  })
})

Input 컴포넌트는 Button 컴포넌트와 다르게 필수 Props가 존재하지 않아 value 값을 설정하고 react-testing-library의 screen.getByDisplayValue를 사용하여 Input 컴포넌트를 찾았으며, 해당 컴포넌트가 화면에 표시되었는지를 toBeInTheDocument를 사용하여 확인하였다. 마지막으로 toMatchSnapshot 함수를 사용하여 스냅샷 생성하였다.

npm run test를 입력하여 all passed를 받았는지 확인해보고 Input 컴포넌트의 다른 Props를 테스트해본다.

Input 컴포넌트의 필수가 아닌 Props인 placeholder를 테스트하기 위해 테스트 명세를 다음과 같이 추가한다.

...
  it('renders placeholder corretly', () => {
      render(<Input placeholder="default placeholder"/>)

      const input = screen.getByPlaceholderText('default placeholder')
      expect(input).toBeInTheDocument()
  })
...

Input 컴포넌트의 placeholder에 값을 설정하고 react-testing-library의 screen.getByPlaceholderText로 컴포넌트를 찾은 다음 해당 컴포넌트가 화면에 표시되었는지를 확인하기 위해 toBeInTheDocument를 사용했다.


마지막으로 사용자가 Input 컴포넌트에 데이터를 입력하는 것을 테스트 해본다. 사용자의 입력 이벤트를 사용하기 위해 react-testing-library의 fireEvent를 상단에 추가해야 한다.

그리고 새로운 it 함수를 만들어서 테스트 명세를 다음과 같이 추가한다.

...
  it('changes the data', () => {
      render(<Input placeholder="default placeholder"/>)

      const input = screen.getByPlaceholderText('default placeholder') as HTMLInputElement
      fireEvent.change(input, { target: { value: 'study react' } })
      expect(input.value).toBe('study react')
  })

Input 컴포넌트의 placeholder를 사용하여 Input 컴포넌트를 화면에 표시하고 해당 컴포넌트를 getByPlaceholderText를 통해 찾았다. 이렇게 찾은 컴포넌트는 기본적으로 HTMLElement 타입이다. 하지만 input 태그를 사용하고 있으므로, Typescript의 as를 사용하여 HTMLInputElement 타입으로 변환을 하였다.

그리고 실제 사용자가 데이터를 입력하는 테스트를 하기 위해 fireEvent의 change 함수를 통해 앞에서 찾은 Input 컴포넌트에 데이터를 입력했다. 입력한 데이터가 실제로 화면에 잘 표시되고 있는지 확인하기 위해 toBe를 사용하여 input.value 값이 fireEvent를 사용하여 입력한 값과 같은지 확인하였다.


npm run test를 입력하여 all passed가 나왔는지 확인해본다. 이것으로 공통 컴포넌트로 만든 Input 컴포넌트가 화면에 잘 표시되는지, Input 컴포넌트가 가지고 있는 Props가 의도한 대로 동작하는 지에 대해 테스트를 했었다.



ToDoItem 컴포넌트

./src/Component/TodoItem/index.test.tsx 파일을 생성하여 Input 컴포넌트에서 했던 것과 같이 수정해본다.

import React from 'react';
import { render, screen } from '@testing-library/react';
import 'jest-styled-components'

import { ToDoItem } from './index';

describe('<ToDoItem />', () => {
  it('renders component correctily', () => {
    const { container } = render(<ToDoItem label='default value'/>)

    const todoItem = screen.getByText('default value')
    expect(todoItem).toBeInTheDocument()

    const deleteButton = screen.getByText('삭제')
    expect(deleteButton).toBeInTheDocument()
    expect(container).toMatchSnapshot()
  })
})

ToDoItem 컴포넌트의 필수 Props인 label을 설정하고 해당 ToDoItem 컴포넌트의 label과 [삭제] 버튼이 잘 표시되는지 확인하는 테스트 명세이다.


이번에는 ToDoItem 컴포넌트의 필수가 아닌 Props인 onDelete를 테스트해본다. 다음과 같이 새로운 it 함수를 추가해본다.

...
  it('clicks the delete button', () => {
      const handleClick = jest.fn()
      render(<ToDoItem label='default value' onDelete={handleClick} />)

      const deleteButton = screen.getByText('삭제')
      expect(handleClick).toHaveBeenCalledTimes(0)
      fireEvent.click(deleteButton)
      expect(handleClick).toHaveBeenCalledTimes(1)
  })
...

ToDoItem 컴포넌트의 onDelete는 사용자가 [삭제] 버튼을 눌렀을 때 호출되며 Jest.fn을 사용하여 모의 함수를 생성하고 [삭제] 버튼을 찾아서 fireEvent의 click을 사용하여 실제로 버튼을 클릭하여 테스트했다.


npm run test를 입력하여 all passed가 떴는지 확인해본다. 이것으로 ToDoItem 컴포넌트에 관한 모든 테스트 명세를 추가했다.



App 컴포넌트

마지막으로 모든 컴포넌트를 사용하고 있고, 다른 컴포넌트들과는 다르게 동적인 데이터를 다루기 위해 State를 가지고 있는 App 컴포넌트를 테스트해본다. ./src/App.test.tsx 파일을 열어 다음과 같이 수정한다.

import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
import 'jest-styled-components'

/**
 * Jest에서는 test 함수와 it 함수는 동일한 기능을 하는 함수이다.
 * 차이는 테스트를 읽기 쉽게 하기 위해서 첫번째 아규먼트와 문장 연결하는 주어(test, it)가 다른 것이다.
 * test('renders learn react link', ~ ) -> test renders learn react link
 * it('should have a button.', ~ ) -> it shoud have a button.
 */
describe('<App />', () => {
  it('renders component correctily', () => {
    const { container } = render(<App />)

    const toDoList = screen.getByTestId('toDoList')
    expect(toDoList).toBeInTheDocument()
    expect(toDoList.firstChild).toBeNull()

    const input = screen.getByPlaceholderText('할 일을 입력해 주세요')
    expect(input).toBeInTheDocument()
    const label = screen.getByText('추가')
    expect(label).toBeInTheDocument()

    expect(container).toMatchSnapshot()
  })
})

App.tsx에서도 ToDoListContainer 컴포넌트에 data-testid 속성을 'toDoList'로 추가한다.

<ToDoListContainer data-testid='toDoList'>

우선, 할 일 목록 데이터가 비어있는지 테스트하기 위해 getByTestId라는 함수를 사용하여 할 일 목록 데이터가 표시될 부분을 가져왔다. 이렇게 가져온 부분이 화면에 표시되는지 확인했고 할 일 목록 데이터는 아직 존재하지 않으므로 할 일 목록 데이터가 표시될 부분의 자식 요소가 비어 있음을 toBeNull을 사용하여 확인했다.

이때, 사용자가 할 일 목록 데이터는 styled-components로 만든 ToDoListContainer 컴포넌트 안에 표시된다. 테스트 명세에서 할 일 목록 데이터가 화면에 표시되었는지 확인하기 위해 ToDoListContainer 컴포넌트를 찾을 수 있어야 하는데, 해당 컴포넌트는 id나 class 같은 어떠한 속성도 가지고 있지 않기 때문에 Test ID(data-testid) 속성을 이용하여 다른 동작에 영향주지 않고 테스트 명세에서 컴포넌트를 쉽게 찾을 수 있게 도와준다.

Test ID를 통해서 할 일 목록 데이터가 표시되지 않았음을 확인했다면 할 일을 입력할 수 있는 Input 컴포넌트와 할 일을 등록할 수 있는 [추가] 버튼이 화면에 표시되어 있는 지 확인한다.

마지막으로, 스냅샷 테스트를 함으로써 의도치 않게 컴포넌트가 변경되는 것을 감지하도록 한다.


이제 할 일을 추가/삭제하는 테스트 명세를 작성해보도록 한다. 할 일을 추가거나 삭제하기 위해서는 클릭 이벤트를 사용해야 한다. 따라서 다음과 같이 테스트 명세를 추가한다.

...
  it('adds and deletes ToDo items', () => {
    render(<App />)

    const input = screen.getByPlaceholderText('할 일을 입력해 주세요')
    const button = screen.getByText('추가')
    fireEvent.change(input, { target: { value: 'study react 1' } })
    fireEvent.click(button)

    const todoItem = screen.getByText('study react 1')
    expect(todoItem).toBeInTheDocument()
    const deleteButton = screen.getByText('삭제')
    expect(deleteButton).toBeInTheDocument()

    const toDoList = screen.getByTestId('toDoList')
    expect(toDoList.childElementCount).toBe(1)

    fireEvent.change(input, { target: { value: 'study react 2' } })
    fireEvent.click(button)

    const todoItem2 = screen.getByText('study react 2')
    expect(todoItem2).toBeInTheDocument()
    expect(toDoList.childElementCount).toBe(2)

    const deleteButtons = screen.getAllByText('삭제')
    fireEvent.click(deleteButtons[0])
    expect(todoItem).not.toBeInTheDocument()
    expect(toDoList.childElementCount).toBe(1)
  })
 ...

우선 render 함수를 이용하여 테스트 대상인 App 컴포넌트를 화면에 표시하도록 했다.
그다음, 할 일을 입력하는 입력창과 [추가] 버튼을 찾은 후 입력창에 fireEvent.change를 사용하여 데이터를 입력하고 입력한 할 일 데이터를 할 일 목록 데이터에 추가하기 위해 fireEvent.click을 사용하여 [추가] 버튼을 클릭한다.

그 후에 추가한 할 일 목록 데이터가 화면에 잘 표시되었는지 확인하기 위해 입력한 할 일 데이터를 화면에서 찾아 할 일 데이터가 화면에 잘 표시되었는지 확인했다. 또한, 할 일 데이터를 삭제할 수 있는 [삭제] 버튼도 잘 표시되었는지 확인했다.
그리고 다시 한번 데이터를 추가해 봄으로써 추가하는 할 일들이 리스트에 정말로 잘 표시되는지 확인했다.

마지막으로 화면에 표시된 두 개의 할 일 중에서 첫번째 할 일을 삭제함으로써 추가한 할 일이 잘 삭제됐는지 테스트해봤다. 화면에는 [삭제] 버튼이 두 개 표시되어 있으므로 getAllByText를 사용하여 모든 삭제 버튼을 찾은 후 첫번째 삭제 버튼을 클릭하도록 테스트 코드를 작성했다.

할 일 목록 첫번째 아이템의 삭제 버튼을 클릭하여 첫번째 아이템을 삭제한 후 not.toBeInTheDocument를 사용하여 첫번째 할 일이 잘 삭제됐는지 확인했다. 그리고 할 일 목록에 할 일이 한 개만 표시되고 있는지도 확인했다.


npm run test를 입력하여 all passed가 됐는지 확인해본다.



테스트 커버리지 확인

테스트 커버리지를 확인하기 위해 npm run test -- --coverage를 입력해본다.

확인해보니 App.tsx 40번 라인에 테스트가 되어 있지 않다고 표시되고 있다.


사용자가 할 일을 입력하지 않은 상태에서 [추가] 버튼을 눌러서 할 일을 추가하려고 했을 때 빈 할 일이 추가되지 않도록 코드가 작성되어 있다. 그리고 이런 테스트 명세가 추가되지 않았음을 알 수 있었다.

다시 ./src/App.test.tsx 파일에서 예상하지 못했던 테스트 명세를 다음과 같이 추가한다.

...
  it('does not add empty ToDo', () => {
    render(<App />)
    
    const toDoList = screen.getByTestId('toDoList')
    const length = toDoList.childElementCount

    const button = screen.getByText('추가')
    fireEvent.click(button)

    expect(toDoList.childElementCount).toBe(length)
  })
...

특별히 아무 동작을 하지 않고 [추가] 버튼을 눌렀을 때 할 일 목록에 변화가 없는지 확인하는 테스트 명세이다. getByTestId를 통해 할 일 목록이 표시되는 부분을 가져온 후, 해당 컴포넌트 안에 자식 컴포넌트의 개수를 나타내는 childElementCount를 저장했다. 이렇게 저장한 개수와 추후 추가 버튼을 눌렀을 때의 개수 비교함으로써 할 일 목록에 변화가 없음을 검증했다.



다시 테스트 커버리지를 측정해보면 모든 코드가 테스트가 커버되고 있음을 확인할 수 있다.



결론

Props와 State를 다루는 컴포넌트들을 테스트하기 위해 Jest와 react-testing-library를 사용하여 테스트 명세를 작성하는 방법을 살펴봤다. 이는 할 일 목록 앱의 기능이 정상 동작함을 보장해주지만, 가장 기본적인 테스트만을 하였다.
그러므로 버그가 발생하거나 문제가 될만한 부분은 테스트 케이스를 더 추가하여 안정성을 확보할 필요가 있다.
다음 챕터에서는 지금까지 만든 앱을 리팩토링 해볼 예정이다.



Github Repo

profile
토이 프로젝트와 기술들 정리하는 블로그

0개의 댓글