React Testing Library 라는 도구에 대해 알아보자

Jeong·2023년 8월 18일
1

테스트

목록 보기
2/4
post-thumbnail

키워드

  • React Testing Library
  • given - when - then 패턴
  • Mocking
  • Test fixture

최종 목표

테스트와 테스트 하는 방법에 대해 알아보자.

학습 목표

React Testing Library 라는 도구에 대해 알아보자.

React Testing Library 란?

React Testing 을 쉽게 할 수 있다. 그리고 사용자 입장에서 Testing 하는 느낌을 준다.

기존에는 엔자임 같은 테스팅 도구들이 있었다. 사용자 입장 보다는 해킹해서 테스팅하는 느낌을 줬다.

반면 React Testing Library 도 구현 자체는 해킹하듯이 되어있지만 사용자 입장에서 테스팅하는 느낌을 준다.

React Testing Library는 사용자 입장에서 Testing 하는 느낌을 줄 수 있다고?

React Testing Library는 사용자 입장에서 Testing 하는 느낌을 줄 수 있다.

실제 브라우저 환경과 유사한 상황을 만들기 때문이다.

Jest 와 React Testing Library 의 역할을 살펴보면서 이해하자.

  • Jest 하는 일은?
    • 테스트 실행 환경을 제공한다.
    • 테스트 결과를 확인한 후에 리포트를 생성하여 보여준다.
  • Jest 는?
    • 자바스크립트 테스트 프레임워크이다.
    • 사용자는 Jest 를 이용하여 테스트 코드를 작성한다.
    • 하지만 Node.js 환경에서 실행되기 때문에 DOM 이 없다.
    • DOM 없이는 React 컴포넌트 테스트가 불가능하다.
    • 그래서 React Testing Library 에서 제공하는 가상의 DOM을 이용한다.

정리하자면, 웹 브라우저가 아닌 환경은 window 전역 객체가 없다. 따라서 DOM에 접근할 수 없으며 각 요소에 대한 접근과 조작이 불가능하다.

하지만 React Testing Library 는 가능한 것처럼 보이게 한다. 이유는 가상의 window 객체를 제공하기 때문이다. 그래서 document.createElement 같은 구문도 동작이 가능하다.

브라우저에서 도는 느낌이 E2E 테스트에 가까운 느낌을 준다. 하지만 진짜 브라우저에서 돌지 않기 때문에 E2E 테스트는 아니다.

give - when - then 이란?

다음 코드에 give - when - then 패턴을 적용해보자.

test('add', () => {
	expect(add(1, 2)).toBe(3);
});

add(1, 2) 를 했을 때(when) → 3이 된다.(then) 로 작성할 수 있다.

// When
const result = add(1, 2);

// Then
test('add', () => {
	expect(result).toBe(3);
});

테스트를 통해 문제를 발견해보자

테스트 코드를 작성하면서, 해당 컴포넌트의 인터페이스를 점검할 수 있다.

다음은 테스트 코드는 통과하지만 문제가 있는 코드이다.

export default function TextField({
	placeholder,
	filterText,
	setFilterText,
}: TextFiledProps) {
	const id = useRef(`input-${Math.random()}`);

	const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
		const {value} = event.target;
		setFilterText(value);
	};

	return (
		<div>
			<label htmlFor={id.current}>
				Search
			</label>
			<input
				id={id.current}
				type='text'
				placeholder={placeholder}
				value={filterText}
				onChange={handleChange}
			/>
		</div>
	);
}
test('TextField', () => {
	// Given
	const setFilterText = () => {};

	// When
	render((
		<TextField
			placeholder='Input your name'
			filterText=''
			setFilterText={setFilterText}
		/>
	));

	// Then
	screen.getByLabelText('Name');
});

범용적인 표현을 사용해야 한다. filterText 가 아니라 text 이다. 그리고 label 을 "Search" 로 고정시키면 안된다.

테스트 코드를 작성하면서 개발을 하면 직후에 문제를 발견할 수 있다. 반면, 개발하면서 발견한 문제는 작성한 시간이 꽤 지난 코드일 경우가 많다. 지식이 감소한 상태에서는 해당 코드를 건들이기 쉽지 않다.

label, text, placeholder 등을 미리 잡을 수 있다. 미리 잡는 게 당연하면 빡세게 잡으면 된다. 아니면 느슨하게 잡는다. 다만 너무 빡세게 잡으면 조금만 고쳐도 테스트가 깨진다.

문제를 수정한 코드

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

import TextField from './TextField';

test('TextField', () => {
  	// Given
	const text = 'Tester';
	const setText = () => {
		// do nothing...
	};

    // When
	render((
		<TextField
			label="Name"
			placeholder="Input your name"
			text={text}
			setText={setText}
		/>
	));
	
  	// Then
	screen.getByLabelText('Name');
});

