In one of our project’s test code, we have to create many objects asynchronously and use them later in our tests. Our codebase is promise-based, so we have to chain these promises in a way that makes it possible to access all the variables we want in later time.
There are many ways to do this, which we’ll explore.
A Gentle Example
Our example will be based on this scenario: To test that the “Following” feature works in a Twitter-like application, we must ensure that when A follows B and B posts a status update, A sees B’s status in A’s timeline.
This involves steps such as:
- Create the users A and B.
- Make A follow B.
- Make B post a status update.
- Verify that A’s timeline contains B’s status.
In our actual project, setting up a scenario is more complicated than that. It involves creating an access key, accounts of different types, and several transactions.
Let’s come back to our simplified example, and explore the ways we can code it.
Nesting the Promises
Assuming an ES6 environment, one can implement the test code like this:
it('should display the status of B in A\'s timeline when A follows B', function() {
return Promise.all([createUser('foo'), createUser('bar')])
.then(function([user1, user2]) {
return user1.follow(user2)
.then(function() {
return user2.updateStatus('My status')
})
.then(function(status) {
return user1.getTimeline()
.then(function(timeline) {
expectTimelineToContainStatus(timeline, status)
})
})
})
})
See how messy it is! Now the callback hell becomes a promise hell.
Passing Them Through the Chain
Another approach is to chain the promises linearly, and pass all the value we need in future steps through the current step.
it('should display the status of B in A\'s timeline when A follows B', function() {
return Promise.all([createUser('foo'), createUser('bar')])
.then(function([user1, user2]) {
return Promise.all([user1, user2, user1.follow(user2)])
})
.then(function([user1, user2]) {
return Promise.all([user1, user2.updateStatus('My status')])
})
.then(function([user1, status]) {
return Promise.all([user1.getTimeline(), status])
})
.then(function([timeline, status]) {
expectTimelineToContainStatus(timeline, status)
})
})
Now, you have to make sure that we are passing the correct variables in every step.
Creating Lots of Local Variables
Yet another approach is to create many local variables to hold everything we need.
it('should display the status of B in A\'s timeline when A follows B', function() {
var user1, user2, status
return Promise.all([createUser('foo'), createUser('bar')])
.then(function([_user1, _user2]) {
user1 = _user1
user2 = _user2
return user1.follow(user2)
})
.then(function() {
return user2.updateStatus('My status')
})
.then(function(_status) {
status = _status
return user1.getTimeline()
})
.then(function(timeline) {
expectTimelineToContainStatus(timeline, status)
})
})
Here, you see the manual work of creating these variables, and storing them manually.
Using a Context Object
How about creating just one object to hold all the things we need (a context)?
it('should display the status of B in A\'s timeline when A follows B', function() {
var the = { }
return Promise.all([createUser('foo'), createUser('bar')])
.then(function([user1, user2]) {
the.firstUser = user1
the.secondUser = user2
return the.firstUser.follow(the.secondUser)
})
.then(function() {
return the.secondUser.updateStatus('My status')
})
.then(function(status) {
the.status = status
return the.firstUser.getTimeline()
})
.then(function(timeline) {
expectTimelineToContainStatus(timeline, the.status)
})
})
Now, we only have to create just one variable. However, after each asynchronous step, we need to store the values we need in the future in the next step manually.
What If?
What if we have some kind of function that
- takes an object of promises,
- adds the results of each promise to the context, and
- returns a promise that will resolve when all these promises have resolved?
Then our code would look like this:
it('should display the status of B in A\'s timeline when A follows B', function() {
var the = { }
return set(the, {
firstUser: createUser('foo'),
secondUser: createUser('bar')
})
.then(function() {
return the.firstUser.follow(the.secondUser)
})
.then(function() {
return set(the, { status: the.secondUser.updateStatus('My status') })
})
.then(function() {
return set(the, { timeline: the.firstUser.getTimeline() })
})
.then(function() {
expectTimelineToContainStatus(the.timeline, the.status)
})
})
See how readable and straightforward it is. It reads like set the first user to (the result of) creating (a) user (called) foo.
Refactoring the setup process into beforeEach is also straightforward:
var the
beforeEach(function() { the = { } })
describe('following another user', function() {
beforeEach(function() {
return set(the, {
firstUser: createUser('foo'),
secondUser: createUser('bar')
})
.then(function() {
return the.firstUser.follow(the.secondUser)
})
.then(function() {
return set(the, { status: the.secondUser.updateStatus('My status') })
})
})
it('should display the status of B in A\'s timeline when A follows B', function() {
return the.firstUser.getTimeline().then(function(timeline) {
expectTimelineToContainStatus(the.timeline, the.status)
})
})
})
Get Real
There’s one problem: The set function didn’t exist. Let’s code it up!
when/keys.all contains a function that resolves the values of promises inside an object. To put it in another way, it’s a function that transforms { key: promise } into { key: resolved value }. Now, we can use that to write the set function.
var keys = require('when/keys')
function set(context, promises) {
return keys.all(promises).then(function(results) {
for (var key in results) {
context[key] = results[key]
}
})
}
function set(context, promises) {
return keys.all(promises).then(function(results) {
Object.assign(context, results)
})
}
And even more concise:
function set(context, promises) {
return keys.all(promises).then(Object.assign.bind(null, context))
}
stateful-context
I wanted to give it a name, so I named it “stateful context.” The stateful-context module provides this set function:
var set = require('stateful-context').set
You can go grab it on
npm. The source code is on
GitHub.
StatefulContext
The stateful-context module provides a StatefulContext class. Instantiating an instance of this class gives you an empty context with the set method, already bound to the created context. This helps you eliminate the first the argument to your set calls.
var StatefulContext = require('stateful-context')
var the, set
beforeEach(function() {
the = new StatefulContext()
set = the.set
})
describe('following another user', function() {
beforeEach(function() {
return set({
firstUser: createUser('foo'),
secondUser: createUser('bar')
})
.then(function() {
return the.firstUser.follow(the.secondUser)
})
})
// ...
})
Naming the the
We’ve had discussions inside the team about how to name the context object. The name context is already taken by mocha (a testing framework), so I proposed the name the.
One issue with it is that the name the is not self-explanatory; it doesn’t describe the intent. Finally, we settled with the name locals, which is not too much to type, while preserving readability and intent:
var locals
beforeEach(function() {
locals = new StatefulContext()
})
describe('following another user', function() {
beforeEach(function() {
return locals.set({
firstUser: createUser('foo'),
secondUser: createUser('bar')
})
.then(function() {
return locals.firstUser.follow(locals.secondUser)
})
})
// ...
})
But in my own projects, I’d stick with the. Even the name is not self-descriptive, I think it makes a good DSL.
Weakness: It’s Not Declarative
But this approach is not without weaknesses. We still have to set up our scenario imperatively, that is, step by step. We also have a global mutable state (but local to our test) that some people may despise.
What I’d actually would like to be able to do is to create something like RSpec’s let helper that’s aware of asynchronous promises. It lets you declaratively declare actors inside your tests.
I still couldn’t come up with an elegant way to do this in ES5 JavaScript. For CoffeeScript, I have a rough idea that it could look like this:
describe 'following another user', ->
declare
firstUser: -> createUser('foo')
secondUser: -> createUser('bar')
timeline: (firstUser) -> firstUser.getTimeline()
status: (secondUser) -> secondUser.updateStatus 'My status'
beforeEach ->
get (firstUser, secondUser) -> firstUser.follow secondUser
it 'should display the status of B in A\'s timeline when A follows B', ->
get (timeline, status) -> expectTimelineToContainStatus timeline, status
Imagine writing that in JavaScript. You’d be distracted by so many functions, parentheses, and curly braces.
So if you have any idea how this could be done in ES5 JavaScript, I’d love to know about them. Until then, enjoy!
Benefits of Using Stateful Context
1. Simplifies Value Management
By using a single object to store multiple values, you eliminate the need to pass individual values from one .then() to another. This reduces boilerplate code and keeps your chain focused on the operations rather than the plumbing.
2. Improves Readability
With a stateful context, the structure of your Promise chain becomes easier to follow. Each step operates on a shared object, making it clear what values are being used or updated.
3. Facilitates Debugging
If something goes wrong in the chain, you can inspect the state object to see the progress of the operation and identify the point of failure.
4. Enables Complex Workflows
When working with interdependent asynchronous operations, a stateful context allows you to manage multiple values efficiently without complicating your chain.
How Stateful Context Works in a Promise Chain
In a typical implementation:
-
Initialize a State Object: At the beginning of the chain, create an object to store the necessary values.
-
Update State in Each Step: Modify or add values to the state object at each step of the chain.
-
Use the Final State: At the end of the chain, use the fully populated state object for your desired outcome.
Use Cases for Stateful Context in Promise Chains
1. Aggregating Data
When fetching data from multiple sources, you can use a stateful context to store the results of each fetch. At the end of the chain, you’ll have a single object containing all the aggregated data.
2. Handling Dependencies
In cases where one operation depends on the result of another, a stateful context allows you to store intermediate results and use them in subsequent steps.
3. Multi-Step Workflows
For workflows involving multiple asynchronous tasks (e.g., file uploads, database queries, API calls), a stateful context provides a structured way to manage intermediate states.
Best Practices for Using Stateful Context
-
Keep State Object Simple: Avoid overloading your state object with unnecessary values. Only store what’s essential for the workflow.
-
Document State Changes: Clearly describe what each step in the chain does and how it modifies the state object.
-
Avoid Mutations: If possible, create new objects at each step instead of mutating the existing state. This makes debugging easier and ensures immutability.
-
Handle Errors Gracefully: Use .catch() to handle errors and inspect the state object to determine where the issue occurred.
-
Use Descriptive Keys: Use meaningful key names in your state object to make it clear what each value represents.
Final Thoughts
Working with multiple values in a Promise chain can be daunting, but a stateful context simplifies the process. By maintaining a shared object throughout the chain, you can manage values efficiently, keep your code clean, and handle complex workflows with ease. Whether you’re aggregating data, handling dependencies, or managing multi-step operations, adopting a stateful context will streamline your approach to asynchronous programming in JavaScript.