|
| 1 | +# Frontend testing |
| 2 | + |
| 3 | +The frontend uses Vitest for unit and integration tests, with Playwright for end-to-end scenarios. Tests live alongside the source code in `__tests__` directories, following the same structure as the components they verify. The setup uses jsdom for DOM simulation and @testing-library/svelte for component rendering, giving you a realistic browser-like environment without the overhead of spinning up actual browsers for every test run. |
| 4 | + |
| 5 | +## Quick start |
| 6 | + |
| 7 | +Run all unit tests from the frontend directory: |
| 8 | + |
| 9 | +```bash |
| 10 | +cd frontend |
| 11 | +npm run test |
| 12 | +``` |
| 13 | + |
| 14 | +For continuous development with watch mode: |
| 15 | + |
| 16 | +```bash |
| 17 | +npm run test:watch |
| 18 | +``` |
| 19 | + |
| 20 | +Run with coverage report: |
| 21 | + |
| 22 | +```bash |
| 23 | +npm run test:coverage |
| 24 | +``` |
| 25 | + |
| 26 | +End-to-end tests require the full stack running locally: |
| 27 | + |
| 28 | +```bash |
| 29 | +npm run test:e2e |
| 30 | +``` |
| 31 | + |
| 32 | +## Test structure |
| 33 | + |
| 34 | +Tests follow a consistent directory layout that mirrors the source code. Each testable module has a `__tests__` folder next to it containing the corresponding test files: |
| 35 | + |
| 36 | +``` |
| 37 | +src/ |
| 38 | +├── stores/ |
| 39 | +│ ├── auth.ts |
| 40 | +│ ├── theme.ts |
| 41 | +│ ├── toastStore.ts |
| 42 | +│ ├── notificationStore.ts |
| 43 | +│ ├── errorStore.ts |
| 44 | +│ └── __tests__/ |
| 45 | +│ ├── auth.test.ts |
| 46 | +│ ├── theme.test.ts |
| 47 | +│ ├── toastStore.test.ts |
| 48 | +│ ├── notificationStore.test.ts |
| 49 | +│ └── errorStore.test.ts |
| 50 | +├── lib/ |
| 51 | +│ ├── auth-init.ts |
| 52 | +│ ├── settings-cache.ts |
| 53 | +│ ├── user-settings.ts |
| 54 | +│ └── __tests__/ |
| 55 | +│ ├── auth-init.test.ts |
| 56 | +│ ├── settings-cache.test.ts |
| 57 | +│ └── user-settings.test.ts |
| 58 | +├── utils/ |
| 59 | +│ ├── meta.ts |
| 60 | +│ └── __tests__/ |
| 61 | +│ └── meta.test.ts |
| 62 | +├── components/ |
| 63 | +│ ├── Spinner.svelte |
| 64 | +│ ├── ErrorDisplay.svelte |
| 65 | +│ ├── Footer.svelte |
| 66 | +│ ├── ToastContainer.svelte |
| 67 | +│ └── __tests__/ |
| 68 | +│ ├── Spinner.test.ts |
| 69 | +│ ├── ErrorDisplay.test.ts |
| 70 | +│ ├── Footer.test.ts |
| 71 | +│ └── ToastContainer.test.ts |
| 72 | +└── e2e/ |
| 73 | + ├── auth.spec.ts |
| 74 | + └── theme.spec.ts |
| 75 | +``` |
| 76 | + |
| 77 | +## What gets tested |
| 78 | + |
| 79 | +The test suite covers several layers of the application, from pure logic to rendered components. |
| 80 | + |
| 81 | +Stores handle reactive state management. Tests verify initial values, state transitions, persistence to localStorage, and subscription behavior. The auth store tests, for example, check login/logout flows, token verification with caching, and graceful handling of network errors with offline-first fallbacks. |
| 82 | + |
| 83 | +Library utilities deal with initialization, caching, and API interactions. The auth-init tests verify the startup sequence that restores persisted sessions, validates tokens with the backend, and handles edge cases like expired or corrupted localStorage data. Settings cache tests ensure proper TTL expiration and nested object updates. |
| 84 | + |
| 85 | +Component tests render Svelte components in jsdom and verify their DOM output, props handling, and user interactions. The Spinner tests check that size and color props produce the expected CSS classes. ErrorDisplay tests verify that network errors show user-friendly messages without exposing raw error details. ToastContainer tests confirm that toasts appear, animate, and disappear on schedule. |
| 86 | + |
| 87 | +E2E tests run in Playwright against the real application. They exercise full user flows like registration, login, theme switching, and protected route access. |
| 88 | + |
| 89 | +## Configuration |
| 90 | + |
| 91 | +Vitest configuration lives in `vitest.config.ts`: |
| 92 | + |
| 93 | +```typescript |
| 94 | +export default defineConfig({ |
| 95 | + plugins: [svelte({ compilerOptions: { runes: true } }), svelteTesting()], |
| 96 | + test: { |
| 97 | + environment: 'jsdom', |
| 98 | + setupFiles: ['./vitest.setup.ts'], |
| 99 | + include: ['src/**/*.{test,spec}.{js,ts}'], |
| 100 | + globals: true, |
| 101 | + coverage: { |
| 102 | + provider: 'v8', |
| 103 | + include: ['src/**/*.{ts,svelte}'], |
| 104 | + exclude: ['src/lib/api/**', 'src/**/*.test.ts'], |
| 105 | + }, |
| 106 | + }, |
| 107 | +}); |
| 108 | +``` |
| 109 | + |
| 110 | +The setup file (`vitest.setup.ts`) provides browser API mocks that jsdom lacks: |
| 111 | + |
| 112 | +```typescript |
| 113 | +// localStorage and sessionStorage mocks |
| 114 | +vi.stubGlobal('localStorage', localStorageMock); |
| 115 | +vi.stubGlobal('sessionStorage', sessionStorageMock); |
| 116 | + |
| 117 | +// matchMedia for theme detection |
| 118 | +vi.stubGlobal('matchMedia', vi.fn().mockImplementation(query => ({ |
| 119 | + matches: false, |
| 120 | + media: query, |
| 121 | + addEventListener: vi.fn(), |
| 122 | + removeEventListener: vi.fn(), |
| 123 | +}))); |
| 124 | + |
| 125 | +// ResizeObserver and IntersectionObserver for layout-dependent components |
| 126 | +vi.stubGlobal('ResizeObserver', vi.fn().mockImplementation(() => ({ |
| 127 | + observe: vi.fn(), |
| 128 | + unobserve: vi.fn(), |
| 129 | + disconnect: vi.fn(), |
| 130 | +}))); |
| 131 | +``` |
| 132 | + |
| 133 | +Playwright configuration in `playwright.config.ts` sets up browser testing: |
| 134 | + |
| 135 | +```typescript |
| 136 | +export default defineConfig({ |
| 137 | + testDir: './e2e', |
| 138 | + timeout: 10000, |
| 139 | + use: { |
| 140 | + baseURL: 'https://localhost:5001', |
| 141 | + screenshot: 'only-on-failure', |
| 142 | + trace: 'on', |
| 143 | + }, |
| 144 | +}); |
| 145 | +``` |
| 146 | + |
| 147 | +## Writing component tests |
| 148 | + |
| 149 | +Component tests use @testing-library/svelte to render components and query the DOM. The library encourages testing from the user's perspective—query by role, label, or text rather than implementation details like CSS classes or component internals. |
| 150 | + |
| 151 | +A typical component test renders the component, queries for elements, and asserts on the output: |
| 152 | + |
| 153 | +```typescript |
| 154 | +import { render, screen } from '@testing-library/svelte'; |
| 155 | +import Spinner from '../Spinner.svelte'; |
| 156 | + |
| 157 | +it('renders with accessible label', () => { |
| 158 | + render(Spinner); |
| 159 | + expect(screen.getByLabelText('Loading')).toBeInTheDocument(); |
| 160 | +}); |
| 161 | + |
| 162 | +it('applies size prop', () => { |
| 163 | + render(Spinner, { props: { size: 'large' } }); |
| 164 | + const svg = screen.getByRole('status'); |
| 165 | + expect(svg.classList.contains('h-8')).toBe(true); |
| 166 | +}); |
| 167 | +``` |
| 168 | + |
| 169 | +For components with user interactions, use `@testing-library/user-event`: |
| 170 | + |
| 171 | +```typescript |
| 172 | +import userEvent from '@testing-library/user-event'; |
| 173 | + |
| 174 | +it('calls reload on button click', async () => { |
| 175 | + const user = userEvent.setup(); |
| 176 | + render(ErrorDisplay, { props: { error: 'Something broke' } }); |
| 177 | + |
| 178 | + await user.click(screen.getByRole('button', { name: /Reload/i })); |
| 179 | + expect(window.location.reload).toHaveBeenCalled(); |
| 180 | +}); |
| 181 | +``` |
| 182 | + |
| 183 | +Svelte 5 components using transitions need the Web Animations API mocked: |
| 184 | + |
| 185 | +```typescript |
| 186 | +Element.prototype.animate = vi.fn().mockImplementation(() => ({ |
| 187 | + onfinish: null, |
| 188 | + cancel: vi.fn(), |
| 189 | + finish: vi.fn(), |
| 190 | +})); |
| 191 | +``` |
| 192 | + |
| 193 | +## Testing stores |
| 194 | + |
| 195 | +Svelte stores are plain JavaScript, so they test without any special setup. Import the store, call its methods, and check the current value with `get()`: |
| 196 | + |
| 197 | +```typescript |
| 198 | +import { get } from 'svelte/store'; |
| 199 | +import { toasts, addToast, removeToast } from '../toastStore'; |
| 200 | + |
| 201 | +beforeEach(() => { |
| 202 | + toasts.set([]); |
| 203 | +}); |
| 204 | + |
| 205 | +it('adds toast with correct type', () => { |
| 206 | + addToast('Success!', 'success'); |
| 207 | + const current = get(toasts); |
| 208 | + expect(current[0].type).toBe('success'); |
| 209 | +}); |
| 210 | +``` |
| 211 | + |
| 212 | +For stores that persist to localStorage, mock the storage API and use `vi.resetModules()` to get fresh module state between tests: |
| 213 | + |
| 214 | +```typescript |
| 215 | +beforeEach(async () => { |
| 216 | + vi.mocked(localStorage.getItem).mockReturnValue(null); |
| 217 | + vi.resetModules(); |
| 218 | +}); |
| 219 | + |
| 220 | +it('restores from localStorage', async () => { |
| 221 | + localStorage.getItem.mockReturnValue(JSON.stringify({ theme: 'dark' })); |
| 222 | + const { theme } = await import('../theme'); |
| 223 | + expect(get(theme)).toBe('dark'); |
| 224 | +}); |
| 225 | +``` |
| 226 | + |
| 227 | +## Mocking API calls |
| 228 | + |
| 229 | +API functions are mocked at the module level using `vi.mock()`. Define mock functions at the top of the file, then configure their return values per test: |
| 230 | + |
| 231 | +```typescript |
| 232 | +const mockLoginApi = vi.fn(); |
| 233 | +vi.mock('../../lib/api', () => ({ |
| 234 | + loginApiV1AuthLoginPost: (...args) => mockLoginApi(...args), |
| 235 | +})); |
| 236 | + |
| 237 | +beforeEach(() => { |
| 238 | + mockLoginApi.mockReset(); |
| 239 | +}); |
| 240 | + |
| 241 | +it('handles successful login', async () => { |
| 242 | + mockLoginApi.mockResolvedValue({ |
| 243 | + data: { username: 'testuser', role: 'user', csrf_token: 'token' }, |
| 244 | + error: null, |
| 245 | + }); |
| 246 | + |
| 247 | + const { login, isAuthenticated } = await import('../auth'); |
| 248 | + await login('testuser', 'password'); |
| 249 | + |
| 250 | + expect(get(isAuthenticated)).toBe(true); |
| 251 | +}); |
| 252 | +``` |
| 253 | + |
| 254 | +## CI integration |
| 255 | + |
| 256 | +The frontend CI workflow runs tests as part of the build process. Unit tests run first, and if they pass, e2e tests run against the built application. Coverage reports go to Codecov for tracking. |
| 257 | + |
| 258 | +```yaml |
| 259 | +- name: Run unit tests |
| 260 | + run: npm run test:coverage |
| 261 | + |
| 262 | +- name: Run e2e tests |
| 263 | + run: npm run test:e2e |
| 264 | +``` |
| 265 | +
|
| 266 | +Tests timeout after 5 minutes for unit tests and 10 minutes for e2e. If you're adding slow tests, consider whether they belong in the e2e suite rather than unit tests. |
| 267 | +
|
| 268 | +## Troubleshooting |
| 269 | +
|
| 270 | +When tests fail with "Cannot read properties of undefined (reading 'matches')", you're missing the matchMedia mock. Add it to your test file or ensure vitest.setup.ts is loading correctly. |
| 271 | +
|
| 272 | +Svelte transition errors like "element.animate is not a function" mean you need to mock the Web Animations API. Add the animate mock before rendering components that use `fly`, `fade`, or other transitions. |
| 273 | + |
| 274 | +Timing issues with fake timers and async components usually mean you're mixing `vi.useFakeTimers()` with `waitFor()`. Either use real timers for that test or manually advance time with `vi.advanceTimersByTimeAsync()`. |
| 275 | + |
| 276 | +Store tests that bleed state between runs need `vi.resetModules()` in beforeEach. This clears the module cache so each test gets fresh store instances. |
0 commit comments