리액트TDD - react-testing-library 기초

jonyChoiGenius·2023년 5월 22일
0

React.js 치트 시트

목록 보기
21/22

react-testing-library

Queries

앞서 설명했듯 react-testing-library는 DOM 위주로 테스트를 진행한다. 이에 따라 돔을 지정하는 Queries를 사용한다.
(querySelector를 사용하는 방법도 있지만 권장하지 않는다.)

주로 사용되는 쿼리는 다음과 같으며, 순위가 높을수록 권장되는 쿼리이다.

1. getByLabelText
2. getByPlaceholderText
3. getByText
4. getByDisplayValue
5. getByAltText
6. getByTitle
7. getByRole
8. getByTestId

모든 요소를 가져오기 위해서는 getAllByLabelText와 같이 All을 붙여주면 된다.

각 쿼리는 첫번째 인자로 container를 받는다.
가령 아래와 같은 코드가 있다고 해보자.
<div id="app"> 안에서 label를 기준으로 쿼리하고 싶다면?

<body>
  <div id="app">
    <label for="username-input">Username</label>
    <input id="username-input" />
  </div>
</body>

document에서 #app을 DOM으로 선택하여 쿼리의 첫번째 인자로 넘겨주면 된다.

const container = document.querySelector('#app')
const inputNode2 = getByLabelText(container, 'Username')

screen

이때 screen이라는 이라는 wrapper를 container 대신 사용할 수 있는데, screen은 'document.body'를 container로 넘겨주는 것과 같은 효과를 지닌다. document.body 전체에서 DOM을 찾는 것이 일반적이기 때문이다.

getByText

string이나 정규표현식으로 매칭할 수 있다.
두번째 인자로 {exact: boolean}을 옵션으로 줄 수 있다.

screen.getByText('llo Worl', {exact: false}) // substring match
screen.getByText('hello world', {exact: false}) // ignore case

// Matching a regex:
screen.getByText(/World/) // substring match
screen.getByText(/world/i) // substring match, ignore case

콜백 함수를 넣을 때에는 첫번째 파라미터로 문자열을, 두번째 파라미터로 요소를 받는다.

screen.getByText((content, element) => content.startsWith('Hello'))

두번째 인자로 옵션 객체를 줄 수 있으며, 아래와 같다.

exact: 기본값은 true; 대/소문자를 구분하여 전체 문자열과 일치합니다. false인 경우 하위 문자열과 일치하며 대소문자를 구분하지 않습니다. regex 등에는 영향을 주지 않습니다.
normalizer : 양 끝 공백을 없애고 여러 공백을 단일 공백으로 축소하는 옵션이다. 콜백함수로 정규표현식을 줄 수도 있다.
screen.getByText('text', {
  normalizer: getDefaultNormalizer({trim: false}),
})

screen.getByText('text', {
  normalizer: str =>
    getDefaultNormalizer({trim: false})(str).replace(/[\u200E-\u200F]*/g, ''),
})

getByTestId

return (
	<div className="App">
  		<header className="App-header">
  			<h3 data-testid="counter>{ counter } </h3>
  		</header>
  	</div>
)

위와 같이 data- 접두사를 이용해 data-testid프로퍼티를 주면 getByTestId를 통해 접근할 수 있다.

const element = screen.getByTestId('custom-element')

한편 getByTestId 역시 getByText와 마찬가지로 options객체를 넘겨줄 수 있다

getBy, findBy, queryBy

쿼리의 타입에는 getBy, findBy, queryBy의 세 가지가 있다.

  1. 매치되는 요소가 없을 때
    getBy... : 에러를 발생시킨다.
    queryBy... : null을 반환한다.
    findBy... : 에러를 발생시킨다.

queryAllBy... : []를 반환한다.

  1. 매치되는 요소가 하나일 때
    getBy... : 요소를 반환한다
    queryBy... : 요소를 반환한다
    findBy... : 요소를 Promise로 반환한다

  2. 매치되는 요소가 많을 때
    getBy... : 에러를 발생시킨다.
    queryBy... : 에러를 발생시킨다.
    findBy... : 에러를 발생시킨다.

  3. Retry (Async/Await)
    getBy... : No
    queryBy... : No
    findBy... : 1000ms 후 다시 확인한다.

