Testing & QA

This document outlines testing strategies, patterns, and quality assurance processes for FT Configs UI.

Current Gates

  • Lintnpm run lint (ESLint 9 + typescript-eslint, flat config in eslint.config.mjs).

  • Typechecknpm run typecheck (tsc --noEmit).

  • Testsnpm run test (Vitest, single run). Colocate specs next to source as .test.ts / .test.tsx.

  • Coverage — not currently enforced. vitest.config.ts contains no coverage section and no test:coverage script is defined; add both intentionally before claiming coverage thresholds in CI.

Testing strategy

Testing pyramid

testing-pyramid

Current status:

  • Static Analysis — TypeScript + ESLint (enforced via npm run lint and npm 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:

  • jsdom environment for browser API simulation

  • @/ path alias support (matching tsconfig.json)

  • Globals enabled (describe, it, expect available 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

Setup React Testing Library

Already included in Vitest setup above.

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:

  1. First run generates baseline snapshots

  2. Subsequent runs compare against baseline

  3. Failures show visual diff

  4. 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

UI/UX

  • Toasts appear for success/error

  • Loading spinners show during async operations

  • Client type selector blocks actions until selected

  • Search/filter works correctly

  • Pagination works (if applicable)

  • Responsive layout on mobile/tablet

  • Keyboard navigation works

  • Screen reader compatibility (basic)

I18n

  • English translations complete

  • Russian translations complete

  • Locale switcher works

  • Locale persists after refresh

  • Error messages localized

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.

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