Excelling with Sinon.js
While the trifecta of Javascript testing — mocha, sinon, and chai — are quite powerful, the à la carte nature of each library can make it tricky to get a solid grip on the strong points of each. This post continues on the testing in Javascript series (part 1, part 2) by taking a look at how to test individual units of code. Note that an understanding of the material covered in previous posts is assumed.
Sinon has quite a lot of functionality, but the primary three things that projects interact with are stubs, spies, and mocks. Before looking at these in-depth, it’s worth considering the problem that Sinon is solving.
Identifying Dependencies
Projects of any amount of complexity are composed together of several components like services, controllers, views, etc. Within these components are typically multiple bits of behavior.
For example, a service may have a handful of public methods that collaborators can invoke. Imagine if one of those service methods made a call out to another service or to a database.
class FooService {
getFoo(fooId) {
return db.where({ id: fooId });
}
}
Those calls out to “other things” are dependencies. In this case db
is said to be a dependency of FooService
.
Understanding this, Sinon.js is primarily a tool for either faking the response of dependencies or observing interactions with dependencies. When testing FooService
, for example, it would be possible to either fake the response of db.where
or to observe the interactions withdb
from the FooService
test. Let’s have a look at how this is done.
Isolating Dependencies
Before dependencies can be faked or observed, it’s necessary to set up the code in such a way that the dependencies are accessible from the unit test. In the code example above, where is db
being defined? There are roughly three approaches for setting up dependencies.
Singleton Dependencies
One of the more common approaches is to initialize a dependency once, export it, then to import or require it throughout the project.
// db.js
const db = new DatabaseClient();
export default db;// fooService.js
import db from './db';class FooService {
getFoo(fooId) {
return db.where({ id: fooId });
}
}
With this structure it’s now possible to fake or observe db
from the tests just by importing it. This works because node only evaluatesdb.js
once, meaning that every time it’s imported, const db
is referencing the same instance. Because the references are identical, any stubbing or spying that’s done on db
within a test will affect the same db
instance that is used when the code is run.
A side effect of this singleton behavior is that faking or observing interactions with a singleton instance will affect all tests using that singleton. We’ll look at a method for solving this later.
Initialized Property Dependencies
Another approach to isolating dependencies is to initialize them in the constructor of the class.
// fooService.js
class FooService {
constructor(someArg) {
this.db = new DatabaseClient(someArg);
}getFoo(fooId) {
return this.db.where({ id: fooId });
}
}
This approach is often useful in cases where the constructor of a class may take specific arguments that also become construction arguments for dependency instances, someArg
in the example above. From a testing perspective, having the dependencies as instance properties allows them to be manipulated from the tests, where the manipulation is specifically on the subject-under-test instance.
Construction-Time Dependencies
Lastly, in cases where a common dependency is initialized once on some interval that instance can be given to the class at construction time.
// fooService.js
class FooService {
constructor(db) {
this._db = db;
}getFoo(fooId) {
return this._db.where({ id: fooId });
}
}
This approach can typically be seen in cases where a certain set of services are initialized once per request, to allow service-level memoization per request. The initialized instances can be passed in as a dependency.
Observing Interactions with Spies
When unit testing, sometimes the only thing that matters is just to know that a small unit of code interacted with a dependency. This is what spies are for.
Consider the oversimplifiedFooService
again:
class FooService {
getFoo(fooId) {
return db.where({ id: fooId });
}
}
It’s likely that db
is a robust, open-source, community-supported library that is used by literally thousands of projects in production every day. From a testing perspective it might be enough to say, “If I call db.where
with the correct arguments, I can reasonably assume it will do the right thing.” From that perspective, the only thing worth testing then is what the rest of the code is doing, not what db
is doing.
it('invokes the database', async function() {
const subject = new FooService();
sinon.spy(subject.db, 'where');
await subject.getFoo(1234);
const dbArgs = db.where.getCall(0).args[0];
expect(dbArgs.id).to.eql(1234);
});
The first argument of spy
is the object on which the to-be-spied method exists, and the second argument is the string name of that method.
With the whole snippet above of .spy(...)
this code says, “every time db.where
is called, record the arguments and I will inspect them later.” The code which follows, .getCall(0)
retrieves a call, in this case by index getting the first call. getCall
returns an object describing the call, of which .args
has the arguments that were given when the dependency was invoked. Using the args, it’s then possible to make an expectation based on what is required.
WARNING: Spies pass through the call, allowing the original behavior to run. In the example above, the db.where
will be invoked, and run the actual db.where
behavior!
Faking Responses with Stubs
Whereas spies allow observing interactions, stubs allow the unit test to control the return of a function on a dependency. This often allows much richer unit tests to be written.
it('returns records from the database', async function() {
const recordInDb = { id: 1234, name: 'bar' };
const subject = new FooService();
sinon.stub(subject.db, 'where')
.returns(Promise.resolve([recordInDb])); const result = await subject.getFoo(1234);
expect(result).to.deep.eql(recordInDb);
});
Similar to spies, stubs are called with two arguments, the dependency object and the string name of the method to spy on. Unlike spies however, there are additional methods that must be chained when using a stub instead of a spy. In the example above, .returns
, specifies the behavior of what the stub should do when it’s called.
The whole bit of code sinon.stub(...).returns(...)
will cause the value within returns
to always be returned whenever db.where
is invoked. Because the return value of the dependency is then known at test time, the expectations of the test can be specific.
Requiring Arguments
The example test above is good in that it only tests the behavior of the getFoo
method, however it has a little bit of a gap in it: calling subject.getFoo(1234)
would result in the same response as calling subject.getFoo(5678)
. This is because the stub was set up to always return the same record, regardless of what arguments were passed to it.
Qualifiers can be added to a stub which specify to only return a response when a certain set of arguments are given.
sinon.stub(subject.db, 'where')
.withArgs({ id: 1234 })
.returns(Promise.resolve([recordInDb]));
Invoking subject.getFoo(5678)
will now fail, while subject.getFoo(1234)
will continue to pass. This is because the arguments being given to db.where({ id: fooId })
when it is called from within FooService
will match the value in withArgs
.
Stubs support multiple withArgs/returns pairs as well.
const stub = sinon.stub(subject.db, 'where');
stub.withArgs({ id: 1234 }).returns(Promise.resolve([record1]));
stub.withArgs({ id: 5678 }).returns(Promise.resolve([record2]));
Note that sinon.stub
is only being called once. It’s an error to stub a stub.
Call Count
Occasionally the need arises where the differentiating factor between invocations is the order of calls, not the arguments given. Logging and metrics are a great example of this. In this case onCall
can be used.
sinon.stub(subject.db, 'where').onCall(3).returns(...);
Understanding Stubs
It should become clearer now that stubs are just new functions which return a value that they are given.
const func1 = sinon.stub().returns(5);
const func2 = () => 5;
What stubs grant over using a vanilla function is the ability to control the output based on the input in a manner that is shorter to express than defining a new function with branching logic.
const func1 = sinon.stub().withArgs('a').returns(5);
const func2 = (name) => {
if (name === 'a') {
return 5;
}
return null;
};
Fuzzy Matching
As code gets more complex, a situation arises where the the arguments being passed in to dependencies are growing more complex. Consider an example service that accepts a postal address as an argument.
class UserController {
getFriends(req, res, next) {
const friends = friendsService.getFriendsForUser(req.user);
res.json(friends);
}
}
In this snippet, user
, is likely a fairly complex object that has may additional properties that are not a concern when writing a test.
sinon.stub(friendsService, 'getFriendsForUser')
.withArgs({ ...super complex user object... })
.returns(Promise.resolve(friends));
It would be poor form to attempt to test that the entire user object matches. If an additional, unrelated property was ever added to user
, then having a very specific description of that object would cause this test to fail. Imagine if adding one property caused a third of the test suite to fail. That’s not ideal.
For testing the getFriends
behavior, the main concern may be that just the user.id is what is expected. It’s a better approach for the unit test to only require the pieces of the arguments that the test needs, and to ignore everything else. Sinon provides match
to handle this situation.
sinon.stub(friendsService, 'getFriendsForUser')
.withArgs(sinon.match({ id: expectedId }))
.returns(Promise.resolve(friends));
sinon.match
can make unit tests far more robust by only matching arguments on the properties that are relevant to the test. On the other hand, sinon.match
can be a bit unpredictable, so it’s worth taking the time to consider what a particular test does and does not care about. With complex objects the exact set of properties that the test should match on are sometimes not obvious.
Tip: Resolve for Promises
Sinon has a few little tricks that make working with it easier. Since writing .returns(Promise.resolve(value))
is extremely common when working with promises, it’s possible to use the shorthand .resolves(value)
instead. This makes reading and writing stubs quicker and is a great habit to adopt.
Stubs vs. Spies
When to use a spy and when to use a stub is a question that can easily stump those new to Sinon. While spies and stubs seem different in their intended usage, and they are, it’s worth being aware that stubs have all of the functionality that spies do. As a general rule of thumb though, it’s better to prefer spies for observation and stubs for manipulation.
Use a spy when…
- Merely knowing that an interaction happened is enough
- There needs to be deep inspection or manipulation of arguments given to a dependency
Use a stub when…
- The return of the function is part of the expectation and influenced by a dependency
- The underlying behavior should not be executed
Verifying Invocations with Mocks
Stubs are great, but they can invite certain mistakes. In particular, consider a slightly more complex example.
class FooService {
constructor(id) {
this.id = id;
this.value = 0;
}
increment(persist) {
this.value++;
if (persist) {
db.update({ id: this.id, value: this.value });
}
return this.value;
}
}
An incorrect test for this might look something like the following.
it('persists the value to the db', function() {
sinon.stub(db, 'update')
.withArgs({ id: 1234, value: 1 })
.resolves(true);const subject = new CounterService();
const result = subject.increment(1234, 5);
expect(result).to.eql(6);
});
This test should fail, but it does not. The mistake that db.update
isn’t actually ever called. This is an important thing to know about stubs:
NOTICE: Defining a stub does not require that the stub be invoked.
To solve for this, Sinon has a feature called mocks. Mocks are stubs + expectations expressed at once. The above test could be refactored to using a mock.
it('persists the value to the db', function() {
sinon.mock(db)
.expects('update')
.withArgs({ id: 1234, value: 1 })
.resolves(true);const subject = new CounterService();
const result = subject.increment(1234, 5);
expect(result).to.eql(6);
});
This new test would correctly fail, because db.update
is not being called. The primary difference between a stub and a mock is how they are set up.
// stub
sinon.stub(db, 'update')
// mock
sinon.mock(db).expects('update')
Other builder methods like .withArgs
, returns
, and resolves
work the same between the two of them. The primary difference is that a mock requires that a method be invoked as part of that test run, whereas a stub only allows it.
Tips for Better Unit Tests with Sinon
Make a Sane Default Stub in BeforeEach Blocks
While it’s possible to stub things on-demand from within tests, it often makes more sense to stub dependencies in one location, often in the beforeEach
block, and then manipulate them per test. This allows the body of the test to focus on the specifics of that test instead of the tedium of initializing stubs or spies.
Avoid Mocks in beforeEach
Because mocks come with an expectation, it leads to confusing tests when the beforeEach
block contains expectations. As a general rule of thumb, stubs and spies only in the beforeEach
block.
Sandboxing for Cleaner Tests
Sinon includes a sandboxing feature. Instead of calling sinon.stub
or sinon.spy
, sandbox.stub
and sandbox.spy
are used instead. The primary advantage here is that with a single invocation of sandbox.restore
all of the stubbed, mocked, and spied behavior will be undone. Tests can be setup to always call sandbox.restore
in the afterEach
block, enforcing that stubs and spies don’t bleed between tests.
Spies + Chai.js
There’s a great package for Chai.js which provides expectation syntax for spies called sinon-chai. While not officially a part of the sinon package, it’s an excellent addition.
We’re Hiring!
If these kinds of projects and challenges sound interesting to you, Ibotta is hiring! Check out our jobs page for more information.