Skip to content

Commit 5faa8ab

Browse files
committed
feat(react): added integration tests
1 parent 15e4bec commit 5faa8ab

14 files changed

+658
-12
lines changed

package-lock.json

Lines changed: 437 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,18 +28,21 @@
2828
},
2929
"devDependencies": {
3030
"@tanstack/eslint-plugin-query": "^5.50.1",
31+
"@testing-library/jest-dom": "^6.4.6",
3132
"@testing-library/user-event": "^14.5.2",
3233
"@typescript-eslint/eslint-plugin": "^7.16.0",
3334
"@typescript-eslint/parser": "^7.16.0",
3435
"@vitest/coverage-v8": "^2.0.2",
3536
"@vitest/ui": "^2.0.2",
3637
"autoprefixer": "^10.4.19",
3738
"concurrently": "^8.2.2",
39+
"cross-fetch": "^4.0.0",
3840
"eslint": "^8.57.0",
3941
"eslint-config-prettier": "^9.1.0",
4042
"eslint-plugin-prettier": "^5.1.3",
4143
"husky": "^9.0.11",
4244
"jsdom": "^24.1.0",
45+
"msw": "^2.3.1",
4346
"postcss": "^8.4.39",
4447
"prettier": "3.3.2",
4548
"tailwindcss": "^3.4.4",

packages/react/src/App.test.tsx

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { describe, expect, beforeAll, afterAll, test } from 'vitest';
2+
import { render, screen, within } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { todoServer } from 'shared-code/tests/server';
5+
import { GET_TODOS } from 'shared-code/tests/todos.fixtures';
6+
import { App } from './App';
7+
import { Providers } from './Providers';
8+
9+
describe(App.name, () => {
10+
beforeAll(() => {
11+
todoServer.listen();
12+
});
13+
14+
afterAll(() => {
15+
todoServer.close();
16+
});
17+
18+
test('all fetched todos are visible for the user', async () => {
19+
render(
20+
<Providers>
21+
<App />
22+
</Providers>
23+
);
24+
25+
// Iterate over all todos, that the API returns
26+
for (const todo of GET_TODOS) {
27+
// find each item by it's description
28+
const item = await screen.findByText(todo.description);
29+
30+
// check that item is in the document and it is visible for the user
31+
expect(item).toBeVisible();
32+
}
33+
});
34+
35+
test('todo can be created', async () => {
36+
render(
37+
<Providers>
38+
<App />
39+
</Providers>
40+
);
41+
const newTodoDescription = 'I need to walk out my 🐕!';
42+
43+
await userEvent.type(
44+
screen.getByLabelText('Enter your TODO description'),
45+
newTodoDescription
46+
);
47+
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
48+
49+
// Find the rendered item by specified test-id in the component
50+
expect(await screen.findByText(newTodoDescription)).toBeInTheDocument();
51+
});
52+
53+
test("todo can be completed by clicking on it's description", async () => {
54+
render(
55+
<Providers>
56+
<App />
57+
</Providers>
58+
);
59+
60+
// Find todo item that is not yet completed
61+
const uncompletedTodo = GET_TODOS.find((todo) => !todo.completed)!;
62+
63+
// Find the rendered item by specified test-id in the component
64+
const component = await screen.findByTestId(`todo-${uncompletedTodo.id}`);
65+
66+
// Check that item is in fact uncompleted
67+
expect(await within(component).findByTitle('Press to complete')).toBeInTheDocument();
68+
69+
// Click on the description, that should trigger completion request
70+
await userEvent.click(await within(component).findByText(uncompletedTodo.description));
71+
72+
// Check that item is now completed
73+
expect(await within(component).findByTitle('Press to uncomplete')).toBeInTheDocument();
74+
});
75+
76+
test('todo can be deleted', async () => {
77+
render(
78+
<Providers>
79+
<App />
80+
</Providers>
81+
);
82+
83+
// User chooses eg. first todo item that is going to be deleted
84+
const firstTodo = GET_TODOS[0];
85+
86+
// Find the rendered item by specified test-id in the component
87+
const component = await screen.findByTestId(`todo-${firstTodo.id}`);
88+
89+
// Click on the delete button, that should trigger deletion request
90+
await userEvent.click(await within(component).findByText('Remove todo'));
91+
92+
// Check that item is no longer in the DOM (can not be found)
93+
await expect(screen.findByText(firstTodo.description)).rejects.toThrow();
94+
});
95+
});

packages/react/src/Providers.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1+
import React from 'react';
12
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
23
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
3-
import { App } from './App';
44

55
const queryClient = new QueryClient();
66

7-
export const Providers = () => (
7+
export const Providers = ({ children }: React.PropsWithChildren) => (
88
<QueryClientProvider client={queryClient}>
9-
<App />
9+
{children}
1010
<ReactQueryDevtools initialIsOpen={false} />
1111
</QueryClientProvider>
1212
);

packages/react/src/components/TodoItem.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ type Props = {
1010
};
1111

1212
export const TodoItem = ({ todo, onTodoCompleted, onTodoDelete }: Props) => (
13-
<div className="flex gap-2 items-center justify-between border border-gray-400 dark:border-gray-500 rounded-lg px-2.5 py-2">
13+
<div
14+
data-testid={`todo-${todo.id}`}
15+
className="flex gap-2 items-center justify-between border border-gray-400 dark:border-gray-500 rounded-lg px-2.5 py-2"
16+
>
1417
<button
1518
className="flex gap-2 items-center cursor-pointer group px-2 py-2"
1619
onClick={() => onTodoCompleted(todo)}

packages/react/src/main.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ import React from 'react';
22
import ReactDOM from 'react-dom/client';
33
import { Providers } from './Providers';
44
import './index.css';
5+
import { App } from './App';
56

67
ReactDOM.createRoot(document.getElementById('root')!).render(
78
<React.StrictMode>
8-
<Providers />
9+
<Providers>
10+
<App />
11+
</Providers>
912
</React.StrictMode>
1013
);

packages/react/src/queries/useCompleteTodoMutation.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ export const useCompleteTodoMutation = () => {
1616
},
1717
{ completed: !todo.completed }
1818
),
19-
onSuccess: () => queryClient.refetchQueries({ queryKey: [QUERY_KEYS.GET_TODOS] })
19+
onSuccess: (updatedTodo) => {
20+
// Update query cache with the updated item returned from BE
21+
return queryClient.setQueryData<Todo[]>([QUERY_KEYS.GET_TODOS], (cachedTodos) =>
22+
cachedTodos?.map((cachedTodo) =>
23+
cachedTodo.id === updatedTodo.id ? updatedTodo : cachedTodo
24+
)
25+
);
26+
}
2027
});
2128
};

