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:
- sharing behaviors can often lead to over-engineering tests
- 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:
nlm-pro / mocha-shared-behaviors
shared behaviors with Mocha
Shared Behaviors with Mocha
References
- Shared Behaviors on Mocha Wiki
- discussion about Shared Behaviors best practices in @open-wc
- Mocha Gitter
issues
- ☕ Mocha - enhancement: new interface to support arrow functions
- ☕ Mocha - Context Variables and Functions
- ☕ Mocha - Idea: scoped globals
- ❌ ☕ Mocha - Does Mocha encourage
this
for shared behaviors? - ❌ ☕ Mocha - Enhanced control of test context
- ❌ ☕ Mocha - Bind ES5 Arrow function to context
PR
- ☕ Mocha - initial implementation of "functional" interface (no update since 2018-12-12)
projects
- mocha-context
- arrow-mocha (no update since 2015-04-07)
And also...
What I am going to talk about here
- Recommendations from Mocha
- The KISS Principle
- Issues when using arrow functions with Mocha
- Alternative approaches
- Summary
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;
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');
});
});
}
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;
});
});
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);
});
Let's run this and...💥 it fails!
TypeError: Cannot read property 'name' of undefined
at Context.name (test/helpers.js:5:17)
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'
});
});
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;
});
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
});
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;
});
});
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:
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)