Testkit Mastery, Part 3: Building the Core Structure of a Flexible and Reliable Testkit
Dev Experience
Testing

Testkit Mastery, Part 3: Building the Core Structure of a Flexible and Reliable Testkit

Omri Lavi
Omri Lavi

This article is part of a series on building testkits for complex libraries that behave differently in non-production environments. In our case, we needed a testkit for Vulcan, a global state management library that centralizes data across microfrontends and prevents redundant data requests. Like other libraries, Vulcan relies on a specific production setup, which makes it challenging to test in isolated environments such as Vitest and Storybook.

This Series on Testkit Mastery:

  1. Overcoming Testing Challenges for Complex Libraries
  2. Designing a Developer-Friendly Testkit
  3. Building the Core Structure of a Flexible and Reliable Testkit
  4. Design for Easy Integration into Testing Environments
  5. Key Takeaways and Lessons Learned for Building Better Testkits

In the previous part, we discussed the unique challenges of testing Vulcan and defined the essential requirements for a flexible and developer-friendly testkit. Now, with those requirements in place, we’re ready to define and implement the right components to bring the testkit to life. In this article, we’ll walk through the design and implementation of the core components that support the library’s testing needs, covering the technical decisions made to balance flexibility with accuracy. By the end, you’ll see how these components work together to create a robust testing experience.

Simulating Data Fetching Behavior for Tests

In Vulcan, a query is a fundamental part of the API, used to retrieve data across different domains (e.g., user, board). Each query provides metadata that developers can use to manage component state, including properties like isLoading, isError, and data. This query lifecycle allows components to display a loading state, handle errors, and show the final data once it’s available. As in libraries like React Query, queries in Vulcan provide real-time updates to the component as the data state changes.

For example, a real query in production might look like this:

const { data, isLoading } = vulcan.useQuery(vulcan.user.getById(userId));
if (isLoading) {
  return <div>Loading user data...</div>;
}
return <div>User name: {data.name}</div>;

To make the testkit flexible enough for various testing scenarios, we needed a way to simulate Vulcan’s query behavior in a controlled and customizable way. The solution was MockedQuery—a class designed to mirror the lifecycle of Vulcan’s real queries while allowing developers to adjust its behavior for different test cases.

MockedQuery behaves like a real query, providing the same metadata and lifecycle stages (loading, success, error) and relies heavily on Vulcan’s actual implementation. This ensures that it behaves just as it would in a real environment, keeping test behavior consistent with production. Internally, MockedQuery wraps a real query that’s configured to resolve to a specific result immediately. Additionally, it includes utility functions such as withResolvedData, withLoadingTime, and withError, allowing developers to adjust the query’s state for various scenarios.

Here’s a simplified example of how MockedQuery works behind the scenes:

function mockedQuery<Data>(resolvedData: Data): MockedQuery<Data> {
  let timeToLoad = 0;
  let error: Error | null = null;
  let _resolvedData = resolvedData;

  const mockedQuery = createQuery({
    resolve: async () => {
      await sleep(timeToLoad);
      if (error) {
        throw error;
      }
      return _resolvedData;
    },
  });

  mockedQuery.withResolvedData = (data: Data) => {
    _resolvedData = data;
  };
  mockedQuery.withError = (err: Error) => {
    error = err;
  };
  mockedQuery.withTimeToLoad = (ms: number) => {
    timeToLoad = ms;
  };

  return mockedQuery;
}

With MockedQuery, developers can simulate various query states as needed for their tests, keeping the behavior of their components consistent with production without requiring real data or external requests. Here’s how MockedQuery might look in a test:

mockedVulcan.user.getById.withResolvedData({ id: 1, name: 'Test User' });

const { getByText } = render(<UserName userId={1} />);

expect(getByText('User name: Test User')).toBeInTheDocument();

This flexible query configuration was essential, but to ensure the testkit stayed consistent with the real API, we needed a structure that could enforce the library’s correct shape and behavior. This led us to the next key component: DeepVulcanMock.

Enforcing API Structure and Consistency

While MockedQuery allowed developers to control individual queries, we also needed a way to keep the testkit’s structure consistent with the real API. This consistency was crucial for preserving a familiar experience for developers and minimizing discrepancies between production and testing environments.

To achieve this, we created a type called DeepVulcanMock. This type mirrors the library’s real API structure while replacing actual queries with their MockedQuery counterparts. In order to have a single place that creates a fully mocked instance, we wrote a function called generateMockedVulcan, which always returns an instance of DeepVulcanMock. Whenever Vulcan’s API changes, those changes are applied to DeepVulcanMock as well, and generateMockedVulcan must be updated to ensure the same structure and behavior.

Here’s a simplified example of generateMockedVulcan in action:

function generateMockedVulcan(): DeepVulcanMock {
  return {
    user: {
      getById: mockedQuery({ id: 1, name: "Test User" }),
      // Other queries...
    },
    // Other slices...
  };
}

By using DeepVulcanMock to enforce the API’s structure and generateMockedVulcan to keep it up to date, we provided developers with a reliable, consistent test environment that mirrors production behavior. With these core components in place, the next step was to integrate the testkit seamlessly into testing environments like Vitest and Storybook. We’ll cover this in the next part of the series.

Omri Lavi
Client Infrastructure Tech Lead @ monday.com