Jasmine has long standing support for writing asynchronous specs. In days gone by we used the done
callback to achieve this, but these days it is possible to write much more readable specs.
Async specs
As a first example, say we want to check that some form disables the submit button while working.
// bad (how we used to do it)
beforeEach(() => {
this.form = setupMyForm()
this.submitButton = findTheSubmitButton()
})
it('disables the submit button while working', (done) => {
expect(this.submitButton.disabled).toBe(false)
let submitPromise = this.form.onSubmit() // assuming we exposed .onSubmit in our code and return a promise
expect(this.submitButton.disabled).toBe(true)
submitPromise.then(() => {
// form is done
expect(this.submitButton.disabled).toBe(false)
done() // notify Jasmine we're finished
})
})
Newer versions of Jasmine allow our specs to simply return a Promise, so we could rewrite this as
// slightly better
it('disables the submit button while working', () => {
expect(this.submitButton.disabled).toBe(false)
let submitPromise = this.form.onSubmit()
expect(this.submitButton.disabled).toBe(true)
return submitPromise.then(() => {
// form is done
expect(this.submitButton.disabled).toBe(false)
})
})
This becomes much more readable, when using the async/await syntax:
// good
it('disables the submit button while working', async () => {
expect(this.submitButton.disabled).toBe(false)
let submitPromise = this.form.onSubmit()
expect(this.submitButton.disabled).toBe(true)
await submitPromise
// form is done
expect(this.submitButton.disabled).toBe(false)
})
Async setup
Say setting up our form is also asynchronous and takes some time. To allow our specs to wait till we're done, we have exposed a property ourForm.ready
which returns a promise that resolves when setup is finished.
Now we'll simply use an async function inside our beforeEach
as well:
beforeEach(async () => {
this.form = setupMyForm()
await this.form.ready
this.submitButton = findTheSubmitButton()
})
Dealing with non-cooperative code
Sometimes, we want to await
something asyncronous, but cannot get hold of a corresponding promise.
Waiting for a task
In these cases, we can sometimes get away with simply waiting for a task ("tick"), or a set amount of time:
// Use the original `setTimeout()` before it is mocked by `jasmine.clock.install()`.
const unmockedTimeout = window.setTimeout
function wait(ms = 1) {
return new Promise((resolve) => {
unmockedTimeout(resolve, ms)
})
}
it('handles an event', async () => {
triggerAnEventOn(this.form)
await wait() // one tick
// now expect the desired result
})
Waiting for a condition
We can also wait a bit for a certain condition to occur:
async function waitFor(callback) {
let msToWait = [0, 1, 5, 10, 100] // we use some exponential fall-off
while (true) {
let [msToWaitNow, ...msToWaitNext] = msToWait
msToWait = msToWaitNext
if (msToWaitNow != undefined) {
await wait(msToWaitNow)
let result = callback()
if (result) {
return result
}
} else {
throw("timed out waiting")
}
}
}
it('made a request', async () => {
triggerAnEventOn(this.form)
let request = await waitFor(() => { jasmine.Ajax.requests.mostRecent() })
// now expect the request to be correct
})
Waiting for an event
We can also wait for a DOM event to be dispatched:
function nextEvent(...args) {
let eventType = args.pop()
let element = args[0] instanceof Element ? args.shift() : document
return new Promise((resolve, reject) => {
element.addEventListener(eventType, resolve, { once: true })
})
}
it('emits an app:updated event when done', async () => {
triggerAnEventOn(this.form)
await nextEvent('app:updated')
// additional expectations here
})
Asynchronous spies
If you want to mock an asychronous method, the following riff on Jasmine's spyOn
method might be useful:
function asyncSpyOn(object, methodName) {
let spy = spyOn(object, methodName)
spy.and.returnValue(new Promise((resolve, reject) => {
spy.resolve = resolve
spy.reject = reject
}))
return spy
}
You can use it to mock an asynchronous method, check it has been called, and then resolve it inside your spec.
it('generates some token', async () => {
let spy = asyncSpyOn(this.form, 'createToken')
this.form.emailField.value = 'user@example.org' // assuming we exposed emailField
let submitPromise = this.form.onSubmit()
expect(spy).toHaveBeenCalledWith('user@example.org')
spy.resolve('the token')
await submitPromise
// now expect the form to have used the token
})
Alternatively, you can resolve the spy preemptively:
it('generates some token', async () => {
asyncSpyOn(this.form, 'createToken').resolve('the token')
await this.form.onSubmit()
// now expect the form to have used the token
})