본 글은 FEConf Korea에서 최수형님의 프론트엔드에서 TDD가 가능하다는 것을 보여드립니다 발표를 보고 정리한 글입니다.
TDD의 궁극적인 목적
Clean Code that works : 작동하는 깔끔한 코드
왜 어려운가?
코드 자체가 Testable 하지 않아서 그렇다.
어떻게?
관심사 분리 → 개개의 요소가 자신이 관심 갖고 있는 요소에만 집중해야 TDD를 쉽게 할 수 있다.
이를 지키지 않으면 거대한 진흙 덩어리가 된다.
일단 테스트를 통과하기 위한 코드를 짜라! → 그 후에 수정하자.
라이브코딩
React의 컴포넌트 테스팅
React에서 testing하기 위해서 testing-library/react를 사용
jest의 watchAll 모드를 통해 저장될 떄마다 자동으로 실행해서 확인
// App.jsx
import React from 'react';
import List from './List';
export default function App(){
const tasks = [
{ id : 1, title: '아무 일도 하기 싫다'},
{ id : 2, title : '건물 매입'},
];
return (
<div>
<h1>To-Do</h1>
<List tasks = {tasks} />
</div>
);
}
// List.jsx
import React from 'react';
export default function List({ tasks }){
return (
<ul>
{tasks.map((task) => {
<li key={task.id}>
{task.title}
</li>
})}
</ul>
);
}
// List.test.jsx -> lint로 돌릴 떄 마다 자동으로 테스트를 수행하도록 환경 설정
import React from 'react';
import { render } from '@testing-library/react';
import List from './List':
describe('List', () => {
it('renders tesks', () => {
const tasks = [
{ id : 1, title: '아무 일도 하기 싫다'},
{ id : 2, title : '건물 매입'},
];
//List 컴포넌트를 렌더링해서 container를 가져옴.
const { container } = render({
<List
tasks = {tasks}
/>
});
//가져온 container가 특정 문자열을 가지고 있는지 검사
expect(container).toHaveTextContent('아무 일도 하기 싫다');
expect(container).toHaveTextContent('건물 매입');
}
}
);
Redux는 왜 쓸까?
React의 관심사는 상태의 반영(State Reflection)에만 있음.
React는 상태 관리에 대해서는 관심이 없음.
App을 작게 유지하기 위해서 상태에 대한 관심을 분산해야함. → Redux와 같은 상태관리 라이브러리
Container 컴포넌트만 Redux의 존재를 안다. (List Container)
Presentational 컴포넌트는 Redux의 존재를 모른다. (List)
Container 컴포넌트가 상태를 가져오고 이를 하위로 전달하여 Presentational 컴포넌트가 그린다.
→ Container Presenter 패턴
컴포넌트의 입장에서 생각해야하는 것
응~ 그거 내 관심사 아니야!
Single Responsiblilty Principle(단일 책임의 원칙)
TDD를 원할하게 유지하는데 좋음 → 의존성이 생기지 않아 테스트 작성이 쉬워짐.
라이브코딩
기본 설정
// _fixtures/tasks.js
//자주 사용되는 '고정된 형태의 데이터들'을 feature 폴더에 몰아두고 불러와 사용
const tasks = [
{ id : 1, title: '아무 일도 하기 싫다'},
{ id : 2, title : '건물 매입'},
]
export default tasks;
// _mock/react-redux.js
//모킹할 함수들을 _mock 폴더에 모아놓는다.
export const useSelector = jest.fn():
export const useDispatch = jest.fn();
ListContainer의 테스트
// ListContainer.jsx
import React from 'react';
import { useSelector } from 'react-redux';
import List from './List';
export default function ListContainer(){
const { tasks } = useSelector((state) => ({
tasks: state.tasks
}));
return (
<List tasks = {tasks} />
);
}
// ListContainer.test.jsx -> lint로 돌릴 떄 마다 자동으로 테스트를 수행하도록 환경 설정
import React from 'react';
import { useSelector } from 'react-redux';
import { render } from '@testing-library/react';
import ListContainer from './ListContainer ':
import tasks from '../fixtures/tasks';
jest.mock('react-redux'); //react-redux 모듈 모킹
describe('ListContainer ', () => {
//jest에서 제공하는 함수 모킹 함수
useSelector.mockImplementation((selector) => selector({
tasks, //fixtures의 tasks
}))
//ListContainer에서 tasks를 잘 render하는지 테스트
it('renders tesks', () => {
const { container } = render({
<ListContainer />
});
expect(container).toHaveTextContent('아무 일도 하기 싫다');
expect(container).toHaveTextContent('건물 매입');
}
}
);
App의 테스트2
// App.jsx
// App은 List의 존재를 모르고 ListContainer의 존재만 알도록 변경
import React from 'react';
import ListContainer from './ListContainer ';
export default function App(){
return (
<div>
<h1>To-Do</h1>
<ListContainer />
</div>
);
}
// App.test.jsx -> lint로 돌릴 떄 마다 자동으로 테스트를 수행하도록 환경 설정
import React from 'react';
import { useSelector } from 'react-redux';
import { render } from '@testing-library/react';
import App from './App':
import tasks from '../fixtures/tasks';
jest.mock('react-redux');
describe('App', () => {
//jest에서 제공하는 함수 모킹 함수
useSelector.mockImplementation((selector) => selector({
tasks, //fixtures의 tasks
}))
it('renders tesks', () => {
const { container } = render({
<App />
});
expect(container).toHaveTextContent('아무 일도 하기 싫다');
expect(container).toHaveTextContent('건물 매입');
}
}
);
실제로 스토어에서 직접 상태를 가져오도록 구현
이전까지는 임의의 데이터를 모킹한 함수들로 상태를 처리하였다.
이제 실제 store와 연결하여 테스트해보자.
// App.jsx
// App은 List의 존재를 모르고 ListContainer의 존재만 알도록 변경
import React, { useEffect } from 'react';
import { useDispatch } from 'react-redux';
import ListContainer from './ListContainer ';
//Redux action
import {
setTasks,
} from './actions';.
import tasks from '../fixtures/tasks';
export default function App(){
const dispatch = useDispatch();
useEffect(() => {
dispatch(setTasks(tasks));
}, []);
return (
<div>
<h1>To-Do</h1>
<ListContainer />
</div>
);
}
TDD를 할 때 Test Code에서만 interface를 확정하지말고
습관적으로 코드를 사용하는 쪽에서 스펙을 먼저 확정해주면 좋다.
action 구현
export function setTasks(tasks){
return {
type: 'setTasks',
payload: {
tasks,
}
}
}
export default {};
reducer testing 코드
//reducer.test.js
import reducer from './reducer';
import tasks from '../fixtures/tasks';
export {
setTasks,
} from './actions'
describe('render', () => {
describe('setTasks', () => {
//초기 상태 tasks : []인 store에 setTasks(tasks)를 수행한다.
it('changes tasks array', () => {
const state = reducer({
tasks: [],
}, setTasks(tasks));
//이 때 기대값은 tasks의 상태 길이가 0이 아니어야한다.
expext(state.tasks).not.toHaveLength(0);
})
})
})
reducer 구현
const initialState = {
tasks: [],
};
export default function reducer(state = initialState, action){
if(action.type === 'setTasks'){ //객체로 하면 if문이 필요X, 구조분해할당 말하는 듯
const { tasks } = action.payload;
return {
...state,
tasks,
};
}
}
App의 테스트3
// App.test.jsx -> lint로 돌릴 떄 마다 자동으로 테스트를 수행하도록 환경 설정
import React from 'react';
import { useSelector } from 'react-redux';
import { render } from '@testing-library/react';
import App from './App':
import tasks from '../fixtures/tasks';
jest.mock('react-redux');
describe('App', () => {
//App에서는 dispatch 사용이 불가능하기 떄문에?? 가짜 dispatch 함수를 만들고
const dispatch = jest.fn();
//useDispatch가 윗줄의 가짜 dispatch를 return하도록 만듬
useDispatch.mockImplementation(() => dispatch);
useSelector.mockImplementation((selector) => selector({
tasks, //fixtures의 tasks
}))
it('renders tesks', () => {
const { container } = render({
<App />
});
expect(container).toHaveTextContent('아무 일도 하기 싫다');
expect(container).toHaveTextContent('건물 매입');
}
}
);
BDD란?(Behavior-Driven Development)
행위 중심 개발
테스트를 짤 때 행위를 중심으로 생각하기 위해 제시된 방법론
상황에 따라 다르게 행동
describe context it 템플릿을 주로 사용!
→ 이를 바탕으로 리스트를 고도화해보자
라이브코딩
상황에 따라 test 구현(BDD)
jest에서 context를 사용하려면 jest-plugin-context를 의존성 설치
만약 tasks가 있다면 → render tasks
tasks가 없다면 → render no task message
jest-plugin-context를 사용하여 구현한다.
// List.test.jsx -> lint로 돌릴 떄 마다 자동으로 테스트를 수행하도록 환경 설정
import React from 'react';
import { render } from '@testing-library/react';
import List from './List':
//with tasks -> render tasks
//without takss -> render no tasks message
describe('List', () => {
//tasks가 있을 때 test
**context('with tasks', () => {
//tasks가 있는 경우에 대한 tasks
const tasks = [
{ id : 1, title: '아무 일도 하기 싫다'},
{ id : 2, title : '건물 매입'},
]**
**it('renders tesks', () => {
const { container } = render({
<List
tasks = {tasks}
/>
});
expect(container).toHaveTextContent('아무 일도 하기 싫다');
expect(container).toHaveTextContent('건물 매입');
});
//task가 없을 경우 test
context('without tasks', () => {
//tasks가 없는 경우에 대한 tasks
const tasks = [];
it('renders no tasks message', () => {
const { container } = render({
<List
tasks = {tasks} //빈 배열
/>
});
expect(container).toHaveTextContent('할 일이 없어요');
});**
});
// List.jsx
import React from 'react';
export default function List({ tasks }){
if(tasks.length === 0){
return (
<p>할 일이 없어요</p>
);
}
return (
<ul>
{tasks.map({task} => {
<li key={task.id}>
{task.title}
</li>
})}
</ul>
);
}
완료 버튼 만들기 및 deleteTask 테스트 구현
// List.test.jsx -> lint로 돌릴 떄 마다 자동으로 테스트를 수행하도록 환경 설정
import React from 'react';
//fireEvent testing 시 이벤트 발생시키는 함수인 듯
import { render, **fireEvent** } from '@testing-library/react';
import List from './List':
import tasks from '../fixtures/tasks';
//with tasks -> render tasks
//without takss -> render no tasks message
describe('List', () => {
const handleClick = jest.fn();
function renderList(tasks){
return render({
<List
tasks = {tasks}
onClick = {handleClick}
/>
});
}
//tasks가 있을 때 test
context('with tasks', () => {
it('renders tesks', () => {
const { container } = renderList(tasks);
expect(container).toHaveTextContent('아무 일도 하기 싫다');
expect(container).toHaveTextContent('건물 매입');
**it('renders "완료" button to delete tasks', () => {
const { getAllByTest } = renderList(tasks);
const buttons = getAllByTest('완료');
//완료 버튼들 중 첫번째 버튼을 클릭한다.
fireEvent.click(buttons[0]);
//버튼이 눌렸을 때 handleClick이라는 함수가 실제로 call되는가?
//단순 실행 여부 toBeCalled, 매개변수 전달 toBeCalledWith
expect(handleClick).toBeCalledWith(1);**
});
//task가 없을 경우 test
context('without tasks', () => {
const tasks = [];
it('renders no tasks message', () => {
const { container } = renderList(tasks); //빈 배
expect(container).toHaveTextContent('할 일이 없어요');
});
});
// List.jsx
import React from 'react';
export default function List({ tasks, onClick }){
if(tasks.length === 0){
return (
<p>할 일이 없어요</p>
);
}
return (
<ul>
{tasks.map({task} => {
<li key={task.id}>
{task.title}
**<button type="button" onClick={() => onClick(task.id)}>
완료
<button>**
</li>
})}
</ul>
);
}
TDD에서 중요한 것은 중복을 빠르게 제거하는 것 → renderList 함수를 선언
ListContainer의 테스트2
실제 list의 상태 관리는 ListContainer에서 이루어진다.
// ListContainer.test.jsx -> lint로 돌릴 떄 마다 자동으로 테스트를 수행하도록 환경 설정
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { render, fireEvent } from '@testing-library/react';
import ListContainer from './ListContainer ':
import tasks from '../fixtures/tasks';
jest.mock('react-redux');
describe('ListContainer ', () => {
**const dispatch = jest.fn(); //dispatch 가짜 생성
useDispatch.mockImplementation(() => dispatch); //useDispatch 가짜 생성**
//jest에서 제공하는 함수 모킹 함수
useSelector.mockImplementation((selector) => selector({
tasks, //fixtures의 tasks
}))
it('renders tesks', () => {
const { container, **getAllByText** } = render({
<ListContainer />
});
expect(container).toHaveTextContent('아무 일도 하기 싫다');
expect(container).toHaveTextContent('건물 매입');
**const buttons = getAllByTest('완료');
fireEvent.click(buttons[0]);
//버튼이 눌렸을 때 dispatch가 실제로 호출이 되는지 테스트
expect(dispatch).toBeCalledWith({
type: 'deleteTask',
payload: { id : 1 },
});**
}
}
);
// ListContainer.jsx
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import List from './List';
**import {
deleteTask,
} from './actions';**
export default function ListContainer(){
**const dispatch = useDispatch();**
const { tasks } = useSelector((state) => ({
tasks: state.tasks
}));
**function handleClick(id){
dispatch(deleteTask(id));
}**
return (
<List tasks={tasks} onClick={handleClick} />
);
}
deleteTask action 구현
export function setTasks(tasks){
return {
type: 'setTasks',
payload: {
tasks,
}
}
}
export function deleteTask(id){
return {
type: 'deleteTask',
payload: {
id,
},
}
}
export default {}; //막는다고 하는데 뭘까?
deleteTask reducer test 구현
//reducer.test.js
import reducer from './reducer';
import tasks from '../fixtures/tasks';
export {
setTasks,
deleteTask,
} from './actions'
describe('render', () => {
describe('setTasks', () => {
it('changes tasks array', () => {
const state = reducer({
tasks: [],
}, setTasks(tasks));
expext(state.tasks).not.toHaveLength(0);
});
});
**describe('deleteTask', () => {
it('removes the task from tasks', () => {
const state = reducer({
tasks: [
{ id : 1, title: '아무것도 하기 싫다'}
], //초기 상
}, deleteTask(1)); //실행 함
expext(state.tasks).toHaveLength(0);
});
});**
});
deleteTask reducer 구현
const initialState = {
tasks: [],
};
export default function reducer(state = initialState, action){
if(action.type === 'setTasks'){ //객체로 하면 if문이 필요X, 구조분해할당 말하는 듯
const { tasks } = action.payload;
return {
...state,
tasks,
};
}
if(action.type === 'deleteTask'){
const { tasks } = state;
const { id } = action.payload;
return {
...state,
tasks : tasks.filter((task) => task.id !== id),
};
}
}
비동기 action Task
redux-mock-store를 이용하면 임의의 가짜 store를 구현할 수 있음
export function loadTasks(){ //task 로딩 함수
return async (dispatch) => {
dispatch(setTasks([])); //빈 배열로 dispatch
const tasks = await fetchTasks();
dispatch(setTasks(tasks.slice(0, 10))); //불러온 tasks로 dispatch
}
}
describe('loadTasks', () => {
const takss = [
{ id: 1, title: '아무 것도 하기 싫다'},
{ id: 1, title: '건물 매입'},
];
beforeEach(() => {
fetchTasks.mockResolvedValue(tasks);
});
**it('set tasks', async () => {
const store = mockStore({ //스토어 mocking
tasks: [],
});
await store.dispatch(loadTasks()); //loadTasks 대기
const actions = store.getActions(); //actions를 가져온다.
expect(actions).toEqual([ //수행된 actions를 비
{type : 'setTasks', payload: { tasks : [] } },
{type : 'setTasks', payload: { tasks } },
]);
});**
});
정리
TDD는 만능론X, 설계방법론X → 지뢰탐지기
TDD를 잘 하려면 좋은 설계를 해야한다.
테스트 코드를 먼저 작성한다는 것은 미래의 고통을 지금으로 가져오는 기술 → 고치기가 쉽다.
요구사항을 명확하게 → 요구사항에 명확하지 않으면 Test를 정확히 작성할 수 없다.
여의치 않으면 E2E 테스트부터 도입 → 사용자 관점에서 중요한 것은 동작이니까!
Reference
[A5] 프론트엔드에서 TDD가 가능하다는 것을 보여드립니다.
CodeSoom
[Jest] jest.mock() 모듈 모킹
'Frontend > React' 카테고리의 다른 글
태그에 따라 바뀌는 React 컴포넌트 만들기(with TypeScript) (0) | 2023.08.03 |
---|---|
무한스크롤 컴포넌트 구현하기 -1- (0) | 2023.04.25 |
[React] useAxios (0) | 2022.02.03 |
[React] Hook (0) | 2022.01.11 |
[React] 6. React Router (0) | 2021.11.25 |