들어가며
벨로퍼트님의 테스팅 튜토리얼을 공부한 내용을 정리하는 글입니다.
이번에는 TDD 방식으로 Todo List를 만들어본다.
전체적인 시나리오는 벨로퍼트님을 따라가지만 컴포넌트 자체는 스스로 만들어볼 생각이다.
컴포넌트 계획
Todo List를 만들기 위해서 필요한 컴포넌트는 무엇일까?
▶ Todo Form : Input과 Button으로 이루어진 Form 컴포넌트이다.
Todo App으로부터 Submit을 전달받아 새로운 항목을 추가할 수 있어야 한다.
▶ Todo Item : 각 Todo 항목을 보여주는 컴포넌트이다.
텍스트를 클릭하면 텍스트에 삭제선이 그어져야 하고, 우측 삭제 버튼을 누르면
항목이 사라져야 한다.
▶ Todo List : Todo 배열을 받아와서 여러 개의 TodoItem 컴포넌트를 렌더링 한다.
▶ Todo App : 할 일 추가, 토글, 삭제 기능이 구현되어야 하는 컴포넌트이다.
Todo Form 만들기
Todo Form에서 우선 필요한 것은 Input과 Button이다.
그리고 Input에 텍스트가 들어가고 Button을 클릭하면 Submit이 실행되어야 한다.
단, 텍스트가 없다면 실행이 되면 안 된다.
이것을 토대로 먼저 테스트 케이스를 작성해 보자!
UI 구성하기
필요한 것은 Input과 Button이라고 했다.
test("has a Input and Button", () => {
render(<Form />);
screen.getByPlaceholderText("할 일을 추가하세요.");
screen.getByText("추가하기");
});
그러므로, input은 placeholder로 button은 text로 가져오게 설정했다.
이제 이것을 토대로 UI를 작성해 보자
const Form = () => {
return (
<form>
<input type="text" placeholder="할 일을 추가하세요." />
<button>추가하기</button>
</form>
);
};
export default Form;
UI를 작성하고 test를 했을 때, 통과했다면 다음으로 넘어가자!
상태 관리하기
다음으로 Input에 onChange 이벤트를 발생시키면 value 값이 변경되어야 한다.
test("change Input Value", () => {
render(<Form />);
const input = screen.getByPlaceholderText("할 일을 추가하세요.");
fireEvent.change(input, { target: { value: "공부하기" } });
expect(input).toHaveAttribute("value", "공부하기");
});
fireEvent를 사용해서 change 이벤트를 발생시켰다.
click과는 다르게 target의 value에 값을 넣어줘야 한다. 그리고 toHaveAttribute를 사용해서
input의 value가 변경되었는지 확인한다.
이후 테스트를 통과하기 위해서 코드를 작성하자!
import { useState } from "react";
const Form = () => {
const [todo, setTodo] = useState("");
const handleChangeTodo = (e) => {
setTodo(e.target.value);
};
return (
<form>
<input
type="text"
placeholder="할 일을 추가하세요."
value={todo}
onChange={handleChangeTodo}
/>
<button>추가하기</button>
</form>
);
};
export default Form;
useState를 사용해서 input의 값을 변경하고 value에 넣어서 테스트를 통과했다.
submit 이벤트 관리
submit 이벤트를 실행하려면 input에 값이 들어가 있어야 한다.
그리고 이벤트가 실행되면 내부의 input을 초기화가 실행되어야 한다.
test("fail submit", () => {
const onSubmit = jest.fn();
render(<Form submit={onSubmit} />);
const button = screen.getByText("추가하기");
fireEvent.submit(button);
expect(onSubmit).not.toBeCalledWith("공부하기");
});
test("success submit", () => {
const onSubmit = jest.fn();
render(<Form onSubmit={onSubmit} />);
const input = screen.getByPlaceholderText("할 일을 추가하세요.");
const button = screen.getByText("추가하기");
fireEvent.change(input, { target: { value: "공부하기" } });
fireEvent.submit(button);
expect(onSubmit).toBeCalledWith("공부하기");
});
test("clear Input", () => {
const onSubmit = jest.fn();
render(<Form onSubmit={onSubmit} />);
const input = screen.getByPlaceholderText("할 일을 추가하세요.");
const button = screen.getByText("추가하기");
fireEvent.change(input, { target: { value: "공부하기" } });
fireEvent.submit(button);
expect(input).toHaveAttribute("value", "");
});
이것을 각각 테스트를 만들었다.
▶ fail submit : input에 값이 없을 경우 submit 함수가 실행되면 안 됨
▶ success : input 값이 있기 때문에 submit 함수가 실행되어야 함
▶ clear Input : submit이 실행되고 input의 value는 초기화가 되어야 함
을 나타내는 테스트이다.
참고로 Form 태그 내부에 있는 submit 버튼에게 click 이벤트를 줄 때는 fireEvent.submit으로
줘야 한다.
import { useState } from "react";
const checkInputValue = (value) => {
if (!value || value.trim() === "") return true;
return false;
};
const Form = ({ onSubmit }) => {
const [todo, setTodo] = useState("");
const handleChangeTodo = (e) => {
setTodo(e.target.value);
};
const handleSubmitForm = (e) => {
e.preventDefault();
if (checkInputValue(todo)) return;
onSubmit(todo);
setTodo("");
};
return (
<form onSubmit={handleSubmitForm}>
<input
type="text"
placeholder="할 일을 추가하세요."
value={todo}
onChange={handleChangeTodo}
/>
<button type="submit">추가하기</button>
</form>
);
};
export default Form;
테스트를 모두 통과하는 Form 컴포넌트를 만들었다.
리팩토링
컴포넌트에서는 크게 변경할 부분이 없지만 테스트 케이스를 쭉 작성했을 때 반복되는 코드가 있다.
screen.getByPlaceholderText("할 일을 추가하세요.");
screen.getByText("추가하기");
이 부분을 리팩터링 할 수 있다.
const setup = (props) => {
render(<Form {...props} />);
const input = screen.getByPlaceholderText("할 일을 추가하세요.");
const button = screen.getByText("추가하기");
return { input, button };
};
test("has a Input and Button", () => {
const { input, button } = setup();
expect(input).toBeTruthy();
expect(button).toBeTruthy();
});
test("change Input Value", () => {
const { input } = setup();
fireEvent.change(input, { target: { value: "공부하기" } });
expect(input).toHaveAttribute("value", "공부하기");
});
test("fail submit", () => {
const onSubmit = jest.fn();
const { button } = setup({ onSubmit });
fireEvent.submit(button);
expect(onSubmit).not.toBeCalledWith("공부하기");
});
setup 함수를 따로 만들어서 input과 button을 찾아서 반환해주는 작업을 수행했다.
test("has a Input and Button", () => {
const { input, button } = setup();
expect(input).toBeTruthy();
expect(button).toBeTruthy();
});
toBeTruthy 함수를 사용해서 setup으로부터 받은 input과 button이 제대로 된 값인지
검증하였다.
TodoItem 만들기
UI 구성하기
describe("Todo Item", () => {
const SampleTodoItem = {
id: 1,
text: "개발하기",
done: false,
};
const setup = () => {
render(<Item {...SampleTodoItem} />);
const text = screen.getByText(SampleTodoItem.text);
const button = screen.getByText("삭제하기");
return { text, button };
};
test("has a Text and Button", () => {
const { text, button } = setup();
expect(text).toBeTruthy();
expect(button).toBeTruthy();
});
});
Todo 데이터가 없기 때문에 SampleTodoItem을 하나 만들어줬다.
그리고 Form처럼 setup을 통해서 미리 text와 button을 반환해주고 있다.
그래서 테스트 코드에서는 반환받은 text와 button이 정상적인 값인지 확인한다.
const Item = (todo) => {
const { id, text, done } = todo;
return (
<li>
<span>{text}</span>
<button>삭제하기</button>
</li>
);
};
export default Item;
간단하게 UI 부분을 작성했다.
done에 따라 스타일 바꾸기
done의 값에 따라 가운데 줄이 생기거나 사라지는 경우가 있다.
const setup = (props) => {
const todoItem = props ?? SampleTodoItem;
render(<Item {...todoItem} />);
const text = screen.getByText(todoItem.text);
const button = screen.getByText("삭제하기");
return { text, button };
};
test("has a Text and Button", () => {
const { text, button } = setup();
expect(text).toBeTruthy();
expect(button).toBeTruthy();
});
test("done style change true", () => {
const { text } = setup({ ...SampleTodoItem, done: true });
expect(text).toHaveStyle("text-decoration: line-through");
});
test("done style change false", () => {
const { text } = setup({ ...SampleTodoItem, done: false });
expect(text).not.toHaveStyle("text-decoration: line-through");
});
우선 setup을 이번 테스트에 맞춰서 조금 수정했다.
const setup = (props) => {
const todoItem = props ?? SampleTodoItem;
render(<Item {...todoItem} />);
const text = screen.getByText(todoItem.text);
const button = screen.getByText("삭제하기");
return { text, button };
};
done에 값이 달라져야 하므로, props를 사용해서 todoItem을 TodoItem에 넘겨준다.
그리고 toHaveStyle 함수를 사용해서 done에 따라 text-decoration의 line-through 속성이
있는지 확인한다.
const Item = (todo) => {
const { id, text, done } = todo;
return (
<li>
<span style={{ textDecoration: done ? "line-through" : "none" }}>
{text}
</span>
<button>삭제하기</button>
</li>
);
};
export default Item;
span의 style 속성을 사용해서 done이 true이면 line-through 속성을 주었다.
클릭 이벤트 관리하기
텍스트가 클릭되면 done의 값을 반전시키는 onToggle 함수, 삭제하기 버튼을 클릭하면 항목을
삭제하는 onRemove 함수가 호출되어야 한다.
onToggle과 onRemove 둘 다 호출될 때 자신의 id를 파라미터로 넣어서 호출해야 한다.
test("onToggle", () => {
const onToggle = jest.fn();
const { text } = setup({ ...SampleTodoItem, onToggle });
fireEvent.click(text);
expect(onToggle).toBeCalledWith(SampleTodoItem.id);
});
test("onRemove", () => {
const onRemove = jest.fn();
const { button } = setup({ ...SampleTodoItem, onRemove });
fireEvent.click(button);
expect(onRemove).toBeCalledWith(SampleTodoItem.id);
});
toggle과 remove 함수를 jest를 사용해서 만들어주고, 텍스트와 button의 클릭 이벤트를
발생시켰다. 그리고 expect의 toBeCalled는 함수가 호출되었는지 확인하는 함수였다면,
toBeCalledWith 함수는 매개변수를 포함하고 호출되었는지 확인하는 함수이다.
const Item = (todo) => {
const { id, text, done, onToggle, onRemove } = todo;
return (
<li>
<span
style={{ textDecoration: done ? "line-through" : "none" }}
onClick={() => onToggle(id)}
>
{text}
</span>
<button onClick={() => onRemove(id)}>삭제하기</button>
</li>
);
};
export default Item;
Todo Item도 TDD 방식으로 작성이 끝났다.
Todo List 만들기
Todo List는 다른 컴포넌트에 비해 간단하다.
Todos 객체가 들어있는 Props를 받아와서 Todo Item 컴포넌트를 렌더링해준다.
UI 구성하기
describe("Todo List", () => {
const SampleTodoList = [
{
id: 1,
text: "개발하기",
done: false,
},
{
id: 2,
text: "낮잠자기",
done: false,
},
{
id: 3,
text: "산책하기",
done: false,
},
{
id: 4,
text: "게임하기",
done: false,
},
];
test("has Todo Item List", () => {
render(<List todoList={SampleTodoList} />);
SampleTodoList.forEach(({ text }) => {
screen.getByText(text);
});
});
});
todoList를 넘겨줬을 때, 넘겨진 Item이 모두 렌더링 되었는지, forEach를 통해서 확인했다.
import Item from "./item";
const List = ({ todoList = [] }) => {
return (
<ul>
{todoList.map((todoItem) => (
<Item {...todoItem} key={todoItem.id} />
))}
</ul>
);
};
export default List;
UI 구현도 그렇게 어렵지 않게 작업할 수 있다.
onToggle 및 onRemove 함수 호출하기
Todo App에서부터 onToggle, onRemove를 받아와서 Todo Item에게 전달하기 때문에
마찬가지로 Todo List에서도 onToggle과 onRemove 함수 호출이 문제없는지 확인해야 한다.
test("onToggle", () => {
const onToggle = jest.fn();
render(<List todoList={SampleTodoList} onToggle={onToggle} />);
const todoItem = screen.getByText(SampleTodoList[0].text);
fireEvent.click(todoItem);
expect(onToggle).toBeCalledWith(SampleTodoList[0].id);
});
test("onRemove", () => {
const onRemove = jest.fn();
render(<List todoList={SampleTodoList} onRemove={onRemove} />);
const todoItem = screen.getAllByText("삭제하기")[0];
fireEvent.click(todoItem);
expect(onRemove).toBeCalledWith(SampleTodoList[0].id);
});
onToggle의 경우 실제 이벤트가 발생하는 곳은 Todo Item이기 때문에 getByText로
첫 번째 Todo Item을 가져와서 click 이벤트를 발생시킨다.
그리고 마찬가지로 onRemove도 실제 이벤트가 발생하는 곳은 Todo Item이기 때문에
getAllByText로 전체 조회 후 첫 번째 Item을 가져와서 click 이벤트를 발생시킨다.
그리고 toBeCalledWith 함수를 사용해서 해당 이벤트가 실행되고, 매개 변수가 제대로 넘겨졌는지
확인한다.
import Item from "./item";
const List = ({ todoList = [], onToggle, onRemove }) => {
return (
<ul>
{todoList.map((todoItem) => (
<Item
key={todoItem.id}
{...todoItem}
onToggle={onToggle}
onRemove={onRemove}
/>
))}
</ul>
);
};
export default List;
실제 코드 구현은 이부분 역시 어렵지 않게 작업을 완료했다.
단순하게 onToggle과 onRemove를 Todo Item에게 넘겨주면 Todo Item에서 이미 다 구현이 되었기 때문이다.
Todo App 만들기
마지막으로 Todo App에서는 todoList 배열에 대한 모든 상태가 관리된다.
Todo App 컴포넌트에서 테스트를 할 때는, 이미 테스트가 이루어진 컴포넌트들을 함께 사용해서
구현하게 되므로 통합 테스트라고 생각할 수 있다.
Todo Form 및 Todo List 렌더링 확인하기
가장 먼저 Todo App의 구성요소인 Todo Form과 Todo List가 렌더링 되었는지 확인한다.
describe("Todo App", () => {
test("has Todo Form and Todo List", () => {
render(<Todo />);
screen.getByTestId("todoForm");
screen.getByTestId("todoList");
});
});
Todo Form과 Todo List가 렌더링 되었는지 쉽게 확인하기 위해서 getByTestId 함수를 사용하였다.
컴포넌트이기 때문에 다른 방식으로 렌더링 유무를 확인하기 까다롭기 때문에 data-testid를 사용할 것이기 때문이다.
import Form from "./form";
import List from "./list";
const Todo = () => {
return (
<>
<Form />
<List />
</>
);
};
export default Todo;
// Todo Form
<form onSubmit={handleSubmitForm} data-testid="todoForm">
<input
type="text"
placeholder="할 일을 추가하세요."
value={todo}
onChange={handleChangeTodo}
/>
<button type="submit">추가하기</button>
</form>
// Todo List
<ul data-testid="todoList">
{todoList.map((todoItem) => (
<Item
key={todoItem.id}
{...todoItem}
onToggle={onToggle}
onRemove={onRemove}
/>
))}
</ul>
Todo Form과 Todo List에 data-testid를 넣어주었다.
할 일 추가 기능 구현하기
할 일을 추가하는 이벤트를 구현해야 한다.
Todo Form에서 submit으로 구현하였지만, submit 이후 추가한 항목이 있는지 검증하는 과정도 필요하다.
const SampleTodo = "숨 쉬기";
test("submit", () => {
render(<Todo />);
const input = screen.getByPlaceholderText("할 일을 추가하세요.");
const button = screen.getByText("추가하기");
fireEvent.change(input, { target: { value: SampleTodo } });
fireEvent.submit(button);
screen.getByText(SampleTodo);
});
Todo Form의 Input과 Button을 사용해서 할 일을 추가하였다.
그리고 Todo List를 사용해서 추가한 Todo가 존재하는지 확인한다.
const Todo = () => {
const [todoList, setTodoList] = useState([]);
const onSubmit = (text) => {
setTodoList((prev) => [
...prev,
{
id: prev.length,
text,
done: false,
},
]);
};
return (
<>
<Form onSubmit={onSubmit} />
<List todoList={todoList} />
</>
);
};
todoList를 useState를 통해서 관리하고 onSubmit 함수를 만들어 주었다.
만들어진 todoList와 onSubmit을 Todo List와 Todo Form에게 전달했다.
토글 기능 구현하기
토글 기능 역시 TodoItem에서 테스트 했지만 해당 기능을 구현하고 작동하는지 확인한다.
test("toggle", () => {
render(<Todo />);
const input = screen.getByPlaceholderText("할 일을 추가하세요.");
const button = screen.getByText("추가하기");
fireEvent.change(input, { target: { value: SampleTodo } });
fireEvent.submit(button);
const text = screen.getByText(SampleTodo);
fireEvent.click(text);
expect(text).toHaveStyle("text-decoration: line-through");
fireEvent.click(text);
expect(text).not.toHaveStyle("text-decoration: line-through");
fireEvent.click(text);
expect(text).toHaveStyle("text-decoration: line-through");
});
Todo Item을 하나 만들고, 만든 Todo Item의 click 이벤트를 통해서
스타일이 제대로 적용되었는지 확인한다.
const Todo = () => {
const [todoList, setTodoList] = useState([]);
const onSubmit = (text) => {
setTodoList((prev) => [
...prev,
{
id: prev.length,
text,
done: false,
},
]);
};
const onToggle = (id) => {
setTodoList((prev) =>
prev.map((item) =>
id === item.id ? { ...item, done: !item.done } : item
)
);
};
return (
<>
<Form onSubmit={onSubmit} />
<List todoList={todoList} onToggle={onToggle} />
</>
);
};
onToggle 함수를 만들어서 받아온 id와 같은 Todo Item은 done을 반대로 변경하였다.
삭제 기능 구현하기
마지막으로 삭제 기능을 구현하는 것이다.
test("remove", () => {
render(<Todo />);
const input = screen.getByPlaceholderText("할 일을 추가하세요.");
const button = screen.getByText("추가하기");
fireEvent.change(input, { target: { value: SampleTodo } });
fireEvent.submit(button);
const deleteButton = screen.getByText("삭제하기");
fireEvent.click(deleteButton);
expect(deleteButton).not.toBeInTheDocument();
});
toBeInTheDocument 함수는 화면에 해당 Document가 존재하는지 확인하는 함수이다.
앞에 not이 붙어있기 때문에 " 존재하지 않음 "을 의미한다.
const Todo = () => {
const [todoList, setTodoList] = useState([]);
const onSubmit = (text) => {
setTodoList((prev) => [
...prev,
{
id: prev.length,
text,
done: false,
},
]);
};
const onToggle = (id) => {
setTodoList((prev) =>
prev.map((item) =>
id === item.id ? { ...item, done: !item.done } : item
)
);
};
const onRemove = (id) => {
setTodoList((prev) => prev.filter((item) => id !== item.id));
};
return (
<>
<Form onSubmit={onSubmit} />
<List todoList={todoList} onToggle={onToggle} onRemove={onRemove} />
</>
);
};
onRemove 함수도 filter를 사용해서 간단하게 구현을 완료했다.
리팩토링
구현한 코드 부분에서는 딱히 수정할 부분이 없다. 하지만 테스트 코드에서 반복되는 부분이 등장한다.
const input = screen.getByPlaceholderText("할 일을 추가하세요.");
const button = screen.getByText("추가하기");
fireEvent.change(input, { target: { value: SampleTodo } });
fireEvent.submit(button);
const text = screen.getByText(SampleTodo);
이부분을 단축시켜보자!
const setup = () => {
const input = screen.getByPlaceholderText("할 일을 추가하세요.");
const button = screen.getByText("추가하기");
fireEvent.change(input, { target: { value: SampleTodo } });
fireEvent.submit(button);
return screen.getByText(SampleTodo);
};
setup 함수를 만들어서 해당 부분을 함수로 묶어서 테스트 케이스에 넣어서 사용했다.
describe("Todo App", () => {
const setup = () => {
const input = screen.getByPlaceholderText("할 일을 추가하세요.");
const button = screen.getByText("추가하기");
fireEvent.change(input, { target: { value: SampleTodo } });
fireEvent.submit(button);
return screen.getByText(SampleTodo);
};
test("has Todo Form and Todo List", () => {
render(<Todo />);
screen.getByTestId("todoForm");
screen.getByTestId("todoList");
});
const SampleTodo = "숨 쉬기";
test("submit", () => {
render(<Todo />);
const item = setup();
expect(item).toBeTruthy();
});
test("toggle", () => {
render(<Todo />);
const item = setup();
expect(item).toBeTruthy();
fireEvent.click(item);
expect(item).toHaveStyle("text-decoration: line-through");
fireEvent.click(item);
expect(item).not.toHaveStyle("text-decoration: line-through");
fireEvent.click(item);
expect(item).toHaveStyle("text-decoration: line-through");
});
test("remove", () => {
render(<Todo />);
setup();
const deleteButton = screen.getByText("삭제하기");
fireEvent.click(deleteButton);
expect(deleteButton).not.toBeInTheDocument();
});
});
'React > 실험실' 카테고리의 다른 글
[React] 나만의 알고리즘 문제 저장소 만들기 - 시작 (0) | 2023.01.13 |
---|---|
[React] 벨로퍼트와 함께하는 React Testing - 비동기 작업 테스트 (0) | 2023.01.08 |
[React] 벨로퍼트와 함께하는 React Testing - react-testing-library (0) | 2023.01.06 |
[React] 벨로퍼트와 함께하는 React Testing - TDD (1) | 2023.01.05 |
[React] 성능 개선기 (0) | 2022.12.21 |