DEV Community

Cover image for Frontend Tests
nicolas.vbgh
nicolas.vbgh

Posted on • Edited on

Frontend Tests

Part of The Coercion Saga — making AI write quality code.

The UI Gate

Backend tested. API returns correct data. But the button that calls it? The form that submits? That's frontend territory.


Vitest Over Jest

Jest is slow. Vitest runs natively in Vite. Same config, instant startup.

// vitest.config.ts
export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.ts'],
    globals: true,
  },
});
Enter fullscreen mode Exit fullscreen mode

MSW: Fake the Network

Tests that hit real APIs are flaky. MSW intercepts at the network layer. Your code doesn't know it's being lied to.

// src/test/mocks/handlers.ts
export const handlers = [
  http.get('/api/users/me', () => {
    return HttpResponse.json({ id: 1, email: 'test@example.com' });
  }),
];
Enter fullscreen mode Exit fullscreen mode
// src/test/setup.ts
export const server = setupServer(...handlers);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Enter fullscreen mode Exit fullscreen mode

Testing Library

Don't test implementation. Test behavior.

test('displays user email after load', async () => {
  render(<UserProfile />);
  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  await waitFor(() => {
    expect(screen.getByText('test@example.com')).toBeInTheDocument();
  });
});
Enter fullscreen mode Exit fullscreen mode

Use queries in order: getByRole > getByLabelText > getByText > getByTestId. If you need getByTestId, your UI might not be accessible.


What to Test

Interactions:

test('submits form and shows success', async () => {
  const user = userEvent.setup();
  render(<ContactForm />);

  await user.type(screen.getByLabelText('Email'), 'user@example.com');
  await user.click(screen.getByRole('button', { name: /send/i }));

  expect(screen.getByRole('button')).toBeDisabled();  // Loading state

  await waitFor(() => {
    expect(screen.getByText('Message sent')).toBeInTheDocument();
  });
});
Enter fullscreen mode Exit fullscreen mode

The disabled check catches double-submit bugs.

Error states:

test('shows error on failure', async () => {
  server.use(
    http.post('/api/contact', () =>
      HttpResponse.json({ detail: 'Server error' }, { status: 500 })
    )
  );

  // ... submit form, see error message
});
Enter fullscreen mode Exit fullscreen mode

Override the default handler. Test that the UI shows the error.


The Gate

test:frontend:
  stage: test
  image: node:lts-slim
  before_script:
    - cd frontend && npm ci
  script:
    - npm test -- --run --coverage
  allow_failure: false
Enter fullscreen mode Exit fullscreen mode

--run makes Vitest exit after tests. Without it, CI hangs forever.

Copy, paste, adapt. It works.


The Point

Backend tests prove the API is correct. Frontend tests prove the UI is correct. Neither replaces the other.

A button that calls a working API but doesn't show the result? Backend tests pass. Users see nothing. Frontend tests catch it.

That's the deal.


Next up: The Python–TypeScript Contract — Backend works. Frontend works. Now make sure they work together.

Top comments (0)