ESC

Type to search the knowledge base.

Integration Testing UI

Test composed UI with real children and network stubs — the sweet spot between shallow unit tests and full E2E.

intermediate3 min read
  • testing
  • integration-testing

UI integration tests render a coherent slice of the app — page or feature — with real child components, router, and query client, while stubbing the network. They catch wiring bugs without browser/driver cost of full E2E.

Docs: Testing Library, MSW, React Testing Library.

Where they sit

Unit (pure functions, hooks)
  → Integration (page + providers + MSW)
    → E2E (real browser + env)

See testing pyramid.

Example: profile page

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';
import { ProfilePage } from './ProfilePage';
import { AppProviders } from './test-utils';

const server = setupServer(
  http.get('/api/me', () =>
    HttpResponse.json({ id: '1', name: 'Ada', email: 'ada@example.com' }),
  ),
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

test('shows profile and edits name', async () => {
  server.use(
    http.patch('/api/me', async ({ request }) => {
      const body = await request.json();
      return HttpResponse.json({ id: '1', ...body });
    }),
  );

  const user = userEvent.setup();
  render(
    <AppProviders>
      <ProfilePage />
    </AppProviders>,
  );

  expect(await screen.findByRole('heading', { name: 'Ada' })).toBeInTheDocument();
  await user.clear(screen.getByLabelText(/name/i));
  await user.type(screen.getByLabelText(/name/i), 'Ada Lovelace');
  await user.click(screen.getByRole('button', { name: /save/i }));
  expect(await screen.findByRole('status')).toHaveTextContent(/saved/i);
});

What to include

  • Real feature components
  • Router with initial entries
  • Form libraries as used in prod
  • Query cache providers

What to stub

  • HTTP (MSW)
  • Heavy analytics
  • Time (when needed)
  • Feature flags (controlled)

Avoid mocking child presentational components — you retest mocks.

Provider test harness

export function AppProviders({ children }: { children: React.ReactNode }) {
  const client = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });
  return (
    <QueryClientProvider client={client}>
      <MemoryRouter>{children}</MemoryRouter>
    </QueryClientProvider>
  );
}

Disable retries so failures surface fast.

Assertions that matter

  • Roles and labels users see
  • Error and empty states
  • URL changes
  • Disabled/busy buttons during submit

Not: internal Redux actions, CSS class names.

Footguns

  1. Over-mocking so nothing real runs.
  2. One giant “app test” for everything.
  3. Leaving query retries on → slow flake.
  4. Not testing failure HTTP paths.

Interview out-loud answer

“Integration UI tests render a feature with real children and providers, stub network with MSW, and assert user-visible outcomes. They’re my default for pages; pure unit for logic; E2E for a few critical journeys.”

Auth wrappers

Many integration tests fail on redirects to login. Provide a test harness that seeds an authenticated session (cookie/token) or renders the feature with a stubbed auth context. Don’t mock the entire page under test to avoid the auth provider.

Extra practice

Write a minimal demo in a scratch file or the playground: one happy path, one failure path, and one boundary input. If you cannot exhibit a bug that the pattern prevents, you do not own the concept yet — re-read the primary docs linked below and tighten the example until the failure is obvious.

Notes from real codebases

Teams that succeed here keep the rules mechanical: lint where possible, CI for the rest, and a short human checklist for what automation cannot see. Document exceptions with an owner name and a removal date so “temporary” escapes do not become permanent architecture.

Further reading

Related guides