Testkit Mastery, Part 5: Key Takeaways and Lessons Learned for Building Better Testkits
Dev Experience
Testing

Testkit Mastery, Part 5: Key Takeaways and Lessons Learned for Building Better Testkits

Omri Lavi
Omri Lavi

This article is the final part of a series on building testkits for complex libraries that behave differently in non-production environments. Throughout the series, we’ve explored the challenges of testing libraries like Vulcan, our global state management system that relies on a specific production setup. We’ve covered how to identify key requirements, design and implement core components, and integrate the testkit into testing environments like 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 this part, we’ll reflect on the journey of building the testkit, summarizing the key takeaways and lessons learned. Whether you’re maintaining a library that relies on a global setup or one that’s complex to test, these insights can help you streamline your testing workflow and create tools that are developer-friendly and robust.

1. Start by Defining the Problem

Before writing a single line of code for your testkit, it’s critical to define the problem you’re trying to solve. Many libraries, particularly those that rely on a specific setup or infrastructure, behave differently in testing environments like Vitest or Storybook. This can lead to errors, inconsistencies, and frustration for developers who need to test components that depend on the library.

To effectively address these issues, start by asking targeted questions:

  • What specific behaviors of the library are missing or broken in testing environments?
  • How do developers typically interact with the library in production?
  • What are the biggest pain points developers face when testing with the library?

By answering these questions, you can build a clear picture of the gaps your testkit needs to address. For example, in our case, the problem was that our library relied on a production setup that wasn’t available in isolated environments, causing components to fail when accessing global state. The goal became replicating production behavior in testing environments, providing a seamless experience for developers without requiring major changes to their workflows.

2. Design the Optimal API

A well-designed API is the cornerstone of a successful testkit. The goal is to create an API that feels intuitive, minimizes friction, and aligns with how developers already use the library in production. To achieve this, we started by writing tests as if the testkit already existed, imagining how developers might interact with it in real scenarios.

This approach allowed us to identify key requirements for the API. For example, utility functions like withResolvedData, withError, and withLoadingTime made it easy to simulate different states of data fetching. These utilities provided flexibility while keeping tests readable and focused.

Here’s an example of what the API might look like in practice:

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

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

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

When designing the API for your testkit, consider these guiding principles:

  • Simplicity: Keep the API lightweight and focused on the most common testing needs.
  • Flexibility: Allow developers to customize behavior without requiring extensive setup.
  • Consistency: Ensure the API aligns with how the library is used in production, reducing the learning curve for developers.

By prototyping tests and iterating on the API design, you can create a testkit that developers will find both practical and enjoyable to use.

3. Leverage TypeScript for Type Safety

When building a testkit, TypeScript can play a critical role in ensuring consistency between the testkit and the real library. By using TypeScript’s type-checking capabilities, you can create a testkit that evolves alongside the library’s API, reducing the risk of mismatches and maintaining reliability over time.

For example, in our testkit, we used TypeScript to define types that mirrored Vulcan’s real API structure, ensuring that developers could use the testkit in the same way they would use Vulcan in production. For instance, we created a type called DeepVulcanMock, which replicated Vulcan’s API while replacing real methods with mocked counterparts. This allowed the testkit to enforce the same API structure, while providing flexibility for testing. Whenever the Vulcan API changed—such as adding new slices or queries—TypeScript flagged inconsistencies, prompting us to update the testkit accordingly.

Here’s a simplified version of how TypeScript helped maintain alignment between the real API and the testkit:

type DeepVulcanMock = {
  user: {
    getById: MockedQuery<{ id: number; name: string }>;
    // Other queries...
  };
  // Other slices...
};

With DeepVulcanMock, we ensured that the testkit reflected Vulcan’s real API structure while allowing mock-specific behavior. This approach reduced the risk of errors when testing components that relied on Vulcan.

When designing your own testkit, consider these TypeScript strategies:

  • Mirror the Real API: Define types that reflect the library’s structure, ensuring that the testkit provides a consistent interface.
  • Extend with Mock Utilities: Add mock-specific functionality, such as methods for controlling mock behavior, while preserving the original API’s shape.
  • Catch Inconsistencies Early: Leverage TypeScript’s static analysis to identify and address API mismatches as the library evolves.

By integrating TypeScript into your testkit design, you not only improve the developer experience but also reduce maintenance overhead, ensuring that the testkit remains a reliable tool as your library grows.

4. Ensure Reasonable Default Behavior

One of the most effective ways to simplify a testkit is by providing sensible default behaviors. When developers don’t need to manually define every query or function behavior in their tests, the setup becomes faster and less repetitive. A good default behavior ensures the testkit “just works” in most scenarios, while still allowing customization when needed.

In Vulcan, we ensured that the mocked API returned the same data structure as the original API, rather than falling back to meaningless defaults like perpetual loading or empty responses. These defaults, while simple, would have required developers to create extensive mock data fixtures even for basic tests—something we specifically wanted to avoid. Instead, our testkit provided realistic data structures that matched the API’s expectations, making it easier for developers to start testing right away.

Here’s an example of how this worked in practice:

// Default behavior returns realistic data without requiring manual setup.  
const { getByText } = render(<UserName userId={1} />); 

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

When designing a testkit, consider these principles:

  • Align with Production: Default behaviors should reflect the actual API’s structure and expectations, ensuring that tests remain consistent with real-world usage.
  • Avoid Empty States: Avoid defaults that provide no meaningful value (e.g., empty objects or loading states) unless those states are part of the test’s purpose.
  • Keep Flexibility: Ensure developers can override defaults easily when they need to test edge cases or specific behaviors.