비동기 메서드 (findBy, waitFor)

공식문서 Async Methods
findBy는 getBy쿼리에 waitFor를 결합한 것과 같다.

waitFor는 '에러가 발생되지 않는 한' 특정 시간을 기다린다.
await waitFor(() => expect(mockAPI).toHaveBeenCalledTimes(1))
위의 테스트는 1000ms동안 mockAPI가 '1번' 호출되었는지를 확인한다.
만일 2번 호출되었다면 fail이다.
(마찬가지로 findBy...쿼리로 불러온 요소가 2개 이상인 경우에도 fail이다.)

waitFor는 첫번째 인자로 콜백을 받는다.
두번째 인자로 옵션 객체를 받는다.

function waitFor<T>(
  callback: () => T | Promise<T>,
  options?: {
    container?: HTMLElement
    timeout?: number
    interval?: number
    onTimeout?: (error: Error) => Error
    mutationObserverOptions?: MutationObserverInit
  },
): Promise<T>

default 옵션은 timeout = 1000ms 이다.

마찬가지로 findBy도 세번째 인자로 waitFor의 options 객체를 넘길 수 있다.

await screen.findByText('text', queryOptions, waitForOptions)).

fireEvent

fireEvent(
  getByText(container, 'Submit'),
  new MouseEvent('click', {
    bubbles: true,
    cancelable: true,
  }),
)

fireEvent(node: HTMLElement, event: Event)와 같이 첫번째 인자로 HTML요소를 받고, 두번째로 이벤트 객체를 인자로 넣는다.
혹은 fireEvent.click(element)를 이용하여 클릭 이벤트를 발생할 수 있다.

user-event

공식문서 'User Interactions'
현재에는 fireEvent가 user-event로 대체되는 편이다. user-event는 유저가 상호작용함을 강조한다. 또한 각 태그별로 다르게 상호작용됨을 반영한다. (가령 input 요소에 클릭 이벤트를 주면 input요소가 focus된다.)

v14 버전의 user-event는 userEvent.setup()으로 실행하여 메서드와 인스턴스를 반환받은 후, 이를 이용하여 유저 상호작용을 발생시킨다. userEvent.setup()이 항상 먼저 실행되어야 한다.

test('trigger some awesome feature when clicking the button', async () => {
  const user = userEvent.setup() //항상 setup()을 먼저 실행한다.
  render(<MyComponent />)

  await user.click(screen.getByRole('button', {name: /click me!/i}))
})

이를 패턴화하기 위해 아래와 같이 setup 훅을 만들어 사용할 수 있다.

function setup(jsx) {
  return {
    user: userEvent.setup(),
    ...render(jsx),
  }
}

test('render with a setup function', async () => {
  const {user} = setup(<MyComponent />)
  await user.click(screen.getByRole('button', {name: /click me!/i}))
})

기본 예제

yarn create react-app counter-app - jest와 react-testing-library가 기본으로 설치된다.
cd counter-app
yarn add eslint-plugin-testing-library eslint-plugin-jest-dom 를 통해 eslint plugin을 설치한다.

eslintConfig를 수정한다.

  "eslintConfig": {
    "extends": [
      "react-app",
      "react-app/jest"
    ]
  },
  "eslintConfig": {
    "plugins": [
      "eslint-plugin-jest-dom", 
      "eslint-plugin-testing-library"
    ],
    "extends": [
      "react-app",
      "react-app/jest",
      "plugin:testing-library/react",
      "plugin:jest-dom/recommended"
    ]
  },
    "plugins": [
      "jest-dom", 
      "testing-library"
    ],

위와 같이 eslint-plugin- 부분을 생략할 수 있다.

"plugin:testing-library/react"는 vue, angular, react와 같은 라이브러리 중 react버전을 사용한다는 이야기이다.
"plugin:jest-dom/recommended"는 jest-dom에서 추천하는 기본 설정을 사용한다는 의미이다.

