From 20eb1207940650bf46f89e40fe9dd1966caeb42f Mon Sep 17 00:00:00 2001 From: Manol Donev Date: Thu, 17 Mar 2022 19:57:46 +0200 Subject: [PATCH] chore: add jsdom tests --- .eslintrc | 4 +- .gitignore | 3 + package.json | 2 + src/App/App.test.tsx | 333 ++++++++++++++++-- src/components/SearchBox/SearchBox.test.tsx | 82 +++++ src/components/SearchBox/SearchBox.tsx | 5 +- src/index.tsx | 4 +- src/mocks/index.ts | 42 +++ src/mocks/{ => msw}/browser.ts | 0 src/mocks/{ => msw}/handlers.ts | 7 +- src/mocks/{ => msw}/server.ts | 0 src/mocks/{ => msw}/todos.ts | 0 src/mocks/utils.ts | 16 + src/routes/analytics/Analytics.tsx | 2 +- src/routes/layout/Header/Header.tsx | 2 +- .../layout/Navigation/BottomNavigation.tsx | 7 +- src/routes/layout/Navigation/Navigation.tsx | 2 +- src/routes/settings/Settings.tsx | 3 +- src/routes/taskNew/AddNewForm/AddNewForm.tsx | 4 +- src/routes/taskNew/NewTaskModal.tsx | 1 + src/routes/tasks/CtaButton/CtaButton.tsx | 1 + src/routes/tasks/Todos/Todos.tsx | 6 +- src/routes/tasks/Todos/hooks/useTodos.ts | 9 +- src/setupTests.ts | 17 +- tsconfig.json | 2 +- 25 files changed, 498 insertions(+), 56 deletions(-) create mode 100644 src/components/SearchBox/SearchBox.test.tsx create mode 100644 src/mocks/index.ts rename src/mocks/{ => msw}/browser.ts (100%) rename src/mocks/{ => msw}/handlers.ts (92%) rename src/mocks/{ => msw}/server.ts (100%) rename src/mocks/{ => msw}/todos.ts (100%) create mode 100644 src/mocks/utils.ts diff --git a/.eslintrc b/.eslintrc index eb0fb45..5d7f3fe 100644 --- a/.eslintrc +++ b/.eslintrc @@ -86,6 +86,7 @@ "testing-library/no-wait-for-side-effects": "error", "testing-library/prefer-query-by-disappearance": "error", "testing-library/prefer-user-event": "error", + "testing-library/no-debugging-utils": "warn", "react/require-default-props": [ "error", { @@ -123,7 +124,8 @@ { "checksVoidReturn": false } - ] + ], + "jest/no-focused-tests": "warn" }, "settings": { "react": { diff --git a/.gitignore b/.gitignore index 8628ad7..b7b618c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ npm-debug.log* yarn-debug.log* yarn-error.log* + +# temporary test file +/src/generated/output.css diff --git a/package.json b/package.json index 940e668..3c89275 100644 --- a/package.json +++ b/package.json @@ -67,8 +67,10 @@ "build": "react-scripts build", "predeploy": "npm run build", "deploy": "gh-pages -d build", + "pretest": "npx tailwindcss -i ./src/index.css -o ./src/generated/output.css", "test": "react-scripts test", "test:related": "npm run test -- --bail --watchAll=false --findRelatedTests src/**/*.{ts,tsx} --passWithNoTests", + "posttest": "rm ./src/generated/output.css", "eject": "react-scripts eject", "lint": "eslint src/**/*.{ts,tsx} --quiet", "lint:fix": "eslint --fix --ext src/**/*.{ts,tsx}", diff --git a/src/App/App.test.tsx b/src/App/App.test.tsx index 60573f5..00e3659 100644 --- a/src/App/App.test.tsx +++ b/src/App/App.test.tsx @@ -1,39 +1,322 @@ -import { render, screen, within } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved, within } from '@testing-library/react'; import { QueryClientProvider } from 'react-query'; import { BrowserRouter } from 'react-router-dom'; +import userEvent from '@testing-library/user-event'; import { App } from './App'; import { queryClient } from '../queryClient'; +import { matchMedia } from '../setupTests'; -beforeEach(() => { - const mockIntersectionObserver = jest.fn(); - mockIntersectionObserver.mockReturnValue({ - observe: () => null, - unobserve: () => null, - disconnect: () => null, - }); +const TestApp = (): JSX.Element => { + // mock touch screen (as we can only test mobile behavior) + matchMedia.useMediaQuery('(pointer: coarse)'); - window.IntersectionObserver = mockIntersectionObserver; -}); + return ( + + + + + + ); +}; + +const simulateTapEvent = (element: Element): void => { + fireEvent.touchStart(element, { touches: [{ clientX: 0, clientY: 0 }] }); + fireEvent.touchEnd(element, { touches: [{ clientX: 0, clientY: 0 }] }); +}; describe('Todo App', () => { - test('renders todo app link', async () => { - render( - - - - - , - ); + beforeEach(() => { + matchMedia.clear(); + }); + + /* NOTE: it is not possible to properly test tailwind responsive ui behavior + with Jest(jsdom). Generally jsdom neither loads the application css files, + nor does it support media queries. We can address the former by manually + assembling and injecting the tailwind css styles (see setupTests.ts), + however, the latter is a bigger and [currently] unsolvable problem. Mocking + media query support (window.matchMedia) is possible either manually or via + packages like jest-matchmedia-mock but this only patches scenarios where the + component under test actually calls window.matchMedia(...) programmatically. + In our [tailwind] scenario we are dynamically injecting the css (including the + @media statements for sm/md/lg/etc. screen modifiers) but jsdom does not + trigger media query computation hence the screen modifiers remain inactive. As + tailwind is a mobile-first library this effectively means we are stuck with the + mobile view for testing). */ + describe('on mobile (touch-enabled) screen', () => { + test('renders without crashing', async () => { + render(); + + const linkElement = screen.getByText(/todo app/i); + expect(linkElement).toBeVisible(); + + const searchFormElement = screen.getByRole('search'); + expect(searchFormElement).toBeVisible(); + + const listElement = await screen.findByTestId('todo-list'); + expect(listElement).toBeVisible(); + + const listScope = within(listElement); + const itemElements = await listScope.findAllByRole('listitem'); + expect(itemElements.length).toEqual(10); + + const fabElement = screen.getByTestId('cta-button'); + expect(fabElement).toBeVisible(); + expect(fabElement).toHaveClass('fixed'); + + const topNavElement = screen.getByTestId('top-navigation'); + expect(topNavElement).not.toBeVisible(); + + const bottomNavElement = screen.getByTestId('bottom-navigation'); + expect(bottomNavElement).toBeVisible(); + }); + + test('renders no checkboxes for todo items', async () => { + render(); + + const listElement = await screen.findByTestId('todo-list'); + const listScope = within(listElement); + const itemElements = await listScope.findAllByRole('listitem'); + expect(itemElements.length).toEqual(10); + + const checkboxElement = listScope.queryByRole('checkbox'); + expect(checkboxElement).not.toBeInTheDocument(); + }); + + test('renders correct bottom navigation items', async () => { + render(); + + const listElement = await screen.findByTestId('todo-list'); + expect(listElement).toBeVisible(); + + const bottomNavElement = screen.getByTestId('bottom-navigation'); + const navScope = within(bottomNavElement); + const linkElements = navScope.getAllByRole('link'); + expect(linkElements.length).toEqual(3); + + const tasksElement = linkElements[0]; + expect(tasksElement).toBeVisible(); + expect(tasksElement).toHaveTextContent(/tasks/); + expect(tasksElement).toHaveClass('bg-primary-variant'); + + const analyticsElement = linkElements[1]; + expect(analyticsElement).toBeVisible(); + expect(analyticsElement).toHaveTextContent(/analytics/); + expect(analyticsElement).not.toHaveClass('bg-primary-variant'); + + const settingsElement = linkElements[2]; + expect(settingsElement).toBeVisible(); + expect(settingsElement).toHaveTextContent(/settings/); + expect(settingsElement).not.toHaveClass('bg-primary-variant'); + }); + + test('switches tabs through bottom navigation', async () => { + render(); + + let listElement = await screen.findByTestId('todo-list'); + expect(listElement).toBeVisible(); + + const bottomNavElement = screen.getByTestId('bottom-navigation'); + const navScope = within(bottomNavElement); + + const analyticsLink = navScope.getByText(/analytics/i); + expect(analyticsLink).toBeVisible(); + + analyticsLink.click(); + const analyticsTabElement = await screen.findByTestId('analytics'); + expect(analyticsTabElement).toBeVisible(); + expect(listElement).not.toBeVisible(); + + const settingsLink = navScope.getByText(/settings/i); + expect(settingsLink).toBeVisible(); + + settingsLink.click(); + const settingsTabElement = await screen.findByTestId('settings'); + expect(settingsTabElement).toBeVisible(); + expect(analyticsTabElement).not.toBeVisible(); + expect(listElement).not.toBeVisible(); + + const tasksLink = navScope.getByText(/tasks/i); + expect(tasksLink).toBeVisible(); + + tasksLink.click(); + listElement = await screen.findByTestId('todo-list'); + expect(listElement).toBeVisible(); + expect(analyticsTabElement).not.toBeVisible(); + expect(settingsTabElement).not.toBeVisible(); + }); + + test('search for todo item', async () => { + render(); + + const listElement = await screen.findByTestId('todo-list'); + expect(listElement).toBeVisible(); + const listScope = within(listElement); + + let itemElements = await listScope.findAllByRole('listitem'); + expect(itemElements.length).toEqual(10); + + const searchElement = screen.getByRole('searchbox'); + expect(searchElement).toBeVisible(); + + userEvent.type(searchElement, 'vivacious'); + itemElements = await listScope.findAllByRole('listitem'); + expect(itemElements.length).toEqual(1); + + userEvent.clear(searchElement); + userEvent.type(searchElement, 'asdf'); + itemElements = listScope.queryAllByRole('listitem'); + expect(itemElements.length).toEqual(0); + + const resetElement = screen.getByText(/reset search/i); + expect(resetElement).toBeVisible(); + + resetElement.click(); + expect(resetElement).not.toBeInTheDocument(); + + expect(listElement).toBeVisible(); + itemElements = await listScope.findAllByRole('listitem'); + expect(itemElements.length).toEqual(10); + expect(searchElement).toHaveValue(''); + }); + + test('create todo item', async () => { + render(); + + const listElement = await screen.findByTestId('todo-list'); + expect(listElement).toBeVisible(); + const listScope = within(listElement); + + let itemElements = await listScope.findAllByRole('listitem'); + expect(itemElements.length).toEqual(10); + + const fabElement = screen.getByTestId('cta-button'); + expect(fabElement).toBeVisible(); + + userEvent.click(fabElement); + + const modalTestId = 'add-new-modal'; + const modalElement = screen.getByTestId(modalTestId); + expect(modalElement).toBeVisible(); + + const modalScope = within(modalElement); + const inputElements = modalScope.getAllByRole('textbox'); + expect(inputElements.length).toEqual(2); + + const noteElement = inputElements[1]; + const testValue = 'Add the qwerty!'; + + userEvent.type(noteElement, testValue); + + const saveButton = modalScope.getByText(/save/i); + expect(saveButton).toBeVisible(); + + userEvent.click(saveButton); + await waitForElementToBeRemoved(() => screen.queryByTestId(modalTestId)); + + await waitFor(() => expect(listScope.queryByText(testValue)).toBeVisible()); + itemElements = listScope.getAllByRole('listitem'); + expect(itemElements[0]).toHaveTextContent(testValue); + }); + + test('create todo item validation', async () => { + render(); + + const listElement = await screen.findByTestId('todo-list'); + expect(listElement).toBeVisible(); + const listScope = within(listElement); + + let itemElements = await listScope.findAllByRole('listitem'); + expect(itemElements.length).toEqual(10); + + const fabElement = screen.getByTestId('cta-button'); + expect(fabElement).toBeVisible(); + + userEvent.click(fabElement); + + const modalTestId = 'add-new-modal'; + const modalElement = screen.getByTestId(modalTestId); + expect(modalElement).toBeVisible(); + + const modalScope = within(modalElement); + const saveButton = modalScope.getByText(/save/i); + expect(saveButton).toBeVisible(); + + userEvent.click(saveButton); + + const errorElements = await modalScope.findAllByText(/at least one of the fields is required/i); + + expect(errorElements.length).toEqual(2); + errorElements.forEach((errorElement) => expect(errorElement).toBeVisible()); + + const inputElements = modalScope.getAllByRole('textbox'); + expect(inputElements.length).toEqual(2); + + const titleElement = inputElements[0]; + const testValue = 'Add the deadbeef!'; + + userEvent.type(titleElement, testValue); + + userEvent.click(saveButton); + + await waitForElementToBeRemoved(() => screen.queryByTestId(modalTestId)); + + await waitFor(() => expect(listScope.queryByText(testValue)).toBeVisible()); + itemElements = listScope.getAllByRole('listitem'); + expect(itemElements[0]).toHaveTextContent(testValue); + }); + + test('cancel create todo item should have no side effects', async () => { + render(); + + const listElement = await screen.findByTestId('todo-list'); + expect(listElement).toBeVisible(); + const listScope = within(listElement); + + let itemElements = await listScope.findAllByRole('listitem'); + expect(itemElements.length).toEqual(10); + const firstElement = itemElements[0]; + + const fabElement = screen.getByTestId('cta-button'); + expect(fabElement).toBeVisible(); + + userEvent.click(fabElement); + + const modalTestId = 'add-new-modal'; + const modalElement = screen.getByTestId(modalTestId); + expect(modalElement).toBeVisible(); + + const modalScope = within(modalElement); + const closeButton = modalScope.getByText(/close/i); + expect(closeButton).toBeVisible(); + + userEvent.click(closeButton); + + expect(screen.queryByTestId(modalTestId)).not.toBeInTheDocument(); + + itemElements = listScope.getAllByRole('listitem'); + expect(itemElements[0]).toEqual(firstElement); + }); + + test('update todo item', async () => { + render(); + + const listElement = await screen.findByTestId('todo-list'); + expect(listElement).toBeVisible(); + const listScope = within(listElement); + + const itemElements = await listScope.findAllByRole('listitem'); + expect(itemElements.length).toEqual(10); - const linkElement = screen.getByText(/todo app/i); - expect(linkElement).toBeInTheDocument(); + const labelMatcher = /write the damn resolver!/i; + const labelElement = screen.getByText(labelMatcher); + expect(labelElement).not.toHaveClass('line-through'); - const listElement = await screen.findByTestId('todo-list'); - expect(listElement).toBeInTheDocument(); + simulateTapEvent(labelElement); - const listScope = within(listElement); - const itemElements = await listScope.findAllByRole('listitem'); + await waitFor(() => expect(screen.getByText(labelMatcher)).toHaveClass('line-through')); + }); - expect(itemElements.length).toEqual(10); + // TODO: add keyboard support to test in jsdom + test.todo('delete todo item'); }); }); diff --git a/src/components/SearchBox/SearchBox.test.tsx b/src/components/SearchBox/SearchBox.test.tsx new file mode 100644 index 0000000..948057c --- /dev/null +++ b/src/components/SearchBox/SearchBox.test.tsx @@ -0,0 +1,82 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { ChangeEvent } from 'react'; +import { SearchBox } from './SearchBox'; + +describe('SearchBox', () => { + test('renders without crashing', async () => { + render(); + + const inputElement = screen.getByRole('searchbox'); + expect(inputElement).toBeVisible(); + }); + + test('is focusable', () => { + render(); + + const inputElement = screen.getByRole('searchbox'); + expect(inputElement).toBeVisible(); + expect(inputElement).not.toHaveFocus(); + + inputElement.focus(); + expect(inputElement).toHaveFocus(); + }); + + test('change value with event notification', () => { + const changeHandler = jest.fn(); + render(); + + const inputElement = screen.getByRole('searchbox'); + expect(inputElement).toHaveValue(''); + + const testValue = 'test value'; + const [firstLetter, ...rest] = testValue; + + changeHandler.mockImplementationOnce((e: ChangeEvent) => { + expect(e.currentTarget.value).toEqual(firstLetter); + }); + + userEvent.type(inputElement, firstLetter); + expect(inputElement).toHaveValue(firstLetter); + expect(changeHandler).toHaveBeenCalledTimes(1); + + userEvent.type(inputElement, rest.join('')); + expect(inputElement).toHaveValue('test value'); + expect(changeHandler).toHaveBeenCalledTimes('test value'.length); + }); + + test('delete value with event notification', () => { + const changeHandler = jest.fn(); + render(); + + const inputElement = screen.getByRole('searchbox'); + expect(inputElement).toHaveValue(''); + + const testValue = 'test value'; + + userEvent.type(inputElement, testValue); + expect(inputElement).toHaveValue(testValue); + + changeHandler.mockClear(); + changeHandler.mockImplementationOnce((e: ChangeEvent) => { + expect(e.currentTarget.value).toEqual(testValue.slice(0, -1)); + }); + + userEvent.type(inputElement, '{backspace}'); + expect(changeHandler).toHaveBeenCalledTimes(1); + expect(inputElement).toHaveValue(testValue.slice(0, -1)); + + changeHandler.mockClear(); + changeHandler.mockImplementationOnce((e: ChangeEvent) => { + expect(e.currentTarget.value).toEqual(''); + }); + + userEvent.clear(inputElement); + expect(changeHandler).toHaveBeenCalledTimes(1); + expect(inputElement).toHaveValue(''); + }); + + // NOTE: apparently asserting width changes based on + // :focus pseudo-class is not currently possible in jsdom + test.todo('expands / collapses based on focus state'); +}); diff --git a/src/components/SearchBox/SearchBox.tsx b/src/components/SearchBox/SearchBox.tsx index f61f6a0..33325e9 100644 --- a/src/components/SearchBox/SearchBox.tsx +++ b/src/components/SearchBox/SearchBox.tsx @@ -5,8 +5,8 @@ const SearchBox = ({ value, onChange, }: { - value: string | number | readonly string[] | undefined; - onChange: (e: ChangeEvent) => void; + value?: string | number | readonly string[] | undefined; + onChange?: (e: ChangeEvent) => void; }): JSX.Element => { return (
@@ -20,6 +20,7 @@ const SearchBox = ({ />
diff --git a/src/routes/layout/Navigation/BottomNavigation.tsx b/src/routes/layout/Navigation/BottomNavigation.tsx index f519b3b..2ea8f4a 100644 --- a/src/routes/layout/Navigation/BottomNavigation.tsx +++ b/src/routes/layout/Navigation/BottomNavigation.tsx @@ -17,13 +17,16 @@ const mapNavigationIcon = (item: string): JSX.Element => { const BottomNavigation = ({ className = '' }: { className?: string }): JSX.Element => { return ( -