packages/react/src/queries/useCreateTodoMutation.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useMutation, useQueryClient } from '@tanstack/react-query';
22
import { customFetch } from 'shared-code/utils/fetch';
3-
import type { TodoDescription } from 'shared-code/models/Todo';
3+
import type { Todo, TodoDescription } from 'shared-code/models/Todo';
44
import type { CreateTodoResponse } from 'shared-code/models/Api';
55
import { QUERY_KEYS } from './queryKeys';
66

@@ -16,6 +16,11 @@ export const useCreateTodoMutation = () => {
1616
},
1717
{ description: todoDescription }
1818
),
19-
onSuccess: () => queryClient.refetchQueries({ queryKey: [QUERY_KEYS.GET_TODOS] })
19+
onSuccess: (createdTodo) => {
20+
// Add created item to query cache if creation was successful
21+
return queryClient.setQueryData<Todo[]>([QUERY_KEYS.GET_TODOS], (cachedTodos) =>
22+
(cachedTodos ?? []).concat(createdTodo)
23+
);
24+
}
2025
});
2126
};

packages/react/src/queries/useDeleteTodoMutation.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ export const useDeleteTodoMutation = () => {
1313
endpoint: `todos/${todo.id}`,
1414
method: 'DELETE'
1515
}),
16-
onSuccess: () => queryClient.refetchQueries({ queryKey: [QUERY_KEYS.GET_TODOS] })
16+
onSuccess: (wasDeleted, deletedTodo) => {
17+
if (wasDeleted) {
18+
// Remove deleted item from query cache if deletion was successfully
19+
return queryClient.setQueryData<Todo[]>([QUERY_KEYS.GET_TODOS], (cachedTodos) =>
20+
cachedTodos?.filter((cachedTodo) => cachedTodo.id !== deletedTodo.id)
21+
);
22+
}
23+
}
1724
});
1825
};

