MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr πŸ”₯ tagline

Hey there πŸ‘‹ I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 β€” present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 β€” Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms β€” earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Testing Strategies: The Testing Trophy and Testing Library

Optimize your testing strategy: unit, integration, and E2E tests with the Testing Trophy.

TestingStrategyTesting LibraryTDD

By MinhVo

Introduction

The Testing Pyramid has dominated software testing strategy for over a decade, prescribing a large base of unit tests, a middle layer of integration tests, and a thin cap of end-to-end tests. While this model served well for traditional server-rendered applications, modern frontend development demands a different approach. Kent C. Dodds proposed the Testing Trophyβ€”a shape that emphasizes integration tests as the sweet spot for confidence and maintainability in JavaScript applications.

The Testing Trophy reflects a fundamental insight: in frontend applications where components compose together, integration tests that render real component trees and simulate user interactions catch more bugs per test written than isolated unit tests. This doesn't mean unit tests are worthless or that E2E tests are unnecessaryβ€”it means the optimal investment allocation differs from what the pyramid suggests. This guide explores the Testing Trophy model, practical implementation with Testing Library, and strategies for building test suites that maximize confidence while minimizing maintenance burden.

Testing Strategy

Understanding the Testing Trophy: Core Concepts

The Testing Trophy reorganizes testing investment into four layers: static analysis at the base, integration tests forming the largest body, unit tests as a focused middle section, and E2E tests as the crown. Each layer serves a distinct purpose and provides a different type of confidence about your application's correctness.

Static Analysis forms the foundation and costs nothing at runtime. TypeScript type checking, ESLint rules, and Prettier formatting catch entire categories of bugs before tests even run. A TypeScript error that prevents passing a string where a number is expected eliminates the need for a test that verifies type correctnessβ€”the compiler handles it.

Integration Tests occupy the largest portion of the trophy because they test how multiple units work together without the overhead and fragility of full end-to-end tests. When you render a form component with its validation logic and submission handler, you're testing the integration of React hooks, event handlers, validation functions, and DOM updates in a single test that runs in milliseconds.

Unit Tests serve specific purposes: testing complex business logic, utility functions, and algorithms where isolation provides clear value. A date formatting function, a currency converter, or a complex sorting algorithm deserves unit tests because their behavior is deterministic and doesn't depend on DOM rendering.

End-to-End Tests verify critical user flows through the complete application stack including real browsers, databases, and network calls. These tests are expensive to write, slow to execute, and brittle to maintain, so they should focus exclusively on the highest-value paths: sign-up, checkout, payment processing, and other flows where bugs have immediate business impact.

Why Integration Tests Dominate

Integration tests hit the sweet spot between confidence and cost. A unit test that mocks every dependency verifies that a function calls its collaborators correctly, but it doesn't verify that the collaborators actually work together. An E2E test verifies the entire stack but takes seconds to run and breaks when unrelated UI elements change.

Integration tests render real component trees with real hooks and real DOM interactions. When you test that clicking "Add to Cart" updates the cart count displayed in the header, you're testing the integration of the cart store, the button component, the header component, and the shared state managementβ€”a chain that unit tests miss and E2E tests slow down.

// Integration test: tests real component interaction
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { App } from './App';
 
describe('Shopping cart integration', () => {
  it('should update cart count when item is added', async () => {
    const user = userEvent.setup();
    render(<App />);
 
    expect(screen.getByTestId('cart-count')).toHaveTextContent('0');
 
    await user.click(screen.getByRole('button', { name: 'Add to Cart' }));
 
    expect(screen.getByTestId('cart-count')).toHaveTextContent('1');
  });
});

Architecture and Design Patterns

Building an effective test suite following the Testing Trophy requires architectural decisions about test organization, shared utilities, and boundary definitions between testing layers. The goal is a suite that provides comprehensive coverage without redundancyβ€”each test should cover a unique combination of components and interactions that no other test covers.

Test files live alongside their source code, making it immediately clear which code has tests and which doesn't. The *.test.tsx suffix convention keeps tests discoverable while separating them from production code during builds. Shared test utilities including custom render functions, mock data factories, and test helpers live in a test-utils directory accessible from all test files.

Layered Testing Architecture

