Testing & QA
This document outlines testing strategies, patterns, and quality assurance processes for FT Configs UI.
Current Gates
-
Lint —
npm run lint(ESLint 9 +typescript-eslint, flat config ineslint.config.mjs). -
Typecheck —
npm run typecheck(tsc --noEmit). -
Tests —
npm run test(Vitest, single run). Colocate specs next to source as.test.ts/.test.tsx. -
Coverage — not currently enforced.
vitest.config.tscontains nocoveragesection and notest:coveragescript is defined; add both intentionally before claiming coverage thresholds in CI.
Testing strategy
Testing pyramid
Current status:
-
✅ Static Analysis — TypeScript + ESLint (enforced via
npm run lintandnpm run typecheck) -
✅ Unit Tests — Vitest configured and running (
npm run test) -
⚠️ Integration Tests — Not yet implemented (recommended: React Testing Library)
-
⚠️ E2E Tests — Not yet implemented (recommended: Playwright)
Unit testing patterns
Status: Vitest is configured and running. Unit tests are colocated alongside source files as *.test.ts.
Running tests
# Run all tests
npm run test
# Run a specific test file
npx vitest run src/lib/provision-portal-params.test.ts
# Run tests in watch mode (development)
npx vitest src/lib/
Configuration
Vitest is configured in vitest.config.ts with:
-
jsdomenvironment for browser API simulation -
@/path alias support (matchingtsconfig.json) -
Globals enabled (
describe,it,expectavailable without imports) -
Setup file:
vitest.setup.ts
Testing utility functions
// src/lib/error-utils.test.ts
import {describe, it, expect} from 'vitest';
import {classifyError, extractErrorMessage} from './error-utils';
describe('error-utils', () => {
describe('classifyError', () => {
it('classifies server errors correctly', () => {
const error = {
response: {
status: 500,
data: {message: 'Server error'},
},
};
expect(classifyError(error)).toBe('server');
});
it('classifies network errors correctly', () => {
const error = {
code: 'NETWORK_ERROR',
message: 'Network Error',
};
expect(classifyError(error)).toBe('network');
});
it('classifies validation errors correctly', () => {
const error = {
name: 'ValidationError',
errors: [{message: 'Invalid input'}],
};
expect(classifyError(error)).toBe('validation');
});
});
describe('extractErrorMessage', () => {
it('extracts message from Axios error', () => {
const error = {
response: {
status: 400,
data: {message: 'Bad request'},
},
};
const t = (key: string) => key;
expect(extractErrorMessage(error, t)).toBe('Bad request');
});
it('returns fallback for unknown errors', () => {
const error = {};
const t = (key: string) => key === 'errors.unknown' ? 'Unknown error' : key;
expect(extractErrorMessage(error, t)).toBe('Unknown error');
});
});
});
Testing React hooks
// src/hooks/use-debounce.test.ts
import {describe, it, expect, vi} from 'vitest';
import {renderHook, waitFor} from '@testing-library/react';
import {useDebounce} from './use-debounce';
describe('useDebounce', () => {
it('debounces value changes', async () => {
const {result, rerender} = renderHook(
({value, delay}) => useDebounce(value, delay),
{
initialProps: {value: 'initial', delay: 250},
}
);
expect(result.current).toBe('initial');
rerender({value: 'changed', delay: 250});
expect(result.current).toBe('initial'); // Still old value
await waitFor(() => expect(result.current).toBe('changed'), {
timeout: 300,
});
});
});
Testing Zod schemas
// src/lib/validation.test.ts
import {describe, it, expect} from 'vitest';
import {createLoginSchema} from './validation';
describe('validation schemas', () => {
const t = (key: string) => {
const messages: Record<string, string> = {
'auth.validation.usernameMinLength': 'Username must be at least 3 characters',
'auth.validation.passwordMinLength': 'Password must be at least 6 characters',
};
return messages[key] || key;
};
describe('createLoginSchema', () => {
const schema = createLoginSchema(t);
it('accepts valid credentials', () => {
const valid = {
username: 'admin',
password: 'password123',
};
expect(() => schema.parse(valid)).not.toThrow();
});
it('rejects short username', () => {
const invalid = {
username: 'ab',
password: 'password123',
};
expect(() => schema.parse(invalid)).toThrow('Username must be at least 3 characters');
});
it('rejects short password', () => {
const invalid = {
username: 'admin',
password: '12345',
};
expect(() => schema.parse(invalid)).toThrow('Password must be at least 6 characters');
});
});
});
Integration testing strategy
Status: Not currently implemented
Recommendation: Use React Testing Library for component integration tests
Testing form components
// src/components/auth/login-form.test.tsx
import {describe, it, expect, vi} from 'vitest';
import {render, screen, fireEvent, waitFor} from '@testing-library/react';
import {LoginForm} from './login-form';
import {I18nProvider} from '@/contexts/i18n-provider';
const renderWithProviders = (component: React.ReactElement) => {
return render(
<I18nProvider locale="en">
{component}
</I18nProvider>
);
};
describe('LoginForm', () => {
it('renders login form fields', () => {
renderWithProviders(<LoginForm onSubmit={vi.fn()} />);
expect(screen.getByLabelText(/username/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
expect(screen.getByRole('button', {name: /login/i})).toBeInTheDocument();
});
it('shows validation errors for invalid input', async () => {
renderWithProviders(<LoginForm onSubmit={vi.fn()} />);
const submitButton = screen.getByRole('button', {name: /login/i});
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/username must be at least 3 characters/i)).toBeInTheDocument();
expect(screen.getByText(/password must be at least 6 characters/i)).toBeInTheDocument();
});
});
it('calls onSubmit with valid credentials', async () => {
const handleSubmit = vi.fn();
renderWithProviders(<LoginForm onSubmit={handleSubmit} />);
const usernameInput = screen.getByLabelText(/username/i);
const passwordInput = screen.getByLabelText(/password/i);
const submitButton = screen.getByRole('button', {name: /login/i});
fireEvent.change(usernameInput, {target: {value: 'admin'}});
fireEvent.change(passwordInput, {target: {value: 'password123'}});
fireEvent.click(submitButton);
await waitFor(() => {
expect(handleSubmit).toHaveBeenCalledWith({
username: 'admin',
password: 'password123',
});
});
});
});
Testing API integration with MSW
// src/mocks/handlers.ts (Mock Service Worker)
import {rest} from 'msw';
export const handlers = [
rest.post('/auth/login', async (req, res, ctx) => {
const {username, password} = await req.json();
if (username === 'admin' && password === 'password123') {
return res(
ctx.status(200),
ctx.json({
user: {
id: 1,
username: 'admin',
role: 'ADMIN',
},
})
);
}
return res(
ctx.status(401),
ctx.json({
message: 'Invalid credentials',
})
);
}),
rest.get('/acs/dashboard', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
modules: ['Bulk Data', 'Configuration', 'FCC Config'],
})
);
}),
];
// vitest.setup.ts (add MSW)
import {setupServer} from 'msw/node';
import {handlers} from './src/mocks/handlers';
import {afterAll, afterEach, beforeAll} from 'vitest';
export const server = setupServer(...handlers);
beforeAll(() => server.listen({onUnhandledRequest: 'error'}));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
E2E testing strategy
Status: Not currently implemented
Recommendation: Use Playwright for E2E tests
Setup Playwright
npm install -D @playwright/test
npx playwright install
// playwright.config.ts
import {defineConfig, devices} from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:9002',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: {...devices['Desktop Chrome']},
},
{
name: 'firefox',
use: {...devices['Desktop Firefox']},
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:9002',
reuseExistingServer: !process.env.CI,
},
});
E2E test examples
Authentication flow:
// e2e/auth.spec.ts
import {test, expect} from '@playwright/test';
test.describe('Authentication', () => {
test('should login successfully', async ({page}) => {
await page.goto('/login');
await page.fill('input[name="username"]', 'admin');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/');
await expect(page.locator('text=Welcome, admin')).toBeVisible();
});
test('should show error for invalid credentials', async ({page}) => {
await page.goto('/login');
await page.fill('input[name="username"]', 'admin');
await page.fill('input[name="password"]', 'wrongpassword');
await page.click('button[type="submit"]');
await expect(page.locator('text=Invalid credentials')).toBeVisible();
});
test('should redirect unauthenticated users', async ({page}) => {
await page.goto('/acs/dashboard');
await expect(page).toHaveURL('/login');
});
});
Import/Export flow:
// e2e/import-export.spec.ts
import {test, expect} from '@playwright/test';
import path from 'path';
test.describe('Import/Export', () => {
test.beforeEach(async ({page}) => {
// Login
await page.goto('/login');
await page.fill('input[name="username"]', 'admin');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
});
test('should export configuration', async ({page}) => {
await page.goto('/acs/fcc-config');
const downloadPromise = page.waitForEvent('download');
await page.click('button:has-text("Export")');
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/fcc.*\.properties/);
});
test('should run dry-run before import', async ({page}) => {
await page.goto('/acs/fcc-config');
await page.click('button:has-text("Import")');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(path.join(__dirname, 'fixtures', 'fcc.properties'));
// Wait for dry-run to complete
await expect(page.locator('text=Dry-run complete')).toBeVisible();
// Verify summary
await expect(page.locator('text=To add:')).toBeVisible();
await expect(page.locator('text=To update:')).toBeVisible();
// Apply import
await page.click('button:has-text("Apply import")');
await expect(page.locator('text=Import successful')).toBeVisible();
});
test('should block import with errors', async ({page}) => {
await page.goto('/acs/fcc-config');
await page.click('button:has-text("Import")');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles(path.join(__dirname, 'fixtures', 'invalid.properties'));
await expect(page.locator('text=Errors found')).toBeVisible();
await expect(page.locator('button:has-text("Apply import")')).toBeDisabled();
});
});
Visual regression testing
Status: Not currently implemented
Recommendation: Add visual regression testing for critical UI components
Setup with Playwright
// e2e/visual.spec.ts
import {test, expect} from '@playwright/test';
test.describe('Visual Regression', () => {
test('dashboard should match snapshot', async ({page}) => {
await page.goto('/acs/dashboard');
await expect(page).toHaveScreenshot('dashboard.png');
});
test('login page should match snapshot', async ({page}) => {
await page.goto('/login');
await expect(page).toHaveScreenshot('login.png');
});
test('configuration page should match snapshot', async ({page}) => {
await page.goto('/acs/fcc-config');
await expect(page).toHaveScreenshot('fcc-config.png');
});
});
Workflow:
-
First run generates baseline snapshots
-
Subsequent runs compare against baseline
-
Failures show visual diff
-
Developer approves or rejects changes
Test data management
Fixture organization
e2e/
├── fixtures/
│ ├── fcc.properties # Valid FCC config
│ ├── invalid.properties # Invalid format
│ ├── fcc-with-warnings.xml # Valid with warnings
│ └── users.json # Test user data
├── auth.spec.ts
├── import-export.spec.ts
└── visual.spec.ts
Test data factories
// e2e/factories/user.factory.ts
export const createTestUser = (overrides?: Partial<User>) => ({
id: 1,
username: 'testuser',
email: 'test@example.com',
role: 'EDITOR' as UserRole,
...overrides,
});
export const createAdminUser = () =>
createTestUser({
username: 'admin',
role: 'ADMIN',
});
export const createViewerUser = () =>
createTestUser({
username: 'viewer',
role: 'VIEWER',
});
Database seeding (if needed)
// e2e/setup/seed.ts
import {apiService} from '@/lib/axios';
export const seedTestData = async () => {
// Create test users
await apiService.post('/auth/register', {
username: 'viewer',
password: 'password123',
email: 'viewer@example.com',
role: 'VIEWER',
});
// Create test configuration
await apiService.post('/acs/fcc-config', [
{key: 'test.key', value: '123', valueType: 'INT'},
]);
};
export const cleanupTestData = async () => {
// Delete test users and data
await apiService.delete('/test/cleanup');
};
Manual QA Playbook
-
Validate auth flows: login, first-login password rotation, register, change-password, logout.
-
Smoke core dictionaries: list/filter, create/update/delete, import dry-run then import, export download (verify filename/content).
-
Check client type selectors stay visible/disabled with toasts explaining prerequisites.
-
Confirm toasts and FormMessage surface validation and backend errors in en/ru.
-
Run northbound import (properties + XML dry-run) and export; ensure blocked actions until dry-run ok.
Manual testing checklist
Authentication
-
Login with valid credentials
-
Login with invalid credentials (error message)
-
First-login password rotation
-
Change password (existing user)
-
Register new user
-
Logout (session cleared)
-
Token refresh on 401
-
Redirect to login when unauthenticated
Authorization
-
VIEWER role: read-only mode enforced
-
EDITOR role: create/edit/delete enabled
-
ADMIN role: user management accessible
-
Read-only request blocking works
-
Frontend disables write buttons for VIEWER
Import/Export
-
Export downloads correct file format
-
Import dry-run shows preview
-
Import errors block apply
-
Import warnings require confirmation
-
Import success updates list
-
Export filename matches convention
Forms & Validation
-
Required fields show error when empty
-
Email validation works
-
Password complexity enforced
-
Min/max length constraints work
-
Custom validation messages localized
-
Form submission blocked when invalid
Future Automation
-
Add Playwright suites via the bundled Playwright MCP wrapper (see repo tooling) for critical flows (auth, import/export, tab-view, replace-services).
-
Document any new scripts under
package.json(e.g.,npm run test) and add setup notes to release manifests.
Recommended test targets
Coverage is currently not enforced. When coverage is introduced, these are the areas to prioritize first:
Unit tests:
* Utility functions (src/lib/)
* Hooks (src/hooks/)
* Validation schemas (src/lib/validation.ts)
* Error handling (src/lib/error-utils.ts)
Integration tests (Vitest + @testing-library/react + MSW):
* Form components
* API integration via MSW handlers
* Context providers (I18nProvider, AuthProvider, QueryProvider)
E2E tests (Playwright, not yet wired up): * Authentication flow * Import/Export workflow * CRUD operations * Read-only enforcement
CI/CD integration
# .github/workflows/test.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Typecheck
run: npm run typecheck
- name: Unit / integration tests
run: npm run test
- name: Build
run: npm run build