JavaScript Unit Testing in Drupal 8 with Jest

Test Driven Development (TDD) facilitates clean and stable code. Drupal 8 has embraced this paradigm with a suite of testing tools that allow a developer to write unit tests, functional tests, and functional JavaScript tests for their custom code. Unfortunately, there is no JavaScript unit testing framework readily available in Drupal core, but don’t fret. This article will show you how to implement JavaScript unit testing.

Why unit test your JavaScript code?

Testing units of code is a great practice, and also guarantees that any future developer doesn’t commit a regression to your logic. Adding unit coverage for JavaScript code is helpful for testing specific logical blocks of code quickly and efficiently without the overhead both in development time and testing time of functional tests.

An example of JavaScript code that would benefit from unit testing would be an input field validator. For demonstration purposes, let’s say you have a field label that permits certain characters, but you want to let the user know immediately if they entered something incorrectly, maybe with a warning message.

Here’s a crude example of a validator that checks an input field for changes. If the user enters a value that is not permitted, they are met with an error alert.

(($, Drupal) => {
  Drupal.behaviors.labelValidator = {
    attach(context) {
      const fieldName = "form.form-class input[name=label]";
      const $field = $(fieldName);
      $field.on("change", () => {
        const currentValue = $field.val();
        if (currentValue.length > 0 && !/^[a-zA-Z0-9-]+$/.test(currentValue)) {
          alert("The value you entered is incorrect!");
        }
      });
    }
  };
})(jQuery, Drupal);

JavaScript

We only allow letters, numbers, and hyphens in this sample validator. We now have a good idea of test data we can create for our test.

Setting up JS Unit Testing

In the world of JavaScript unit testing, Jest has a well-defined feature set, a large community, and is the most popular choice among developers. To begin using Jest, add jest as a development dependency in your favorite manager. Then create a Jest config file, and add your directories for testing. I recommend enabling lcov ; a test coverage reporter that converts test results into local HTML pages.

Writing a Test

We want to test our Drupal behavior, but we need jQuery and the global Drupal object. Have no fear! We can mock all of this. For simplicity’s sake, we can mock both jQuery and Drupal to test the code we want. The point here is to collect the validation logic and run it on our test cases.

There are a couple of different techniques we can use to meet our requirements. You can create a test DOM using a library like JSDOM and require the jQuery library. This gives you the ability to simulate HTML and DOM events. This approach is fine, but our goal is to test our custom validation logic, not to test third-party libraries, or simulate the DOM. Similar to mocking classes and methods in PHPUnit, we can do the same with jest.

Our testing environment is Node, so we can leverage the global object to mock Drupal, jQuery, and even the alert function. Please see Node’s global variable documentation for more information on this object. We can do this in the setup logic of jest with beforeAll:

beforeAll(() => {
  global.alert = jest.fn();
  global.Drupal = {
    behaviors: {}
  };
  global.jQuery = jest.fn(selector => ({
    on(event, callback) {
      validator = callback;
    },
    val() {
      return fieldValue;
    }
  }));
  const behavior = require("label-validator.es6.js");
  Drupal.behaviors.labelValidator.attach();
});

JavaScript

This makes our behavior available to the global Drupal object. We also have mocked jQuery, so we can collect the callback on which we want to run the tests. We run the attach method on the behavior to collect the callback. You may have noticed that we never declared the validator or fieldValue variables; we do this at the top of our test so we have them available in our tests.

// The validation logic we collect from the `change` event.
let validator = () => "";

// The value of the input we set in our tests.
let fieldValue = "";

JavaScript

With the intention of cleanup, we want to unset all the global objects after we have run our tests. In our case, the globals we are mocking do not exist in Node, so it is safe to set them to null. In cases in which we are mocking defined values, we would want to save a backup of that global and then mock it. After we are done testing, we would set the backup back to its corresponding global. There are also many techniques related to mocking globals and even core Node libraries. For an example, check out the documentation on the jest website.

