diff --git a/package.json b/package.json
index e51db5bc..795f335a 100644
--- a/package.json
+++ b/package.json
@@ -23,6 +23,13 @@
"typescript": "4.7.4",
"web-vitals": "2.1.4"
},
+ "jest": {
+ "collectCoverageFrom": [
+ "src/**/*.{js,jsx,ts,tsx}",
+ "!src/index.tsx",
+ "!src/test-utils/*"
+ ]
+ },
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
@@ -47,4 +54,4 @@
"last 1 safari version"
]
}
-}
\ No newline at end of file
+}
diff --git a/src/App.test.tsx b/src/App.test.tsx
index 838df452..a28519f5 100644
--- a/src/App.test.tsx
+++ b/src/App.test.tsx
@@ -1,7 +1,11 @@
-import { render } from "@testing-library/react";
-
+import { render, screen } from "@testing-library/react";
+import { Provider } from "react-redux";
import App from "./App";
-
-test("renders learn react link", () => {
- render();
+import { store } from "./store";
+test("renders App.tsx", () => {
+ render(
+
+
+
+ );
});
diff --git a/src/components/Todo/Todo.test.tsx b/src/components/Todo/Todo.test.tsx
new file mode 100644
index 00000000..3bd26338
--- /dev/null
+++ b/src/components/Todo/Todo.test.tsx
@@ -0,0 +1,24 @@
+import { render, screen } from "@testing-library/react";
+import Todo from "./Todo";
+describe("", () => {
+ it("should render without errors", () => {
+ render();
+ //screen 으로 렌더링된 것을 접근
+ //getByText throws error when cannot find element
+ screen.getByText("TODO_TITLE"); // Implicit assertion
+ const doneButton = screen.getByText("Done"); // Implicit assertion
+ expect(doneButton).toBeInTheDocument(); // Explicit assertion
+ });
+ it("should render done mark when done is true", () => {
+ render();
+ const title = screen.getByText("TODO_TITLE");
+ expect(title.classList.contains("done")).toBe(true);
+ screen.getByText("Undone");
+ });
+ it("should render undone mark when done is false", () => {
+ render();
+ const title = screen.getByText("TODO_TITLE");
+ expect(title.classList.contains("done")).toBe(false);
+ screen.getByText("Done");
+ });
+});
diff --git a/src/components/TodoDetail/TodoDetail.test.tsx b/src/components/TodoDetail/TodoDetail.test.tsx
new file mode 100644
index 00000000..dcfff3d3
--- /dev/null
+++ b/src/components/TodoDetail/TodoDetail.test.tsx
@@ -0,0 +1,52 @@
+import { MemoryRouter, Navigate, Route, Routes } from "react-router";
+import { renderWithProviders } from "../../test-utils/mocks";
+import TodoDetail from "./TodoDetail";
+import { screen } from "@testing-library/react";
+import axios from "axios";
+
+const renderTodoDetail = () => {
+ //this is render
+ renderWithProviders(
+
+
+ } />
+ } />
+
+ ,
+ {
+ preloadedState: {
+ todo: {
+ todos: [
+ {
+ id: 3,
+ title: "title3",
+ content: "content3",
+ done: false,
+ },
+ ],
+ selectedTodo: null,
+ },
+ },
+ }
+ );
+};
+
+describe("", () => {
+ it("should render todo", async () => {
+ jest.spyOn(axios, "get").mockImplementation(() => {
+ return Promise.resolve({
+ data: {
+ id: 3,
+ title: "title3",
+ content: "content3",
+ done: false,
+ },
+ });
+ });
+ //render todo detail must happen after jest mocking
+ renderTodoDetail();
+ await screen.findByText("title3");
+ //only findbyDisplayValue works on await
+ await screen.findByText("content3");
+ });
+});
diff --git a/src/containers/TodoList/NewTodo/NewTodo.test.tsx b/src/containers/TodoList/NewTodo/NewTodo.test.tsx
new file mode 100644
index 00000000..0c47a784
--- /dev/null
+++ b/src/containers/TodoList/NewTodo/NewTodo.test.tsx
@@ -0,0 +1,62 @@
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import axios from "axios";
+import * as todoSlice from "../../../store/slices/todo";
+import { getMockStore, renderWithProviders } from "../../../test-utils/mocks";
+import NewTodo from "./NewTodo";
+
+const mockNavigate = jest.fn();
+
+jest.mock("react-router", () => ({
+ //그래야 NavLink 같은 걸 쓸 수 있다.
+ ...jest.requireActual("react-router"),
+ Navigate: (props: any) => {
+ //we need to check navigate to
+ mockNavigate(props.to);
+ return null;
+ },
+ useNavigate: () => mockNavigate,
+}));
+
+describe("the test of NewTodo", () => {
+ //branch submitted
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+ it("should render NewTodo", () => {
+ renderWithProviders();
+ screen.getByText("Add a Todo");
+ });
+ it("should change inputs on enter", async () => {
+ //this changes the dispatch return value
+ axios.post = jest.fn().mockResolvedValueOnce({
+ //this is the result value
+ data: {
+ id: 1,
+ title: "안녕하세요",
+ content: "내용 채우기",
+ done: false,
+ },
+ });
+ renderWithProviders();
+ const titleInput = screen.getByLabelText("Title");
+ const contentInput = screen.getByLabelText("Content");
+ const submitButton = screen.getByText("Submit");
+ fireEvent.change(titleInput, { target: { value: "안녕하세요" } });
+ fireEvent.change(contentInput, { target: { value: "내용 채우기" } });
+ await screen.findByDisplayValue("안녕하세요"); //test setstate
+ await screen.findByDisplayValue("내용 채우기"); //test setstate
+ fireEvent.click(submitButton);
+ //this waits for dispatch being called
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith("/todos"));
+ });
+ it("should alert when inputs are empty", async () => {
+ const mockPostTodo = jest.spyOn(todoSlice, "postTodo");
+ window.alert = jest.fn();
+ console.error = jest.fn();
+ axios.post = jest.fn().mockResolvedValueOnce(new Error());
+ renderWithProviders();
+ const submitButton = screen.getByText("Submit");
+ fireEvent.click(submitButton);
+ await waitFor(() => expect(window.alert).toBeCalled());
+ });
+});
diff --git a/src/containers/TodoList/NewTodo/NewTodo.tsx b/src/containers/TodoList/NewTodo/NewTodo.tsx
index ece2d4b8..3ec2e6ea 100644
--- a/src/containers/TodoList/NewTodo/NewTodo.tsx
+++ b/src/containers/TodoList/NewTodo/NewTodo.tsx
@@ -12,14 +12,6 @@ export default function NewTodo() {
const [submitted, setSubmitted] = useState(false);
const dispatch = useDispatch();
- // const navigate = useNavigate()
- // const postTodoHandler = () => {
- // const data = { title: title, content: content };
- // alert("Submitted\n" + data.title + "\n" + data.content);
- // setSubmitted(true);
- // navigate('/todos')
- // };
-
const postTodoHandler = async () => {
const data = { title: title, content: content };
const result = await dispatch(postTodo(data));
@@ -38,11 +30,19 @@ export default function NewTodo() {
Add a Todo
diff --git a/src/containers/TodoList/TodoList.test.tsx b/src/containers/TodoList/TodoList.test.tsx
new file mode 100644
index 00000000..4487a0c8
--- /dev/null
+++ b/src/containers/TodoList/TodoList.test.tsx
@@ -0,0 +1,123 @@
+// containers/TodoList/TodoList.test.tsx
+import { IProps as TodoProps } from "../../components/Todo/Todo";
+import { fireEvent, render, screen } from "@testing-library/react";
+import { Provider } from "react-redux";
+import { MemoryRouter, Route, Routes } from "react-router";
+import { getMockStore } from "../../test-utils/mocks";
+import TodoList from "./TodoList";
+import { TodoState } from "../../store/slices/todo";
+
+jest.mock("../../components/Todo/Todo", () => (props: TodoProps) => (
+ //this data-testid is fake id
+
+
+ {props.title}
+
+
+
+
+));
+
+const stubInitialState: TodoState = {
+ todos: [
+ {
+ id: 1,
+ title: "TODO_TEST_TITLE_1",
+ content: "TODO_TEST_CONTENT_1",
+ done: false,
+ },
+ {
+ id: 2,
+ title: "TODO_TEST_TITLE_2",
+ content: "TODO_TEST_CONTENT_2",
+ done: false,
+ },
+ {
+ id: 3,
+ title: "TODO_TEST_TITLE_3",
+ content: "TODO_TEST_CONTENT_3",
+ done: false,
+ },
+ ],
+ selectedTodo: null,
+};
+const mockStore = getMockStore({ todo: stubInitialState });
+
+const mockNavigate = jest.fn();
+
+//외부 dependency useNavigate
+jest.mock("react-router", () => ({
+ //그래야 NavLink 같은 걸 쓸 수 있다.
+ ...jest.requireActual("react-router"),
+ useNavigate: () => mockNavigate,
+}));
+const mockDispatch = jest.fn();
+
+//useDispatch mockign
+jest.mock("react-redux", () => ({
+ ...jest.requireActual("react-redux"),
+ //useDispatch만 우리가 mocking
+ useDispatch: () => mockDispatch,
+}));
+
+describe("", () => {
+ let todoList: JSX.Element;
+ beforeEach(() => {
+ //mock들을 다 지우기
+ jest.clearAllMocks();
+ todoList = (
+
+
+
+ }
+ />
+
+
+
+ );
+ });
+ it("should render TodoList", () => {
+ const { container } = render(todoList);
+ expect(container).toBeTruthy();
+ });
+ it("should render todos", () => {
+ render(todoList);
+ const todos = screen.getAllByTestId("spyTodo");
+ //initailly 3개 넣기로 했기 때문에 3개
+ expect(todos).toHaveLength(3);
+ });
+ it("should handle clickDetail", () => {
+ render(todoList);
+ const todos = screen.getAllByTestId("spyTodo");
+ const todo = todos[0];
+ // eslint-disable-next-line testing-library/no-node-access
+ const title = todo.querySelector(".title");
+ //querySelector는 class name을 이용해서 받음
+ fireEvent.click(title!);
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ });
+ it("should handle clickDone", () => {
+ render(todoList);
+ const todos = screen.getAllByTestId("spyTodo");
+ const todo = todos[0];
+ // eslint-disable-next-line testing-library/no-node-access
+ const doneButton = todo.querySelector(".doneButton");
+ fireEvent.click(doneButton!);
+ expect(mockDispatch).toHaveBeenCalled();
+ });
+ it("should handle clickDelete", () => {
+ render(todoList);
+ const todos = screen.getAllByTestId("spyTodo");
+ const todo = todos[0];
+ // eslint-disable-next-line testing-library/no-node-access
+ const deleteButton = todo.querySelector(".deleteButton");
+ fireEvent.click(deleteButton!);
+ expect(mockDispatch).toHaveBeenCalled();
+ });
+});
diff --git a/src/store/slices/todo.test.ts b/src/store/slices/todo.test.ts
new file mode 100644
index 00000000..f9800575
--- /dev/null
+++ b/src/store/slices/todo.test.ts
@@ -0,0 +1,102 @@
+import { AnyAction, configureStore, EnhancedStore } from "@reduxjs/toolkit";
+import axios from "axios";
+import { ThunkMiddleware } from "redux-thunk";
+import reducer, { TodoState } from "./todo";
+import {
+ fetchTodos,
+ fetchTodo,
+ postTodo,
+ deleteTodo,
+ toggleDone,
+} from "./todo";
+
+describe("todo reducer", () => {
+ let store: EnhancedStore<
+ { todo: TodoState },
+ AnyAction,
+ [ThunkMiddleware<{ todo: TodoState }, AnyAction, undefined>]
+ >;
+ const fakeTodo = {
+ id: 1,
+ title: "test",
+ content: "test",
+ done: false,
+ };
+ beforeAll(() => {
+ store = configureStore({ reducer: { todo: reducer } });
+ }); // end beforeAll
+
+ it("should handle initial state", () => {
+ //reducer(state, action)
+ //store는 undefined
+ //초기 상태 잘 만드는지 확인
+ expect(reducer(undefined, { type: "unknown" })).toEqual({
+ //to Equal is sort of like deep Equal
+ todos: [],
+ selectedTodo: null,
+ });
+ });
+ it("should handle fetchTodos", async () => {
+ //axios는 외부 dependency이기에 mock을 한다
+ //axios.get을 mock 함수로 대체
+ axios.get = jest.fn().mockResolvedValue({ data: [fakeTodo] });
+ await store.dispatch(fetchTodos());
+ expect(store.getState().todo.todos).toEqual([fakeTodo]);
+ });
+
+ it("should handle fetchTodo", async () => {
+ axios.get = jest.fn().mockResolvedValue({ data: fakeTodo });
+ await store.dispatch(fetchTodo(1));
+ expect(store.getState().todo.selectedTodo).toEqual(fakeTodo);
+ });
+
+ it("should handle deleteTodo", async () => {
+ axios.delete = jest.fn().mockResolvedValue({ data: null });
+ await store.dispatch(deleteTodo(1));
+ expect(store.getState().todo.todos).toEqual([]);
+ });
+
+ it("should handle postTodo", async () => {
+ //spyOn은 mock과 똑같이 mock 함수를 만든다
+ jest.spyOn(axios, "post").mockResolvedValue({
+ data: fakeTodo,
+ });
+ await store.dispatch(postTodo({ title: "test", content: "test" }));
+ expect(store.getState().todo.todos).toEqual([fakeTodo]);
+ });
+
+ it("should handle toggleDone", async () => {
+ jest.spyOn(axios, "put").mockResolvedValue({
+ data: fakeTodo,
+ });
+ await store.dispatch(toggleDone(fakeTodo.id));
+ expect(
+ store.getState().todo.todos.find((v) => v.id === fakeTodo.id)?.done
+ ).toEqual(true);
+ });
+
+ it("should handle error on postTodo", async () => {
+ const mockConsoleError = jest.fn();
+ console.error = mockConsoleError;
+ jest.spyOn(axios, "post").mockRejectedValue({
+ response: { data: { title: ["error"] } },
+ });
+ await store.dispatch(postTodo({ title: "test", content: "test" }));
+ expect(mockConsoleError).toBeCalled();
+ });
+
+ it("should handle null on fetchTodo", async () => {
+ axios.get = jest.fn().mockResolvedValue({ data: null });
+ await store.dispatch(fetchTodo(1));
+ expect(store.getState().todo.selectedTodo).toEqual(null);
+ });
+
+ it("should handle not existing todo toggle", async () => {
+ const beforeState = store.getState().todo;
+ jest.spyOn(axios, "put").mockResolvedValue({
+ data: { ...fakeTodo, id: 10 },
+ });
+ await store.dispatch(toggleDone(2));
+ expect(store.getState().todo).toEqual(beforeState);
+ });
+}); // end describe
diff --git a/src/store/slices/todo.ts b/src/store/slices/todo.ts
index 5f1a2dce..a9e8104c 100644
--- a/src/store/slices/todo.ts
+++ b/src/store/slices/todo.ts
@@ -60,13 +60,10 @@ export const todoSlice = createSlice({
name: "todo",
initialState,
reducers: {
- getAll: (state, action: PayloadAction<{ todos: TodoType[] }>) => {},
- getTodo: (state, action: PayloadAction<{ targetId: number }>) => {
- const target = state.todos.find((td) => td.id === action.payload.targetId);
- state.selectedTodo = target ?? null;
- },
toggleDone: (state, action: PayloadAction<{ targetId: number }>) => {
- const todo = state.todos.find((value) => value.id === action.payload.targetId);
+ const todo = state.todos.find(
+ (value) => value.id === action.payload.targetId
+ );
if (todo) {
todo.done = !todo.done;
}
@@ -77,7 +74,10 @@ export const todoSlice = createSlice({
});
state.todos = deleted;
},
- addTodo: (state, action: PayloadAction<{ id: number; title: string; content: string }>) => {
+ addTodo: (
+ state,
+ action: PayloadAction<{ id: number; title: string; content: string }>
+ ) => {
const newTodo = {
id: action.payload.id,
title: action.payload.title,
diff --git a/src/test-utils/mocks.tsx b/src/test-utils/mocks.tsx
new file mode 100644
index 00000000..41b8d61f
--- /dev/null
+++ b/src/test-utils/mocks.tsx
@@ -0,0 +1,34 @@
+import { configureStore, PreloadedState } from "@reduxjs/toolkit";
+import { render, RenderOptions } from "@testing-library/react";
+import { PropsWithChildren } from "react";
+import { Provider } from "react-redux";
+import { AppStore, RootState } from "../store";
+import todoReducer from "../store/slices/todo";
+
+interface ExtendedRenderOptions extends Omit {
+ preloadedState?: PreloadedState;
+ store?: AppStore;
+}
+
+export const getMockStore = (preloadedState?: PreloadedState) => {
+ return configureStore({
+ reducer: { todo: todoReducer },
+ preloadedState,
+ });
+};
+
+export function renderWithProviders(
+ ui: React.ReactElement,
+ {
+ preloadedState,
+ // Automatically create a store instance if no store was passed in
+ store = getMockStore(preloadedState),
+ ...renderOptions
+ }: ExtendedRenderOptions = {}
+) {
+ function Wrapper({ children }: PropsWithChildren): JSX.Element {
+ return {children};
+ }
+ // Return an object with the store and all of RTL's query functions
+ return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) };
+}