Aug 29 2019

Introducing Prismatest Testing Library.

Today I’m officially announcing the launch of Prismatest, a Javascript library to decouple your front-end tests from the view layer details.

Writing tests for front-end applications is hard. Prismatest aims to make it easier. I've seen and written tests that read like a puzzle, and deciphering the path of the user through the app was impossible due to all the noise and details about how the test interacted with the view layer. With Prismatest I can abstract that noise away to a separate location and leave my tests clear. Thus making it easy to check them for correctness.

Prismatest is based on the page object pattern. In Prismatest they are called test views. A test view is an encapsulation of a piece of the view, along with operations relevant to that piece of the view. A simple test view might encapsulate a button, with a single operation to click the button. Importantly, test views can be combined like Lego blocks to create larger, more complicated views from smaller ones. In this fashion I can create small test views encapsulating the primitive UI elements of my application, and reuse them with different test views encapsulating different view contexts. Prismatest is structured as a core library with several adapters for different rendering layers. These adapters affect how you implement your test views, but they don’t affect how you use them. Each adapter also comes with a set of default test views to cover some commonly used HTML controls.

A selector is the first key part of a test view. Selectors identify a specific piece of the rendered view that the test view operates on. I can parameterize the first selector in a composed chain of test views, allowing me to reuse the composition in different contexts. This is useful for things like form inputs which might share functionality but represent different data points. The specific adapter I use determines how I write my selectors and how they are combined when composing test views. Here are some examples of test views with different selectors using the CSS adapter.

import testView from "@mojotech/prismatest-css";

const SigninForm = testView("#signin-form");
const SignupForm = testView("#signup-form");

// Parameterizing and combining test views
const LabelledInput = testView(
  (label) => `label[for="${label}"]`
)(testView.defaultViews.input);

The second piece of a test view is the action. Actions operate on each selected element individually or a single selected element. They can also perform some rudimentary assertions such as ensuring only a single element was selected by a selector. I can do anything I want with an action, and I can also parameterize them to add contextual behavior. They can also return values. As with selectors, the adapter in use determines the specifics of how an action is implemented. Good action design allows my tests to reflect the words used to describe the feature being tested. For example, a sign-in form might have an action named signin.

The final piece of a test view is the aggregate operation. Aggregate operations are like actions but they operate on every selected element at once. As with an action I can do anything I want with an aggregate including return some value. They are useful for controls like radio buttons, where every button must be examined to determine the state of the control as a whole.

Using test views in my tests is straightforward. First, the test view must be materialized. This requires something rendered to be passed into the test view. At this point I can also pass in any parameters required by a parameterized selector. Now I can call actions or aggregate operations and make assertions on the return values. A key feature of Prismatest is that changes to my view layer won’t necessitate changes to my test logic. They only require changes to the test views themselves. This keeps my tests clean and self-describing. For example:

import testView from "@mojotech/prismatest-css";

// Test views are set up separately from the test, and could be imported from another file
const formIds = {
  "signin": "signin-form",
  "signup": "signup-form"
};
const LabelledInput = testView((label) => `label[for="${label}"]`)(testView.defaultViews.input);
const AuthForm = testView(
  (authType) => `#${formIds[authType]}`,
  {
    // Test views can return other views as an action
    input: (e, name) => LabelledInput.materialize(e, name)
  }
);
const FormInfo = testView('.info', { message: (e) => e.textContent });

test("User can log in", () => {
  const app = renderApp(); // Elided for brevity

  // Materialization is not a cached process, so can be done as much as I want
  AuthForm.materialize(app, "signin").input("email").enterText("john@example.com");
  AuthForm.materialize(app, "signin").input("password").enterText("password");
  AuthForm(testView.defaultViews.form).materialize(app, "signin").submit();

  expect(AuthForm(FormInfo).materialize(app, "signin")).message.one()).toEqual("Signed in!");
});

If I were to change some of the details of my view layer, for example the class names or ids, I would only need to change the test views. The test code would not need to change at all. This promotes tests that match closely to the requirements. Compare this with a similar test constructed without test views. While the code itself is shorter, I would have to modify the test code itself to address any changes in the markup. For a single test this isn't so bad, but when I have multiple tests each working with the same elements this adds up to a significant maintenance burden. The test views abstract those changes so I can make them in a single place.

test("User can log in", () => {
  const app = renderApp(); // Elided for brevity

  // Get the interactive elements
  const signInForm = app.querySelector("#signin-form");
  const email = signInForm.querySelector('label[for="email"] input');
  const password = signInForm.querySelector('label[for="password"] input');

  // Interact with the form
  email.value = "john@example.com";
  password.value = "password";
  form.submit();

  const formInfoMessage = signInForm.querySelector('.info').textContent;
  expect(formInfoMessage).toEqual("Signed in!");
});

Prismatest has been in use internally, and is ready for use and feedback by others. There are two adapters currently, one to work with CSS selectors and raw DOM APIs, and another that interfaces with Enzyme. As usage increases, more adapters will be created to integrate with other testing libraries.