expressive-test v0.1.0
expressive-test
I am a fan of short and simple tests. My preference is single-line tests. When done correctly, this tends to produce tests that are easy to both write and comprehend.
I have found it challenging to write tests this way in Javascript. In spite of my best intentions, I find myself wrangling large amounts of test setup code and struggling to keep the tests themselves succinct.
I created this library to experiment with ways to write more succinct and expressive tests. I have started to find some patterns that are helpful and would like to share them.
I hope others will try out what I've been working on and that they'll in turn share how this does or does not help with their particular testing issues. Be aware, however, that this is more playground than productized test tool. No features should be considered stable. Future versions may drastically change how even fundamental things here work. That's sort of the point of a playground.
I will, however, adhere to semantic versioning. An upgrade path, if there ever is one, may require significant rework, but that will be labeled with a major version number change. It won't suddenly appear in a minor version bump.
Installation
Using npm:
npm install expressive-test --save-dev
or if you prefer to install it globally:
npm install --global expressive-test
Running Tests
This package includes a test runner script. It's installed with the name etr
.
The test runner script is very simple. It accepts one or more arguments which are file names or directories. If no arguments are provided, it defaults to tests
.
The script then recursively searches the provided list for files matching the pattern *-test.js
and requires them. It expects any required file to declare one or more tests. Once it has required all of the files, it evaluates the declared tests.
Test Files
Format
Tests use a describe
and it
syntax similar to the "BDD" style of mocha tests. The full syntax is explained in the Test DSL section, below. This tool includes an additional facility for declaring test properties, which is explained in Understanding Test Properties.
Unlike mocha, tests are not supplied callbacks. That is to say, no done
function is passed to it
or before
. If a test is asynchronous, it must return a promise. It can do that directly, or indirectly by declaring the function async
.
The chai expect
function is available globally as expect
, although you may use any assertion library you choose. As with other test frameworks, a test passes if it does not throw an exception or reject.
For the test declaration functions to be available, you must require the expressive-test module. That is not supplied by the test runner. You can do that in each test file if you choose. I prefer to create a setup file at the top of my test directory and include it instead. The setup file can then include this module and do any other initialization that's required.
Understanding Test Properties
The time that I first started writing code to run under Node.js coincided with my journey toward more test-driven development. I had previously written a lot of unit tests in PHP, so when I first used Mocha, it seemed like a big step forward. Later, I found myself working in a Rails environment with Rspec. I had written plenty of code in Ruby before, but it was my first significant use of Rspec. That was a great experience, and it was the first time I felt like I was getting any good at writing tests.
One of the game changers for me were the let
and let!
statements that Rspec provides. ES6, of course, gives us a native let
keyword, but it's not as useful as the method in Rspec.
Why? A let
declaration in Rspec is inherited by nested examples and can be overriden. This is a very useful pattern. Under the hood, the Rspec method describe
and its alias context
is actually defining a class. Nested calls to describe
define derived classes. let
defines methods on that class. That means definitions are evaluated lazily at runtime instead of at declaration time. And that lets you override individual definitions in inner examples.
An example helps illustrate how this can be beneficial. For simplicity's sake, let's say I'm building a to do list application and I've written part of a trivial model class that can load its data from a file on the file system:
const util = require('util');
const fs = require('fs');
const readFile = util.promisify(fs.readFile);
class TodoItem {
constructor(attrs = {}) {
Object.assign(this, attrs);
}
static reset() {
delete this._items;
}
static async _loadItems() {
try {
const data = await readFile('items.json', 'utf8');
return JSON.parse(data).map(item => new TodoItem(item));
} catch (e) {
if ('ENOENT' == e.code)
return [];
throw e;
}
}
static async items() {
return this._items = this._items || await this._loadItems();
}
static async uncompletedItems() {
return (await this.items())
.filter(item => ! item.done);
}
};
module.exports = TodoItem;
I'd now like to write some tests for the uncompletedItems
method. To start, I'd like to verify the following:
- It returns all items if none are done
- It returns only uncompleted items if some are done
- It returns an empty list if the data file does not exist
- It produces an error if the data file isn't parseable
Using a tool like Mocha, I would write tests something like the following:
const TodoItem = require('./todo-item');
const mock = require('mock-fs');
const chai = require('chai');
const expect = chai.expect;
const asPromised = require('chai-as-promised');
chai.use(asPromised);
describe('TodoItem', () => {
describe('#uncompletedItems()', () => {
afterEach(() => mock.restore());
afterEach(() => TodoItem.reset());
context('when no items are done', () => {
before(() => mock({'items.json': '[{"id":1,"title":"foo","done":false},{"id":2,"title":"bar","done":false}]'}));
it('returns all items', async () => {
expect((await TodoItem.uncompletedItems()).map(x => x.id)).to.deep.equal([1,2]);
});
});
context('when some items are done', () => {
before(() => mock({'items.json': '[{"id":1,"title":"foo","done":true},{"id":2,"title":"bar","done":false}]'}));
it('returns non-completed items', async () => {
expect((await TodoItem.uncompletedItems()).map(x => x.id)).to.deep.equal([2]);
});
});
context('when the data file does not exist', () => {
before(() => mock({}));
it('returns an empty list', async () => {
expect(await TodoItem.uncompletedItems()).to.deep.equal([]);
});
});
context('when the data file is not parseable', () => {
before(() => mock({'items.json': '-- bad data --'}));
it('produces an error', async() => {
await expect(TodoItem.uncompletedItems()).to.be.rejectedWith('Unexpected number');
});
});
});
});
The expressive-test library provides a function named property
or prop
for short that functions similar to let
in Rspec. That lets me write these in a different style:
require('./setup');
const TodoItem = require('./todo-item');
const mock = require('mock-fs');
describe(TodoItem, () => {
describe('#uncompletedItems()', () => {
prop('fileSystem', function() { return {'items.json': this.fileData}; });
prop('fileData', function() { return JSON.stringify(this.items); });
prop('items', function() { return [this.item1, this.item2]; });
prop('item1', function() { return new TodoItem({id: 1, title: 'foo', done: this.done1}); });
prop('item2', function() { return new TodoItem({id: 2, title: 'bar', done: this.done2}); });
prop('done1', false);
prop('done2', false);
prop('result', function() { return TodoItem.uncompletedItems(); });
before(function() { mock(this.fileSystem); });
before(() => TodoItem.reset());
after(() => mock.restore());
context('when no items are done', () => {
it('returns all items', async function () {
expect(await this.result).to.deep.equal([this.item1, this.item2]);
});
});
context('when some items are done', () => {
prop('done1', true);
it('returns non-completed items', async function () {
expect(await this.result).to.deep.equal([this.item2]);
});
});
context('when the data file does not exist', () => {
prop('fileSystem', {});
it('returns an empty list', async function () {
expect(await this.result).to.deep.equal([]);
});
});
context('when the data file is not parseable', () => {
prop('fileData', '-- bad data --');
it('produces an error', async function () {
await expect(this.result).to.be.rejectedWith('Unexpected number');
});
});
});
});
In this particular example I created quite a few properties, but it's helpful for showing how they can be used. For me, the value is being able to override specific settings for the setup in each test cases instead of having to redefine the set up each time. When it's done well, I believe it makes the tests easier to read, and in real world tests I often find it saves me some typing.
If it's not clear how this might be useful, I'd ask you to consider something like the file system mocking above. Here the file system setup is simple, so it can be mocked in each describe block with the setup needed for that section. That doesn't scale up very well, though. If the mock setup is more complicated, it gets tedious to have to redeclare it for each example. It can also be hard to maintain over time. If you need to add an additional setting to the mock in the future, you're forced to update each test. In the past I've dealt with that by adding utility functions to my test files to help with the setup.
Properties are another way to solve this problem. In the example above, using properties I only have to setup the mock once. If I want to change what goes into the mock, I only need to override the single property I want to change and that's the value that's used when the mock is created for that section. It also means if I need to alter the mock in the future, I only have to do that in one place. Utility functions can achieve a similar result. I just find it's faster for me to set up with properties.
One technical detail to make note of here is the use of this
. Properties are accessed at runtime through the use of this
. That is why you see function
being used to declare functions passed to it
and before
blocks above. That won't work with arrow functions. For this reason, the same value is also provided as the only argument to the function.
For example, I could rewrite the first "it" test above as:
it('returns all items', async (test) => {
expect(await test.result).to.deep.equal([test.item1, test.item2]);
});
If you are using this
to refer to a property in a function body, you must use function
, not an arrow function. If you wish to use an arrow function, use the function argument instead of this
. That substitution works for prop
, property
, before
, after
, and it
.
Test DSL
When included, this library declares the following globals:
It exports the following:
If you prefer not to pollute the global namespace, you could instead require expressive-test/lib/test/test-dsl
. The DSL module exports all of the above globals with the exception of expect
and any aliases.
after(callback)
Aliases
- afterEach
Parameters
callback
: Function A function to call after each testThe callback is passed the following arguments:
test
: TestCase The test case that ran
Return Value
undefined
Description
Accepts a function to be called after the completion of each test. Note that this is the equivalent of an afterEach function, which is different from other test frameworks.
The value of this
is the TestCase
that ran. The TestCase
is also passed as a single argument to the function, which is useful if you supply an arrow function. The callback may return a Promise.
after
may only be called inside of a describe
or context
block. If it is called more than once in the same block, the provided functions are called in the order they were supplied.
afterAll(callback)
Parameters
callback
: Function A function to call after all tests
Return Value
undefined
Description
Accepts a function to be called once following the completion of all tests in the section.
No arguments are passed to the callback function. The value of this
is the TestSuite
it was declared within. The callback may return a Promise.
afterAll
may only be called inside of a describe
or context
block. If it is called more than once in the same block, the provided functions are called in the order they were supplied.
before(callback)
Aliases
- beforeEach
Parameters
callback
: Function A function to call before each testThe callback is passed the following arguments:
test
: TestCase The test case that is to be run
Return Value
undefined
Description
Accepts a function to be called before each test begins. Note that this is the equivalent of a beforeEach function, which is different from other test frameworks.
The value of this
is the TestCase
that is to be run. The TestCase
is also passed as a single argument to the function, which is useful if you supply an arrow function. The callback may return a Promise.
before
may only be called inside of a describe
or context
block. If it is called more than once in the same block, the provided functions are called in the order they were supplied.
beforeAll(callback)
Parameters
callback
: Function A function to call before all tests
Return Value
undefined
Description
Accepts a function to be called once before any tests in the section are run.
No arguments are passed to the callback function. The value of this
is the TestSuite
it was declared within. The callback may return a Promise.
beforeAll
may only be called inside of a describe
or context
block. If it is called more than once in the same block, the provided functions are called in the order they were supplied.
describe(description, callback)
Aliases
- context
Parameters
description
: String | Class A description or the class being describedIf
description
is a class, the name of the class is used as the section description and the class is made available as a property nameddescribedClass
.callback
: Function A function which declares additional sections or tests
Return Value
The TestSuite
defined by the callback.
Description
Creates a new test section or suite.
No arguments are passed to the callback function. It is invoked immediately. The value of this
is the TestSuite
being declared. Any return value of the callback is ignored.
describe
and its alias context
are the only functions in this DSL which may be called outside of a describe
or context
block.
expect
Chai's expect
function. See chaijs.com for more information.
it(description, testFunction)
Parameters
description
: String A description of the expected outcometestFunction
: Function A function which defines the test and performs any assertionsThe
testFunction
receives the following arguments:test
: TestCase The test case being run
If
testFunction
is omitted, it creates a pending test.
Return Value
The created TestCase
.
Description
Defines a new test or example.
testFunction
is invoked when the test is run. The value of this
is the TestCase
being run. The TestCase
is also passed as a single argument to the function, which is useful if you supply an arrow function. The function may return a Promise. If it throws an exception or rejects, the test fails. Otherwise, it is considered to have succeeded.
it
may only be called inside of a describe
or context
block.
property(name, definition, options)
Aliases
- prop
Parameters
name
: String The name of the property to definedefinition
: Any The value of the property or a function that provides the valueoptions
: Object An optional parameter specifying settings for the property
Return Value
undefined
Description
Defines a property available to all tests in the section where the property is defined. Properties are inherited by nested sections and may also be overridden.
The definition
may be a literal value for the property. If definition
is a function, it is used as a getter function for the property.
For a getter function, the value of this
is the TestCase
being run. Unlike traditional getter functions, it is passed a single argument, which is also the value of the TestCase
being run. That is to facilitate the use of arrow functions, where this
cannot be altered.
By default, getter functions are memoized. That is, after they are invoked the first time time, that return value is used for any subsequent property access (and the getter function is not invoked). Note that getter functions are not invoked until the property is first accessed. In effect, they are lazily evaluated.
The options
argument is an object specifying additional settings for the property. Optional settings are:
memoize
: Boolean Iffalse
, thedefinition
function is not memoized. Defaults totrue
.
prop
or property
may only be called inside of a describe
or context
block.
For more information on properties and usage examples, see Understanding Test Properties.
xit(description, testFunction)
xit
accepts the same arguments as it, but defines a pending test. A pending test is listed in the test output, but is not run. This is useful for writing the descriptions of tests you intend to implement in the future or for temporarily disabling specific tests.
chai
chai
is not available globally, but is exported by this package. It is the chai package associated with expect
. This is useful if you want to include chai plugins.
For example, a simple test setup script that adds a chai-as-promised plugin would look like:
const et = require('expressive-test');
const asPromised = require('chai-as-promised');
et.chai.use(asPromised);