From c882b3caf736d99022cdf51430f1eba10df600ca Mon Sep 17 00:00:00 2001 From: Park Shin Hong Date: Thu, 6 Oct 2022 12:06:48 +0900 Subject: [PATCH] complete practice session --- package.json | 7 ++ src/App.test.tsx | 13 ++- src/components/Todo/Todo.test.tsx | 25 +++++ src/components/TodoDetail/TodoDetail.test.tsx | 50 ++++++++++ .../TodoList/NewTodo/NewTodo.test.tsx | 78 +++++++++++++++ src/containers/TodoList/TodoList.test.tsx | 94 +++++++++++++++++++ src/store/slices/todo.test.ts | 79 ++++++++++++++++ src/store/slices/todo.ts | 5 - src/test-utils/mock.tsx | 35 +++++++ 9 files changed, 379 insertions(+), 7 deletions(-) create mode 100644 src/components/Todo/Todo.test.tsx create mode 100644 src/components/TodoDetail/TodoDetail.test.tsx create mode 100644 src/containers/TodoList/NewTodo/NewTodo.test.tsx create mode 100644 src/containers/TodoList/TodoList.test.tsx create mode 100644 src/store/slices/todo.test.ts create mode 100644 src/test-utils/mock.tsx diff --git a/package.json b/package.json index e51db5bc..9185d341 100644 --- a/package.json +++ b/package.json @@ -46,5 +46,12 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "jest": { + "collectCoverageFrom": [ + "src/**/*.{js,jsx,ts,tsx}", + "!src/index.tsx", + "!src/test-utils/*" + ] } } \ No newline at end of file diff --git a/src/App.test.tsx b/src/App.test.tsx index 838df452..ba9343e3 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,7 +1,16 @@ +// import { render, screen } from "@testing-library/react"; import { render } from "@testing-library/react"; +import { Provider } from "react-redux"; import App from "./App"; +import { store } from "./store"; -test("renders learn react link", () => { - render(); +test("renders App.tsx", () => { + render( + + + + ); + // screen.debug(); + //expect(true).toBe(false); // This make failing test case }); diff --git a/src/components/Todo/Todo.test.tsx b/src/components/Todo/Todo.test.tsx new file mode 100644 index 00000000..b01bdcb7 --- /dev/null +++ b/src/components/Todo/Todo.test.tsx @@ -0,0 +1,25 @@ +import { render, screen } from "@testing-library/react"; +import Todo from "./Todo"; + +describe("", () => { + it("should render without errors", () => { + render(); + 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..7030d978 --- /dev/null +++ b/src/components/TodoDetail/TodoDetail.test.tsx @@ -0,0 +1,50 @@ +import { screen } from "@testing-library/react"; +import axios from "axios"; +import { MemoryRouter, Navigate, Route, Routes } from "react-router"; +import { renderWithProviders } from "../../test-utils/mock"; +import TodoDetail from "./TodoDetail"; + +const renderTodoDetail = () => { + renderWithProviders( + + + } /> + } /> + + , + { + preloadedState: { + todo: { + todos: [ + { id: 3, title: "TODO_TEST_TITLE_3", content: "TODO_TEST_CONTENT_3", done: false }, + ], + selectedTodo: null, + }, + }, + } + ); +}; + +describe("", () => { + it("should render without errors", async () => { + jest.spyOn(axios, "get").mockImplementation(() => { + return Promise.resolve({ + data: { + id: 3, + title: "TODO_TEST_TITLE_3", + content: "TODO_TEST_CONTENT_3", + done: false, + }, + }); + }); + renderTodoDetail(); + await screen.findByText("TODO_TEST_TITLE_3"); + await screen.findByText("TODO_TEST_CONTENT_3"); + }); + + it("should not render if there is no todo", async () => { + renderTodoDetail(); + jest.spyOn(axios, "get").mockImplementationOnce(() => Promise.reject()); + expect(screen.queryAllByText("TODO_TEST_TITLE_3")).toHaveLength(0); + }); +}); diff --git a/src/containers/TodoList/NewTodo/NewTodo.test.tsx b/src/containers/TodoList/NewTodo/NewTodo.test.tsx new file mode 100644 index 00000000..a6837d45 --- /dev/null +++ b/src/containers/TodoList/NewTodo/NewTodo.test.tsx @@ -0,0 +1,78 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import axios from "axios"; + +import NewTodo from "./NewTodo"; +import { renderWithProviders } from "../../../test-utils/mock"; +import * as todoSlice from "../../../store/slices/todo"; + +const mockNavigate = jest.fn(); +jest.mock("react-router", () => ({ + ...jest.requireActual("react-router"), + Navigate: (props: any) => { + mockNavigate(props.to); + return null; + }, + useNavigate: () => mockNavigate, +})); + +describe("", () => { + it("should render without errors", () => { + renderWithProviders(); + screen.getByText("Add a Todo"); + }); + it("should render title input", () => { + renderWithProviders(); + screen.getByLabelText("Title"); + }); + it("should render content input", () => { + renderWithProviders(); + screen.getByLabelText("Content"); + }); + it("should render submit button", () => { + renderWithProviders(); + screen.getByText("Submit"); + }); + it("should render navigate to /todos when submitted", async () => { + jest.spyOn(axios, "post").mockResolvedValueOnce({ + data: { + id: 1, + title: "TITLE", + content: "CONTENT", + done: false, + }, + }); + renderWithProviders(); + const titleInput = screen.getByLabelText("Title"); + const contentInput = screen.getByLabelText("Content"); + const submitButton = screen.getByText("Submit"); + fireEvent.change(titleInput, { target: { value: "TITLE" } }); + fireEvent.change(contentInput, { target: { value: "CONTENT" } }); + await screen.findByDisplayValue("TITLE"); + await screen.findByDisplayValue("CONTENT"); + fireEvent.click(submitButton); + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith("/todos")); + }); + it("should alert error when submitted", async () => { + const mockPostTodo = jest.spyOn(todoSlice, "postTodo"); + window.alert = jest.fn(); + console.error = jest.fn(); + jest.spyOn(axios, "post").mockRejectedValueOnce(new Error("ERROR")); + renderWithProviders(); + const titleInput = screen.getByLabelText("Title"); + const contentInput = screen.getByLabelText("Content"); + const submitButton = screen.getByText("Submit"); + fireEvent.change(titleInput, { target: { value: "TITLE" } }); + fireEvent.change(contentInput, { target: { value: "CONTENT" } }); + + await screen.findByDisplayValue("TITLE"); + await screen.findByDisplayValue("CONTENT"); + fireEvent.click(submitButton); + + expect(mockPostTodo).toHaveBeenCalledWith({ + title: "TITLE", + content: "CONTENT", + }); + await waitFor(() => expect(mockNavigate).not.toHaveBeenCalled()); + await waitFor(() => expect(window.alert).toHaveBeenCalledWith("Error on post Todo")); + }); +}); diff --git a/src/containers/TodoList/TodoList.test.tsx b/src/containers/TodoList/TodoList.test.tsx new file mode 100644 index 00000000..b8b2d4a9 --- /dev/null +++ b/src/containers/TodoList/TodoList.test.tsx @@ -0,0 +1,94 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { MemoryRouter, Route, Routes } from "react-router"; +import { TodoState } from "../../store/slices/todo"; +import { getMockStore } from "../../test-utils/mock"; +import TodoList from "./TodoList"; +import { IProps as TodoProps } from "../../components/Todo/Todo"; + +jest.mock("../../components/Todo/Todo", () => (props: TodoProps) => ( +
+
+ {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(); +jest.mock("react-router", () => ({ + ...jest.requireActual("react-router"), + useNavigate: () => mockNavigate, +})); +const mockDispatch = jest.fn(); +jest.mock("react-redux", () => ({ + ...jest.requireActual("react-redux"), + useDispatch: () => mockDispatch, +})); + +describe("", () => { + let todoList: JSX.Element; + beforeEach(() => { + 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"); + 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"); + 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..3d8a92d8 --- /dev/null +++ b/src/store/slices/todo.test.ts @@ -0,0 +1,79 @@ +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 } }); + }); + it("should handle initial state", () => { + expect(reducer(undefined, { type: "unknown" })).toEqual({ + todos: [], + selectedTodo: null, + }); + }); + it("should handle fetchTodos", async () => { + 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 () => { + 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); + }); +}); diff --git a/src/store/slices/todo.ts b/src/store/slices/todo.ts index 5f1a2dce..8b49fa71 100644 --- a/src/store/slices/todo.ts +++ b/src/store/slices/todo.ts @@ -60,11 +60,6 @@ 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); if (todo) { diff --git a/src/test-utils/mock.tsx b/src/test-utils/mock.tsx new file mode 100644 index 00000000..943f1034 --- /dev/null +++ b/src/test-utils/mock.tsx @@ -0,0 +1,35 @@ +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 }) }; +}