Schedule > UI Testing: LoginPage (component tests)
This walkthrough is a gentle introduction to React Testing Library component testing using a real page:
ui/src/pages/auth/LoginPage.tsx
You’ll practice:
- Simulating typing/clicking with
userEvent - Mocking
fetch(network) - Mocking
useAuth(context dependency) - Mocking
useNavigate+ using fake timers (redirect after success)
1. Goal
Add component tests for:
ui/src/pages/auth/LoginPage.tsx
2. Setup
From ui/:
docker exec -it tma_frontend npm test
3. Open the page you’re testing
Open: ui/src/pages/auth/LoginPage.tsx
Identify key behaviors:
- Renders username/password inputs and a submit button
- On failed login: shows an error
Alert - On successful login: calls
login(access_token), shows successAlert, and then navigates to/after a timeout
4. Create the test file
Create:
ui/src/pages/auth/__tests__/LoginPage.test.tsx
5. Starter tests (copy/paste)
This activity is easiest if you do it in small steps. Don’t start by copy/pasting a huge test file.
5.1. What we’re testing (beginner-friendly minimum)
Required (good beginner set):
- The form renders (Username, Password, Login button)
- Failed login shows an error message
- Successful login calls
login(access_token)and shows the success message
Stretch (optional):
- Verify it navigates to
/after 500ms (requires mocking navigation + fake timers)
5.2. What we need to “fake” in tests
LoginPage depends on two things outside the component:
useAuth()(so we’ll mock it to supply a fakeAPI_URLand a fakelogin()function)fetch(so tests don’t do real network calls)
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { MantineProvider } from '@mantine/core';
import { theme } from '../../../theme';
import LoginPage from '../LoginPage';
// Mock AuthContext so we control API_URL + login().
// Without this, the component would use the real AuthContext (and real app state).
const loginMock = vi.fn();
vi.mock('../../../contexts/AuthContext', () => ({
useAuth: () => ({
isAuthenticated: false,
API_URL: 'http://localhost:8000/api',
login: loginMock,
userInfo: null,
loading: false,
}),
}));
function renderLoginPage() {
// This page uses react-router components (<Link>) so it needs a router.
// Mantine components work best inside MantineProvider so styles/behavior are consistent.
return render(
<MemoryRouter>
<MantineProvider theme={theme}>
<LoginPage />
</MantineProvider>
</MemoryRouter>
);
}
describe('LoginPage', () => {
beforeEach(() => {
// Reset mocks between tests so tests can’t affect each other.
vi.restoreAllMocks();
loginMock.mockReset();
});
afterEach(() => {
// If a later test uses fake timers, this ensures we restore real timers.
vi.useRealTimers();
});
it('renders the login form', () => {
renderLoginPage();
// `getByLabelText(/username/i)` finds the input the same way a user would:
// by looking at the visible <label> text ("Username"). The `/.../i` means
// “case-insensitive regex match”.
//
// `toBeInTheDocument()` is just “this element rendered on the page”.
expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
// Button text is "Login" in this page
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument();
});
it('shows an error alert on failed login', async () => {
const user = userEvent.setup();
// Stub the global fetch() so clicking Login doesn’t hit a real backend.
// This simulates a failed login response with a JSON error message.
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
json: async () => ({ detail: 'Invalid credentials' }),
})
);
renderLoginPage();
// `user.type(...)` simulates a real user typing into the input.
// That fires keyboard + input/change events, which triggers the component’s
// `onChange` handlers and updates React state.
//
// We `await` typing because userEvent is async (it simulates keystrokes over time).
// Targets the form control, not the label element:
await user.type(screen.getByLabelText(/username/i), 'testuser');
await user.type(screen.getByLabelText(/password/i), 'wrongpassword');
await user.click(screen.getByRole('button', { name: /login/i }));
// The page shows the server-provided error message in an Alert.
expect(await screen.findByText(/invalid credentials/i)).toBeInTheDocument();
expect(loginMock).not.toHaveBeenCalled();
});
it('on success: calls login(token) and shows success message', async () => {
const user = userEvent.setup();
// Simulate a successful login response: server returns an access_token.
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ access_token: 'test-token' }),
})
);
renderLoginPage();
// Note: LoginPage trims username before sending it.
await user.type(screen.getByLabelText(/username/i), ' alice ');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /login/i }));
// Page should call login() with the token from the response
expect(loginMock).toHaveBeenCalledWith('test-token');
expect(await screen.findByText(/login successful/i)).toBeInTheDocument();
});
});
6. Add one more test (recommended)
Pick one:
- Assert
fetchwas called with the expected URL:${API_URL}/auth/login - Assert the request body uses
username.trim()(e.g.,' alice 'becomes'alice') - Assert the submit button shows a loading state while waiting
7. Stretch: test the redirect after 500ms (optional)
LoginPage does setTimeout(() => navigate('/'), 500). To test that, you need:
- a mocked
useNavigate - fake timers
Add at the top of the file (above renderLoginPage()):
// We'll replace react-router's real navigate() function with a spy.
// In the real app, navigate() changes the URL. In tests, we want to *observe* redirects.
const navigateMock = vi.fn();
// IMPORTANT: this must be top-level (not inside a test), so it applies when LoginPage is imported.
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>(
'react-router-dom'
);
return {
...actual,
// Any component that calls useNavigate() will now receive our navigateMock.
useNavigate: () => navigateMock,
};
});
Then add a test like:
it('after success: navigates to / after 500ms (stretch)', async () => {
// Fake timers let us advance time instantly (instead of waiting a real 500ms).
vi.useFakeTimers();
navigateMock.mockReset();
// userEvent uses timers internally; this tells it how to advance timers while typing.
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ access_token: 'test-token' }),
})
);
renderLoginPage();
await user.type(screen.getByLabelText(/username/i), 'alice');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /login/i }));
// Wait until the page shows the success message.
// This tells us the "success branch" ran and the setTimeout(…, 500) redirect was scheduled.
await screen.findByText(/login successful/i);
// Run the timeout callback (500ms) immediately.
vi.advanceTimersByTime(500);
expect(navigateMock).toHaveBeenCalledWith('/');
});
8. Run your tests
From ui/:
docker exec -it tma_frontend npm test