초기 렌더링 테스트

App.test.js를 보면 아래와 같다.

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

test('renders learn react link', () => {
  render(<App />);
  const linkElement = screen.getByText(/learn react/i);
  expect(linkElement).toBeInTheDocument();
});

App.js를 아래와 같이 초기화하고

function App() {
  return <div className="App"></div>;
}

export default App;

yarn test를 실행시켜보자
TestingLibraryElementError: Unable to find an element with the text에러가 정상적으로 발생한다.

우리가 만들 counter 앱은 초기에 0이라는 숫자가 렌더링 된다. +/-버튼을 통해 이 숫자를 변경시킬 것이다.

아래와 같이 App.test.js를 작성해보자

import { render, screen } from "@testing-library/react";
import App from "./App";

test("the counter starts at 0", () => {
  render(<App />);
  const counterValueElement = screen.getByTestId("counterValue");
  expect(counterValueElement).toBe(0);
});

그리고 App.js를 아래와 같이 작성한다.

function App() {
  return (
    <div className="App">
      <header className="App-header">
        <h3 data-testid="counterValue">0</h3>
      </header>
    </div>
  );
}

export default App;

테스트를 실행하면...실패한다.

    Expected: 0
    Received: <h3 data-testid="counterValue">0</h3>

counterValueElement 전체가 아니라 해당 요소의 text만을 추출해야 한다.

import { render, screen } from "@testing-library/react";
import App from "./App";

test("the counter starts at 0", () => {
  render(<App />);
  const counterValueElement = screen.getByTestId("counterValue");
  //- expect(counterValueElement).toBe(0);
  expect(counterValueElement.textContent).toBe(0);
});

yarn test를 실행하면 테스트를 통과하게 된다.

이때 eslint 플러그인을 깔았다면 아래와 같은 추천 문구가 뜬다

Use toHaveTextContent instead of asserting on DOM node attributeseslintjest-dom/prefer-to-have-text-content
(property) Node.textContent: string | null

권장되는 Matcher를 사용하라는 의미이다.

아래와 같이 수정한다.

import { render, screen } from "@testing-library/react";
import App from "./App";

test("the counter starts at 0", () => {
  render(<App />);
  const counterValueElement = screen.getByTestId("counterValue");
  //- expect(counterValueElement.textContent).toBe(0);
  expect(counterValueElement).toHaveTextContent(0);
});

올바르게 첫 테스트가 완료되었다.

+/- 버튼 테스트

새로운 test를 추가하기 위해 아래와 같이 describe로 묶어준다.

describe("counters", () => {
  test("the counter starts at 0", () => {
    render(<App />);
    const counterValueElement = screen.getByTestId("counterValue");
    expect(counterValueElement).toHaveTextContent(0);
  });
});

user-event를 사용하기 위해 아래와 같이 설치한다
yarn add -D @testing-library/user-event

그리고 아래와 같이 +/- 버튼을 클릭하는 이벤트를 추가한다.

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import App from "./App";

describe("counters", () => {
  test("the counter starts at 0", () => {
    render(<App />);
    const counterValueElement = screen.getByTestId("counterValue");
    expect(counterValueElement).toHaveTextContent(0);
  });
  test("the counterValue will be 1 when the + button is pressed once", async () => {
    const user = userEvent.setup();
    render(<App />);
    await user.click(screen.getByRole("button", { name: "plus-button" }));
    const counterValueElement = screen.getByTestId("counterValue");
    expect(counterValueElement).toHaveTextContent(1);
  });
  test("the counterValue will be 1 when the - button is pressed once", async () => {
    const user = userEvent.setup();
    render(<App />);
    await user.click(screen.getByRole("button", { name: "minus-button" }));
    const counterValueElement = screen.getByTestId("counterValue");
    expect(counterValueElement).toHaveTextContent(1);
  });
});

그리고 당연히 TestingLibraryElementError: Unable to find an accessible element with the role "button" and name "minus-button"라는 에러가 발생한다.