src/
β”œβ”€β”€ features/
β”‚   β”œβ”€β”€ auth/
β”‚   β”‚   β”œβ”€β”€ LoginForm.tsx
β”‚   β”‚   β”œβ”€β”€ LoginForm.test.tsx        ← Integration tests
β”‚   β”‚   β”œβ”€β”€ useAuth.ts
β”‚   β”‚   β”œβ”€β”€ useAuth.test.ts           ← Unit tests (hook logic)
β”‚   β”‚   └── auth.service.ts
β”‚   β”œβ”€β”€ cart/
β”‚   β”‚   β”œβ”€β”€ CartPage.tsx
β”‚   β”‚   β”œβ”€β”€ CartPage.test.tsx
β”‚   β”‚   └── cart.store.ts
β”‚   └── products/
β”‚       β”œβ”€β”€ ProductList.tsx
β”‚       └── ProductList.test.tsx
β”œβ”€β”€ lib/
β”‚   β”œβ”€β”€ format-currency.ts
β”‚   β”œβ”€β”€ format-currency.test.ts       ← Unit tests (utility)
β”‚   β”œβ”€β”€ validate-email.ts
β”‚   └── validate-email.test.ts
β”œβ”€β”€ test-utils/
β”‚   β”œβ”€β”€ render-with-providers.tsx
β”‚   β”œβ”€β”€ mock-data.ts
β”‚   └── server.ts                     ← MSW mock server
└── e2e/
    β”œβ”€β”€ checkout.spec.ts              ← Playwright E2E
    └── auth.spec.ts

Test Data Management

Consistent test data reduces boilerplate and makes tests readable. Factory functions generate test objects with sensible defaults while allowing individual tests to override specific properties. This pattern keeps tests focused on the behavior being tested rather than data setup.

// test-utils/mock-data.ts
export function createMockUser(overrides: Partial<User> = {}): User {
  return {
    id: '1',
    name: 'Alice Johnson',
    email: 'alice@example.com',
    role: 'user',
    avatar: 'https://example.com/avatar.jpg',
    createdAt: '2024-01-15T10:00:00Z',
    ...overrides,
  };
}
 
export function createMockProduct(overrides: Partial<Product> = {}): Product {
  return {
    id: '101',
    name: 'Wireless Keyboard',
    price: 79.99,
    description: 'Ergonomic wireless keyboard with backlight',
    inStock: true,
    rating: 4.5,
    reviewCount: 128,
    ...overrides,
  };
}

Step-by-Step Implementation

Implementing the Testing Trophy starts with establishing static analysis as the foundation, then building integration tests for critical component interactions, adding unit tests for complex logic, and finally covering key user flows with E2E tests.

The static analysis layer requires TypeScript strict mode, ESLint with testing plugins, and pre-commit hooks that prevent code with type errors or lint violations from entering the repository. This foundation catches more bugs per second of developer time than any runtime test.

# Install testing dependencies
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
npm install --save-dev msw
npm install --save-dev @playwright/test
 
# TypeScript strict configuration
# tsconfig.json - enable strict mode
{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true
  }
}

Setting up Mock Service Worker (MSW) for integration tests creates a realistic API layer that intercepts network requests at the service worker level. Unlike mocking fetch directly, MSW uses the same request handling mechanism as real service workers, ensuring tests exercise the same code paths that production uses.

// test-utils/server.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { createMockUser, createMockProduct } from './mock-data';
 
export const handlers = [
  http.get('/api/users/me', () => {
    return HttpResponse.json(createMockUser());
  }),
 
  http.get('/api/products', () => {
    return HttpResponse.json([
      createMockProduct({ id: '1', name: 'Wireless Keyboard' }),
      createMockProduct({ id: '2', name: 'Ergonomic Mouse', price: 49.99 }),
    ]);
  }),
 
  http.post('/api/cart', async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ success: true, cartItemId: 'new-id' });
  }),
];
 
export const server = setupServer(...handlers);

Writing integration tests that cover the Testing Trophy's middle section requires testing component compositions that span multiple files. A product page integration test renders the product component, its image gallery, price display, review summary, and add-to-cart button togetherβ€”verifying they work as a cohesive unit.

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProductPage } from './ProductPage';
import { CartProvider } from '../cart/CartContext';
import { server } from '../test-utils/server';
import { http, HttpResponse } from 'msw';
 
function renderProductPage(productId = '101') {
  return render(
    <CartProvider>
      <ProductPage productId={productId} />
    </CartProvider>
  );
}
 
describe('ProductPage', () => {
  it('should display product information after loading', async () => {
    renderProductPage();
 
    expect(screen.getByRole('status')).toBeInTheDocument();
 
    await waitFor(() => {
      expect(screen.getByRole('heading', { name: 'Wireless Keyboard' })).toBeInTheDocument();
      expect(screen.getByText('$79.99')).toBeInTheDocument();
      expect(screen.getByText('4.5 stars')).toBeInTheDocument();
      expect(screen.getByRole('img', { name: 'Wireless Keyboard' })).toBeInTheDocument();
    });
  });
 
  it('should add product to cart and update cart count', async () => {
    const user = userEvent.setup();
    renderProductPage();
 
    await waitFor(() => {
      expect(screen.getByRole('button', { name: 'Add to Cart' })).toBeEnabled();
    });
 
    await user.click(screen.getByRole('button', { name: 'Add to Cart' }));
 
    await waitFor(() => {
      expect(screen.getByText('Added to cart!')).toBeInTheDocument();
      expect(screen.getByTestId('cart-count')).toHaveTextContent('1');
    });
  });
 
  it('should show error state when product fetch fails', async () => {
    server.use(
      http.get('/api/products/:id', () => {
        return new HttpResponse(null, { status: 404 });
      })
    );
 
    renderProductPage('nonexistent');
 
    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent('Product not found');
    });
  });
});

