Skip to content

Instantly share code, notes, and snippets.

@Robdel12
Last active September 29, 2024 04:20
Show Gist options
  • Save Robdel12/45a9dc3461337f66511d4cd7a03be076 to your computer and use it in GitHub Desktop.
Save Robdel12/45a9dc3461337f66511d4cd7a03be076 to your computer and use it in GitHub Desktop.
Using Jest and BigTest Interactors together to write kickass component tests!

Jest + BigTest Interactor = Component Test ❤️

Over the past year, my friend Wil and I have been building an acceptance testing library for single page apps called BigTest. We strongly feel the highest value tests you can write are ones that run in different browsers and test the whole application together.

While building out BigTest Wil wrote a library called interactor (@bigtest/interactor). You can think about interactors as composable page objects that are super fast. They wait for the element to be present before interacting, so you don’t have to put any sleeps in or sync up with any run loops. It also has a super-expressive API that makes writing complex tests more readable and maintainable.

The best part about interactors is they are composable so you can build on top of other interactors. Another great thing is you can use them anywhere there’s DOM. They were specifically built to be used with any framework.

Since interactors can work anywhere there’s DOM, they pair perfectly with Jest + jsdom component testing. With the mount helper from @bigtest/react, interactors can replace enzyme or react-testing-library.

Rather than talking about it, let's just show it!

A simple checkbox component

For this example, I wanted to keep it simple so we can focus on the testing API. When I wrote these tests I forked the ant-design component library and picked the Checkbox component as my test subject. But really any checkbox React component should work with these tests.

I picked the ant-design component library because it’s well maintained and already has a lot of Jest tests written. This allowed me to drop right in and use @bigtest/interactor. These tests are currently using enzyme to test the components. Let’s write a test for focusing the component to make sure it calls an onFocus callback when focus is set on the element.

First, we’re going to create our interactor. This will describe all of the different ways we can interact with a component. Things like clicking, scrolling, dragging, filling in inputs, focusing, etc.

import Interactor, { is, focusable } from '@bigtest/interactor';

@Interactor.extend
class CheckboxInteractor {
  hasFocus = is("input", ":focus");
  focusCheckbox = focusable("input");
}

// You can use `@bigtest/interactor` without a 
// decorator by passing a POJO to `Interactor.from`
// https://www.bigtestjs.io/docs/interactor/#/Interactor.from
const CheckboxInteractor = Interactor.from({
  hasFocus: is("input", ":focus"),
  focusCheckbox: focusable("input")
});

We import two different methods from @bigtest/interactor:

  • is - returns true or false depending on if the element matches the provided query selector.
  • focusable - focuses the matching element in the DOM

Using these two methods we can focus the component, assert the onFocus callback works, and assert that the element is actually focused in the DOM. Now we can write our test!

We have to import the interactor we just created and then initialize it at the top of the test file. You can initialize an interactor to be scoped to a specific element on the page by passing a selector when calling new. We scope this checkbox interactor to a label element since the outer wrapper of the checkbox is a <label>. If you don’t want to scope it to an element, don’t pass anything. That will scope the interactor to the pages body.

Once it’s initialized we can start using it in our test:

//...typical imports
import { mount } from '@bigtest/react';
import CheckboxInteractor from './interactor';

describe("Checkbox", () => {
  let checkbox = new CheckboxInteractor("label");

  it("calls onFocus() when focus is set", async () => {
    let handleFocus = jest.fn();

    await mount(() => <Checkbox onFocus={handleFocus} />);
    await checkbox.focusCheckbox();

    expect(handleFocus).toBeCalled();
    expect(checkbox.hasFocus).toBe(true);
  });
});

[insert gif of tests running]

Pretty neat! We mount the component in the DOM (jsdom, specifically) and then interact with it there. Let’s write a few more tests like checking for disabled state, toggling the checkbox, and more.

describe("Checkbox", () => {
  let checkbox = new CheckboxInteractor("label");
  //...previous test
    
  it("has the right tabindex", async () => {
    await mount(() => <Checkbox tabIndex="2" />);

    expect(checkbox.tabIndex).toBe("2");
  });

  it("toggles the checkbox via label click", async () => {
    await mount(() => <Checkbox />);
    expect(checkbox.isChecked).toBe(false);

    // the label is the interactors $root element (label)
    await checkbox.click();
    expect(checkbox.isChecked).toBe(true);
  });

  it("toggles the checkbox when a default value is passed", async () => {
    await mount(() => <Checkbox defaultChecked />);
    expect(checkbox.isChecked).toBe(true);

    await checkbox.toggleCheckbox();
    expect(checkbox.isChecked).toBe(false);
  });

  it("is disabled with prop", async () => {
    await mount(() => <Checkbox disabled />);

    expect(checkbox.isDisabled).toBe(true);
  });

  it("is not disabled by default", async () => {
    await mount(() => <Checkbox />);

    expect(checkbox.isDisabled).toBe(false);
  });
});

And then this is what the interactor looks like to back up those tests:

import Interactor, {
  attribute,
  property,
  clickable,
  focusable,
  blurrable,
  is
} from "@bigtest/interactor";

@Interactor.extend
class CheckboxInteractor {
  tabIndex = attribute("input", "tabindex");
  isChecked = property("input", "checked");
  isDisabled = property("input", "disabled");
  hasFocus = is("input", ":focus");
  focusCheckbox = focusable("input");
  blurCheckbox = blurrable("input");