이제 name이 minus-button인 버튼을 만든다. getByRole의 옵션에서 name은 accessible name을 의미한다.accessible name or description accessible name을 만드는 가장 보편적인 방법은 'aria-label' 프로퍼티를 지정하는 것이다.

App.js를 아래와 같이 수정하면 테스트를 통과한다.

import { useState } from "react";

function App() {
  const [counterValue, setCounterValue] = useState(0);

  return (
    <div className="App">
      <header className="App-header">
        <h3 data-testid="counterValue">{counterValue}</h3>
      </header>
      <button
        aria-label="plus-button"
        onClick={() => setCounterValue((count) => count + 1)}
      >
        +
      </button>
      <button
        aria-label="minus-button"
        onClick={() => setCounterValue((count) => count + 1)}
      >
        -
      </button>
    </div>
  );
}

export default App;
Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        3.714 s
Ran all test suites related to changed files.

Watch Usage: Press w to show more.

비활성화 버튼 테스트 + 스타일 확인

'on/off'버튼을 누르면 +, - 버튼이 disabled되는 테스트 코드를 만들어보자

  test("the buttons will be disabled when on/off button is pressed once", async () => {
    const user = userEvent.setup();
    render(<App />);
    const plusButtonElement = screen.getByRole("button", {
      name: "plus-button",
    });
    const minusButtonElement = screen.getByRole("button", {
      name: "minus-button",
    });
    await user.click(screen.getByRole("button", { name: "on/off-button" }));
    expect(plusButtonElement).toBeDisabled();
    expect(minusButtonElement).toBeDisabled();
  });

이제 disabled버튼을 만들고,
이것이 plus, minus 버튼의 상태로 이어지면 된다.

import { useState } from "react";

function App() {
  const [counterValue, setCounterValue] = useState(0);
  const [disabled, setDisabled] = useState(false);

  return (
    <div className="App">
      <header className="App-header">
        <h3 data-testid="counterValue">{counterValue}</h3>
      </header>
      <button
        aria-label="plus-button"
        onClick={() => setCounterValue((count) => count + 1)}
        disabled={disabled}
      >
        +
      </button>
      <button
        aria-label="minus-button"
        onClick={() => setCounterValue((count) => count + 1)}
        disabled={disabled}
      >
        -
      </button>
      <button
        aria-label="on/off-button"
        onClick={() => setDisabled((disabled) => !disabled)}
      >
        -
      </button>
    </div>
  );
}

export default App;

같은 방식으로 'toHaveStyle'이라는 matcher를 이용해 스타일을 확인한다.

  test("the buttons have gray font when on/off button is pressed once", async () => {
    const user = userEvent.setup();
    render(<App />);
    const plusButtonElement = screen.getByRole("button", {
      name: "plus-button",
    });
    const minusButtonElement = screen.getByRole("button", {
      name: "minus-button",
    });
    await user.click(screen.getByRole("button", { name: "on/off-button" }));
    expect(plusButtonElement).toHaveStyle({ color: "gray" });
    expect(minusButtonElement).toHaveStyle({ color: "gray" });
  });
});

이제 스타일 객체를 이용하여 스타일을 준다.

  const [buttonStyle, setButtonStyle] = useState({});

  useEffect(() => {
    if (disabled) {
      setButtonStyle({
        color: "gray",
      });
    } else {
      setButtonStyle({});
    }
  }, [disabled]);

...

      <button
        aria-label="plus-button"
        onClick={() => setCounterValue((count) => count + 1)}
        disabled={disabled}
        style={buttonStyle}
      >
        +
      </button>

모든 테스트에 통과했다.

 PASS  src/App.test.js
  counters
    √ the counter starts at 0 (30 ms)
    √ the counterValue will be 1 when the + button is pressed once (92 ms)
    √ the counterValue will be 1 when the - button is pressed once (48 ms)
    √ the buttons will be disabled when on/off button is pressed once (61 ms)
    √ the buttons have gray font when on/off button is pressed once (67 ms)

Test Suites: 1 passed, 1 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        3.623 s
Ran all test suites related to changed files.

Watch Usage: Press w to show more.
profile
천재가 되어버린 박제를 아시오?

0개의 댓글