Real-World Use Cases

Use Case 1: E-Commerce Checkout Flow

An e-commerce checkout flow spans multiple components: cart summary, shipping address form, payment method selection, order review, and confirmation. The Testing Trophy approach tests each component's integration with its immediate dependencies (form validation, API submission, error handling) while using a single E2E test to verify the complete flow from adding an item to receiving an order confirmation.

The integration tests cover each checkout step independentlyβ€”address form validation submits correctly formatted data, payment form handles card validation and declines, and order review displays accurate totals. The single E2E test verifies that all steps connect end-to-end with a real payment processor in sandbox mode.

Use Case 2: Authentication and Authorization

Authentication testing requires integration tests that verify login form submission, token storage, protected route redirection, and logout cleanup. RTL tests render the login component with a mocked auth API, verify that successful login stores the token and redirects to the dashboard, and confirm that accessing protected routes without authentication redirects to login.

Unit tests cover the token management utility (encoding, decoding, expiration checking), while a single E2E test verifies the complete authentication flow with real OAuth providers in test mode.

Use Case 3: Real-Time Data Dashboard

Dashboard components that display real-time data from WebSockets require testing initial data loading, incremental updates, connection error handling, and reconnection logic. Integration tests mock the WebSocket connection and simulate incoming messages, verifying that dashboard widgets update correctly and display connection status indicators.

Best Practices for Production

  1. Invest Most in Integration Tests: Aim for 60-70% of your test effort in integration tests that render component compositions with realistic data flows. These tests catch the most bugs per minute of test writing and maintenance.

  2. Use Unit Tests for Complex Logic: Algorithms, data transformations, date calculations, and validation functions deserve isolated unit tests where the inputs and outputs are clearly defined without DOM dependencies.

  3. Reserve E2E Tests for Critical Paths: Payment flows, authentication, data persistence, and multi-step processes that cross system boundaries justify the cost of E2E tests. Don't E2E test every button click.

  4. Leverage Static Analysis Aggressively: TypeScript strict mode, ESLint rules, and Prettier formatting catch bugs that no runtime test would find efficiently. These tools run in milliseconds and require zero test maintenance.

  5. Share Test Utilities: Custom render functions, mock data factories, and MSW handlers shared across test files reduce duplication and ensure consistent test setup across the entire suite.

  6. Test in Isolation, Integrate in CI: Unit and integration tests run in isolation during development for fast feedback. CI/CD pipelines run the complete suite including E2E tests to catch integration issues before deployment.

  7. Monitor Test Suite Health: Track test execution time, flaky test rates, and coverage trends. A test suite that takes 20 minutes to run or has 5% flaky tests undermines developer confidence and slows delivery.

  8. Review Tests in Pull Requests: Tests deserve the same code review rigor as production code. Poorly written tests create maintenance burden and false confidence that's worse than no tests at all.

Common Pitfalls and Solutions

PitfallImpactSolution
Too many unit tests with heavy mockingHigh coverage but low confidence; mocks drift from realityShift effort to integration tests that use real implementations
Testing implementation detailsTests break on refactoring without catching regressionsTest user-visible behavior using accessible queries
E2E tests for everythingSlow CI/CD pipeline, brittle tests, delayed feedbackReserve E2E for critical user flows; use integration for component behavior
No test data strategyDuplicated setup code, inconsistent test data across filesCreate factory functions and shared MSW handlers
Ignoring flaky testsTeam loses confidence; tests get skipped or deletedFix flaky tests immediately; investigate root causes rather than adding retries
Not testing error statesProduction errors display blank screens or crashEvery data-fetching component needs an error state test

Performance Optimization

Test suite performance directly impacts developer productivity and CI/CD pipeline costs. A suite that takes 30 minutes discourages frequent execution, reducing the feedback loop that makes testing valuable. Performance optimization starts with test architectureβ€”parallel execution, shared setup, and strategic test selection.

Jest distributes test files across worker processes automatically, but shared module caching and DOM setup create per-worker overhead. The --shard flag enables distributing tests across multiple CI machines, splitting a 20-minute suite into 5-minute chunks that run on 4 parallel machines.

# Run tests with specific parallelism
npx jest --maxWorkers=50%
 
# Run only changed tests during development
npx jest --onlyChanged
 
# Run tests matching specific patterns
npx jest --testPathPattern="features/auth"
 
