Unit vs integration test boundaries for UI components
This page is part of the Test Scope Definition section, which covers how to decide what a given test should and should not exercise. It builds directly on the broader guide to defining test scope for UI component libraries, narrowing that discussion to one recurring argument: when a component renders several children, is the test a unit test or an integration test?
The specific problem: a test renders a <CheckoutForm> that itself renders <AddressFields>, <PaymentMethod>, and <OrderSummary>. One engineer calls it a unit test because it targets one component; another calls it an integration test because three children run. The label decides where the test lives, how often it runs, and whether someone writes a second test covering the same ground. Without a shared rule, teams duplicate coverage and let slow, real-dependency tests pile up at the wrong layer.
Problem statement
“Unit” and “integration” are not intrinsic properties of a component test — they describe how much of the real system participates. A React component almost always renders children, so “renders more than one component” cannot be the dividing line, or nothing would ever be a unit test. Teams that use component count as the criterion end up with two failure modes.
The first is duplicated coverage: the same “submitting an empty form shows a validation error” assertion is written once as a fast test with stubbed children and again as a slow test with real children and a real store. Both pass, both must be maintained, and neither engineer trusts the other’s version.
The second is a mislabelled, slow base layer: integration-scope tests — real store, real children, real timers — get filed as unit tests and run on every keystroke, so the “fast” suite is no longer fast. The fix is a boundary rule based on what is mocked, not on how many components render.
Root cause
The confusion comes from classifying by component count instead of by boundary. The useful question is not “how many components rendered?” but “where did the test cut the system?” A test that replaces the network or a service module with a mock, and renders the component with lightweight or stubbed children, is exercising one unit of behaviour. A test that keeps the real child components and a real store in play is exercising the integration between them. Drawing that line consistently is the core of test scope definition; getting it wrong is why the same behaviour ends up tested twice.
The rule, stated once: mock at the network or module boundary and stub the children → unit test; keep real children and a real store, mock only the network → integration test.
Minimal reproduction
The ambiguity in one file: the same component, tested two ways, with nothing telling a reader which layer either test belongs to.
// CheckoutForm renders three children and calls a service module
import { submitOrder } from '@/services/checkout'; // module boundary
import { AddressFields } from './AddressFields';
import { PaymentMethod } from './PaymentMethod';
import { OrderSummary } from './OrderSummary';
export function CheckoutForm() {
// ...reads from a shared store, renders the three children, calls submitOrder()
}
// checkout.ambiguous.test.tsx — is this unit or integration? Nobody can tell.
test('shows a validation error for an empty address', async () => {
render(<CheckoutForm />); // real children? real store? mocked network? unclear
await userEvent.click(screen.getByRole('button', { name: /place order/i }));
expect(screen.getByText(/address is required/i)).toBeVisible();
});
Because the test does not declare where it cut the system, a second engineer writes a near-identical test at the other layer, and now the same assertion runs twice with different setups.
Step-by-step fix
1. List the boundaries the component crosses
Before writing a test, enumerate what the component depends on. CheckoutForm crosses three: its child components, the shared store, and the submitOrder service module (which hits the network).
CheckoutForm boundaries
├─ child components : AddressFields, PaymentMethod, OrderSummary
├─ shared store : useCheckoutStore() (Zustand)
└─ service / network: submitOrder() → POST /api/orders
What this does: it turns the vague “how many components?” question into a concrete list of cut points, so the choice becomes which of these do I keep real? rather than an argument about labels.
2. Write the unit test at the module boundary
Mock the service module, stub the children to trivial markers, and assert only on the parent’s own logic. This is a unit test because the only real thing under assertion is CheckoutForm itself.
// CheckoutForm.unit.test.tsx
import { vi, test, expect, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CheckoutForm } from './CheckoutForm';
// Mock at the module boundary — the network never runs.
vi.mock('@/services/checkout', () => ({ submitOrder: vi.fn() }));
// Stub children so only the parent's own logic is exercised.
vi.mock('./AddressFields', () => ({ AddressFields: () => <div data-testid="address" /> }));
vi.mock('./PaymentMethod', () => ({ PaymentMethod: () => <div data-testid="payment" /> }));
vi.mock('./OrderSummary', () => ({ OrderSummary: () => <div data-testid="summary" /> }));
import { submitOrder } from '@/services/checkout';
beforeEach(() => vi.mocked(submitOrder).mockReset());
test('does not call submitOrder when the form is invalid', async () => {
render(<CheckoutForm />);
await userEvent.click(screen.getByRole('button', { name: /place order/i }));
expect(submitOrder).not.toHaveBeenCalled(); // asserts the parent's own guard
});
What this does: the mocked module boundary keeps the test fast and deterministic, and the stubbed children guarantee the assertion is about CheckoutForm’s submit guard, not about any child’s rendering. This test belongs at the base of the pyramid and can run on every save.
3. Write the integration test with real collaborators
Keep the real children and a real store; mock only the network at the network boundary with MSW. This is an integration test because the assertion depends on the children and store composing correctly.
// CheckoutForm.integration.test.tsx
import { test, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/mocks/server';
import { CheckoutStoreProvider } from '@/store/checkout'; // real store
import { CheckoutForm } from './CheckoutForm'; // real children render
test('places an order once every real child reports valid', async () => {
server.use(
http.post('/api/orders', () => HttpResponse.json({ id: 'ord_1' }, { status: 201 }))
);
render(
<CheckoutStoreProvider>
<CheckoutForm />
</CheckoutStoreProvider>
);
await userEvent.type(screen.getByLabelText(/street/i), '10 Downing St'); // real AddressFields
await userEvent.click(screen.getByLabelText(/card/i)); // real PaymentMethod
await userEvent.click(screen.getByRole('button', { name: /place order/i }));
expect(await screen.findByText(/order ord_1 confirmed/i)).toBeVisible(); // real OrderSummary
});
What this does: real children run their own validation and the real store carries state between them, so the test verifies that the pieces integrate — exactly the behaviour the unit test deliberately stubbed out. Only the network is mocked, so the test is still deterministic without being a full end-to-end run.
4. Classify with a decision table and map to the pyramid
Codify the rule so reviewers apply it consistently. Any test whose row lands in “unit” should not be re-written as “integration” and vice versa — that is how duplicated coverage is prevented.
| What is real in the test | Children | Shared store | Network / service | Classification | Pyramid layer |
|---|---|---|---|---|---|
| Parent logic only | Stubbed | Not needed | Mocked (module) | Unit | Base (many, fast) |
| Parent + one child’s contract | One real, rest stubbed | Optional | Mocked (module) | Unit (focused) | Base |
| Composed behaviour | All real | Real | Mocked (MSW at network) | Integration | Middle (fewer) |
| Full user journey | All real | Real | Real backend | End-to-end | Top (fewest) |
What this does: the table makes the mocked boundary — not the component count — the deciding column. A test with all-real children and a real store is integration no matter how few components render; a test with stubbed children is a unit test no matter how many the component would render in production.
Verification
Run the two layers as separate scripts so the fast base stays fast and integration runs less often. Naming the files *.unit.test.tsx and *.integration.test.tsx lets you filter cleanly.
# Base layer — runs on every save, milliseconds per test
npx vitest run --exclude '**/*.integration.test.tsx'
✓ CheckoutForm.unit > does not call submitOrder when the form is invalid (9ms)
Test Files 1 passed (1)
Tests 1 passed (1)
# Integration layer — runs pre-merge, real children + store
npx vitest run '**/*.integration.test.tsx'
✓ CheckoutForm.integration > places an order once every real child reports valid (86ms)
Test Files 1 passed (1)
Tests 1 passed (1)
The signal that the boundary is working: the unit test runs in single-digit milliseconds and never touches MSW, while the integration test is the only place the child-composition assertion lives. If you find the same assertion in both files, delete it from the layer where the mocked boundary makes it meaningless.
Edge cases and caveats
- A “focused” unit test may keep one real child. Rendering a parent with one real child (to test the contract between exactly those two) is still a unit test as long as the network stays mocked and the other children are stubbed. The label follows the mocked boundary, not a strict “zero real children” rule.
- Mocking child components can hide real bugs. Over-stubbing means a prop-shape mismatch between parent and child never surfaces in the unit test. Keep at least one integration test per composed component so the real prop contracts are exercised somewhere.
- The store is the tell for integration. If a test needs a real shared store for the assertion to make sense (state set by one child read by another), it is integration by definition — do not file it in the fast base layer where it will slow every run. Where store gating happens in the pipeline is covered under pipeline gating thresholds.
FAQ
Is a component test that renders three child components a unit or integration test?
It depends on whether the children are real. If the children are stubbed or trivial presentational components and the only mocked boundary is the network or a service module, it is a unit test of the parent. If the real child components run their own logic and share a real store, the test exercises the integration between them and is an integration test.
Where exactly should I put the mock boundary?
Put it at the network or module boundary — mock the service module the component imports, or intercept HTTP with MSW. Mocking there keeps the component’s own rendering logic real while cutting the external dependency. Mocking child components instead couples the test to internal composition and tends to hide real bugs.
How do unit and integration component tests map to the test pyramid?
Unit tests form the wide base: many small, fast tests of individual components with the network mocked. Integration tests sit above them: fewer tests that mount real children with a real store to verify they compose correctly. End-to-end tests sit at the narrow top. Duplicating the same assertion at two layers is the waste this boundary rule prevents.
Related
- Test Scope Definition — the parent section on deciding what each test should and should not exercise.
- Defining Test Scope for UI Component Libraries — the broader guide this page narrows to the unit-versus-integration question.
- Mock Boundaries — how to design the network and module boundaries that decide a test’s classification.
- Resetting MSW Handlers Between Vitest Tests — keeping the network boundary deterministic in the integration layer above.
- How to Isolate React Components for Unit Testing — the isolation techniques that make the unit layer of this boundary practical.
- Component Testing Fundamentals — the top-level guide to the entire testing workflow this page sits within.