React Testing Library
컴포넌트를 내부 구현사항에 의존하지 않고 사용자 관점에서 테스트를 작성할 수 있게 도와주는 라이브러리
npm i -D @testing-library/react
Jest 만으로도 UI 테스트를 할 수 있다. with jsdom
jsdom: 브라우저에서 실행되는 JavaScript를 모방하여 Node.js 환경에서 DOM을 제공하는 라이브러리
Jest에서 제공하는 jsdom을 통해 화면단이 어떻게 보여지는지 테스트할 수 있다.
실제 돔을 렌더링해서 테스트하는 것이 아니라 코드상에서 가상으로 돔 구조를 만들어 테스트하는 것이기 때문에 테스트 실행속도가 빠르다.
Jest만으로 UI 테스트가 가능하지만, 사용자 관점에서 더욱 직관적인 테스트를 작성하기 위해 React Testing Library를 사용한다.
장점
컴포넌트 내부에서 어떤 코드들을 가지고 있는지 어떤 CSS 클래스를 사용하는지 내부 구현사항에 의존하지 않고 화면상에 UI가 어떤 텍스트를 가지고 있고 보이는지 안보이는지 보다 외부적인 사용자 관점에서 테스트를 작성할 수 있게 도와준다.
React Testing Library는 shallow rendering을 지원하지 않기 때문에, 컴포넌트를 테스트할 때 해당 컴포넌트의 모든 자식 컴포넌트를 함께 렌더링한다. 따라서 Jest mocking을 활용하여 자식 컴포넌트들의 의존성까지 모두 처리해주어야 한다
API
Render
document.body 하위로 컴포넌트를 렌더시킨다.
import {render, screen} from '@testing-library/react'
describe('Component', () => {
it('render correctly', () => {
render(<Component />);
// 김아무개를 포함하는 텍스트가 UI 상에 표시되는지 검증
expect(screen.getByText(/김아무개/)).toBeInTheDocument();
})
})
waitFor
비동기적으로 처리되는 상황을 기다릴 때 사용된다.
즉, 사용자의 관점에서 화면이 제대로 렌더링되었는지 확인하기 위해 특정 요소가 화면에 보여질 때까지 기다린다
기본적으로 최대 5초까지 비동기 작업을 기다리고 options
매개변수를 사용하여 이 시간을 조절할 수 있다.
await waitFor(() => ..., { timeout: 8000 });
waitFor() → 일반적으로 요소가 화면에 나타날 때까지 기다리는 데 사용됩
waitForElementToBeRemoved() → 요소가 화면에서 사라질 때 까지 기다리는 데 사용됨
import {render, screen, waitFor, waitForElementToBeRemoved} from '@testing-library/react';
const context = describe;
describe('Component', () => {
it('render correctly', async () => {
render(<Component />);
// 로딩 텍스트가 사라질 때 까지 기다림
await waitForElementToBeRemoved(screen.queryByText('Loading...'));
// 김아무개를 포함하는 텍스트가 UI 상에 표시될 때까지 기다림
await wailtFor(() => expect(screen.getByText(/김아무개/)).toBeInTheDocument());
// findBy로 사용하면 waitFor 없이도 사용 가능하다.
await screen.findByText(/김아무개/)
})
})
findBy
쿼리는 waitFor
함수와 동일한 역할을 한다.
findBy -> DOM 요소가 화면에 나타날 때까지 자동으로 대기하며, 요소를 찾으면 해당 요소를 반환
waitFor -> 콜백 함수를 사용하여 비동기 작업의 완료 여부를 확인
Queries
테스팅 라이브러리에서 제공하는 screen
객체를 사용하면, 렌더링된 dom 내부에서 특정 요소를 쉽게 찾을 수 있다.
screen
객체는 getBy
, queryBy
, getAllBy
, queryAllBy
, findBy
, findAllBy
등의 함수를 제공하여 텍스트, 역할 등을 기준으로 요소를 선택할 수 있다.
getBy → 찾는 요소가 없는 경우 에러를 던진다.
queryBy → 찾는 요소가 없는 경우 null을 반환한다.
찾고자 하는 요소가 여러 개인 경우 **AllBy(ex: getAllBy)를 사용하면 배열 형태로 받아온다.
자주쓰는 쿼리
getByText → 지정한 텍스트를 포함하는 요소 선택
getByRole → 지정한 역할(role)을 가진 요소 선택
getByLabelText → 지정한 aria-label
가진 요소 선택
getByDisplayValue → 지정한 값을 가진 요소 선택 (input)
User Event
테스팅 라이브러리에서 제공하는 이벤트 처리 API
실제 사용자들의 동작을 시뮬레이션하여 테스트를 할 수 있도록 도와준다.
fireEvent
이벤트를 직접 발생시키는 함수
import { render, screen, fireEvent } from '@testing-library/react';
const context = describe;
describe('Input Component', () => {
const text = 'woong';
const setText = jest.fn();
afterEach(() =>{
setText.mockClear();
})
function renderTextField() {
rneder(
<TextField
label="Name"
placeholder="Input your name"
text={text}
setText={setText}
/>
)
}
context('when user types text', () => {
it('calls the change handler', () => {
renderTextField();
fireEvent.change(screen.getByLabelText('Name'), {
target: {
value: 'New Name',
},
});
expect(setText).toBeCalledWith('New Name');
})
})
})
userEvent
실제 사용자가 수행하는 것과 유사한 방식으로 이벤트를 발생 시키는 함수
import userEvent from '@testing-library/user-event';
보다 직관적이고 복잡한 동작도 간편하게 작성할 수 있다.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
const context = describe;
describe('Input Component', () => {
const text = 'woong';
const setText = jest.fn();
afterEach(() =>{
setText.mockClear();
})
function renderTextField() {
rneder(
<TextField
label="Name"
placeholder="Input your name"
text={text}
setText={setText}
/>
)
}
context('when user types text', () => {
it('calls the change handler', () => {
renderTextField();
userEvent.type(screen.getByLabelText('Name'), 'New Name');
expect(setText).toBeCalledWith('New Name');
})
})
})
Mocking
실제 코드에서 쓰이는 외부 의존성 모듈을 대체할때 쓰이는 것으로 테스트의 격리성(isolation)을 확보할 수 있다.
"테스트를 독립시키기 위해 의존성을 개발자가 컨트롤하고 검사할 수 있는 오브젝트로 변환하는 테크닉"
Mock
구현사항이 없고 내가 원하는 부분만 가짜로 흉내내는 용도로 사용
jest.fn()
테스트 코드에서 함수를 목(mock) 함수로 대체할 때 사용
실제 함수처럼 동작하며, 호출된 내용을 기록하고 반환값을 지정할 수 있다.
//#1. 선언과 동시에 반환값 지정
const mockFn = jest.fn(() => 'mock');
const mockFn = jest.fn();
//#2. 각 테스트마다 다르게 반환값을 지정해주고 싶을때 사용
mockFn.mockImplementation(() => 'mock');
Mock Function과 자주 쓰이는 Matcher
toHaveBeenCalledTimes → 호출된 횟수
toHaveBeenCalledWith → 호출된 함수의 인자
jest.mock()
테스트 코드에서 모듈을 목킹(mocking)하고 그 안에 함수와 객체를 대체할 때 사용
모듈이 exports하는 모든 것들을 Mocking한다.
import UserService from '../user_service';
import UserClient from '../user_client';
jest.mock('../user_client', () => jest.fn().mockImplementation(() => ({
login: jest.fn(async (id: string, password: string) => true),
})));
const context = describe;
describe('user service', () => {
let userService: UserService;
let client: UserClient;
beforeEach(() => {
client = new UserClient();
userService = new UserService(client);
});
context('when a successful login', () => {
it('isLogedIn should be true', async () => {
await userService.login('tv', 'dd');
expect(userService.isLogedIn).toBeTruthy();
expect(client.login).toHaveBeenCalled();
expect(client.login).toHaveBeenCalledWith('tv', 'dd');
});
});
});
jest.mocked() 활용 ver
목킹할 모듈의 특정 부분만 목킹할때 사용
import UserService from '../user_service';
import UserClient from '../user_client';
jest.mock('../user_client');
const context = describe;
describe('user service', () => {
const login = jest.fn(async (id: string, password: string) => true);
let userService: UserService;
jest.mocked(UserClient).mockImplementation(() => ({login}));
beforeEach(() => {
userService = new UserService(new UserClient());
});
context('when a successful login', () => {
it('isLogedIn should be true', async () => {
await userService.login('tv', 'dd');
expect(userService.isLogedIn).toBeTruthy();
expect(login).toHaveBeenCalled();
expect(login).toHaveBeenCalledWith('tv', 'dd');
});
});
});
Mock Clear
각 테스트는 독립적으로 실행되고 다른 테스트에 영향을 끼치지 않아야 한다.
{
clearMocks: true,
resetMocks: true,
restoreMocks: true,
resetModules: true // 상황에 따라 다릅니다.
}
jest.clearAllMocks()
모듈 내의 모든 mock 함수의 호출 내역과 호출 횟수를 초기화
jest.resetAllMocks()
모듈 내의 모든 mock 함수의 호출 내역, 호출 횟수 + 구현을 초기화
jest.restoreAllMock()
mock 함수를 실제 구현으로 복원
jest.resetModules()
완전히 분리된 환경에서 실행되도록 모든 모듈을 초기화
Stub
기존에 쓰이는 찐 인터페이스를 다 충족하는 실제로 구현된 코드이며 대체 가능한 것
// 찐 인터페이스
export default class ProductClient {
async fetchItems(): Promise<Item[]> {
return fetch('http://example.com/login/id+password').then(async response =>
response.json(),
);
}
}
// Stub
export default class StubProductClient {
async fetchItems(): Promise<Item[]> {
return [
{key: '💪🏻', available: true},
{key: '👋', available: false},
];
}
}
// Test Code
import ProductService from '../product_service';
import StubProductClient from './stub_product_service';
describe('Product Service', () => {
let service: ProductService;
beforeEach(() => { // 찐 인터페이스 대신 Stub를 주입해서 사
service = new ProductService(new StubProductClient());
});
it('should filter out only available items', async () => {
const items = await service.fetchAvailableItems();
expect(items.length).toBe(1);
expect(items).toEqual([{
key: '💪🏻',
available: true,
}]);
});
});
Test fixture
테스트를 수행하는데 필요한 환경을 설정하는 코드 블록
즉, 테스트에서 특정 객체, 모듈, 함수 등을 재사용하기 위해 필요한 데이터나 설정등을 정의하는 코드이다.
테스트는 일관된 결과를 얻기 위해 매번 같은 환경에서 실행되어야 하며, 이를 위해 Test fxitures
를 사용하여 환경을 설정해야 한다.