Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pnpm test:run
11 changes: 10 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"preview": "vite preview",
"build:gh-pages": "tsc -b && vite build --base=/rs-react/",
"deploy:gh-pages": "pnpm build:gh-pages && gh-pages -d dist",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"prepare": "husky"
},
"lint-staged": {
Expand All @@ -27,10 +30,14 @@
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.5",
"eslint": "^10.2.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import": "^2.32.0",
Expand All @@ -41,10 +48,12 @@
"gh-pages": "^6.3.0",
"globals": "^17.5.0",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.2",
"prettier": "^3.8.3",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.10"
"vite": "^8.0.10",
"vitest": "^4.1.5"
}
}
855 changes: 855 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

164 changes: 164 additions & 0 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { fetchCharacters } from './api/charactersApi';
import App from './App';
import ErrorBoundary from './components/ErrorBoundary';
import { SEARCH_TERM_STORAGE_KEY } from './constants/storage';
import {
mockCharactersResponse,
mockMorty,
mockSinglePageResponse,
} from './test-utils/mockCharacters.ts';

vi.mock('./api/charactersApi', () => ({
fetchCharacters: vi.fn(),
}));

const mockedFetchCharacters = vi.mocked(fetchCharacters);

describe('App', () => {
beforeEach(() => {
localStorage.clear();
mockedFetchCharacters.mockReset();
mockedFetchCharacters.mockResolvedValue(mockCharactersResponse);
});

it('loads and displays characters on initial render', async () => {
render(<App />);

expect(screen.getByText(/loading/i)).toBeInTheDocument();

expect(await screen.findByText('Rick Sanchez')).toBeInTheDocument();
expect(screen.getByText('Morty Smith')).toBeInTheDocument();

expect(mockedFetchCharacters).toHaveBeenCalledWith('', 1);
});

it('restores search term from localStorage on app start', async () => {
localStorage.setItem(SEARCH_TERM_STORAGE_KEY, 'morty');

render(<App />);

expect(await screen.findByDisplayValue('morty')).toBeInTheDocument();

await waitFor(() => {
expect(mockedFetchCharacters).toHaveBeenCalledWith('morty', 1);
});
});

it('submits trimmed search term and saves it to localStorage', async () => {
const user = userEvent.setup();

mockedFetchCharacters
.mockResolvedValueOnce(mockCharactersResponse)
.mockResolvedValueOnce(mockSinglePageResponse);

render(<App />);

await screen.findByText('Rick Sanchez');

mockedFetchCharacters.mockClear();

const input = screen.getByPlaceholderText(/search characters/i);

await user.clear(input);
await user.type(input, ' rick ');
await user.click(screen.getByRole('button', { name: /search/i }));

await waitFor(() => {
expect(mockedFetchCharacters).toHaveBeenCalledWith('rick', 1);
});

expect(localStorage.getItem(SEARCH_TERM_STORAGE_KEY)).toBe('rick');
expect(input).toHaveValue('rick');
});

it('does not make a new request when submitted search term has not changed', async () => {
const user = userEvent.setup();

localStorage.setItem(SEARCH_TERM_STORAGE_KEY, 'rick');

render(<App />);

await screen.findByDisplayValue('rick');
await screen.findByText('Rick Sanchez');

mockedFetchCharacters.mockClear();

await user.click(screen.getByRole('button', { name: /search/i }));

expect(mockedFetchCharacters).not.toHaveBeenCalled();
});

it('shows an error message when API request fails', async () => {
mockedFetchCharacters.mockRejectedValueOnce(new Error('API error'));

render(<App />);

expect(
await screen.findByText(/characters not found/i),
).toBeInTheDocument();
});

it('handles pagination with next and previous buttons', async () => {
const user = userEvent.setup();

mockedFetchCharacters
.mockResolvedValueOnce(mockCharactersResponse)
.mockResolvedValueOnce({
info: {
count: 1,
pages: 2,
next: null,
prev: 'https://rickandmortyapi.com/api/character?page=1',
},
results: [mockMorty],
})
.mockResolvedValueOnce(mockCharactersResponse);

render(<App />);

expect(await screen.findByText('Rick Sanchez')).toBeInTheDocument();

mockedFetchCharacters.mockClear();

await user.click(screen.getByRole('button', { name: /next/i }));

await waitFor(() => {
expect(mockedFetchCharacters).toHaveBeenCalledWith('', 2);
});

expect(await screen.findByText('Morty Smith')).toBeInTheDocument();
expect(screen.getByText(/page 2 of 2/i)).toBeInTheDocument();

mockedFetchCharacters.mockClear();

await user.click(screen.getByRole('button', { name: /prev/i }));

await waitFor(() => {
expect(mockedFetchCharacters).toHaveBeenCalledWith('', 1);
});

expect(screen.getByText(/page 1 of 2/i)).toBeInTheDocument();
});

it('renders ErrorBoundary fallback when Throw Error button is clicked', async () => {
const user = userEvent.setup();

vi.spyOn(console, 'error').mockImplementation(() => undefined);

render(
<ErrorBoundary>
<App />
</ErrorBoundary>,
);

await screen.findByText('Rick Sanchez');

await user.click(screen.getByRole('button', { name: /throw error/i }));

expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
});
});
55 changes: 55 additions & 0 deletions src/api/charactersApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { mockCharactersResponse } from '../test-utils/mockCharacters';
import { fetchCharacters } from './charactersApi';

