테스팅 라이브러리 (Testing Library) 란?
현업에서 개발을 하면서 테스트 코드를 짤 때 여러가지 도구들을 리서치 하다가 테스팅 라이브러리에 대해서 알게 되었고, 개인적으로 공부도 필요하고 정리할 겸 포스팅을 하게 되었습니다. 기본적인 내용은 모두 공식 문서에 있으므로 더 자세한 내용을 알기 원하시면 참고해 주시면 감사하겠습니다.
시작하며
@testing-library는 UI 컴포넌트를 사용자 친화적으로 테스트할 수 있게 도와주는 패키지 묶음이다.
우리는 테스트의 목적 중 하나로 구현의 세부사항들을 포함시키지 않는 것을 가지고 있다. 그래서 리팩터링 시에 테스트를 깨트리지 않아야 하고 팀의 속도를 항상 유지시켜야 한다.
코어 라이브러리인 DOM Testing Library는 웹 페이지를 테스트 할 수 있는 경량 솔루션으로 쿼리와 (JSDOM/Jest에서 시뮬레이션 되거나 브라우저에서 실행되는) DOM 노드 상호작용에 의해 테스트가 이루어진다. 이 도구가 제공하는 주요 유틸리티에는 DOM 노드를 쿼리하는 것이 포함되는데, 이는 사용자가 페이지에서 요소를 찾는 방법과 비슷하다.
이 테스팅 라이브러리는 테스트 러너(test runner), 프레임워크(framework)가 아니고 하나의 테스트 프레임워크에 종속되지 않는다. DOM API를 제공하는 Jest, Mocha + JSDOM 또는 실제 브라우저 등 모든 환경에서 동작한다.
가이드 원칙 (Guiding Principles)
The more your tests resemble the way your software is used, the more confidence they can give you.
더 많은 테스트가 소프트웨어의 사용법과 닮아진다면, 더 많은 자신감을 당신에게 줄 것이다.
리액트 테스팅 라이브러리는 당신의 웹사이트가 어떻게 사용되는지에 최대한 가깝게 테스트를 작성할 수 있도록 장려하는 메서드와 유틸리티만을 제공할 것이다.
이 라이브러리에 포함된 유틸리티들은 다음과 같은 가이드 원칙들을 따른다.
- 만약 어떤 유틸리티가 렌더링 컴포넌트와 관련이 있다면, 이는 컴포넌트 인스턴스가 아닌 DOM 노드를 직접 다루어야 한다. 그리고 이 동작이 컴포넌트 인스턴스를 다루도록 장려하면 안된다.
- 사용자가 실제로 사용하는 방법으로 어플리케이션 컴포넌트를 테스트를 하는데 유용해야 한다. 여기에서 트레이드 오프(trade-off)를 만들게 되는데, 왜냐하면 우리는 컴퓨터나 또는 시뮬레이션된 브라우저 환경을 사용하기 때문이다. 그러나 일반적으로, 유틸리티는 컴포넌트가 사용되기 의도되는 방식으로 테스트 되는 것을 권장한다.
- 유틸리티 구현과 API는 간결하고 유연해야 한다.
Core API
쿼리 (Queries)
쿼리는 테스팅 라이브러리가 페이지에서 요소를 찾기 위해 제공하는 메서드이다. 여러 가지 타입이 있는데(get, find, query 등), 이들 간의 차이는 쿼리가 요소를 찾지 못했을 때 에러를 반환하는지 여부 또는 Promise를 반환하고 재시도를 하는지 여부 등에 있다. 어떤 컨텐츠를 찾을지에 따라 다른 쿼리들이 더 혹은 덜 적절할 수 있다.
요소를 선택한 후에, Event API나 user-event를 사용하여 이벤트를 실행하고 페이지에서 사용자 인터렉션을 시뮬레이션 할 수 있다. 또는 Jest나 jest-dom을 사용하여 요소의 권리를 행사(assertion)할 수도 있다.
쿼리와 함께 동작하는 테스팅 라이브러리 헬퍼 메서드도 있다. 액션에 대한 응답으로 요소가 나타나거나 사라질 때, waitFor 나 findBy 쿼리와 같은 Async API는 DOM에서 변화들을 기다리는 데(await) 사용된다. 특정 요소의 자식 요소를 찾고 싶다면 within을 사용할 수 있다.
예시 코드
import {render, screen} from '@testing-library/react' // (or /dom, /vue, ...)
test('should show login form', () => {
render(<Login />)
const input = screen.getByLabelText('Username')
// Events and assertions...
})
우선순위 (Priority)
가이드 원칙에 따르면, 테스트는 사용자가 당신의 코드(컴포넌트, 페이지 등)와 어떻게 상호작용 하는지와 가능한 한 닮아야 한다. 이러한 원칙을 새기면서, 다음과 같은 우선순위를 둘 것을 권장한다.
- 모두에게 접근 가능한 쿼리 (Queries Accessible to Everyone) : 보조적인 기술을 사용하거나 시각적/마우스 사용을 하는 사용자들의 경험을 반영하는 쿼리
- getByRole : 접근가능 트리(accessibility tree)에 보여지는 모든 요소들을 쿼리할 때 사용할 수 있다. name 옵션을 통해 접근가능 이름(accessible name)으로 요소들을 필터해서 반환할 수 있다.
- getByLabelText : 폼 필드에 유용한 방법이다. 웹사이트 폼을 읽을 때 사용자는 라벨 텍스트로 요소를 찾는다. 이 방법은 그 행동을 모방한 것이며 따라서 당신에게 가장 높은 우선순위가 될 것이다.
- 이하는 공식문서를 참고
- 의미론적 쿼리 (Sementic Queries) : HTML5와 ARIA에 순응적인 선택자. 이러한 속성(attribute)과 상호작용하는 사용자 경험은 브라우저와 보조적 기술이 많이 다를 수 있다.
- getByAltText : 만약 요소가 alt 텍스트 속성을 지원한다면 (ex. img, area, input 등) 이 메서드를 통해 요소를 찾을 수 있다.
- Test IDs
- getByTestId : 사용자가 읽거나 들을 수 없을 때, 역할이나 텍스트, 또는 그 어떤 것도 매치를 할 수 없을 때 유일하게 권장되는 방법이다.
쿼리 사용하기 (Using Queries)
DOM 테스팅 라이브러리의 기본 쿼리들은 container를 첫 번째 인자로 보내주어야 할 것을 요구한다. 대부분의 프레임워크 구현된(framework-implementation) 테스트 라이브러리는 이러한 쿼리가 사전 바운딩(pre-bound)된 버전을 제공하며, 이 말은 여러분이 컨테이너를 제공할 필요가 없음을 의미한다. 덧붙여서, 만약 document.body를 쿼리하고 싶다면, screen을 사용할 수도 있으며 권장한다. (아래 예시 코드 참고)
- HTML
<body>
<div id="app">
<label for="username-input">Username</label>
<input id="username-input" />
</div>
</body>
import {screen, getByLabelText} from '@testing-library/dom'
// With screen:
const inputNode1 = screen.getByLabelText('Username')
// Without screen, you need to provide a container:
const container = document.querySelector('#app')
const inputNode2 = getByLabelText(container, 'Username')
- React
import {render, screen} from '@testing-library/react'
render(
<div>
<label htmlFor="example">Example</label>
<input id="example" />
</div>,
)
const exampleInput = screen.getByLabelText('Example')
- Cypress
cy.findByLabelText('Example').should('exist')
주의할 점 : screen을 사용하기 위해서는 전역 DOM 환경이 필요하다. jest를 사용한다면, testEnvironment 상태를 jsdom 으로 해야 전역 DOM 환경이 가능하다.
TextMatch
대부분의 쿼리 API는 TextMatch를 인자로 받는다. 이는 인자가 string, regex 또는 매칭 결과여부에 따라 true/false를 반환하는 함수여야 함을 의미한다.
<div>Hello World</div>
// Will find the div
// Matching a string:
screen.getByText('Hello World') // full string match
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(/^hello world$/i) // full string match, ignore case
screen.getByText(/Hello W?oRlD/i) // substring match, ignore case, searches for "hello world" or "hello orld"
// Matching with a custom function:
screen.getByText((content, element) => content.startsWith('Hello'))
// Will not find the div
// full string does not match
screen.getByText('Goodbye World')
// case-sensitive regex with different case
screen.getByText(/hello world/)
// function looking for a span when it's actually a div:
screen.getByText((content, element) => {
return element.tagName.toLowerCase() === 'span' && content.startsWith('Hello')
})
사용자 액션 (User Actions)
주의할 점 : 대부분의 프로젝트는 fireEvent에 대한 케이스가 몇 개 있다. 그러나 주로 사용하는 것은 아마 @testing-library/user-event일 것이다.
fireEvent(node: HTMLElement, event: Event)
// <button>Submit</button>
fireEvent(
getByText(container, 'Submit'),
new MouseEvent('click', {
bubbles: true,
cancelable: true,
}),
)
fireEvent[eventName]
fireEvent[eventName](node: HTMLElement, eventProperties: Object)
DOM 이벤트를 점화(fire)하는 간편한 방법이다.
- target : 한 이벤트가 요소에 디스패치 되면, 그 이벤트는 그 요소에 target이라는 프로퍼티를 가지게 된다. 편리함을 주기 위해서, 만약 당신이 target프로퍼티를 eventProperties안에 제공한다면, 이러한 프로퍼티들은 이벤트를 받는 노드에 할당이 될 것이다.
fireEvent.change(getByLabelText(/username/i), {target: {value: 'a'}})
// note: attempting to manually set the files property of an HTMLInputElement
// results in an error as the files property is read-only.
// this feature works around that by using Object.defineProperty.
fireEvent.change(getByLabelText(/picture/i), {
target: {
files: [new File(['(⌐□_□)'], 'chucknorris.png', {type: 'image/png'})],
},
})
// Note: The 'value' attribute must use ISO 8601 format when firing a
// change event on an input of type "date". Otherwise the element will not
// reflect the changed value.
// Invalid:
fireEvent.change(input, {target: {value: '24/05/2020'}})
// Valid:
fireEvent.change(input, {target: {value: '2020-05-24'}})
- dataTransfer : 드래그 이벤트는 dataTransfer 속성을 가지는데 오퍼레이션 동안 전송되는 데이터를 저장한다. 편리함을 주기 위해서 dataTransfer 속성을 eventProperties 안에 제공한다면, 이러한 속성들은 이벤트에 추가가 될 것이다. 대개 드래그 앤 드랍 상호작용에서 사용된다.
fireEvent.drop(getByLabelText(/drop files here/i), {
dataTransfer: {
files: [new File(['(⌐□_□)'], 'chucknorris.png', {type: 'image/png'})],
},
})
- keyboard events : 키보드 입력과 관련해서 3가지 이벤트 타입이 있다. keyPress, keyDown, keyUp. 이들을 점화할 때 참조할 요소를 DOM에서 찾아주고, 점화할 키값(keyCode) 역시 참조해 주어야 한다.
fireEvent.keyDown(domNode, {key: 'Enter', code: 'Enter', charCode: 13})
fireEvent.keyDown(domNode, {key: 'A', code: 'KeyA'})
createEvent[eventName]
createEvent[eventName](node: HTMLElement, eventProperties: Object)
DOM 이벤트를 생성할 수 있고 fireEvent로 점화될 수 있는 편리한 메서드는 생성된 이벤트를 참조할 수 있게 허용해준다. 이는 만약 당신이 이벤트 속성에 접근해야 할 때 해당 속성이 프로그래밍적으로 초기화가 되지 않을 경우 유용할 것이다.
const myEvent = createEvent.click(node, {button: 2})
fireEvent(node, myEvent)
// myEvent.timeStamp can be accessed just like any other properties from myEvent
// note: The access to the events created by `createEvent` is based on the native event API,
// Therefore, native properties of HTMLEvent object (e.g. `timeStamp`, `cancelable`, `type`) should be set using Object.defineProperty
// For more info see: <https://developer.mozilla.org/en-US/docs/Web/API/Event>
일반적인 이벤트 역시 생성 가능하다.
// simulate the 'input' event on a file input
fireEvent(
input,
createEvent('input', input, {
target: {files: inputFiles},
...init,
}),
)
Jest 함수 모킹 사용하기 (Using Jest Function Mock)
Jest 모킹 함수(Jest Mock Function)는 함수에 전달 된 콜백 함수가 호출될 때, 또는 콜백 함수를 트리거 하는 이벤트가 엮여 있는 콜백을 트리거 할 때 사용된다. jest 모킹 함수에 대해 더 알고 싶다면 공식 문서를 참고할 것.
import {render, screen, fireEvent} from '@testing-library/react'
const Button = ({onClick, children}) => (
<button onClick={onClick}>{children}</button>
)
test('calls onClick prop when clicked', () => {
const handleClick = jest.fn()
render(<Button onClick={handleClick}>Click Me</Button>)
fireEvent.click(screen.getByText(/click me/i))
expect(handleClick).toHaveBeenCalledTimes(1)
})
비동기 메서드 (Async Methods)
여러 유틸리티가 비동기 코드를 처리하기 위해 제공된다. 이들은 어떤 이벤트, 타임아웃, 프로미스(Promise) 등에 대한 응답으로 요소가 나타나는지, 사라지는지 기다릴 때 유용하다. 비동기 메서드는 프로미스를 반환한다. 따라서 호출 할 때 await 이나 .then 을 사용해야 한다.
findBy 메서드는 getBy 쿼리와 waitFor 쿼리의 조합이다. 이 메서드는 waitFor 옵션을 마지막 인자로 받는다. (e.g await screen.findByText('text', queryOptions, waitForOptions))
findBy 쿼리는 어떤 요소가 나타나기를 기대하지만 DOM 변화가 즉각적으로 일어나지는 않을 때 동작한다.
const button = screen.getByRole('button', {name: 'Click Me'})
fireEvent.click(button)
await screen.findByText('Clicked once')
fireEvent.click(button)
await screen.findByText('Clicked twice')
waitFor
function waitFor<T>(
callback: () => T | Promise<T>,
options?: {
container?: HTMLElement
timeout?: number
interval?: number
onTimeout?: (error: Error) => Error
mutationObserverOptions?: MutationObserverInit
},
): Promise<T>
특정 시간에 기다림이 필요하다면 waitFor를 사용한다. waitFor는 타임아웃에 도달할 때 까지 콜백을 정해진 숫자 만큼 실행한다. timeout 과 interval 옵션을 통해 호출 횟수를 조절할 수 있다.
// ...
// Wait until the callback does not throw an error. In this case, that means
// it'll wait until the mock function has been called once.
await waitFor(() => expect(mockAPI).toHaveBeenCalledTimes(1))
// ...
단위 테스트에 모킹 API 호출이 있고 모킹 Promise가 모두 리졸브(resolve)될 때 까지 기다려야 할 때 유용하게 사용할 수 있다.
waitForElementToBeRemoved
function waitForElementToBeRemoved<T>(
callback: (() => T) | T,
options?: {
container?: HTMLElement
timeout?: number
interval?: number
onTimeout?: (error: Error) => Error
mutationObserverOptions?: MutationObserverInit
},
): Promise<void>
DOM에서 요소가 제거됨을 기다릴 때 waitForElementToBeRemoved를 사용할 수 있다. 이 함수는 waitFor유틸리티의 작은 Wrapper 이다.
요소가 제거되어 프로미스가 리졸브 된 예제 코드는 아래와 같다.
const el = document.querySelector('div.getOuttaHere')
waitForElementToBeRemoved(document.querySelector('div.getOuttaHere')).then(() =>
console.log('Element no longer in DOM'),
)
el.setAttribute('data-neat', true)
// other mutations are ignored...
el.parentElement.removeChild(el)
// logs 'Element no longer in DOM'
심화 (Advanced)
접근성 (Accessibility)
앞서 우리는 가이드 원칙 중 하나는 테스팅 라이브러리 API가 테스트를 할 때 실제 사용자가 쓰는 것처럼 해야 한다고 살펴보았다. 여기에는 스크린 리더(Screen Reader)와 같은 접근성 인터페이스(accessibility Interface)도 포함이 된다.
getRoles
이 함수는 DOM 노드의 주어진 트리에서 나타난 내재적인(implicit) ARIA 역할에서의 반복을 허용한다. 이 함수는 역할 이름에 따라 인덱싱이 이루어진 오브젝트를 반환한다. 각각의 값은 내재적 ARIA 역할 요소의 배열로 이루어져 있다.
import {getRoles} from '@testing-library/dom'
const nav = document.createElement('nav')
nav.innerHTML = `
<ul>
<li>Item 1</li>
<li>Item 2</li>
</ul>`
console.log(getRoles(nav))
// Object {
// navigation: [<nav />],
// list: [<ul />],
// listitem: [<li />, <li />]
// }
더 많은 함수는 공식 문서를 참고하자.
커스텀 쿼리 (Custom Queries)
DOM 테스팅 라이브러리는 많은 헬퍼함수들을 보여주는데, 이들은 기본 쿼리를 구현하기 위해 사용된다. 이러한 헬퍼 함수들을 가지고 커스텀 쿼리를 만들 수도 있다. 예를 들어 아래의 코드는 기본 testId 쿼리에서 다른 데이터 속성을 사용할 수 있게 오버라이드(override) 하는 방법을 나타낸다.
// test-utils.js
const domTestingLib = require('@testing-library/dom')
const {queryHelpers} = domTestingLib
export const queryByTestId = queryHelpers.queryByAttribute.bind(
null,
'data-test-id',
)
export const queryAllByTestId = queryHelpers.queryAllByAttribute.bind(
null,
'data-test-id',
)
export function getAllByTestId(container, id, ...rest) {
const els = queryAllByTestId(container, id, ...rest)
if (!els.length) {
throw queryHelpers.getElementError(
`Unable to find an element by: [data-test-id="${id}"]`,
container,
)
}
return els
}
export function getByTestId(container, id, ...rest) {
// result >= 1
const result = getAllByTestId(container, id, ...rest)
if (result.length > 1) {
throw queryHelpers.getElementError(
`Found multiple elements with the [data-test-id="${id}"]`,
container,
)
}
return result[0]
}
// re-export with overrides
module.exports = {
...domTestingLib,
getByTestId,
getAllByTestId,
queryByTestId,
queryAllByTestId,
}
더 많은 함수는 공식 문서를 참고하자.
지금까지 테스팅 라이브러리 공식 문서를 읽으면서 중요한 내용들을 번역하고 정리해 보았다. 프론트엔드 테스팅은 적절하게 필요한 곳에 테스트 케이스를 만들기가 백엔드에 비해 비교적 어렵다고 개인적으로 생각하는데, 이러한 도구들을 가지고 잘 활용해서 의미 있는 테스트 케이스를 잘 만들고 좋은 소프트웨어를 개발하는 팀 그리고 개발자가 되길 바라는 마음으로 글을 마무리한다.