Web Testing that Scales: Testing Strategies

AnthanhAnthanhMay 8, 2021
Web Testing that Scales: Testing Strategies

Testing Strategies for Large-Scale Projects

Testing is a fundamental part of software development, yet many projects lack a clear strategy to make testing sustainable over time.

In this series, we'll explore practical strategies for testing large-scale projects, ensuring that tests serve a specific purpose and remain maintainable over time.

Series Overview

  • Part 1: Testing Strategies
  • Part 2: Testing Decision Tree
  • Part 3: Incremental Testing, a Monorepo Use-Case

Goals

Our goal is to manage the testing of a web application monorepo with the following objectives:

  1. Define product acceptance criteria using business-friendly language.
  2. Ensure a fast and scalable testing pipeline, regardless of the number of tests executed.

Testing Stack

We will reference the following tools:

  • Gherkin → BDD language for executable test specifications.
  • Jest → Test runner for unit and component tests.
  • Testing Library → Renders applications in memory for testing.
  • Storybook → Interactive UI component explorer.
  • Cypress → Functional and E2E testing framework.

While the focus is on monorepo projects, these strategies are applicable to any project at any scale.

Testing Options

Depending on the goal, different types of tests can be used.

Unit Tests with Jest

Use for: Testing isolated parts of code, such as functions, logic-heavy utilities, or algorithms.

// Example: Testing a price calculation function
const calculatePrice = (base, tax, discount) => base + tax - discount;

it("calculates price correctly", () => {
  expect(calculatePrice(100, 20, 10)).toBe(110);
});

Snapshot Testing with Jest

Use for: Ensuring UI components render consistently over time.

  • Renders a component and saves its HTML structure.
  • If changes occur, the snapshot must be reviewed and updated if necessary.
npx jest -u

Risk: Easy to ignore by updating snapshots without reviewing them.

Behavior Testing with Jest + waitFor

Use for: Testing UI behavior, interactions, and component logic.

  • Simulates user actions (click, input, focus, blur, etc.).
  • Unlike Cypress, tests run in memory (not in a browser), making them faster.
  • Mocks external dependencies to isolate component behavior.
// Example: Checking form validation
await waitFor(() => {
  fireEvent.click(screen.getByText("Submit"));
  expect(screen.getByText("Error: Required field")).toBeInTheDocument();
});

Limitations: Cannot test browser-dependent features.

Storybook for UI Testing

Use for:

  • Developing and testing UI components in isolation.
  • Serving as interactive documentation.
  • Helping teams visualize the available UI components.

Many teams integrate Storybook development into their Definition of Done for UI-related tasks.

End-to-End Testing with Cypress

Use for: Validating full user flows (e.g., authentication, checkout, form submission).

  • Simulates a real user interacting with the application.
  • Runs in actual browsers (Chrome, Firefox).
  • Provides video playback of test runs.
// Example: User login test
cy.visit("/login");
cy.get("input[name='email']").type("test@example.com");
cy.get("input[name='password']").type("password123");
cy.get("button[type='submit']").click();
cy.url().should("include", "/dashboard");

Challenges:

  • Slower execution (requires full browser rendering).
  • Can be flaky if not designed well.

Optimizing Cypress with Time-Traveling State

Use for: Making Cypress tests faster and more maintainable.

  • Instead of performing all previous steps to reach a test scenario, use state injection.
  • Expose Redux store (or similar state management) and dispatch actions to set the desired state instantly.
// Example: Preloading user state before running tests
cy.window()
  .its("store")
  .invoke("dispatch", { type: "LOGIN", payload: { userId: 123 } });

More details: Cypress Team's blog on time-travel testing

Balancing Test Coverage vs. Pipeline Performance

Key Considerations:

  • More tests increase test coverage but also slow down pipelines.
  • Complexity vs. resource cost must be considered.

Performance Comparison: Jest vs. Cypress

Test Type Resources Used Execution Speed
Jest + Unit 🔹 Low 🚀 Fast
Jest + waitFor 🔹 Low 🚀 Fast
Cypress (E2E) 🔥 High 🐢 Slow

Example:

  • Cypress (3 functional tests)17s
  • Jest (unit + waitFor + same functional tests)10.5s

Scaling Tests in a Monorepo

For large projects, incremental testing is critical:

  • Incremental Tests → Run only tests related to changed code (jest --changedSince).
  • Tag-based Tests → Categorize Cypress tests (acceptance, e2e, regression, etc.) to run smaller groups selectively.

Summary

Testing strategies should be tailored to project needs and resources:

Jest (Unit & Snapshot) → For isolated logic & UI consistency. Jest + waitFor → For UI behavior tests (without a browser). Storybook → For visual UI testing & documentation. Cypress → For full user journeys (E2E testing). Time-traveling state in Cypress → For faster test execution. Incremental & tag-based testing → For scalable CI/CD pipelines.

More to Come!

There's no one-size-fits-all approach to testing. The key is choosing the right test for the right scenario.

In the next post, we'll discuss how to decide the most appropriate test strategy for different cases.

💬 What testing challenges have you faced? Let's discuss in the comments!