95 tests across 22 files covering all frontend modules: - API layer: client, auth - Components: BondDetails, Layout, PriceChart, ProtectedRoute, SearchBar, StockDetails - Context: AuthContext - Hooks: useAuth, useBond, useBondCandles, useSearch, useStock, useStockCandles, useStockDividends - Pages: BondPage, HomePage, LoginPage, ProfilePage, RegisterPage, StockPage Infrastructure: - vitest + @testing-library/react + MSW v2 with 13 API handlers - Co-located test files alongside source files - Test utilities: setup, server, factories, test-utils - BrowserRouter future flags for MemoryRouter test compatibility - Root test:frontend script for workspace-wide execution
64 KiB
Frontend Test Coverage Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add comprehensive frontend unit tests (vitest + RTL + MSW) across all existing modules
Architecture: MSW intercepts fetch at the network level, so no production code changes are needed. Each test uses renderWithProviders (QueryClient + AuthProvider + MemoryRouter wrapper). Fresh QueryClient per test with retry: false.
Tech Stack: vitest, @testing-library/react, @testing-library/jest-dom, @testing-library/user-event, jsdom, msw v2
Task 1: Infrastructure — vitest config + test helpers
Files:
-
Create:
apps/frontend/vitest.config.ts -
Create:
apps/frontend/src/test/setup.ts -
Create:
apps/frontend/src/test/server.ts -
Create:
apps/frontend/src/test/handlers.ts -
Create:
apps/frontend/src/test/test-utils.tsx -
Create:
apps/frontend/src/test/factories.ts -
Modify:
apps/frontend/package.json(add test scripts) -
Modify:
package.json(add root workspace script) -
Step 1: Create vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true,
},
});
- Step 2: Create test/setup.ts
import '@testing-library/jest-dom';
import { server } from './server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
- Step 3: Create test/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
- Step 4: Create test/handlers.ts
import { http, HttpResponse } from 'msw';
import type {
ShareResponse,
BondResponse,
CandleItem,
DividendItem,
SearchResultItem,
AuthResponse,
UserResponse,
} from '../api/responses';
const API = '/api/v1';
const mockUser: UserResponse = {
id: 1,
email: 'user@test.com',
name: 'Test User',
role: 'user',
};
const mockAuth: AuthResponse = {
user: mockUser,
accessToken: 'mock-access-token',
};
const mockShare: ShareResponse = {
secid: 'SBER',
isin: 'RU0009029540',
name: 'Сбер Банк',
shortName: 'Сбер',
latName: 'Sberbank',
listLevel: 1,
issueSize: 21586900000,
faceValue: 3,
faceUnit: 'RUB',
type: 'common_share',
marketData: {
price: 289.5,
change: 2.5,
changePercent: 0.87,
open: 287,
high: 291,
low: 286.5,
volume: 15000000,
value: 4350000000,
issueCapitalization: 6250000000000,
updatedAt: '2024-01-15T10:00:00Z',
},
};
const mockBond: BondResponse = {
secid: 'SU26238RMFS5',
isin: 'RU000A101XU7',
name: 'ОФЗ 26238',
shortName: 'ОФЗ 26238',
latName: null,
listLevel: 1,
issueSize: 500000000,
faceValue: 1000,
faceUnit: 'RUB',
matDate: '2027-05-15',
couponValue: 36.9,
couponPercent: 7.5,
couponPeriod: 182,
nextCoupon: '2024-07-15',
accruedInt: 8.45,
bondType: 'ОФЗ',
bondSubType: 'ОФЗ-ПД',
offerDate: null,
buybackDate: null,
marketData: {
price: 98.5,
yieldToMaturity: 8.2,
duration: 3.5,
accruedInt: 8.45,
couponValue: 36.9,
couponPercent: 7.5,
nextCouponDate: '2024-07-15',
open: 98.0,
high: 99.0,
low: 97.5,
volume: 1000000,
updatedAt: '2024-01-15T10:00:00Z',
},
};
const mockCandles: CandleItem[] = [
{ open: 280, high: 290, low: 278, close: 289.5, volume: 1000000, value: 280000000, begin: '2024-01-15T10:00:00Z', end: '2024-01-15T18:00:00Z' },
{ open: 289, high: 292, low: 285, close: 288, volume: 800000, value: 231200000, begin: '2024-01-16T10:00:00Z', end: '2024-01-16T18:00:00Z' },
];
const mockDividends: DividendItem[] = [
{ registryCloseDate: '2024-07-10', value: 35.0, currency: 'RUB' },
{ registryCloseDate: '2023-10-05', value: 30.0, currency: 'RUB' },
];
const mockSearchResults: SearchResultItem[] = [
{ secid: 'SBER', isin: 'RU0009029540', shortName: 'Сбер', type: 'share', listLevel: 1, currency: 'RUB', price: 289.5 },
{ secid: 'VTBR', isin: 'RU000A0JP5V6', shortName: 'ВТБ', type: 'share', listLevel: 1, currency: 'RUB', price: 0.0234 },
];
export const handlers = [
http.get(`${API}/securities/search`, ({ request }) => {
const url = new URL(request.url);
const q = url.searchParams.get('q') || '';
if (q.length < 2) return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } });
const filtered = mockSearchResults.filter(r =>
r.secid.toLowerCase().includes(q.toLowerCase()) ||
r.shortName.toLowerCase().includes(q.toLowerCase()),
);
return HttpResponse.json({ data: { data: filtered, meta: { fromCache: false, cachedAt: null } } });
}),
http.get(`${API}/securities/shares/:secid`, ({ params }) => {
const { secid } = params;
if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 });
return HttpResponse.json({ data: { data: { ...mockShare, secid } as ShareResponse, meta: { fromCache: false, cachedAt: null } } });
}),
http.get(`${API}/securities/shares/:secid/candles`, () =>
HttpResponse.json({ data: { data: mockCandles, meta: { fromCache: false, cachedAt: null } } }),
),
http.get(`${API}/securities/shares/:secid/dividends`, () =>
HttpResponse.json({ data: { data: mockDividends, meta: { fromCache: false, cachedAt: null } } }),
),
http.get(`${API}/securities/bonds/:secid`, ({ params }) => {
const { secid } = params;
if (secid === 'NOTFOUND') return new HttpResponse(null, { status: 404 });
return HttpResponse.json({ data: { data: { ...mockBond, secid } as BondResponse, meta: { fromCache: false, cachedAt: null } } });
}),
http.get(`${API}/securities/bonds/:secid/candles`, () =>
HttpResponse.json({ data: { data: mockCandles, meta: { fromCache: false, cachedAt: null } } }),
),
http.get(`${API}/auth/me`, () =>
HttpResponse.json({ data: { data: mockUser, meta: { fromCache: false, cachedAt: null } } }),
),
http.post(`${API}/auth/login`, () =>
HttpResponse.json({ data: { data: mockAuth, meta: { fromCache: false, cachedAt: null } } }),
),
http.post(`${API}/auth/register`, () =>
HttpResponse.json({ data: { data: mockAuth, meta: { fromCache: false, cachedAt: null } } }),
),
http.post(`${API}/auth/refresh`, () =>
HttpResponse.json({ data: { data: mockAuth, meta: { fromCache: false, cachedAt: null } } }),
),
http.post(`${API}/auth/logout`, () => new HttpResponse(null, { status: 204 })),
http.patch(`${API}/auth/me`, () =>
HttpResponse.json({ data: { data: { ...mockUser, name: 'Updated' }, meta: { fromCache: false, cachedAt: null } } }),
),
http.get(`${API}/health`, () =>
HttpResponse.json({ data: { data: { status: 'ok', timestamp: new Date().toISOString(), uptime: 12345 }, meta: { fromCache: false, cachedAt: null } } }),
),
];
export { mockUser, mockAuth, mockShare, mockBond, mockCandles, mockDividends, mockSearchResults };
- Step 5: Create test/test-utils.tsx
import { type ReactElement } from 'react';
import { render, type RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { AuthProvider } from '../context/AuthContext';
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
queryClient?: QueryClient;
route?: string;
}
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 },
mutations: { retry: false },
},
});
}
export function renderWithProviders(
ui: ReactElement,
{ queryClient = createTestQueryClient(), route = '/', ...renderOptions }: CustomRenderOptions = {},
) {
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[route]}>
<AuthProvider>
{children}
</AuthProvider>
</MemoryRouter>
</QueryClientProvider>
);
}
return { ...render(ui, { wrapper: Wrapper, ...renderOptions }), queryClient };
}
- Step 6: Create test/factories.ts
import type { ShareResponse, BondResponse, CandleItem, DividendItem, SearchResultItem, UserResponse, AuthResponse } from '../api/responses';
export function createMockShare(overrides: Partial<ShareResponse> = {}): ShareResponse {
return {
secid: 'SBER',
isin: 'RU0009029540',
name: 'Сбер Банк',
shortName: 'Сбер',
latName: 'Sberbank',
listLevel: 1,
issueSize: 21586900000,
faceValue: 3,
faceUnit: 'RUB',
type: 'common_share',
marketData: {
price: 289.5,
change: 2.5,
changePercent: 0.87,
open: 287,
high: 291,
low: 286.5,
volume: 15000000,
value: 4350000000,
issueCapitalization: 6250000000000,
updatedAt: '2024-01-15T10:00:00Z',
},
...overrides,
};
}
export function createMockBond(overrides: Partial<BondResponse> = {}): BondResponse {
return {
secid: 'SU26238RMFS5',
isin: 'RU000A101XU7',
name: 'ОФЗ 26238',
shortName: 'ОФЗ 26238',
latName: null,
listLevel: 1,
issueSize: 500000000,
faceValue: 1000,
faceUnit: 'RUB',
matDate: '2027-05-15',
couponValue: 36.9,
couponPercent: 7.5,
couponPeriod: 182,
nextCoupon: '2024-07-15',
accruedInt: 8.45,
bondType: 'ОФЗ',
bondSubType: 'ОФЗ-ПД',
offerDate: null,
buybackDate: null,
marketData: {
price: 98.5,
yieldToMaturity: 8.2,
duration: 3.5,
accruedInt: 8.45,
couponValue: 36.9,
couponPercent: 7.5,
nextCouponDate: '2024-07-15',
open: 98.0,
high: 99.0,
low: 97.5,
volume: 1000000,
updatedAt: '2024-01-15T10:00:00Z',
},
...overrides,
};
}
export function createMockUser(overrides: Partial<UserResponse> = {}): UserResponse {
return { id: 1, email: 'user@test.com', name: 'Test User', role: 'user', ...overrides };
}
export function createMockAuth(overrides: Partial<AuthResponse> = {}): AuthResponse {
return { user: createMockUser(), accessToken: 'mock-token', ...overrides };
}
export function createMockCandles(count = 2): CandleItem[] {
return Array.from({ length: count }, (_, i) => ({
open: 280 + i, high: 290 + i, low: 278 + i, close: 289.5 + i,
volume: 1000000, value: 280000000,
begin: `2024-01-${15 + i}T10:00:00Z`,
end: `2024-01-${15 + i}T18:00:00Z`,
}));
}
export function createMockDividends(): DividendItem[] {
return [
{ registryCloseDate: '2024-07-10', value: 35.0, currency: 'RUB' },
];
}
export function createMockSearchResults(): SearchResultItem[] {
return [
{ secid: 'SBER', isin: 'RU0009029540', shortName: 'Сбер', type: 'share', listLevel: 1, currency: 'RUB', price: 289.5 },
{ secid: 'VTBR', isin: 'RU000A0JP5V6', shortName: 'ВТБ', type: 'share', listLevel: 1, currency: 'RUB', price: 0.0234 },
];
}
- Step 7: Add scripts to apps/frontend/package.json
"test": "vitest run",
"test:watch": "vitest"
- Step 8: Add to root package.json
"test:frontend": "npm run test -w apps/frontend"
- Step 9: Install dependencies
Run: npm install -w apps/frontend vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom msw @types/node
- Step 10: Verify setup
Run: npm run test:frontend
Expected: "No test files found, exiting with code 0" (no tests yet, but config works)
Task 2: API client tests
Files:
-
Create:
apps/frontend/src/api/client.test.ts -
Step 1: Create client.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { request, setAccessToken, getAccessToken, setOnUnauthorized } from './client';
const API = '/api/v1';
beforeEach(() => {
setAccessToken(null);
});
describe('request', () => {
it('makes GET request and returns data', async () => {
const result = await request<{ message: string }>('/api/v1/health');
expect(result.data).toEqual({
status: 'ok',
timestamp: expect.any(String),
uptime: 12345,
});
});
it('includes Authorization header when token is set', async () => {
setAccessToken('test-token');
let capturedAuth: string | null = null;
server.use(
http.get(`${API}/test-auth`, ({ request }) => {
capturedAuth = request.headers.get('Authorization');
return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } });
}),
);
await request('/api/v1/test-auth');
expect(capturedAuth).toBe('Bearer test-token');
});
it('retries on 401 and succeeds after refresh', async () => {
setAccessToken('expired-token');
let attempts = 0;
server.use(
http.get(`${API}/test-retry`, ({ request }) => {
attempts++;
const auth = request.headers.get('Authorization');
if (auth === 'Bearer expired-token') {
return new HttpResponse(null, { status: 401 });
}
return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } });
}),
http.post(`${API}/auth/refresh`, () =>
HttpResponse.json({ data: { data: { user: { id: 1, email: 'user@test.com', name: null, role: 'user' }, accessToken: 'new-token' }, meta: { fromCache: false, cachedAt: null } } }),
),
);
const result = await request<{ ok: boolean }>('/api/v1/test-retry');
expect(attempts).toBe(2);
expect(result.data).toEqual({ ok: true });
expect(getAccessToken()).toBe('new-token');
});
it('throws on persistent 401 and clears token', async () => {
setAccessToken('expired-token');
let unauthorizedCalled = false;
setOnUnauthorized(() => { unauthorizedCalled = true; });
server.use(
http.get(`${API}/test-fail`, () => new HttpResponse(null, { status: 401 })),
http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
);
await expect(request('/api/v1/test-fail')).rejects.toThrow('Сессия истекла');
expect(getAccessToken()).toBeNull();
expect(unauthorizedCalled).toBe(true);
});
it('throws on non-ok response with status text', async () => {
server.use(
http.get(`${API}/test-error`, () => new HttpResponse('Not found', { status: 404, statusText: 'Not Found' })),
);
await expect(request('/api/v1/test-error')).rejects.toThrow('Ошибка API: 404');
});
it('sends JSON body for POST requests', async () => {
let capturedBody: string | null = null;
server.use(
http.post(`${API}/test-post`, async ({ request }) => {
capturedBody = await request.text();
return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } });
}),
);
await request('/api/v1/test-post', undefined, { method: 'POST', body: { foo: 'bar' } });
expect(capturedBody).toBe(JSON.stringify({ foo: 'bar' }));
});
it('does not send auth header when skipAuth is true', async () => {
setAccessToken('test-token');
let capturedAuth: string | null = null;
server.use(
http.get(`${API}/test-skip`, ({ request }) => {
capturedAuth = request.headers.get('Authorization');
return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } });
}),
);
await request('/api/v1/test-skip', undefined, { skipAuth: true });
expect(capturedAuth).toBeNull();
});
it('sets query params correctly', async () => {
let capturedUrl = '';
server.use(
http.get(`${API}/test-params`, ({ request }) => {
capturedUrl = request.url;
return HttpResponse.json({ data: { data: { ok: true }, meta: { fromCache: false, cachedAt: null } } });
}),
);
await request('/api/v1/test-params', { q: 'sber', type: 'share' });
expect(capturedUrl).toContain('q=sber');
expect(capturedUrl).toContain('type=share');
});
});
- Step 2: Run tests
Run: npx vitest run apps/frontend/src/api/client.test.ts -w apps/frontend
Expected: all passing
Task 3: Auth API tests
Files:
-
Create:
apps/frontend/src/api/auth.test.ts -
Step 1: Create auth.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { setAccessToken, getAccessToken } from './client';
import { login, register, refresh, logout, getMe, updateProfile } from './auth';
const API = '/api/v1';
beforeEach(() => {
setAccessToken(null);
});
describe('login', () => {
it('returns auth data and sets access token', async () => {
const result = await login('user@test.com', 'password');
expect(result.user.email).toBe('user@test.com');
expect(result.accessToken).toBe('mock-access-token');
expect(getAccessToken()).toBe('mock-access-token');
});
it('throws on invalid credentials', async () => {
server.use(
http.post(`${API}/auth/login`, () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' })),
);
await expect(login('wrong@test.com', 'wrong')).rejects.toThrow();
});
});
describe('register', () => {
it('returns auth data and sets access token', async () => {
const result = await register('new@test.com', 'password', 'New User');
expect(result.user.email).toBe('user@test.com');
expect(getAccessToken()).toBe('mock-access-token');
});
});
describe('refresh', () => {
it('returns auth data and sets access token', async () => {
const result = await refresh();
expect(result.accessToken).toBe('mock-access-token');
expect(getAccessToken()).toBe('mock-access-token');
});
});
describe('logout', () => {
it('clears access token', async () => {
setAccessToken('test-token');
await logout();
expect(getAccessToken()).toBeNull();
});
});
describe('getMe', () => {
it('returns current user', async () => {
const result = await getMe();
expect(result.email).toBe('user@test.com');
});
});
describe('updateProfile', () => {
it('updates and returns user', async () => {
const result = await updateProfile({ name: 'Updated' });
expect(result.name).toBe('Updated');
});
});
- Step 2: Run tests
Run: npx vitest run apps/frontend/src/api/auth.test.ts -w apps/frontend
Expected: all passing
Task 4: AuthContext tests
Files:
-
Create:
apps/frontend/src/context/AuthContext.test.tsx -
Step 1: Create AuthContext.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { AuthProvider, AuthContext, type AuthContextValue } from './AuthContext';
import { useContext } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const API = '/api/v1';
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>,
);
}
function TestConsumer() {
const ctx = useContext(AuthContext);
if (!ctx) return <div>no context</div>;
return (
<div>
<span data-testid="auth">{ctx.isAuthenticated ? 'authenticated' : 'anonymous'}</span>
<span data-testid="email">{ctx.user?.email ?? ''}</span>
<button onClick={() => ctx.login('a@b.com', 'p')}>login</button>
<button onClick={() => ctx.register('a@b.com', 'p')}>register</button>
<button onClick={() => ctx.logout()}>logout</button>
<button onClick={() => ctx.updateProfile({ name: 'New' })}>updateProfile</button>
</div>
);
}
describe('AuthContext', () => {
it('starts unauthenticated when refresh fails', async () => {
server.use(
http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
);
renderWithProviders(
<AuthProvider>
<TestConsumer />
</AuthProvider>,
);
await waitFor(() => {
expect(screen.getByTestId('auth')).toHaveTextContent('anonymous');
});
});
it('restores session on mount when refresh succeeds', async () => {
renderWithProviders(
<AuthProvider>
<TestConsumer />
</AuthProvider>,
);
await waitFor(() => {
expect(screen.getByTestId('auth')).toHaveTextContent('authenticated');
expect(screen.getByTestId('email')).toHaveTextContent('user@test.com');
});
});
it('updates state after login', async () => {
server.use(
http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
);
const user = userEvent.setup();
renderWithProviders(
<AuthProvider>
<TestConsumer />
</AuthProvider>,
);
await waitFor(() => expect(screen.getByTestId('auth')).toHaveTextContent('anonymous'));
await user.click(screen.getByRole('button', { name: 'login' }));
await waitFor(() => {
expect(screen.getByTestId('auth')).toHaveTextContent('authenticated');
});
});
it('updates state after logout', async () => {
const user = userEvent.setup();
renderWithProviders(
<AuthProvider>
<TestConsumer />
</AuthProvider>,
);
await waitFor(() => expect(screen.getByTestId('auth')).toHaveTextContent('authenticated'));
await user.click(screen.getByRole('button', { name: 'logout' }));
await waitFor(() => {
expect(screen.getByTestId('auth')).toHaveTextContent('anonymous');
});
});
});
- Step 2: Run tests
Run: npx vitest run apps/frontend/src/context/AuthContext.test.tsx -w apps/frontend
Expected: all passing
Task 5: Hook tests
Files:
-
Create:
apps/frontend/src/hooks/useAuth.test.tsx -
Create:
apps/frontend/src/hooks/useSearch.test.tsx -
Create:
apps/frontend/src/hooks/useStock.test.tsx -
Create:
apps/frontend/src/hooks/useStockCandles.test.tsx -
Create:
apps/frontend/src/hooks/useStockDividends.test.tsx -
Create:
apps/frontend/src/hooks/useBond.test.tsx -
Create:
apps/frontend/src/hooks/useBondCandles.test.tsx -
Step 1: Create useAuth.test.tsx
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { useAuth } from './useAuth';
import { renderWithProviders } from '../test/test-utils';
describe('useAuth', () => {
it('returns auth context values', async () => {
const { result } = renderHook(() => useAuth(), { wrapper: ({ children }) => {
const { Wrapper } = renderWithProviders(<>{children}</>);
return <>{children}</>;
}});
// Actually use renderWithProviders via wrapper
});
it('throws when used outside AuthProvider', () => {
const { renderHook: render } = await import('@testing-library/react');
// Will be tested inline
});
});
Wait — actually let me write proper, complete tests. Let me re-do this.
- Step 1: Create useAuth.test.tsx properly
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from '../context/AuthContext';
import { useAuth } from './useAuth';
import { type ReactNode } from 'react';
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
};
}
describe('useAuth', () => {
it('returns auth context with user after mount', async () => {
const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
await waitFor(() => {
expect(result.current.isAuthenticated).toBe(true);
});
expect(result.current.user?.email).toBe('user@test.com');
expect(result.current.accessToken).toBe('mock-access-token');
});
it('provides login function', async () => {
const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
expect(typeof result.current.login).toBe('function');
});
it('provides logout function', async () => {
const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
expect(typeof result.current.logout).toBe('function');
});
it('provides register function', async () => {
const { result } = renderHook(() => useAuth(), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isAuthenticated).toBe(true));
expect(typeof result.current.register).toBe('function');
});
it('throws when used without AuthProvider', () => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
expect(() => {
renderHook(() => useAuth(), {
wrapper: ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
),
});
}).toThrow('useAuth must be used within an AuthProvider');
});
});
- Step 2: Create useSearch.test.tsx
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { useSearch } from './useSearch';
import { type ReactNode } from 'react';
const API = '/api/v1';
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
describe('useSearch', () => {
it('does not fetch when query is empty', () => {
const { result } = renderHook(() => useSearch(''), { wrapper: createWrapper() });
expect(result.current.isFetching).toBe(false);
expect(result.current.data).toBeUndefined();
});
it('does not fetch when query is too short', () => {
const { result } = renderHook(() => useSearch('a'), { wrapper: createWrapper() });
expect(result.current.data).toBeUndefined();
});
it('returns search results for valid query', async () => {
const { result } = renderHook(() => useSearch('sber'), { wrapper: createWrapper() });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toBeDefined();
expect(result.current.data?.length).toBeGreaterThan(0);
expect(result.current.data?.[0].secid).toBe('SBER');
});
it('returns empty array when no results', async () => {
server.use(
http.get(`${API}/securities/search`, () => {
return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } });
}),
);
const { result } = renderHook(() => useSearch('zzzzz'), { wrapper: createWrapper() });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
});
it('returns error state on network failure', async () => {
server.use(
http.get(`${API}/securities/search`, () => new HttpResponse(null, { status: 500 })),
);
const { result } = renderHook(() => useSearch('error'), { wrapper: createWrapper() });
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
});
- Step 3: Create useStock.test.tsx
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { useStock } from './useStock';
import { type ReactNode } from 'react';
const API = '/api/v1';
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
describe('useStock', () => {
it('returns share data', async () => {
const { result } = renderHook(() => useStock('SBER'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.secid).toBe('SBER');
expect(result.current.data?.shortName).toBe('Сбер');
expect(result.current.data?.marketData.price).toBe(289.5);
});
it('returns error for not found', async () => {
const { result } = renderHook(() => useStock('NOTFOUND'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isError).toBe(true));
});
it('starts in loading state', () => {
const { result } = renderHook(() => useStock('SBER'), { wrapper: createWrapper() });
expect(result.current.isLoading).toBe(true);
});
});
- Step 4: Create useStockCandles.test.tsx
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { useStockCandles } from './useStockCandles';
import { type ReactNode } from 'react';
const API = '/api/v1';
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
describe('useStockCandles', () => {
it('returns candle data', async () => {
const { result } = renderHook(
() => useStockCandles('SBER', '24h', '2024-01-01', '2024-01-31'),
{ wrapper: createWrapper() },
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toHaveLength(2);
expect(result.current.data?.[0].open).toBe(280);
});
it('returns empty array when no candles', async () => {
server.use(
http.get(`${API}/securities/shares/:secid/candles`, () => {
return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } });
}),
);
const { result } = renderHook(
() => useStockCandles('SBER', '24h', '2024-01-01', '2024-01-31'),
{ wrapper: createWrapper() },
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
});
- Step 5: Create useStockDividends.test.tsx
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { useStockDividends } from './useStockDividends';
import { type ReactNode } from 'react';
const API = '/api/v1';
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
describe('useStockDividends', () => {
it('returns dividend data', async () => {
const { result } = renderHook(() => useStockDividends('SBER'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toHaveLength(2);
expect(result.current.data?.[0].value).toBe(35);
});
it('returns empty array when no dividends', async () => {
server.use(
http.get(`${API}/securities/shares/:secid/dividends`, () => {
return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } });
}),
);
const { result } = renderHook(() => useStockDividends('SBER'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
});
- Step 6: Create useBond.test.tsx
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { useBond } from './useBond';
import { type ReactNode } from 'react';
const API = '/api/v1';
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
describe('useBond', () => {
it('returns bond data', async () => {
const { result } = renderHook(() => useBond('SU26238RMFS5'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data?.shortName).toBe('ОФЗ 26238');
expect(result.current.data?.marketData.price).toBe(98.5);
});
it('returns error on 404', async () => {
const { result } = renderHook(() => useBond('NOTFOUND'), { wrapper: createWrapper() });
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
- Step 7: Create useBondCandles.test.tsx
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { useBondCandles } from './useBondCandles';
import { type ReactNode } from 'react';
const API = '/api/v1';
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
describe('useBondCandles', () => {
it('returns candle data', async () => {
const { result } = renderHook(
() => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'),
{ wrapper: createWrapper() },
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toHaveLength(2);
});
it('returns empty array when no candles', async () => {
server.use(
http.get(`${API}/securities/bonds/:secid/candles`, () => {
return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } });
}),
);
const { result } = renderHook(
() => useBondCandles('SU26238RMFS5', '24h', '2024-01-01', '2024-01-31'),
{ wrapper: createWrapper() },
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual([]);
});
});
- Step 8: Run all hook tests
Run: npx vitest run apps/frontend/src/hooks/ -w apps/frontend
Expected: all passing
Task 6: UI Component tests
Files:
-
Create:
apps/frontend/src/components/SearchBar.test.tsx -
Create:
apps/frontend/src/components/ProtectedRoute.test.tsx -
Create:
apps/frontend/src/components/StockDetails.test.tsx -
Create:
apps/frontend/src/components/BondDetails.test.tsx -
Create:
apps/frontend/src/components/PriceChart.test.tsx -
Create:
apps/frontend/src/components/Layout.test.tsx -
Step 1: Create SearchBar.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../test/test-utils';
describe('SearchBar', () => {
it('renders input with placeholder', () => {
renderWithProviders(<div />); // SearchBar is inside Layout, test it within
});
});
Actually the SearchBar is rendered through Layout but can be tested standalone. Let me fix:
import { describe, it, expect } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { SearchBar } from './SearchBar';
import { renderWithProviders } from '../test/test-utils';
const API = '/api/v1';
function renderSearchBar() {
return renderWithProviders(<SearchBar />);
}
describe('SearchBar', () => {
it('renders search input', () => {
renderSearchBar();
expect(screen.getByPlaceholderText('Поиск акций и облигаций...')).toBeInTheDocument();
});
it('shows dropdown on focus', async () => {
renderSearchBar();
const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
await userEvent.type(input, 'sber');
await waitFor(() => {
expect(screen.getByText('Сбер')).toBeInTheDocument();
});
});
it('shows loading state while fetching', async () => {
server.use(
http.get(`${API}/securities/search`, () => {
return new Promise(() => {}); // never resolves
}),
);
renderSearchBar();
const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
await userEvent.type(input, 'sber');
await waitFor(() => {
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
});
});
it('shows no results message', async () => {
server.use(
http.get(`${API}/securities/search`, () => {
return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } });
}),
);
renderSearchBar();
const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
await userEvent.type(input, 'zzzzz');
await waitFor(() => {
expect(screen.getByText('Ничего не найдено')).toBeInTheDocument();
});
});
it('hides dropdown when clicking outside', async () => {
renderSearchBar();
const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
await userEvent.type(input, 'sber');
await waitFor(() => {
expect(screen.getByText('Сбер')).toBeInTheDocument();
});
await userEvent.click(document.body);
await waitFor(() => {
expect(screen.queryByText('Сбер')).not.toBeInTheDocument();
});
});
it('navigates to stock page on share result click', async () => {
const { mockNavigate } = vi.hoisted(() => ({ mockNavigate: vi.fn() }));
vi.mock('react-router-dom', async (importOriginal) => {
const actual = await importOriginal();
return { ...(actual as object), useNavigate: () => mockNavigate };
});
renderSearchBar();
const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
await userEvent.type(input, 'sber');
await waitFor(() => {
expect(screen.getByText('Сбер')).toBeInTheDocument();
});
await userEvent.click(screen.getByText('Сбер'));
expect(mockNavigate).toHaveBeenCalledWith('/stocks/SBER');
});
});
- Step 2: Create ProtectedRoute.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ProtectedRoute } from './ProtectedRoute';
describe('ProtectedRoute', () => {
it('renders children when authenticated', async () => {
// Renders through AuthProvider which auto-authenticates via refresh
const { renderWithProviders } = await import('../test/test-utils');
renderWithProviders(
<ProtectedRoute>
<div data-testid="protected-content">Secret</div>
</ProtectedRoute>,
);
const content = await screen.findByTestId('protected-content');
expect(content).toBeInTheDocument();
});
it('redirects to login when not authenticated', async () => {
const { http, HttpResponse } = await import('msw');
const { server } = await import('../test/server');
const API = '/api/v1';
server.use(
http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
);
const { renderWithProviders } = await import('../test/test-utils');
renderWithProviders(
<ProtectedRoute>
<div data-testid="protected-content">Secret</div>
</ProtectedRoute>,
{ route: '/profile' },
);
await screen.findByText('Загрузка...');
// After loading, should redirect to login
// Use findByText for async redirect check
});
});
Actually this imports are messy with dynamic imports. Let me write cleaner:
import { describe, it, expect } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { ProtectedRoute } from './ProtectedRoute';
import { renderWithProviders } from '../test/test-utils';
const API = '/api/v1';
describe('ProtectedRoute', () => {
it('renders children when authenticated', async () => {
renderWithProviders(
<ProtectedRoute>
<div data-testid="protected-content">Secret</div>
</ProtectedRoute>,
);
expect(await screen.findByTestId('protected-content')).toBeInTheDocument();
});
it('shows loading state initially then redirects when not authenticated', async () => {
server.use(
http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
);
renderWithProviders(
<ProtectedRoute>
<div data-testid="protected-content">Secret</div>
</ProtectedRoute>,
{ route: '/profile' },
);
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
await waitFor(() => {
// After auth resolves, should redirect — we check the protected content is gone
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
});
});
});
- Step 3: Create StockDetails.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { StockDetails } from './StockDetails';
import { createMockShare } from '../test/factories';
describe('StockDetails', () => {
it('renders stock details', () => {
const stock = createMockShare();
render(<StockDetails stock={stock} />);
expect(screen.getByText('Сбер (SBER)')).toBeInTheDocument();
expect(screen.getByText('Сбер Банк · RU0009029540')).toBeInTheDocument();
});
it('displays positive change in green', () => {
const stock = createMockShare({ marketData: { ...createMockShare().marketData, change: 5, changePercent: 2 } });
render(<StockDetails stock={stock} />);
const changeEl = screen.getByText('+5.00 (2.00%)');
expect(changeEl).toBeInTheDocument();
expect(changeEl).toHaveStyle({ color: 'var(--color-positive)' });
});
it('displays negative change in red', () => {
const stock = createMockShare({ marketData: { ...createMockShare().marketData, change: -3, changePercent: -1 } });
render(<StockDetails stock={stock} />);
const changeEl = screen.getByText('-3.00 (-1.00%)');
expect(changeEl).toBeInTheDocument();
expect(changeEl).toHaveStyle({ color: 'var(--color-negative)' });
});
it('shows price formatted', () => {
const stock = createMockShare();
render(<StockDetails stock={stock} />);
expect(screen.getByText('289,50')).toBeInTheDocument();
});
it('shows dash for null high', () => {
const stock = createMockShare({ marketData: { ...createMockShare().marketData, high: null } });
render(<StockDetails stock={stock} />);
const rows = screen.getAllByText('—');
expect(rows.length).toBeGreaterThanOrEqual(1);
});
it('shows capitalization in billions', () => {
const stock = createMockShare();
render(<StockDetails stock={stock} />);
expect(screen.getByText('6250,00 млрд ₽')).toBeInTheDocument();
});
});
- Step 4: Create BondDetails.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { BondDetails } from './BondDetails';
import { createMockBond } from '../test/factories';
describe('BondDetails', () => {
it('renders bond details', () => {
const bond = createMockBond();
render(<BondDetails bond={bond} />);
expect(screen.getByText('ОФЗ 26238')).toBeInTheDocument();
expect(screen.getByText('RU000A101XU7')).toBeInTheDocument();
});
it('shows price as percentage', () => {
const bond = createMockBond();
render(<BondDetails bond={bond} />);
expect(screen.getByText('98.50%')).toBeInTheDocument();
});
it('renders maturity date', () => {
const bond = createMockBond();
render(<BondDetails bond={bond} />);
expect(screen.getByText('2027-05-15')).toBeInTheDocument();
});
it('shows coupon with percentage', () => {
const bond = createMockBond();
render(<BondDetails bond={bond} />);
expect(screen.getByText('36.9 ₽ (7.5%)')).toBeInTheDocument();
});
it('shows coupon without percentage when null', () => {
const bond = createMockBond({ marketData: { ...createMockBond().marketData, couponPercent: null } });
render(<BondDetails bond={bond} />);
expect(screen.getByText('36.9 ₽')).toBeInTheDocument();
});
it('shows dash for missing next coupon date', () => {
const bond = createMockBond({ marketData: { ...createMockBond().marketData, nextCouponDate: null } });
render(<BondDetails bond={bond} />);
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(1);
});
it('shows dash for null yieldToMaturity', () => {
const bond = createMockBond({ marketData: { ...createMockBond().marketData, yieldToMaturity: null } });
render(<BondDetails bond={bond} />);
expect(screen.getByText('—')).toBeInTheDocument();
});
it('shows bond type', () => {
const bond = createMockBond();
render(<BondDetails bond={bond} />);
expect(screen.getByText('ОФЗ')).toBeInTheDocument();
});
});
- Step 5: Create PriceChart.test.tsx
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { PriceChart } from './PriceChart';
describe('PriceChart', () => {
it('renders chart container', () => {
const { container } = render(<PriceChart data={[]} />);
expect(container.querySelector('div')).toBeInTheDocument();
});
it('renders with candle data', () => {
const data = [
{ open: 100, high: 110, low: 95, close: 105, begin: '2024-01-15T10:00:00Z' },
];
const { container } = render(<PriceChart data={data} />);
expect(container.querySelector('div')).toBeInTheDocument();
});
it('accepts custom height', () => {
const { container } = render(<PriceChart data={[]} height={600} />);
expect(container.querySelector('div')).toBeInTheDocument();
});
});
- Step 6: Create Layout.test.tsx
import { describe, it, expect } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { Layout } from './Layout';
import { renderWithProviders } from '../test/test-utils';
const API = '/api/v1';
describe('Layout', () => {
it('renders logo and search bar', async () => {
renderWithProviders(<Layout />);
expect(screen.getByText('MoexVibe')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Поиск акций и облигаций...')).toBeInTheDocument();
});
it('shows login link when not authenticated', async () => {
server.use(
http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
);
renderWithProviders(<Layout />);
await waitFor(() => {
expect(screen.getByText('Войти')).toBeInTheDocument();
});
});
it('shows user name and logout when authenticated', async () => {
renderWithProviders(<Layout />);
await waitFor(() => {
expect(screen.getByText('Test User')).toBeInTheDocument();
expect(screen.getByText('Выйти')).toBeInTheDocument();
});
});
it('shows email when user has no name', async () => {
server.use(
http.post(`${API}/auth/refresh`, () => {
return HttpResponse.json({
data: {
data: { user: { id: 1, email: 'user@test.com', name: null, role: 'user' }, accessToken: 'mock' },
meta: { fromCache: false, cachedAt: null },
},
});
}),
);
renderWithProviders(<Layout />);
await waitFor(() => {
expect(screen.getByText('user@test.com')).toBeInTheDocument();
});
});
});
- Step 7: Run component tests
Run: npx vitest run apps/frontend/src/components/ -w apps/frontend
Expected: all passing
Task 7: Page tests
Files:
-
Create:
apps/frontend/src/pages/HomePage.test.tsx -
Create:
apps/frontend/src/pages/StockPage.test.tsx -
Create:
apps/frontend/src/pages/BondPage.test.tsx -
Create:
apps/frontend/src/pages/LoginPage.test.tsx -
Create:
apps/frontend/src/pages/RegisterPage.test.tsx -
Create:
apps/frontend/src/pages/ProfilePage.test.tsx -
Step 1: Create HomePage.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { HomePage } from './HomePage';
describe('HomePage', () => {
it('renders welcome title', () => {
render(<HomePage />);
expect(screen.getByText('MoexVibe')).toBeInTheDocument();
});
it('renders description', () => {
render(<HomePage />);
expect(screen.getByText('Анализ акций и облигаций Московской биржи')).toBeInTheDocument();
});
it('renders hint text', () => {
render(<HomePage />);
expect(screen.getByText(/Введите название или тикер/)).toBeInTheDocument();
});
it('renders data delay notice', () => {
render(<HomePage />);
expect(screen.getByText(/Данные задерживаются на 15 минут/)).toBeInTheDocument();
});
});
- Step 2: Create StockPage.test.tsx
import { describe, it, expect } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { StockPage } from './StockPage';
import { renderWithProviders } from '../test/test-utils';
const API = '/api/v1';
describe('StockPage', () => {
it('shows loading state', () => {
server.use(
http.get(`${API}/securities/shares/:secid`, () => new Promise(() => {})),
);
renderWithProviders(<StockPage />, { route: '/stocks/SBER' });
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
});
it('renders stock details after loading', async () => {
renderWithProviders(<StockPage />, { route: '/stocks/SBER' });
expect(await screen.findByText('Сбер (SBER)')).toBeInTheDocument();
});
it('renders price chart', async () => {
renderWithProviders(<StockPage />, { route: '/stocks/SBER' });
expect(await screen.findByText('График цены')).toBeInTheDocument();
});
it('renders dividends section', async () => {
renderWithProviders(<StockPage />, { route: '/stocks/SBER' });
expect(await screen.findByText('Дивиденды')).toBeInTheDocument();
expect(await screen.findByText('Дата закрытия реестра')).toBeInTheDocument();
});
it('hides dividends section when empty', async () => {
server.use(
http.get(`${API}/securities/shares/:secid/dividends`, () => {
return HttpResponse.json({ data: { data: [], meta: { fromCache: false, cachedAt: null } } });
}),
);
renderWithProviders(<StockPage />, { route: '/stocks/SBER' });
await waitFor(() => {
expect(screen.queryByText('Дивиденды')).not.toBeInTheDocument();
});
});
it('shows error state for not found', async () => {
server.use(
http.get(`${API}/securities/shares/:secid`, () => new HttpResponse(null, { status: 404 })),
http.get(`${API}/securities/shares/:secid/candles`, () => new HttpResponse(null, { status: 404 })),
http.get(`${API}/securities/shares/:secid/dividends`, () => new HttpResponse(null, { status: 404 })),
);
renderWithProviders(<StockPage />, { route: '/stocks/NOTFOUND' });
expect(await screen.findByText('Инструмент не найден')).toBeInTheDocument();
});
});
- Step 3: Create BondPage.test.tsx
import { describe, it, expect } from 'vitest';
import { screen } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { BondPage } from './BondPage';
import { renderWithProviders } from '../test/test-utils';
const API = '/api/v1';
describe('BondPage', () => {
it('shows loading state', () => {
server.use(
http.get(`${API}/securities/bonds/:secid`, () => new Promise(() => {})),
);
renderWithProviders(<BondPage />, { route: '/bonds/SU26238RMFS5' });
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
});
it('renders bond details after loading', async () => {
renderWithProviders(<BondPage />, { route: '/bonds/SU26238RMFS5' });
expect(await screen.findByText('ОФЗ 26238')).toBeInTheDocument();
});
it('renders price chart', async () => {
renderWithProviders(<BondPage />, { route: '/bonds/SU26238RMFS5' });
expect(await screen.findByText('График цены')).toBeInTheDocument();
});
it('shows error state for not found', async () => {
server.use(
http.get(`${API}/securities/bonds/:secid`, () => new HttpResponse(null, { status: 404 })),
http.get(`${API}/securities/bonds/:secid/candles`, () => new HttpResponse(null, { status: 404 })),
);
renderWithProviders(<BondPage />, { route: '/bonds/NOTFOUND' });
expect(await screen.findByText('Инструмент не найден')).toBeInTheDocument();
});
});
- Step 4: Create LoginPage.test.tsx
import { describe, it, expect, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { LoginPage } from './LoginPage';
import { renderWithProviders } from '../test/test-utils';
const API = '/api/v1';
describe('LoginPage', () => {
it('renders login form', () => {
// Mock refresh to fail so we don't auto-auth
server.use(
http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
);
renderWithProviders(<LoginPage />, { route: '/login' });
expect(screen.getByText('Вход')).toBeInTheDocument();
expect(screen.getByPlaceholderText('email@example.com')).toBeInTheDocument();
expect(screen.getByPlaceholderText('••••••••')).toBeInTheDocument();
});
it('redirects to home on successful login', async () => {
server.use(
http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
);
const user = userEvent.setup();
renderWithProviders(<LoginPage />, { route: '/login' });
await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com');
await user.type(screen.getByPlaceholderText('••••••••'), 'password');
await user.click(screen.getByRole('button', { name: 'Войти' }));
await waitFor(() => {
// Login succeeded, should redirect
expect(screen.queryByText('Вход')).not.toBeInTheDocument();
});
});
it('shows error on failed login', async () => {
server.use(
http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
http.post(`${API}/auth/login`, () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' })),
);
const user = userEvent.setup();
renderWithProviders(<LoginPage />, { route: '/login' });
await user.type(screen.getByPlaceholderText('email@example.com'), 'bad@test.com');
await user.type(screen.getByPlaceholderText('••••••••'), 'wrong');
await user.click(screen.getByRole('button', { name: 'Войти' }));
expect(await screen.findByText(/Ошибка API/)).toBeInTheDocument();
});
it('shows loading state while submitting', async () => {
server.use(
http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
http.post(`${API}/auth/login`, () => new Promise(() => {})),
);
const user = userEvent.setup();
renderWithProviders(<LoginPage />, { route: '/login' });
await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com');
await user.type(screen.getByPlaceholderText('••••••••'), 'password');
await user.click(screen.getByRole('button', { name: 'Войти' }));
expect(screen.getByText('Вход...')).toBeInTheDocument();
});
});
- Step 5: Create RegisterPage.test.tsx
import { describe, it, expect } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { RegisterPage } from './RegisterPage';
import { renderWithProviders } from '../test/test-utils';
const API = '/api/v1';
describe('RegisterPage', () => {
it('renders registration form', () => {
server.use(
http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
);
renderWithProviders(<RegisterPage />, { route: '/register' });
expect(screen.getByText('Регистрация')).toBeInTheDocument();
expect(screen.getByPlaceholderText('Иван Иванов')).toBeInTheDocument();
expect(screen.getByPlaceholderText('email@example.com')).toBeInTheDocument();
});
it('shows password mismatch error', async () => {
server.use(
http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
);
const user = userEvent.setup();
renderWithProviders(<RegisterPage />, { route: '/register' });
await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com');
await user.type(screen.getAllByPlaceholderText(/Пароль/)[0], 'password1');
await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password2');
await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' }));
expect(await screen.findByText('Пароли не совпадают')).toBeInTheDocument();
});
it('redirects on successful registration', async () => {
server.use(
http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
);
const user = userEvent.setup();
renderWithProviders(<RegisterPage />, { route: '/register' });
await user.type(screen.getByPlaceholderText('email@example.com'), 'test@test.com');
await user.type(screen.getAllByPlaceholderText(/Пароль/)[0], 'password');
await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password');
await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' }));
await waitFor(() => {
expect(screen.queryByText('Регистрация')).not.toBeInTheDocument();
});
});
it('shows error on failed registration', async () => {
server.use(
http.post(`${API}/auth/refresh`, () => new HttpResponse(null, { status: 401 })),
http.post(`${API}/auth/register`, () => new HttpResponse(null, { status: 409, statusText: 'Conflict' })),
);
const user = userEvent.setup();
renderWithProviders(<RegisterPage />, { route: '/register' });
await user.type(screen.getByPlaceholderText('email@example.com'), 'existing@test.com');
await user.type(screen.getAllByPlaceholderText(/Пароль/)[0], 'password');
await user.type(screen.getByPlaceholderText('Повторите пароль'), 'password');
await user.click(screen.getByRole('button', { name: 'Зарегистрироваться' }));
expect(await screen.findByText(/Ошибка API/)).toBeInTheDocument();
});
});
- Step 6: Create ProfilePage.test.tsx
import { describe, it, expect } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
import { ProfilePage } from './ProfilePage';
import { renderWithProviders } from '../test/test-utils';
const API = '/api/v1';
describe('ProfilePage', () => {
it('renders user profile', async () => {
renderWithProviders(
<ProfilePage />,
{ route: '/profile' },
);
expect(await screen.findByText('Профиль')).toBeInTheDocument();
expect(await screen.findByText('user@test.com')).toBeInTheDocument();
expect(await screen.findByText('user')).toBeInTheDocument();
});
it('shows user name in input', async () => {
renderWithProviders(<ProfilePage />, { route: '/profile' });
const input = await screen.findByDisplayValue('Test User');
expect(input).toBeInTheDocument();
});
it('updates profile on save', async () => {
const user = userEvent.setup();
renderWithProviders(<ProfilePage />, { route: '/profile' });
const input = await screen.findByDisplayValue('Test User');
await user.clear(input);
await user.type(input, 'Updated User');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
expect(await screen.findByText('Профиль обновлён')).toBeInTheDocument();
});
it('shows error message on failed update', async () => {
server.use(
http.patch(`${API}/auth/me`, () => new HttpResponse(null, { status: 500 })),
);
const user = userEvent.setup();
renderWithProviders(<ProfilePage />, { route: '/profile' });
const input = await screen.findByDisplayValue('Test User');
await user.clear(input);
await user.type(input, 'New Name');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
expect(await screen.findByText('Не удалось обновить профиль')).toBeInTheDocument();
});
it('shows saving state', async () => {
server.use(
http.patch(`${API}/auth/me`, () => new Promise(() => {})),
);
const user = userEvent.setup();
renderWithProviders(<ProfilePage />, { route: '/profile' });
const input = await screen.findByDisplayValue('Test User');
await user.clear(input);
await user.type(input, 'New Name');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
expect(await screen.findByText('Сохранение...')).toBeInTheDocument();
});
});
- Step 7: Run all page tests
Run: npx vitest run apps/frontend/src/pages/ -w apps/frontend
Expected: all passing
Task 8: Final verification
- Step 1: Run all tests
Run: npm run test:frontend
Expected: all tests passing
- Step 2: Run format check
Run: npm run format:check
Expected: no formatting issues