# Generate coverage report for specific modules
npx jest --coverage --collectCoverageFrom="src/features/**/*.ts"

MSW handlers configured once in a setup file share across all test files, avoiding the overhead of creating new mock servers for each test. The setupFilesAfterSetup configuration ensures MSW starts before any test runs and stops after all tests complete.

Comparison with Testing Models

AspectTesting PyramidTesting TrophyTesting Diamond
Unit Tests70% (largest)20% (focused)10% (minimal)
Integration Tests20% (middle)60% (largest)30%
E2E Tests10% (thin cap)10% (critical paths)50% (largest)
Best ForBackend servicesFrontend applicationsMicroservices
Mock UsageHeavyMinimalMinimal
Refactoring ResilienceLow (mocks break)High (tests behavior)High (tests real flows)
Execution SpeedFastFastSlow

The Testing Trophy suits frontend applications where component composition creates integration points that are both high-risk and testable without full E2E infrastructure. Backend services with well-defined function boundaries still benefit from the pyramid's emphasis on unit tests. Microservices architectures that span multiple services may prefer the diamond model's emphasis on E2E tests that verify cross-service contracts.

Advanced Patterns

Contract testing addresses the challenge of testing integrations between independently deployed services. Tools like Pact verify that API consumers and providers agree on request/response formats without requiring both services to run simultaneously during tests. This approach is particularly valuable in microservices architectures where coordinating deployments across teams is impractical.

// Consumer-side contract test
import { Pact } from '@pact-foundation/pact';
 
const provider = new Pact({
  consumer: 'FrontendApp',
  provider: 'UserAPI',
});
 
describe('User API contract', () => {
  beforeAll(() => provider.setup());
  afterAll(() => provider.finalize());
 
  it('should return user by ID', async () => {
    await provider.addInteraction({
      state: 'user 123 exists',
      uponReceiving: 'a request for user 123',
      withRequest: { method: 'GET', path: '/api/users/123' },
      willRespondWith: {
        status: 200,
        body: { id: '123', name: 'Alice' },
      },
    });
 
    const user = await fetchUser('123');
    expect(user).toEqual({ id: '123', name: 'Alice' });
  });
});

Mutation testing with tools like Stryker validates test suite quality by introducing small changes (mutations) to source code and checking whether tests detect the changes. If a mutation survives (tests still pass), it indicates a gap in test coverage that written assertions don't catchβ€”more valuable information than simple line coverage metrics.

Testing Strategies

Implementing the Testing Trophy incrementally prevents the overwhelming task of rewriting an entire test suite. Start by identifying the highest-value integration tests for your most critical features. Replace unit tests that mock everything with integration tests that render real component trees. Keep unit tests for pure functions and add E2E tests only for flows where bugs have immediate business impact.

The migration path from pyramid to trophy typically follows this sequence: establish static analysis tooling, create the custom render utility and MSW setup, write integration tests for the top 5 user flows, identify utility functions that need unit tests, and finally add E2E tests for the 3-5 most critical user journeys.

Future Outlook

Testing strategies continue evolving as application architectures change. Server components in React present new challenges for Testing Trophy practitioners because component rendering happens on the server, requiring different test execution strategies than client-side component testing. Testing libraries are adapting with server-side rendering support and hybrid testing approaches.

AI-assisted test generation represents a significant trend where language models analyze component code and generate integration tests automatically. While these generated tests require human review, they accelerate initial test suite creation and help teams achieve baseline coverage quickly before investing in targeted, hand-written tests for complex scenarios.

Conclusion

The Testing Trophy provides a practical framework for allocating testing effort in modern frontend applications. By emphasizing integration tests that verify how components work together, teams achieve high confidence with manageable maintenance overhead. The combination of static analysis, targeted unit tests, comprehensive integration tests, and focused E2E tests creates a defense-in-depth strategy that catches bugs at every layer.

Key takeaways for implementing the Testing Trophy:

  1. Static analysis is your first line of defense β€” TypeScript and ESLint catch more bugs per second than any runtime test
  2. Integration tests provide the highest ROI β€” they test real component interactions without E2E overhead
  3. Unit tests serve specific purposes β€” complex business logic and pure functions deserve isolated testing
  4. E2E tests protect critical flows β€” reserve expensive browser tests for payment, auth, and data persistence
  5. Invest in test infrastructure β€” custom render utilities, MSW setup, and factory functions multiply testing productivity

The right testing strategy balances confidence, speed, and maintenance cost for your specific application and team. Start with the Trophy model, measure what catches bugs in your codebase, and adjust the allocation based on empirical evidence from your production incidents and test failures.

For deeper exploration, consult Kent C. Dodds' Testing Trophy article, the Testing Library documentation, and Martin Fowler's testing articles for broader testing strategy perspectives.