describe('fetchCharacters', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('fetches characters without search term', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(mockCharactersResponse),
});

vi.stubGlobal('fetch', fetchMock);

const result = await fetchCharacters('', 1);

expect(fetchMock).toHaveBeenCalledWith(
'https://rickandmortyapi.com/api/character?page=1',
);
expect(result).toEqual(mockCharactersResponse);
});

it('fetches characters with search term and page', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(mockCharactersResponse),
});

vi.stubGlobal('fetch', fetchMock);

await fetchCharacters('rick', 2);

expect(fetchMock).toHaveBeenCalledWith(
'https://rickandmortyapi.com/api/character?name=rick&page=2',
);
});

it('throws an error when response is not ok', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: false,
json: vi.fn(),
});

vi.stubGlobal('fetch', fetchMock);

await expect(fetchCharacters('unknown', 1)).rejects.toThrow(
'Characters not found',
);
});
});
32 changes: 32 additions & 0 deletions src/components/Card/Card.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import type { Character } from '../../types/character';
import Card from './Card';

const character: Character = {
id: 1,
name: 'Rick Sanchez',
status: 'Alive',
species: 'Human',
gender: 'Male',
image: 'https://example.com/rick.png',
};

describe('Card', () => {
it('renders character information', () => {
render(<Card character={character} />);

expect(
screen.getByRole('heading', { name: /rick sanchez/i }),
).toBeInTheDocument();

expect(screen.getByText('Human')).toBeInTheDocument();
expect(screen.getByText('Alive')).toBeInTheDocument();

expect(screen.getByRole('img', { name: /rick sanchez/i })).toHaveAttribute(
'src',
character.image,
);
});
});
2 changes: 1 addition & 1 deletion src/components/Card.tsx → src/components/Card/Card.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';

import type { Character } from '../types/character';
import type { Character } from '../../types/character';

interface Props {
character: Character;
Expand Down
1 change: 1 addition & 0 deletions src/components/Card/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './Card';
39 changes: 39 additions & 0 deletions src/components/CardList/CardList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';

import type { Character } from '../../types/character';
import CardList from './CardList';

const characters: Character[] = [
{
id: 1,
name: 'Rick Sanchez',
status: 'Alive',
species: 'Human',
gender: 'Male',
image: 'https://example.com/rick.png',
},
{
id: 2,
name: 'Morty Smith',
status: 'Alive',
species: 'Human',
gender: 'Male',
image: 'https://example.com/morty.png',
},
];

describe('CardList', () => {
it('renders all provided characters', () => {
render(<CardList characters={characters} />);

expect(screen.getByText('Rick Sanchez')).toBeInTheDocument();
expect(screen.getByText('Morty Smith')).toBeInTheDocument();
});

it('renders one image for each character', () => {
render(<CardList characters={characters} />);

expect(screen.getAllByRole('img')).toHaveLength(characters.length);
});
});
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react';

import type { Character } from '../types/character';
import Card from './Card';
import type { Character } from '../../types/character';
import Card from '../Card';

interface Props {
characters: Character[];
Expand Down
1 change: 1 addition & 0 deletions src/components/CardList/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './CardList';
Loading