render 를 React Testing Library 제공받기 때문에

main.txt 에서 작성하는 것처럼 root.render(( ... )); 로 작성하지 않는다.
아까랑 같은 이유인데, React Testing Library 가 가상 DOM을 만든다. 실제 DOM 을 조작하지 않고 컴포넌트를 테스트하는 것이다.

fireEvent 란?

fireEvent 는 React Testing Library 에서 제공하는 함수 중 하나이다.
사용자가 실제로 하는 것처럼 UI와 상호작용 할 수 있다.

fireEvent.change 는 요소의 변경 이벤트를 발생시킨다.
아래는 target 속성의 value 값을 'New Name' 으로 설정하여 UI 요소의 값을 'New Name' 으로 변경했다.

fireEvent.change(screen.getByLabelText(label), {
  target: {value: 'New Name'},
});

getByLabelText 을 하게 되면 label을 찾고 label과 연결된 input 요소를 반환한다.

전체 코드

test('TextField', () => {
	// Given
	const text = 'Tester';
	const label = 'Name';
	const setText = () => {
		// Do nothing...
	};

	// When
	render((
		<TextField
			label={label}
			placeholder='Input your name'
			text={text}
			setText={setText}
		/>
	));

	// Then
	screen.getByLabelText('Name');

	// ----

	// When
	fireEvent.change(screen.getByLabelText(label), {
		target: {value: 'New Name'},
	});
});

setText 함수가 불렸는지 어떻게 확인할 수 있을까?

setText 함수가 불렸는지 어떻게 확인할 수 있을까?

false 값을 가진 임의의 변수 called 를 만든다. 그리고 호출했을 때만 값을 true로 바꿔준다. 그러면 expect(called).toBeTruthy(); 이렇게 변수를 가지고 호출 성공 여부를 따질 수 있다.

하지만 좀 더 편한 방법이 있다.

Mocking 한 함수의 호출 상태 테스트하기

jest.fn 함수를 사용하여 가짜 함수(mock function)를 만든다.

가짜 함수는 실제 함수와 비슷한 동작을 하지만, 실제 함수의 로직을 실행하지 않는다.

함수의 호출 상태를 테스트 할 때 쓰인다.

const setText = jest.fn();

그리고 호출 여부를 확인한다.

expect(setText).toBeCalled();

jest.fn(() => { ... }) 이렇게 무슨 일이 일어났는지 잡을 수도 있다.

expect(setText).toBeCalledWith("New Name"); 이렇게 setText의 인자에 대한 테스트도 함께 잡을 수 있다.

전체 코드

test('TextField', () => {
	// Given
	const text = 'Tester';
	const label = 'Name';
	const setText = jest.fn();

	// When
	render((
		<TextField
			label={label}
			placeholder='Input your name'
			text={text}
			setText={setText}
		/>
	));

	// Then
	screen.getByLabelText('Name');

	// ----

	// When
	fireEvent.change(screen.getByLabelText(label), {
		target: {value: 'New Name'},
	});

	// Then
	expect(setText).toBeCalled();
});

BDD 스타일로 코드를 바꿔보자

BDD 스타일로 코드로 바꿔보자.

컴포넌트를 호출같은 반복되는 코드는 Extract Function 한다.

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

import TextField from './TextField';

const context = describe;

describe('TextField', () => {
	// Given
	const text = 'Tester';
	const label = 'Name';
	const setText = jest.fn();

	function renderTextField() {
		render((
			<TextField
				label={label}
				placeholder='Input your name'
				text={text}
				setText={setText}
			/>
		));
	}

	it('renders an input control', () => {
		// When
		renderTextField();

		// Then
		screen.getByLabelText('Name');
	});

	context('when user types text', () => {
		it('calls the change handler', () => {
			// Given
			renderTextField();

			// When
			fireEvent.change(screen.getByLabelText('Name'), {
				target: {
					value: 'New Name',
				},
			});

			// Then
			expect(setText).toBeCalledWith('New Name');
		});
	});
});

어떤게 Given 인지 When 인지 Then 인지는 크게 신경쓰지 않고 잡아도 된다.

beforeEach로 Mocking 초기화하기

각 테스트마다 새로 컴포넌트를 부르지만, setText 를 Mocking 한 코드는 바깥 쪽에 있기 때문에 하나의 setText 를 여기저기서 사용하게 된다.

renderTextField();
const setText = jest.fn();

그래서 setText 를 매번 초기화 하는 작업을 추가해야 한다.

