DEV Community

Noël for Open Web Components

Posted on

Shared Behaviors best practices with Mocha

Like many of you, I love unit testing! Because good coverage on a codebase makes me confident. Tests help me understand what a code is about. Above all, they make me feel less frustrated when I debug 😉

But here is something that can frustrate any developer when they write or read tests: sharing behaviors.

I see two reasons for this:

  1. sharing behaviors can often lead to over-engineering tests
  2. there are too many (bad) ways to do it

So, have a nice cup of tea, relax, and let's have a look at some ways to do it right...

tl;dr

Check out the examples and the decision flowchart in the associated project on Github:


What I am going to talk about here

The (old) Mocha way

complete example on Github ➡️ test/mocha-way

First things first! Let's see what the Mocha documentation
itself says about this.

Mocha binds its context (the Mocha "contexts", aka the "this" keyword) to every callback you give to it. Meaning, in the function you give to describe, before, beforeEach, it, after & afterEach, you can assign to this any data or function you want, making it available for all the callbacks to be called in the same describe.

To illustrate how to use this to write shared behaviors, Mocha gives the following example.

FYI, I took the liberty to update this code as "Open WC," using ES Modules and expect instead of CommonJS and
should.

Here is the code we want to test.

/// user.js
export function User(first, last) {
  this.name = {
    first: first,
    last: last
  };
}

User.prototype.fullname = function() {
  return this.name.first + ' ' + this.name.last;
};

/// admin.js
import { User } from './user.js';

export function Admin(first, last) {
  User.call(this, first, last);
  this.admin = true;
}

Admin.prototype.__proto__ = User.prototype;
Enter fullscreen mode Exit fullscreen mode

Admin obviously shares some behaviors with User. So, we can write these shared behaviors in a function using "contexts":

/// helpers.js
import { expect } from '@open-wc/testing';

