최근에 면접을 많이 보면서 느꼈던 건 프론트엔드에서도 테스팅에 대한 중요성이 점점 늘어나고 있다는 것이었는데요.
저는 그 중에서 E2E테스트를 투두앱을 만들면서 적용해보려고 해요.
프론트엔드에서 테스트를 나타내는 트로피 그림이 하나 있는데요.
이 그림에서 보면 e2e(End to End)테스트는 비용이 크지만 그만큼 확실하게 기능이 동작하는 것을 검증할 수 있다는 장점이 있어요.
먼저 프로젝트를 구축할게요.
Vite를 이용해서 React + TypeScript 프로젝트를 세팅해요.
여기서는 패키지 매니저로 pnpm을 사용할게요.
> pnpm create vite
이후에 Cypress를 설치해요.
> pnpm install -D cypress
cypress의 Launchpad를 편하게 실행시키기 위해 package.json 파일에 아래 스크립트를 추가해요.
"scripts" : {
"cypress:open": "cypress open"
}
아래 명령어로 Launchpad를 실행시켜줘요.
> npx cypress open
그러면 아래와 같은 화면을 볼 수 있어요.
우리는 여기서 E2E Testing을 할 예정이에요.
따라서 왼쪽에 있는 E2E Testing을 클릭해주세요.
브라우저를 선택할 수 있는 화면이 나오는데요.
Chrome 브라우저에서 테스트를 할 예정이라 Chrome이 선택된 상태로
Start E2E Testing in Chrome 버튼을 클릭해주세요.
그러면 아래와 같은 화면을 확인할 수 있어요.
여기서 Create new spec을 클릭해주세요.
파일명을 todo로 변경해주고 Create spec 버튼을 눌러줍니다.
좌측의 Spec 탭에서 방금 생성한 todo.cy.ts 파일을 확인할 수 있어요.
그리고 해당 파일을 클릭하면 바로 e2e 테스트가 실행이 되요.
Cypress 자체에서 작성된 테스트 코드가 실행되고 통과하는 것을 확인할 수 있어요.
이제 테스트 코드를 작성할 수 있는 환경은 구축이 된 것 같아요.
추가적인 기능을 배제하고 단순하게 생각하면 아래와 같은 테스트 코드를 작성할 수 있을 것 같아요.
(아래에 작성되는 코드는 편의상 간단하게 작성하게 되었는데요. 흐름을 위주로 가볍게 봐주시면 좋을 것 같아요!)
1. 사용자는
1-1. 할일을 추가할 수 있다.
1-2. 할일을 수정할 수 있다.
1-3. 할일을 삭제할 수 있다.
1-4. 할일 목록을 확인할 수 있다.
먼저 1-1을 만족하기 위해 아래와 같이 작성해봤어요.
추후에 cy.visit에서 사용하는 baseUrl이나 data-attribute로 dom을 가져오는 부분은 별도로 모듈화를 하면 좋을 것 같아요.
describe('todo page', () => {
context('사용자는', () => {
beforeEach(() => {
cy.visit('http://localhost:5173');
});
it('할일을 추가할 수 있다.', () => {
cy.get('[data-cy="todo-input"]').type('리액트 공부하기');
cy.get('[data-cy="todo-form"]').submit();
cy.get('[data-cy="todo-list"]').should('contain', '리액트 공부하기');
});
});
});
그러면 cy.get('\[data-cy="todo-input"\]').type('리액트 공부하기')
부분이 터지게 되는데요.
테스트를 통과시키기 위해서 먼저 input 태그를 만들어볼 수 있을 것 같아요.
export default function Todo() {
return (
<div>
<input type="text" data-cy="todo-input" />
</div>
);
}
이제 cy.get('\[data-cy="todo-input"\]').type('리액트 공부하기')
코드는 통과하게 되었는데요.
리액트에서 input을 핸들링하려면 state를 만들고 이벤트 핸들러 함수를 작성해야 하기 때문에 이 처리를 해줄게요.
import { ChangeEvent, useState } from 'react';
export default function Todo() {
const [todo, setTodo] = useState('');
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setTodo(e.target.value);
};
return (
<div>
<input
type="text"
value={todo}
onChange={handleChange}
data-cy="todo-input"
/>
</div>
);
}
이제 cy.get('[data-cy="todo-form"]').submit();
코드가 터지는 것을 확인할 수 있는데요.
이 테스트를 통과시켜볼게요.
form 태그만 추가하면 우선 테스트는 통과할 수 있어요.
form을 제출했을 때 처리를 해주기 위해서 이벤트 핸들러를 만들고 등록해줄게요.
import { ChangeEvent, FormEvent, useState } from 'react';
export default function Todo() {
const [todo, setTodo] = useState('');
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setTodo(e.target.value);
};
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
return (
<div>
<form onSubmit={handleSubmit} data-cy="todo-form">
<input
type="text"
value={todo}
onChange={handleChange}
data-cy="todo-input"
/>
</form>
</div>
);
}
이제 아래 테스트코드를 통과시켜보려고 해요.
그러면 form을 제출했을 때 리스트에 입력한 할일이 추가되어야 할 것 같아요.
cy.get('[data-cy="todo-list"]').should('contain', '리액트 공부하기');
먼저 테스트만 통과시키려면 아래와 같이 통과시킬 수 있어요.
import { ChangeEvent, FormEvent, useState } from 'react';
export default function Todo() {
const [todo, setTodo] = useState('');
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setTodo(e.target.value);
};
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
};
return (
<div>
<form onSubmit={handleSubmit} data-cy="todo-form">
<input
type="text"
value={todo}
onChange={handleChange}
data-cy="todo-input"
/>
</form>
<ul data-cy="todo-list">
<li>리액트 공부하기</li>
</ul>
</div>
);
}
여기서 우리는 리팩토링을 해야하는데요, 실제로 입력한 할일이 리스트에 추가되도록 해야할 것 같아요.
import { ChangeEvent, FormEvent, useState } from 'react';
export default function Todo() {
const [todo, setTodo] = useState('');
const [todos, setTodos] = useState<string[]>([]);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setTodo(e.target.value);
};
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setTodos((prev) => [...prev, todo]);
setTodo('');
};
return (
<div>
<h1>Todo App</h1>
<form data-cy="todo-form" onSubmit={handleSubmit}>
<input
type="text"
value={todo}
onChange={handleChange}
data-cy="todo-input"
/>
<button type="submit">추가하기</button>
</form>
<ul data-cy="todo-list">
{todos.map((todo) => (
<li key={todo}>{todo}</li>
))}
</ul>
</div>
);
}
이렇게 되면 테스트 코드를 통과하게 되었어요.
여기서 구현 코드의 리팩토링을 진행하면 좋을 것 같아요.
관심사에 따라서 컴포넌트를 분리해볼게요.
아래와 같은 느낌으로 리팩토링을 진행했어요.
import { TodoForm, TodoList } from '@/components';
import useTodo from './todo.hooks';
export default function Todo() {
const { todos, addTodo } = useTodo();
return (
<div>
<h1>Todo App</h1>
<TodoForm addTodo={addTodo} />
<TodoList todos={todos} />
</div>
);
}
이제 사용자 시나리오에 맞춰서 삭제하는 부분을 확인할 수 있는 테스트 코드를 작성하려고 해요.
it('할일을 삭제할 수 있다.', () => {
cy.get('[data-cy="todo-input"]').type('리액트 공부하기');
cy.get('[data-cy="todo-form"]').submit();
cy.get('[data-cy="todo-list"]').should('have.length', 1);
cy.contains('삭제').click();
cy.get('[data-cy="todo-list"]').should('not.contain', '리액트 공부하기');
});
여기서는 cy.contains('삭제').click();
이 부분이 터지게 되어요.
당연히 삭제 버튼이 없기 때문인데요, 이 테스트를 통과시켜볼게요.
TodoItem 컴포넌트에 삭제 버튼을 추가해주면 될 것 같아요.
interface TodoItemProps {
todo: string;
}
export default function TodoItem({ todo }: TodoItemProps) {
return (
<li>
<span>{todo}</span>
<button type="button">삭제</button>
</li>
);
}
이제 cy.get('[data-cy="todo-list"]').should('not.contain', '리액트 공부하기');
이 부분이 터지게 되는데요.
테스트를 통과시켜주기 위해서 삭제 기능을 구현해볼게요.
// todo.hooks.ts
import { useState } from 'react';
export default function useTodo() {
const [todos, setTodos] = useState<string[]>([]);
const addTodo = (todo: string) => {
setTodos((prev) => [...prev, todo]);
};
const deleteTodo = (text: string) => {
setTodos((prev) => prev.filter((todo) => todo !== text));
};
return { todos, addTodo, deleteTodo };
}
// todo.tsx
import { TodoForm, TodoList } from '@/components';
import useTodo from './todo.hooks';
export default function Todo() {
const { todos, addTodo, deleteTodo } = useTodo();
return (
<div>
<h1>Todo App</h1>
<TodoForm addTodo={addTodo} />
<TodoList todos={todos} deleteTodo={deleteTodo} />
</div>
);
}
// todo-item.tsx
interface TodoItemProps {
todo: string;
deleteTodo: (text: string) => void;
}
export default function TodoItem({ todo, deleteTodo }: TodoItemProps) {
return (
<li>
<span>{todo}</span>
<button type="button" onClick={() => deleteTodo(todo)}>
삭제
</button>
</li>
);
}
삭제 기능을 구현하게 되면 이제 테스트 코드가 모두 통과하게 되어요.
이제 남은 테스트 케이스는 할일 목록을 수정하는 경우와, 이미 추가했던 할일 목록을 저장하고 해당 페이지에 방문했을 때 이전에 작성한 할일 목록을 확인할 수 있도록 하는 2가지 기능인데요. 이 기능들을 테스트하면서 위에서 작성했던 로직의 문제점도 찾게 되고 더욱 견고한 테스트를 작성하는 데 도움이 될 것 같아요. 이 기능을 구현하는 것은 다음 스텝으로 남겨놓겠습니다!
출처
'TIL > 개발' 카테고리의 다른 글
유데미(Udemy) 옆집 개발자와 같이 진짜 이해하며 만들어보는 첫 Spring Boot 프로젝트 (1) | 2024.03.17 |
---|---|
유데미(Udemy) 프로젝트로 배우는 React.js & Next.js 마스터리 클래스 수강 후기 (1) | 2024.02.18 |
그림으로 배우는 Http & Network Basic - 11장 (0) | 2024.01.08 |
누군가 나를 멘토라고 부르기 시작했다 (1) | 2024.01.07 |
그림으로 배우는 Http & Network Basic - 10장 (1) | 2024.01.06 |