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..893840e3 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,7 +1,13 @@ -import { render } from "@testing-library/react"; +import { render, screen } from "@testing-library/react"; import App from "./App"; +import { Provider } from 'react-redux'; +import { store } from './store'; -test("renders learn react link", () => { - render(); +test("renders App.tsx", () => { + render( + + ); + // screen.debug(); + // expect(true).toBe(true) }); diff --git a/src/components/Todo/Todo.test.tsx b/src/components/Todo/Todo.test.tsx new file mode 100644 index 00000000..b2a3df13 --- /dev/null +++ b/src/components/Todo/Todo.test.tsx @@ -0,0 +1,18 @@ +import Todo from './Todo'; +import { render, screen } from '@testing-library/react'; + +describe('', () => { + it('should render done mark when done is false', () => { + render() + const title = screen.getByText('TODO_TITLE') + expect(title.classList.contains('done')).toBe(false) + screen.getByText('Done') + }) + + it('should render undone mark when done is true', () => { + render() + const title = screen.getByText('TODO_TITLE') + expect(title.classList.contains('done')).toBe(true) + screen.getByText('Undone') + }) +}) \ No newline at end of file diff --git a/src/components/TodoDetail/TodoDetail.test.tsx b/src/components/TodoDetail/TodoDetail.test.tsx new file mode 100644 index 00000000..0754a587 --- /dev/null +++ b/src/components/TodoDetail/TodoDetail.test.tsx @@ -0,0 +1,52 @@ +import { Provider } from 'react-redux'; +import { TodoState } from '../../store/slices/todo'; +import { getMockStore } from '../../test-utils/mock'; +import { MemoryRouter } from 'react-router'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import TodoDetail from './TodoDetail'; +import { render, screen } from '@testing-library/react'; +import axios from 'axios'; + +const stubInitialState: TodoState = { + todos: [ + { id: 3, title: 'TODO_TEST_TITLE_3', content: 'TODO_TEST_CONTENT_3', done: false }, + ], + selectedTodo: null, +} + +const mockStore = getMockStore({ todo: stubInitialState }) + +describe('', () => { + let todoDetail: JSX.Element = ( + + + + } /> + } /> + + + + ) + + it('should render TodoDetail', async () => { + jest.spyOn(axios, 'get').mockImplementation(() => { + return Promise.resolve({ + data: { + id: 3, + title: 'TODO_TEST_TITLE_3', + content: 'TODO_TEST_CONTENT_3', + done: false, + }, + }) + }) + render(todoDetail) + await screen.findByText('TODO_TEST_TITLE_3') + await screen.findByText('TODO_TEST_CONTENT_3') + }) + + it('should not render if there is no todo', async () => { + render(todoDetail) + 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..69b825c8 --- /dev/null +++ b/src/containers/TodoList/NewTodo/NewTodo.test.tsx @@ -0,0 +1,96 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import NewTodo from './NewTodo'; +import axios from 'axios'; +import * as todoSlice from "../../../store/slices/todo"; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import TodoDetail from '../../../components/TodoDetail/TodoDetail'; +import { getMockStore } from '../../../test-utils/mock'; +import { TodoState } 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, +})) + +const stubInitialState: TodoState = { + todos: [], + selectedTodo: null, +} + +const mockStore = getMockStore({ todo: stubInitialState }) + +describe('', () => { + let todoDetail: JSX.Element = ( + + + + ) + + it('should render without errors', () => { + render(todoDetail) + screen.getByText('Add a Todo') + }) + it('should render title input', () => { + render(todoDetail) + screen.getByLabelText('Title') + }) + it('should render content input', () => { + render(todoDetail) + screen.getByLabelText('Content') + }) + it('should render submit button', () => { + render(todoDetail) + 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, + }, + }) + render(todoDetail) + 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')) + + render(todoDetail) + 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..74344361 --- /dev/null +++ b/src/containers/TodoList/TodoList.test.tsx @@ -0,0 +1,99 @@ +import { IProps as TodoProps } from "../../components/Todo/Todo" +import { TodoState } from '../../store/slices/todo'; +import { getMockStore } from '../../test-utils/mock'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router'; +import { Route, Routes } from 'react-router-dom'; +import TodoList from './TodoList'; +import { fireEvent, render, screen } from '@testing-library/react'; +import exp from 'constants'; + +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..d2bc5d2b --- /dev/null +++ b/src/store/slices/todo.test.ts @@ -0,0 +1,73 @@ +import { AnyAction, configureStore, EnhancedStore } from '@reduxjs/toolkit'; +import reducer, { deleteTodo, fetchTodo, fetchTodos, postTodo, TodoState, toggleDone } from './todo'; +import { ThunkMiddleware } from 'redux-thunk' +import exp from 'constants'; +import axios from 'axios'; + +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.ts b/src/test-utils/mock.ts new file mode 100644 index 00000000..ae612042 --- /dev/null +++ b/src/test-utils/mock.ts @@ -0,0 +1,11 @@ +import { PreloadedState } from 'redux'; +import { RootState } from '../store'; +import { configureStore } from '@reduxjs/toolkit'; +import todoReducer from "../store/slices/todo"; + +export const getMockStore = (preloadedState?: PreloadedState) => { + return configureStore({ + reducer: { todo: todoReducer }, + preloadedState, + }) +}