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:
- Define product acceptance criteria using business-friendly language.
- 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!