Here is our tear-down logic. We use the jest function afterAll to achieve this:

afterAll(() => {
  global.Drupal = null;
  global.jQuery = null;
  global.alert = null;
});

JavaScript

We need to create an array of values that we know should pass validation and fail validation. We will call them validLabels and invalidLabels, respectively:

/**
 * List of valid labels for the input.
 *
 * @type {string[]}
 */
const validLabels = [
  "123ABVf123",
  "123",
  "AB",
  "1",
  "",
  "abcdefghijklmnop12345678910",
  "ab-3-cd"
];

/**
 * List of invalid labels for the input.
 *
 * @type {string[]}
 */
const invalidLabels = [
  "!@#fff",
  "test test",
  "(123)",
  "ABCDEF123!",
  "^Y1",
  " ",
  "'12346'",
];

JavaScript

Finally, we are ready to start writing our tests. We can use jest’s provided test function, or we can use the “describe it” pattern. I prefer the “describe it” pattern because you can provide detailed information on what you are testing and keep it in the same test scope.

Firstly, we want to test our valid data, and we know that these values should never trigger an alert. We will call the validator on each test value and set the expectation that the alert function is never called. But before we write the test, we want to make sure to clear all our mocks between tests to prevent mock pollution. We can achieve this with beforeEach:

beforeEach(() => {
  jest.clearAllMocks();
});

JavaScript

After writing our valid data test, we will write our invalid data test. This test should expect an alert for each invalid value sent. Putting it all together we have:

describe("Tests label validation logic", () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });
  it("valid label test", () => {
    validLabels.forEach(value => {
      fieldValue = value;
      validator();
    });
    expect(global.alert.mock.calls.length).toBe(0);
  });
  it("invalid label test", () => {
    invalidLabels.forEach(value => {
      fieldValue = value;
      validator();
    });
    expect(global.alert.mock.calls.length).toBe(invalidLabels.length);
  });
});

JavaScript

After writing our tests, we can check our coverage and see we have hit 100%!

Visual output of Jest using the lcov test reporter

Jest is extremely flexible and has a large ecosystem. There are many different ways we could have achieved the above results; hopefully this gives you some useful ideas on how to unit test your javascript code.


The entire sample Jest test:

/* global test expect beforeEach afterAll beforeAll describe jest it */
// The validation logic we collect from the `change` event.
let validator = () => "";
// The value of the input we set in our tests.
let fieldValue = "";
// the setup function where we set our globals.
beforeAll(() => {
  global.alert = jest.fn();
  global.Drupal = {
    behaviors: {}
  };
  global.jQuery = jest.fn(selector => ({
    on(event, callback) {
      validator = callback;
    },
    val() {
      return fieldValue;
    }
  }));
  const behavior = require("label-validator.es6.js");
  Drupal.behaviors.labelValidator.attach();
});
// Global tear down function we use to remove our mocks.
afterAll(() => {
  global.Drupal = null;
  global.jQuery = null;
  global.alert = null;
});
/**
 * List of valid labels for the input.
 *
 * @type {string[]}
 */
const validLabels = [
  "123ABVf123",
  "123",
  "AB",
  "1",
  "",
  "abcdefghijklmnop12345678910",
  "ab-3-cd"
];
/**
 * List of invalid labels for the input.
 *
 * @type {string[]}
 */
const invalidLabels = [
  "!@#fff",
  "test test",
  "(123)",
  "ABCDEF123!",
  "^Y1",
  " ",
  "'12346'",
];
// The tests.
describe("Tests label validation logic", () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });
  it("valid label test", () => {
    validLabels.forEach(value => {
      fieldValue = value;
      validator();
    });
    expect(global.alert.mock.calls.length).toBe(0);
  });
  it("invalid label test", () => {
    invalidLabels.forEach(value => {
      fieldValue = value;
      validator();
    });
    expect(global.alert.mock.calls.length).toBe(invalidLabels.length);
  });
});

JavaScript

Resources

APIs Drupal