By providing meaningful and realistic defaults, you make the testkit easier to use while maintaining consistency with production, reducing the burden on developers to configure every detail manually.

5. Keep the Testkit Framework-Agnostic

When designing a testkit, it’s tempting to rely on behaviors specific to the testing frameworks you’re using, such as Jest’s global instance (jest). However, this can create implicit dependencies, making the testkit less portable and harder to adapt to other environments. Instead, aim to keep the testkit as self-sufficient and framework-agnostic as possible.

In our case, we avoided relying on Jest-specific features by explicitly declaring our dependencies. For example, instead of relying on Jest’s global mocking behavior, we used jest-mock as a standalone dependency to implement mocking functionality. This ensured that the testkit didn’t introduce side effects, such as injecting jest into the global namespace, and made it easier to use in non-Jest environments, like Storybook.

Here’s an example of how we approached this:

// Instead of this... 
const myMock = jest.fn(); 

// ... do this: 
import { fn } from 'jest-mock'; 

const myMock = fn();

By designing your testkit to be self-contained, you make it more adaptable to different environments. Consider these principles:

  • Declare Dependencies Explicitly: Use framework-independent tools where possible, and make any required dependencies clear.
  • Avoid Implicit Framework Behavior: Ensure the testkit doesn’t rely on behaviors that are specific to one framework, such as global variables or setup.
  • Ensure Portability: Test the testkit in multiple environments to verify that it works consistently outside the framework it was initially built for.

Keeping your testkit framework-agnostic reduces its dependency footprint and ensures it can be used in a wider range of scenarios, making it a more versatile tool for developers.

6. Simplify Setup for Developers

One of the most effective ways to improve a testkit’s adoption is to make its setup as effortless as possible. Developers should be able to focus on writing meaningful tests rather than spending time configuring the testkit for their environment. Simplifying setup can be especially impactful for libraries with complex behaviors or global dependencies.

In our case, we streamlined setup by integrating the testkit into shared configurations for Vitest and Storybook. While this approach worked for us, it may not be relevant for all libraries. The key is to encapsulate the internal complexities of the testkit while still providing convenient utility functions to customize it as needed.For example, a testkit could provide a default setup file, such as test-setup.ts, that handles all the internal setup logic:

import { setupTestkit } from "./testkit";

setupTestkit();

beforeEach(() => {
    setupTestkit(); // Reset the testkit before each test
});

This file can be directly referenced as a setup file in Jest or Vitest configurations:

module.exports = {
  setupFiles: ["./node_modules/testkit/test-setup.ts"],
};

Alternatively, consumers who need more control can define their own setup file, using the testkit’s exposed setup functions:

import { setupTestkit } from "testkit";

function setup() {
    setupTestkit({ /* custom properties */ });
}

setup();

beforeEach(setup);

When designing your testkit’s setup, consider these principles:

  • Encapsulate Internal Complexity: Provide a default setup file that abstracts away the internal setup process.
  • Allow Customization: Expose utility functions to let consumers tailor the setup to their needs.
  • Minimize Developer Effort: Ensure the default setup works out of the box for the majority of use cases, while still supporting advanced configurations.

By reducing the complexity of setup while offering flexibility, you make it easier for developers to adopt the testkit and integrate it into their workflows. This approach improves productivity and ensures consistent usage across projects.

7. Gather Feedback and Iterate

A testkit isn’t just a tool—it’s part of the developer workflow, and its success depends on how well it meets the needs of its users. Gathering feedback is critical for identifying pain points, understanding developer workflows, and ensuring the testkit evolves alongside the library it supports.

Why Gather Feedback?

Feedback provides a window into how the testkit is being used in real-world scenarios. Developers may encounter edge cases or workflows that the testkit doesn’t fully support, or they might find certain features unintuitive. By addressing these issues, you can improve the testkit’s usability, making it a more valuable tool.

How to Collect Feedback

The method of collecting feedback depends on whether the testkit is for internal use or open source:

  • For Internal Testkits: Hold feedback sessions or create dedicated channels for developers to share their experiences. Encourage them to report bugs, request features, or suggest improvements. Regular check-ins or surveys can also help gauge satisfaction and identify areas for improvement.
  • For Open Source Testkits: Leverage community forums, GitHub issues, or discussions to collect feedback. Monitor how developers interact with the testkit and encourage contributions or detailed bug reports.

What Should the Feedback Focus On?

When collecting feedback, focus on:

  1. Usability: Are the APIs intuitive and easy to use? Are there common frustrations or workarounds?
  2. Coverage: Does the testkit address the most common use cases? Are there gaps where developers need to build custom solutions?
  3. Performance: Does the testkit perform well in different environments, such as CI pipelines or Storybook?
  4. Evolving Needs: As the library or application evolves, does the testkit keep pace with new requirements or API changes?

By actively gathering and iterating on feedback, you create a testkit that not only aligns with developer needs but also becomes a reliable part of their workflow. Whether the testkit is internal or open source, a culture of feedback and iteration ensures it remains a valuable and trusted tool.

Final Thoughts

Building a testkit for a complex library is both a technical and strategic challenge. In this series, we explored the journey of creating a testkit for a library that behaves differently in non-production environments, covering everything from identifying challenges and designing APIs to integrating with testing environments.

The key takeaways—defining the problem, designing an intuitive API, leveraging TypeScript, providing meaningful defaults, simplifying setup, and iterating based on feedback—offer a practical framework for building testkits for any library. These principles can help maintainers address challenges like global setups or complex testing needs, empowering developers to test confidently and efficiently.

If this subject interests you, we’d love to hear from you—or even have you join us at monday.com to tackle challenges like these together!

Omri Lavi
Client Infrastructure Tech Lead @ monday.com