beforeEach 는 테스트 케이스가 실행되기 전에 지정된 코드를 실행한다. 일반적으로 각 테스트 케이스가 실행되기 전에 공통적으로 수행해야 할 작업을 넣는다.

여기서는 mockClear 를 이용해서 setText 가 불렸던 기록을 다 지워주는 작업을 넣었다.

beforeEach(() => {
  setText.mockClear();
});

jest.clearAllMoacks(); 로 대체 가능하다. 이건 mocking 으로 잡은 것을 모두 clear 해준다.

외부 의존성이 큰 코드를 작성한다면 Mocking 하자

./hooks/useFetchProducts 모듈은 네트워크를 통해 제품 데이터를 가져오는 기능을 가지고 있다.

하지만 이 모듈을 테스트할 때마다 실제 네트워크 요청을 보내는 것은 비효율적이고 예측하기 어렵다. 서버가 꺼져있을 수도 있고, 원하는 데이터(Apple) 가 없을 수도 있다.

이런 경우에 jest.mock()을 사용하여 모듈을 가짜로 만들 수 있다.

useFetchProducts 는 함수이다. 따라서 이를 대체할 펙토리 함수를 제공해야 한다.

펙토리 함수는 모듈의 실제 구현을 대체하는 함수로서, 이 함수는 모듈을 호출할 때 대신 실행된다. 이 펙토리 함수를 사용하여 원하는 동작을 정의하거나 가짜 데이터를 반환할 수 있다.

() => [
	{
		category: 'Fruits', price: '$1', stocked: true, name: 'Apple',
	},
]

전체 코드

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

import App from './App';

jest.mock('./hooks/useFetchProducts', () => () => [
	{
		category: 'Fruits', price: '$1', stocked: true, name: 'Apple',
	},
]);

test('App', () => {
	render(<App />);

	screen.getByText('Apple');
});

fixture

프론트엔드에서 이렇게 백엔드와 소통하는 부분은 상당히 비중이 크다.

그래서 다음과 같이 똑같은 부분이 반복될 수 있다. 중복을 없애기 위해서 fixture 로 잡아놓자.

[
	{
		category: 'Fruits', price: '$1', stocked: true, name: 'Apple',
	},
]

방법1: 직접 쓰자

fixtures/products.ts

const products = [
  	{
    	category: 'Fruits', price: '$1', stocked: true, name: 'Apple',
	},
];

export default products;

fixtures/index.ts

import products from './products';

export default {
	products,
};

App.test.ts

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

import App from './App';

import fixtures from '../fixtures';

jest.mock('./hooks/useFetchProducts', () => () => fixturs.products);

test('App', () => {
	render(<App />);

	screen.getByText('Apple');
});

방법2: mocks 로 따로 빼자

방법 1에서는 jest.mock('./hooks/useFetchProducts', () => () => fixturs.products); 로 했는데,

방법 2는 () => () => fixturs.products 부분을 useFetchProducts 내부로 가져갔다.

hooks/ __mocks __/useFetchProducts.ts

import fixtures from '../../../fixtures';

const useFetchProducts = jest.fn(() => fixtures.products);

export default useFetchProducts;

const useFetchProducts = () => fixtures.products;
이렇게 써도 되지만 jest.fn()을 쓰면 가짜라는 것이 명확하다.

App.test.ts

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

import App from './App';

import fixtures from '../fixtures';

jest.mock('./hooks/__mocks__/useFetchProducts');

test('App', () => {
	render(<App />);

	screen.getByText('Apple');
});

아샬님은 방법1을 훨씬 많이 쓰신다고 한다.
방법1이 불편한 상황에 방법2를 쓰라고 하셨다.

npm run check

많이 쓰는 script 명령어이다.

코드를 컴파일하고 실제로 실행 가능한 자바스크립트로 변환하지 않는다. 단순히 타입스크립트 파일 내의 타입 오류만 검사한다.

"check": "tsc --noEmit"

다음에는?

hook이 많아지면 다음과 같은 상황이 발생한다.

App.test.ts

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

import App from './App';

import fixtures from '../fixtures';

jest.mock('./hooks/useFetchProducts');
jest.mock('./hooks/useFetchUsers');
jest.mock('./hooks/useFetchShops');
jest.mock('./hooks/useFetch블라블라');
jest.mock('./hooks/useFetch블라블라');
jest.mock('./hooks/useFetch블라블라');
jest.mock('./hooks/useFetch블라블라');

test('App', () => {
	render(<App />);

	screen.getByText('Apple');
});

백엔드와 통신하는 부분을 더 효율적으로 처리하는 방법으로 MSW를 알아보자.

profile
성장중입니다 🔥 / 나무위키처럼 끊임없이 글이 수정됩니다!

0개의 댓글