Jasmine: using async/await to write nice asynchronous specs

Updated . Posted . Visible to the public. Repeats.

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
})
Tobias Kraze
Last edit
Henning Koch
License
Source code in this card is licensed under the MIT License.
Posted by Tobias Kraze to makandra dev (2019-06-28 08:43)