packages/react/tsconfig.app.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
"strict": true,
2222
"noUnusedLocals": true,
2323
"noUnusedParameters": true,
24-
"noFallthroughCasesInSwitch": true
24+
"noFallthroughCasesInSwitch": true,
25+
26+
// Add types support for @testing-library/jest-dom asserts like .toBeInTheDocument()
27+
"types": ["@testing-library/jest-dom/vitest"]
2528
},
2629
"include": [
2730
"src"

packages/react/vite.config.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { defineConfig } from 'vite';
1+
import {defineConfig} from 'vite';
22
import react from '@vitejs/plugin-react';
33

44
// https://vitejs.dev/config/
@@ -7,6 +7,7 @@ export default defineConfig({
77
// @ts-ignore
88
test: {
99
globals: true,
10-
environment: 'jsdom'
10+
environment: 'jsdom',
11+
setupFiles: ['../shared-code/tests/setupTests.ts']
1112
}
1213
});

packages/shared-code/tests/server.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import {http, HttpResponse} from "msw";
2+
import {setupServer} from "msw/node";
3+
import {GET_TODOS} from "./todos.fixtures";
4+
import {GetTodosResponse, PatchTodoResponse, CreateTodoResponse, DeleteTodoResponse} from '../models/Api';
5+
6+
// Simulates getting TODO from DB
7+
const findTodoById = (id: string) => GET_TODOS.find((todo) => todo.id === Number(id))!;
8+
9+
export const todoServer = setupServer();
10+
11+
const API = 'http://localhost:3000/todos';
12+
13+
const handlers = [
14+
// Getting TODOs
15+
http.get<never, never, GetTodosResponse>(API, () => HttpResponse.json(GET_TODOS)),
16+
17+
18+
// For creating TODO item
19+
http.post<never, { description: string }, CreateTodoResponse>(API, async ({
20+
request,
21+
}) => {
22+
const {description} = await request.json();
23+
24+
return HttpResponse.json({
25+
id: 100,
26+
description,
27+
completed: false,
28+
created_at: new Date(),
29+
updated_at: new Date()
30+
})
31+
}),
32+
33+
// For completing TODOs
34+
http.patch<{ id: string }, { completed: boolean }, PatchTodoResponse>(`${API}/:id`, async ({
35+
request,
36+
params
37+
}) => {
38+
const data = await request.json();
39+
const {id} = params;
40+
const todo = findTodoById(id);
41+
return HttpResponse.json({...todo, ...data})
42+
}),
43+
44+
45+
// For completing TODOs
46+
http.delete<{ id: string }, never, DeleteTodoResponse>(`${API}/:id`, () => {
47+
// No logic for deletion is needed
48+
return HttpResponse.json(true)
49+
}),
50+
] as const;
51+
52+
todoServer.use(...handlers);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// Fixes `Reference error: Response` while using msw
2+
import 'cross-fetch/polyfill';
3+
4+
// Adds support for DOM functions like expect().toBeInTheDocument()
5+
import '@testing-library/jest-dom/vitest';
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import {GetTodosResponse} from "../models/Api";
2+
3+
export const GET_TODOS: GetTodosResponse = [
4+
{
5+
id: 1,
6+
description: "Finish the project report 📈",
7+
completed: true,
8+
created_at: new Date("2023-07-01T09:00:00Z"),
9+
updated_at: new Date("2023-07-10T10:00:00Z")
10+
},
11+
{
12+
id: 2,
13+
description: "Book ✈️ tickets",
14+
completed: false,
15+
created_at: new Date("2023-06-15T08:30:00Z"),
16+
updated_at: new Date("2023-06-16T11:45:00Z")
17+
},
18+
{
19+
id: 3,
20+
description: "Buy groceries 🥦🥕🥬🍅",
21+
completed: false,
22+
created_at: new Date("2023-07-10T14:00:00Z"),
23+
updated_at: new Date("2023-07-10T14:30:00Z")
24+
}
25+
] as const

0 commit comments

Comments
 (0)