Skip to content

07 Writing your own modules

Łukasz Makuch edited this page Jul 14, 2022 · 1 revision

Once you start writing some more challenging tests, you'll find yourself looking for a way to organize your code and remove repetition.

Custom modules help with that. Judging from my experience, you'll want to write them even for relatively simple tasks for the sake of readibility.

So let's get started!

Registering modules and methods

Below is the source code of the container module. We'll use it as an example to learn how to write our own modules. Have a quick look. It'll be explained in more details later.

module.exports = {
  name: "container",
  methods: {
    set: async function (containerElement, name) {
      this._containers = {
        ...(this._containers ?? {}),
        [name]: containerElement,
      };
    },

    get: function (name) {
      const container = this._containers?.[name];
      if (!container)
        throw new Error(
          `No container with the name "${name}" has been declared.`
        );
      return container;
    },
  },
};

That's how we install it in one of the Jest's setupFilesAfterEnv files:

// First we require it:
const containerModule = require("./modules/container");

// And then we call the swapModule method of the global testCtx object:
testCtx.swapModule(containerModule);

And now it's ready to be used in test files!

testCtx[0].containerSet(await testCtx[0].tlFindByTestId("myForm"), "form");

So what exactly happened?

A module is defined by an object with two properties:

  • name
  • methods

The name is unique among all modules and may be seen at the beginning of every method call. So if a module is called container, then all of the methods it exposes will start with container, like containerSet, containerGet, containerDoSomething etc.

Methods are defined as an object under the methods key. If we wanted to use a containerDoSomething method in our tests, we'd define it this way:

module.exports = {
  name: "container",
  methods: {
    doSomething: function () {
      console.log("containerDoSomething");
    },
  },
};

There are two things worth paying attention to:

  1. The "d" letter in "doSomething" is lower-case and it's automatically converted to upper-case when it's conjugated with the module name, giving us "containerDoSomething".
  2. The method is defined as a tranditional function.

The shared this

The reason why a method isn't an arrow function is because it's bound to the whole context.

It means that you can access a shared this variable across all your methods. This is how the "browser" module sets something:

module.exports = {
  name: "browser",
  methods: {
    open: async function () {
      // ...
      this.driver = driver;
      //...

And here you can see how the "screenshot" module reads it:

module.exports = {
  name: "screenshot",
  methods: {
    take: async function (id) {
      // ...
      const screenshot = await this.driver.takeScreenshot();
      // ...

Calling other modules from within your module

What's great is that you can even call other modules from within your module. For instance, the "browser" module uses the "teardown" module:

module.exports = {
  name: "browser",
  methods: {
    open: async function () {
      // ...
      this.teardownRegister(() => this.browserClose());
    },
    // ...
  },
};

A convention that makes chaining easier

If your methods accepts multiple arguments, then you may want to consider ordering these arguments so that the users of your module may benefit from chaining.

The idea is:

If some of the arguments accepted by your method may come from calling other methods, consider placing these arguments at the beginning.

Let's say you're writing a method that clicks some element some number of times.

It could look something like this:

{
  name: 'click',
  methods: {
    times: async function (times, element) {
      for (let i = 0; i < times; i++) {
        await element.click();
      }
    }
  }
}

Now imagine you want to call it with a WebElement obtained using Testing Library. It'd look something like this:

const toClick = await testCtx[0].tlFindByText("Add");
await testCtx[0].clickTimes(3, toClick);

It's fine. It works. But it ain't pretty!

It'd be much better if the order of arguments accepted by the clickTimes method was like this:

times: async function (element, times) {

Then you could benefit from the chaining capabilities provided by the context:

testCtx[0]
  .tlFindByText("Add")
  .clickTimes(3)