Component testing and E2E testing are not competitors — they are complementary layers that catch different categories of defects. The challenge is knowing when to write a component test versus an E2E test. Over-investing in either layer wastes effort: too many E2E tests are slow and fragile; too many component tests miss integration defects. This lesson provides the decision framework and patterns that production teams use to allocate testing effort across both layers effectively.
CT vs E2E — The Decision Framework
The decision comes down to one question: does this test verify a single component’s rendering and interaction, or does it verify a cross-component workflow that involves routing, API integration, or multiple pages?
// Decision framework: Component Test or E2E Test?
const DECISION_FRAMEWORK = {
'Use Component Testing when': [
'Testing prop variations — different data, states, edge cases',
'Testing conditional rendering — show/hide based on props',
'Testing user interactions within one component — click, type, select',
'Testing event emission — verify callback props are called correctly',
'Testing CSS/layout edge cases — overflow, long text, responsive breakpoints',
'Testing form validation logic — required fields, format rules, error messages',
'Testing loading/error/empty states — difficult to reproduce in E2E',
'Testing accessibility — ARIA attributes, keyboard navigation within a component',
],
'Use E2E Testing when': [
'Testing multi-page user journeys — login, browse, checkout',
'Testing API integration — real backend responses, data persistence',
'Testing routing — navigation between pages, deep links, back button',
'Testing authentication flows — login, logout, session expiry, OAuth',
'Testing cross-component state — items added in shop appear in cart',
'Testing third-party integrations — payment, email, analytics',
'Testing deployment verification — smoke tests after release',
],
};
// ── Practical allocation for a typical web application ──
const ALLOCATION = {
'Component Tests (~60% of UI tests)': {
count: '100-200 tests',
speed: '< 2 minutes total',
covers: [
'Every prop variation of shared components (buttons, forms, cards)',
'All validation rules and error states',
'Loading, empty, and error UI states',
'Edge cases: long text, zero values, null fields, missing images',
'Responsive breakpoints for key components',
],
},
'E2E Tests (~40% of UI tests)': {
count: '30-50 tests',
speed: '5-15 minutes total',
covers: [
'Complete checkout flow (browse → cart → pay → confirmation)',
'User registration and login',
'Search and filter with real results',
'Admin CRUD operations',
'Critical error recovery flows',
],
},
};
// ── Component testing patterns catalogue ──
const CT_PATTERNS = [
{
pattern: 'Props Matrix Testing',
description: 'Test every meaningful combination of props',
example: `
// Test all button variants
['primary', 'secondary', 'danger'].forEach((variant) => {
['small', 'medium', 'large'].forEach((size) => {
it(\`renders \${variant} \${size} button\`, () => {
cy.mount();
cy.get('button').should('be.visible').and('have.class', variant);
});
});
});
`,
},
{
pattern: 'State Transition Testing',
description: 'Test component behaviour across state changes',
example: `
it('transitions from loading to loaded', () => {
cy.intercept('GET', '/api/data', { delay: 1000, body: mockData }).as('fetch');
cy.mount( );
cy.get('[data-cy="skeleton"]').should('be.visible');
cy.wait('@fetch');
cy.get('[data-cy="skeleton"]').should('not.exist');
cy.get('[data-cy="content"]').should('be.visible');
});
`,
},
{
pattern: 'Accessibility Verification',
description: 'Test keyboard navigation and ARIA within a component',
example: `
it('is keyboard navigable', () => {
cy.mount( );
cy.get('[data-cy="trigger"]').focus().type('{enter}');
cy.get('[role="menu"]').should('be.visible');
cy.get('[role="menuitem"]').first().should('have.focus');
cy.focused().type('{downarrow}');
cy.get('[role="menuitem"]').eq(1).should('have.focus');
cy.focused().type('{escape}');
cy.get('[role="menu"]').should('not.exist');
});
`,
},
];
// Summary
Object.entries(DECISION_FRAMEWORK).forEach(([key, items]) => {
console.log(`\n${key}:`);
items.forEach(item => console.log(` - ${item}`));
});
useState was called with a specific value, or that a specific CSS class was applied, makes tests brittle — they break when the implementation changes even if the visual output is identical. Test what the user sees and interacts with: visible text, element visibility, click responses, and emitted events. If a developer refactors the internal state management without changing the component’s external behaviour, the tests should still pass.Common Mistakes
Mistake 1 — Writing E2E tests for component-level concerns
❌ Wrong: An E2E test that logs in, navigates to the product page, and checks that the “Out of Stock” badge renders correctly — 15 seconds to test one component’s conditional rendering.
✅ Correct: A component test: cy.mount(<ProductCard inStock={false} />) → verify badge renders. Takes 0.3 seconds, no login, no navigation, no backend.
Mistake 2 — Testing internal state instead of visible behaviour
❌ Wrong: Asserting that React’s useState returns a specific value or that an internal variable equals a certain number.
✅ Correct: Asserting on what the user sees: cy.get('[data-cy="counter"]').should('have.text', '5') — verifies the rendered output, not the internal mechanism.