export function shouldBehaveLikeAUser() {
  it('should have .name.first', function() {
    expect(this.user.name.first).to.equal('tobi');
  });

  it('should have .name.last', function() {
    expect(this.user.name.last).to.equal('holowaychuk');
  });

  describe('.fullname()', function() {
    it('should return the full name', function() {
      expect(this.user.fullname()).to.equal('tobi holowaychuk');
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Finally, here are the tests:

/// user.test.js
import { User } from '../user.js';
import { shouldBehaveLikeAUser } from './helpers.js';
import { expect } from '@open-wc/testing';

describe('User', function() {
  beforeEach(function() {
    this.user = new User('tobi', 'holowaychuk');
  });

  shouldBehaveLikeAUser();
});

/// admin.test.js
import { User } from '../user.js';
import { shouldBehaveLikeAUser } from './helpers.js';
import { expect } from '@open-wc/testing';

describe('Admin', function() {
  beforeEach(function() {
    this.user = new Admin('tobi', 'holowaychuk');
  });

  shouldBehaveLikeAUser();

  it('should be an .admin', function() {
    expect(this.user.admin).to.be.true;
  });
});
Enter fullscreen mode Exit fullscreen mode

What's wrong with this approach

This wiki page hasn't been (significantly) edited since January 2012! Way before ES2015!

This is why Mocha decided to discourage using arrow functions in 2015 ... and no update to this section of the documentation has been done since.

It's pretty old. There is also no documentation about field ownership, so you're exposed to future conflicts any time you use the Mocha "contexts".

Yet, those aren't the main issues with this approach. Using it, there is no way to clearly identify the requirements of your shared behavior. In other words, you can't see the required data types and signature in its declaration context (i.e. closure) or in the function signature (i.e. arguments). This isn't the best choice for readability and maintainability.

There are some ongoing discussions about this approach. Especially noteworthy: Christopher Hiller (aka Boneskull), maintainer of Mocha since July 2014, published a first attempt of a "functional" interface in May 2018 (there are references at the end of this article for more information on this). Yet, this PR is still open, and we can't, I think, expect any advancement on this soon.

Keep it simple, stupid! (KISS)

In short: over-engineering is one of the main dangers when defining shared behaviors in your tests!

I believe the KISS principle is the key principle to keep in mind when you write tests. Think YAGNI (short for "You Ain't Gonna Need It")! Do not add a functionality before it's necessary! In most cases, Worse is better!

KISS is at the core of all good engineering. But when it comes to testing, it's its FUSION REACTOR CORE 💣! If you forget this, it's the apocalypse of your project! Guaranteed!

If still have doubts, here is an argument from authority 😉 :

Jasmine permits handling shared behaviors pretty much the same way Mocha does (i.e. using the "this" keyword). Concerned about this same issue, the contributors added the following "Caveats" chapter to the related documentation page.

Sharing behaviors in tests can be a powerful tool, but use them with caution.

  • Overuse of complex helper functions can lead to logic in your tests, which in turn may have bugs of its own - bugs
    that could lead you to think you're testing something that you aren't. Be especially wary about conditional logic (if
    statements) in your helper functions.

  • Having lots of tests defined by test loops and helper functions can make life harder for developers. For example,
    searching for the name of a failed spec may be more difficult if your test names are pieced together at runtime. If
    requirements change, a function may no longer "fit the mold" like other functions, forcing the developer to do more
    refactoring than if you had just listed out your tests separately.

So writing shared behaviors using the "this keyword" does work. And it can be pretty useful from time to time. But it can also bring a lot of unneeded complexity to your tests.

Avoid using the Mocha context as much as you can!
Same thing for shared behaviors in general!

Let's deconstruct the previous example, and minimize its complexity step-by-step.

using arrow functions with Mocha

complete example on Github ➡️ test/mocha-way-arrow

Back to the "functional" interface PR. Why would we need a "functional" interface in Mocha in the first place?

Let's try to rewrite the previous example using an arrow function. Of course, a lambda doesn't have a "this", so here I'll use its closure.

/// helpers.js
export function shouldBehaveLikeAUser(user) {
  it('should have .name.first', () => {
    expect(user.name.first).to.equal('tobi');
  });
  // other tests
}

/// user.test.js
describe('User', () => {
  let user;

  beforeEach(() => {
    user = new User('tobi', 'holowaychuk');
  });

  shouldBehaveLikeAUser(user);
});
Enter fullscreen mode Exit fullscreen mode

Let's run this and...💥 it fails!

TypeError: Cannot read property 'name' of undefined
  at Context.name (test/helpers.js:5:17)
Enter fullscreen mode Exit fullscreen mode

This is because Mocha identifies and "records" your test suite first, and then runs your callbacks. So here, it runs beforeEach and shouldBehaveLikeAUser (user being undefined at this point) and only then beforeEach.fn and it.fn.

"All-in-one"

complete example on Github ➡️ test/all-in-one

One solution is to move the beforeEach in shouldBehaveLikeAUser.

/// helpers.js
export function shouldBehaveLikeAUser(buildUserFn, { firstName, lastName, fullname }) {
  let userLike;

  beforeEach(() => {
    userLike = buildUserFn();
  });

  it('should have .name.first', () => {
    expect(userLike.name.first).to.equal(firstName);
  });
  // other tests
};

/// user.test.js
describe('User', () => {
  shouldBehaveLikeAUser(() => new User("tobi", "holowaychuk"), {
    firstName: "tobi",
    lastName: "holowaychuk",
    fullname: 'tobi holowaychuk'
  });
});

/// admin.test.js
describe('Admin', () => {
  shouldBehaveLikeAUser(() => new Admin("tobi", "holowaychuk"), {
    firstName: "tobi",
    lastName: "holowaychuk",
    fullname: 'tobi holowaychuk'
  });
});
Enter fullscreen mode Exit fullscreen mode

Here, nothing is "hidden." Just by looking at the signature, we understand that shouldBehaveLikeAUser will test that the constructor you gave will fit the "User" behavior definition. This can be enhanced by adding a JSDoc @param or some TypeScript.

And it's self-sufficient. No side effects or closure requirements here.

More important, it's completely isolated! You can't reuse userLike! You would have to repeat yourself, like this:

it('should be an .admin', () => {
  expect(new Admin().admin).to.be.true;
});
Enter fullscreen mode Exit fullscreen mode

This last point could be seen as an issue. Yet, I believe it's actually an advantage! It's obvious that this helper isn't really useful if you need the same setup before or after using it. You should use it if and only if you're actually testing a complex, self-sufficient behavior.

"one-by-one"

complete example on Github ➡️ test/one-by-one

If you need to share setups, it could mean that your behavior isn't well defined or identified. Or maybe you shouldn't be working with this level of complexity (YAGNI, remember?).

Defining the behavior spec by spec, like in the following example, is often simpler.

/// helpers.js
export const expectUserLike = user => ({
  toHaveNameFirstAs: expectation => {
    expect(user.name.first).to.equal(expectation);
  },
  toHaveNameLastAs: expectation => {
    expect(user.name.last).to.equal(expectation);
  },
  toHaveFullnameThatReturnAs: expectation => {
    expect(user.fullname()).to.equal(expectation);
  }
});

/// user.test.js
let user = 'foo';
const constructorArgs = ['tobi', 'holowaychuk'];

describe('User', () => {
  beforeEach(() => {
    user = new User(...constructorArgs);
  });

  it('should have .name.first', () => {
    expectUserLike(user).toHaveNameFirstAs(constructorArgs[0]);
  });

  // other tests
});
Enter fullscreen mode Exit fullscreen mode

Now, this shared behavior isn't isolated anymore. And it's simple 💋!

Not being able to test every aspect of the behavior, or define an order, spec description, setup and tear down, could be an important downside for some use cases. Yet, in my opinion, this isn't really needed as often as you may think.

This approach is often my preference. It's simple, explicit and permits definition of shared behaviors in separate files.

Yet, I only use it if separate files is an absolute requirement.

The power of closures

complete example on Github ➡️ test/closure

If it isn't, simply use the lambda closure to share data between your shared behaviors.

Take the first example, from the Mocha Wiki. user.test.js and admin.test.js are actually in a single file, test.js. User and Admin are from the same "feature scope," so it feels right and logical to test those two as one.

With this idea, let's refactor a little.

let userLike;

const shouldBehaveLikeAUser = (firstName, lastName) => {
  it('should have .name.first', () => {
    expect(userLike.name.first).to.equal(firstName);
  });
  // other tests
};

describe('User', () => {
  const firstName = 'tobi';
  const lastName = 'holowachuk';

  beforeEach(() => {
    userLike = new User(firstName, lastName);
  });

  shouldBehaveLikeAUser(firstName, lastName);
});

describe('Admin', () => {
  const firstName = 'foo';
  const lastName = 'bar';

  beforeEach(() => {
    userLike = new Admin(firstName, lastName);
  });

  shouldBehaveLikeAUser(firstName, lastName);

  it('should be an .admin', () => {
    expect(userLike.admin).to.be.true;
  });
});
Enter fullscreen mode Exit fullscreen mode

This is the lowest level of shared behavior you can get. It's a "give or take": either you share some behaviors this way, or you need to repeat yourself (sometimes a lot). And guess what: both are OK.

So, here are all the best ways you should write shared behaviors with Mocha. And now you know what to do if you need any of them. 🙂

But remember: ask yourself how you should design your tests, before asking how you should write them.

When following a Given-When-Then approach (which I often do) for example, using closures as I have done above is very handy. But you could also write your own Chai extension... Or a whole new testing library. But these are topics are for another time. Maybe some blog posts I should write sometime soon. Stay tuned 😉!

Summary

Requirements, Pros & Cons

Mocha this all-in-one one-by-one closures only
👍 KISS 💋 ✔️
👍 No side effects or closure ✔️ ✔️
👍 no hidden nor added logic
several tests at once ✔️ ✔️ ✔️
can be exported ✔️ ✔️ ✔️

✅ = most of the time

Guidelines

✔️ DO Use arrow functions by default. This makes it clear that the Mocha contexts shouldn't be used in your project (probably most of the time!)

✔️ DO Check if YAGNI before anything, every time!

DON'T Write shared behaviors without thinking about it carefully. You probably don't need to write a shared behavior as often as you may think!

DON'T use the Mocha "contexts" if at least one of the following ❔IF is met

shared behaviors in one file

IF you don't need to use a shared behavior in another file straight away

✔️ DO favor using closures

✔️ DO keep a variable declaration close to it's initialization (& use)

"one-by-one"

IF you don't need to define a whole set of tests in the same order with the same description.

✔️ DO define one lambda for each test in another file

DON'T use a higher-order function to join these lambdas if there are less than 2 or 3 tests for the same "scope."

"all-in-one"

IF your pre- and post- conditions are always the same for this behavior

✔️ DO define your shared behaviors with the 'before', 'beforeEach', 'after' and 'afterEach' in one big lambda function.

how to choose

Last but not least, here is a flowchart to help you make the right decision every time:

flowchart

Do you have other ideas for defining good shared behaviors? Any feedback or questions about the one I have shown here?

Leave a comment below, tweet at me @noel_mace, or open an issue for the associated project on Github

Top comments (0)