Introduction
As developers, we understand the importance of a fast and reliable test suite. Slow tests can significantly hinder productivity, and redundant or inefficient testing practices can lead to slow, flaky tests and frustration. Recently, I embarked on a journey to optimize the test performance in a React app, and I’m excited to share the technical strategies and insights that made a measurable impact.
In this post, I’ll detail the changes I implemented, including converting tests into unit tests, restructuring components, adding parallelism in CircleCI, mocking heavy components, and removing redundant tests.
1. Converting to Unit Tests
One of the first steps was converting some integration or end-to-end (E2E) tests into unit tests. While E2E tests are crucial for validating the complete functionality, we rely on Cypress to run E2E tests when pull requests are merged. This eliminates the need to write E2E tests in React Testing Library (RTL). Since tests in RTL are run by CircleCI for every commit, speed is more important than full end-to-end coverage.
Why Unit Tests?
Unit tests focus on individual functions or components in isolation, offering several advantages:
- Speed: Since they run in isolation without the overhead of rendering an entire application or navigating through pages, they execute much faster.
- Debuggability: Failures are easier to pinpoint, as unit tests target specific pieces of functionality.
Example
Before optimization, a test for a dashboard screen validated multiple charts, tables, and tabs. I broke down this screen into smaller components like Chart
, Table
, and SearchBar
, each with its own dedicated unit tests:
// Before: Testing the entire dashboard
render(<Dashboard />);
expect(screen.getByText("Chart Title")).toBeInTheDocument();
// After: Unit test for Chart component
render(<Chart data={mockData} />);
expect(screen.getByText("Chart Title")).toBeInTheDocument();
By testing components individually, I reduced the runtime and complexity of the test suite.
2. Component Restructuring for Isolated Tests
To enable better unit testing, I moved parts of complex screens into their own reusable components. This not only improved testability but also enhanced maintainability and readability of the codebase.
Example
A screen originally handled rendering bookmarks, search functionality, and large tables within tabs. After restructuring, these features were moved into separate components:
Bookmark
SearchBar
Tabs
Each component now has its own dedicated unit tests, reducing the reliance on broader integration tests.
// Unit test for SearchBar
render(<SearchBar onSearch={mockOnSearch} />);
fireEvent.change(screen.getByPlaceholderText("Search..."), {
target: { value: "test" },
});
expect(mockOnSearch).toHaveBeenCalledWith("test");
3. Parallelism in CircleCI
Test suites can grow large, and running them sequentially becomes a bottleneck. To address this, I enabled parallelism in CircleCI, allowing tests to be distributed across multiple containers.
Implementation
In the .circleci/config.yml
file, I:
- Added
parallelism: 8
to the test job. - Sharded the test suite using Vitest's --shard command and environment variables (
CIRCLE_NODE_INDEX
andCIRCLE_NODE_TOTAL
).
jobs:
test:
docker:
- image: cimg/node:lts
parallelism: 8
steps:
- checkout
- run:
name: Run Tests
command: |
yarn test:ci --shard $((CIRCLE_NODE_INDEX + 1))/$CIRCLE_NODE_TOTAL
With this setup, the test suite was divided into smaller chunks, running simultaneously on different nodes. This change alone reduced test runtime by a significant amount.
4. Mocking Heavy Components
Some components, like charts or maps, are resource-intensive to render. While these components were crucial for the application, they weren’t always the focus of the tests.
Solution
I mocked these components in tests where they weren’t the subject of validation. For example, a Chart
component was replaced with a simple mock:
import * as Chart from './components/charts'
vi.spyOn(Chart, 'default').mockImplementation(() => <div data-testId="mock-chart" />)
// Test
render(<Dashboard />);
expect(screen.getByTestId("mock-chart")).toBeInTheDocument();
This approach ensured that the test suite remained fast without compromising on coverage.
5. Removing Redundant Tests
Another optimization was identifying and removing redundancy in the test suite. Previously, the same functionality was tested:
- At the route level (e.g.,
/dashboard
tests all components on the page). - Individually in component-level unit tests.
Streamlining Tests
By trusting the unit tests for component behavior, I simplified route-level tests to focus on integration scenarios, reducing duplication.
Results
These optimizations led to significant improvements:
- Test runtime: Test step (including installing npm packages and linting) was reduced by 50% in CircleCI after introducing these changes. The tests themselves made even bigger gains, going from on average 7 min to under 3 min.
- Reliability: Fewer flaky tests, thanks to mocking and isolated unit tests.
- Maintainability: Simplified codebase with reusable components and streamlined tests.
Conclusion
Optimizing test performance isn’t just about speed—it’s about creating a testing strategy that’s reliable, maintainable, and scalable. By converting to unit tests, restructuring components, leveraging parallelism, mocking heavy components, and eliminating redundancy, I was able to achieve a faster, more efficient test suite.
If you’re facing similar challenges, I encourage you to explore these techniques in your projects. Feel free to share your experiences or ask questions in the comments!
Top comments (0)