Want fewer bloated tests? Build yourself a Testkit!
At Administrate we have a shiny Design System that we use in our different client applications that contains all the UI components required to build up our user’s experience. It makes use of React with Typescript as do all our core UI client applications. For testing, we use Testing Library across the board.
What is Testing Library? “It’s a family of packages that helps you test UI components in a user-centric way”.
The primary goal of the UI Testkit project was to reduce the time it took engineers to implement tests in our client UIs. Fewer bloated tests, more happy productive engineers.
We found when engineers add tests there are a lot of references to the internal workings of a component, and this pattern is repeated throughout the test suite - not exactly adhering to the DRY principle! This meant they would write long pieces of code that inspect the DOM to get an HTML Element to execute against in each test.
We also want the Design System UI components to be able to evolve into a better form of themselves which will require updates to how the core HTML of the components are built. An example of this might be changing a semantic <table>
to use a <div role="grid">
based component.
Here’s a basic example of how tests were originally written with a semantic <table>
HTML structure.
const tableBody = screen.getByRole("table").getElementsByTagName("tbody")[0];
const noRecordFoundText = tableBody.queryByText('No records found');
expect(noRecordFoundText).toBeInTheDocument();
expect(within(tableBody).getAllByRole("row")).toHaveLength(1);
This granular level of identifying parts of a component in a client makes it time-consuming to write and more cumbersome to evolve a UI component.
By providing the Testkit properties an engineer no longer needs to dive into the core of a component and most of the time it’s a one-liner to do the common test interaction.
Here is the same example as above, except using properties.
const tableComponent = getByTestId("table-component");
expect(tableDriver.toHaveNoResults(tableComponent)).toBeTruthy();
You can see there are no references to the HTML structure. All the hard lifting is carried out by the Testkit property.
How it works
Your UI components need to have a unique identifier; we implemented the commonly used data-testid
. Each of our UI components accepts this as a prop, then when it’s being consumed by the client an engineer can set the prop and it can be easily identified when it’s being rendered in the test.
The Testkit will be the collection of properties that execute what you need to happen or that return a value you are looking for. Simple but very effective. Here’s an example for an Input component:
export const InputTestkit = (): {
enterText: (value: string, element: HTMLElement) => Promise<void>;
} => {
const enterText = async (value: string, element: HTMLElement) => {
// Create a function that finds your input from the passed in element and enter the text value
};
return { enterText };
};
We have a few larger UI components that make use of smaller UI elements. To allow easy identification of these components we added a data-hook
prop solution. By setting the optional prop on the child component from the parent component we can easily find it and support Testkit properties from the parent.
Here’s an expanded version of how it’s used
import React, { FunctionComponent } from "react";
import { render } from "@testing-library/react";
import { Input, Form, useTypedFormValues } from "@administrate/piston-ux";
// 1. import
import {
InputTestkit,
FormTestkit
} from "@administrate/piston-ux/lib/test-kit";
// 2. initialize
const inputDriver = InputTestkit();
const formDriver = FormTestkit();
describe("SimpleForm", () => {
test("we can get the value from the component and change it", async () => {
const { getByTestId } = render(<SimpleForm />);
// 3. get elements
const inputComponent = getByTestId("input-component");
const formComponent = getByTestId("form-component");
// 4. interact
expect(inputDriver.getValue(inputComponent)).toBe("this value");
expect(inputDriver.getLabel(inputComponent)).toBe("Component Label");
await inputDriver.clearText(inputComponent);
expect(
formDriver.isSubmitDisabled(formComponent)
).toBeTruthy();
await inputDriver.enterText("New value", inputComponent);
expect(inputDriver.getValue(inputComponent)).toBe("New value");
await formDriver.clickOnSubmit(formComponent);
});
});
const SimpleForm: FunctionComponent = () => {
const values = useTypedFormValues({ input: "this value" });
return (
<Form
values={values}
onSubmit={console.log}
dataTestId="form-component"
>
<Input
name="input"
label="Component Label"
dataTestId="input-component"
/>
</Form>
);
};
Some additional benefits
As well as exporting the Testkit properties to be used by the client, we also test the Testkit properties in the Design System which gives the main UI component greater test coverage. Each property will have its own test to check that it can execute what’s expected, thereby reducing the potential breakages in client applications.
We can use these same Testkit APIs and provide solutions for other forms of testing like Cypress or Playwright.