  toggleCheckbox = clickable("input");
 }

[insert gif of tests running]

This is the tip of the iceberg for what you can do with interactors. You can create custom interactions on your interactor class, which make it so you can extend Interactors to fit the needs of your components. Check out the interactor guides and API docs to learn more!

What makes Interactors composable?

I’ve mentioned a couple of times throughout this blog that interactors are composable. What does that mean exactly? Imagine your entire component library is tested with Jest and BigTest interactors. Let’s assume in that component library there’s a <Modal> and a <ConfirmationModal> component. When <ConfirmationModal> was built, it was built on top of the <Modal> component, which already has a tests & component interactor.

Both of these modals share similar actions but differ slightly. We can extend ModalInteractor and add what’s different for ConfirmationModalInteractor:

import Interactor from '@bigtest/interactor';
import ModalInteractor from '../modal/test/interactor';

@ModalInteractor.extend
class ConfirmationModalInteractor {
    // new things
}

Just like when we composed the <Modal> component to make the <ConfirmationModal> component, we composed the ModalInteractor with ConfirmationModalInteractor to make writing tests easier.

It doesn’t stop there! When building the <Modal> component you probably used a <Button> component that takes all kinds of props. Things like disabling, handling actions, changing its style based on primary or secondary, etc. That button also has an interactor so you can add it to your ModalInteractor:

import Interactor, { scoped } from '@bigtest/interactor';
import ButtonInteractor from '../button/test/interactor';

@Interactor.extend
class ModalInteractor {
  confirmationButton = scoped('[type="submit"]', ButtonInteractor);
  cancelButton = scoped('.cancel', ButtonInteractor);
}

Now in the tests you have access to all of the ButtonInteractors properties through confimrationButton & cancelButton:

describe('Modal test' () => {
  let modal = new ModalInteractor('.modal');
  
  it('has a disabled submit button with empty data', async () => {
    await mount(()=> <Modal />);

    expect(modal.confirmationButton.isDisabled).toBe(true);
  });
});

Modals can have all kinds of different components inside like buttons, checkboxes, input fields, etc. All of those components have their own tests and interactors for you to use! You can see how this would be really useful as you build more components and write more tests.

If you would like to see the ant-design example here’s a link to the commit on my fork. If a component library like ant-design were to use interactors in their tests, they could ship the interactors with the library. Then the developers who consume the library can use the interactors in their tests. 🔥

Taking those interactors to acceptance tests

It doesn’t stop there either. Now that you have an entire component library tested with interactors, you can write application acceptance tests with ease. With BigTest we can import those interactors from our component library and use them in our acceptance tests.

Let’s imagine we’re writing an acceptance test for our apps sign up form. This form has two text inputs for a name, a checkbox for email subscribe, and a submit button.

import { setupAppForTesting } from "@bigtest/react";
import AppRootComponent from "../../src/index";

describe("Signup acceptance test", () => {
  let signup = new SignUpInteractor();

  beforeEach(async () => {
    await setupAppForTesting(AppRootComponent);
  });

  describe("filling in the form", async () => {
    beforeEach(async () => {
      await signup
        .firstName.fillAndBlur("Bob")
        .lastName.fillAndBlur("Ross")
        .subscribeCheckbox.toggle()
        .submitBtn.click();
    });

    it("properly submits the form", async () => {
      // ...
    });
  });
});

On this sign up page we fill in the first name field, the last name field, check the email subscribe checkbox and click the submit button. All of this is done through-composed interactors:

import InputInteractor from 'your-component-library';
import ButtonInteractor from 'your-component-library';
import CheckboxInteractor from 'your-component-library';

@Interactor.extend
class SignUpInteractor {
  firstName = scoped("[data-test-first-name]", InputInteractor);
  lastName = scoped("[data-test-last-name]", InputInteractor);
  subscribeCheckbox = scoped("[data-test-email-checkbox]", CheckboxInteractor);
  submitBtn = scoped('[type="submit"]', ButtonInteractor);
}

Your interactors for pages will compose a lot of different components. Writing tests now is a whole lot easier because you’ve already figured out how to move your components around. In the acceptance test example above, we didn’t write any custom interactions specific to the page to write the test. All of the interactions were expressed through nested interactors supplied from the component library.

BigTest makes this possible

Using @bigtest/interactor with Jest is a power combo for making component testing painless. On top of that, the BigTest framework makes writing acceptance tests for single page apps easier by utilizing interactors from the component tests.

I think this can be very powerful for design systems to leverage. Since BigTest is UI framework and test framework agnostic anyone can consume the interactors to start testing their components. Large companies who have a design system that spans the organization and ship in different frameworks (like Vue, React, & Angular) can leverage interactors and each team can still import those interactors to use in their tests. One interactor for three different UI frameworks. That is super powerful, imo.

If you’re interested in using BigTest, check out the new getting started guides. We’re always available to answer any questions on Gitter too!

BigTest resources

I feel like I have to put a small disclaimer at the bottom of this post stating that while a lot of people love jsdom, I’m not so much of a fan. It’s an implementation of the DOM that isn’t used by any actual browsers. BigTest was created specifically to not do that and run tests across many real browsers.

That’s all to say I think you should cross-browser test your app at some point. If that’s done by using Jest + jsom + interactors for components and BigTest for acceptance tests then that warms my heart ❤️😎

Lastly, shout out to Ryan Rauh for opening my eyes to using Jest with BigTest interactors! 💯

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment