Unit Testing a fetch API Service in Aurelia

Mock Yeah! Ing Yeah! Bird Yeah! Yeah! Yeah!

Thursday June 2, 2016 - Permalink -

Not sure how to mock your API service which uses fetch? Well you came to the right place.

I was recently trying to unit test an API service I had created using fetch. I'm glad I did because I found a bug that I would of otherwise missed!

This example is using Aurelia with Jasmine.

If you're using the navigation skeleton as a base for your project then we can find some guidance on how to mock the HttpClient.

class HttpStub extends HttpClient {
  url;
  itemStub;
  
  fetch(url) {
    var response = this.itemStub;
    this.url = url;
    return new Promise((resolve) => {
      resolve({ json: () => response });
    });
  }

  configure(config) {
    return this;
  }
}

This works well for HTTP GET. In order to test for POST, CREATE, DELETE and other methods we'll need to expand it a bit. Now there is obviously different ways of doing it, but I tried to make this example as simple as possible.

Unit Test

Let's start by creating the describe method in api-service.spec.ts:

import {HttpClient} from 'aurelia-fetch-client';
import './setup';
import {ApiService} from 'api-service';

describe('the ApiService module', () => {

  let http = new HttpStub(); //we'll get to this later

  let sut;
  sut = new ApiService(http); //We're using DI for our HttpClient

  beforeEach(() => {
    sut.http.status = 200; //we can return whatever status code we want
    sut.http.object = {id: '1', artist: 'Prince', record: 'Purple Rain'} //we set an object to be returned
    sut.http.returnKey = 'date' //key returned from PUT/CREATE/POST
    sut.http.returnValue = '6/24/1984' //value returned from PUT/CREATE/POST
    sut.http.responseHeaders = {accept: "json"}
  })

})

Let's say we first we want to test our save method which uses the PUT method to save a record.

Our save function in our apiService viewModel is as follows:

//api-service.ts
save(record) {
  this.http.fetch(this.recordsURL, {
    method: 'put',
    body: json(record)
  })
  .then(response => response.json())
  .then(savedRecord => record = savedRecord);
}

Our unit test then looks like:

//api-service.spec.ts
it('saves a record', (done) => {
  sut.save(sut.http.object).then( (record) => {
    expect(record).toEqual({id: '1', artist: 'Prince', record: 'Purple Rain', date: '6/24/1984'})
    done();
  }
})

You'll notice here our record has an extra property of date. We've set returnKey and returnValue to be these two things. This is useful in a test case where your server appends data to your request.

Mocked Fetch Method

Here is what we'll use for our mocked fetch http client:

class HttpStub extends HttpClient {
  status: number; //response status code
  statusText: string; //response status text
  object: any = {} //the request object
  returnKey: string; //response added Key
  returnValue: any; //response added Value
  responseHeaders: any; //response headers
  returnMethods = []; //methods which we add returnKey & returnValue

  fetch(input, init) {
    let request
    let response

    let responseInit: any = {}
    responseInit.headers = new Headers()

    for (let name in this.responseHeaders || {}) {
      responseInit.headers.set(name, this.responseHeaders[name])
    }
    this.returnMethods = this.returnMethods || ['POST']
    responseInit.status = this.status || 200

    if (Request.prototype.isPrototypeOf(input)) {
       request = input;
    } else {
      request = new Request(input,init || {})
    }
    if (request.body && Blob.prototype.isPrototypeOf(request.body) && request.body.type) {
      request.headers.set('Content-Type', request.body.type);
    }

    let promise = Promise.resolve().then( () => {
      let response
      if (request.headers.get('Content-Type') === 'application/json' && this.returnMethods.indexOf(request.method) >= 0) {
        return request.json().then(object => {
          object[this.returnKey] = this.returnValue
          let data = new Blob([JSON.stringify(object)])
          let response = new Response(data, responseInit)
          return this.status >= 200 && this.status < 300 ? Promise.resolve(response) : Promise.reject(response)
        })
      } else {
        let data = new Blob([JSON.stringify(this.object)])
        let response = new Response(data, responseInit)
        return this.status >= 200 && this.status < 300 ? Promise.resolve(response) : Promise.reject(response)
      }
    })
    return promise
  }
}

We're able to control the status code, status text, response object, we've dicussed the returnKey and returnValue properties above. The returnMethods is an array containing the methods which we want our mocked API response to add returnKey and returnValue.

What our function does is create a request if there isn't already one created. Then if the body type is JSON we retrieve the object from the body and add in our Key-Value property if our method type is in 'returnMethods'.

Otherwise we return a response with our object which has been set before each test case.

Test for Error Status Code

We can also test if our API returns a error code. Let's say in your save method if the record isn't able to be saved for whatever reason you display a modal to your user.

Our save function might look something like this:

//api-service.ts
save(record) {
  this.http.fetch(this.recordsURL, {
    method: 'put',
    body: json(record)
  })
  .then(response => response.json())
  .then(savedRecord => record = savedRecord)
  .catch(e => { this.popModel("Unable to Save Record", e.statusText()) });
}

We would test it this way:

  it('displays an modal to the user when a record cannot be saved', (done) => {
    sut.http.status = 400
    sut.http.statusText = "Record Not Found"

    spyOn(sut, "popModel")
   
    sut.save(sut.http.object).catch( (error) => { //notice we use a catch here.
      expect(error.status).toEqual(400)
      expect(sut.popModel).toHaveBeenCalledWith("Unable to Save Record", error.statusText)
      done()
    })
  })

Completed Unit Test and Mock Fetch Method

Here is our final api-service.ts:

import {HttpClient, json} from 'aurelia-fetch-client';
import './setup';
import {ApiService} from '../../src/api-service';


class HttpStub extends HttpClient {
  status: number;
  statusText: string;
  object: any = {}
  returnKey: string;
  returnValue: any;
  responseHeaders: any;

  fetch(input, init) {
    let request
    let response

    let responseInit: any = {}
    responseInit.headers = new Headers()

    for (let name in this.responseHeaders || {}) {
      responseInit.headers.set(name, this.responseHeaders[name])
    }

    responseInit.status = this.status || 200

    if (Request.prototype.isPrototypeOf(input)) {
       request = input;
    } else {
      request = new Request(input,init || {})
    }
    if (request.body && Blob.prototype.isPrototypeOf(request.body) && request.body.type) {
      request.headers.set('Content-Type', request.body.type);
    }

    let promise = Promise.resolve().then( () => {
      let response
      if (request.headers.get('Content-Type') === 'application/json' && request.method != 'GET') {
        return request.json().then(object => {
          object[this.returnKey] = this.returnValue
          let data = new Blob([JSON.stringify(object)])
          let response = new Response(data, responseInit)
          return this.status >= 200 && this.status < 300 ? Promise.resolve(response) : Promise.reject(response)
        })
      } else {
        let data = new Blob([JSON.stringify(this.object)])
        let response = new Response(data, responseInit)
        return this.status >= 200 && this.status < 300 ? Promise.resolve(response) : Promise.reject(response)
      }
    })
    return promise
  }
}


describe('the ApiService module', () => {

  let http = new HttpStub(); //we'll get to this later

  let sut;
  sut = new ApiService(http); //We're using DI for our HttpClient

  beforeEach(() => {
    sut.http.status = 200; //we'll check for errors later
    sut.http.object = {id: '1', artist: 'Prince', record: 'Purple Rain'} //this is what we expect to from the GET
    sut.http.returnKey = 'date' //key returned from PUT/CREATE/POST
    sut.http.returnValue = '6/24/1984' //value returned from PUT/CREATE/POST
    sut.http.responseHeaders = {accept: "json"}
  })

  it('saves a record', (done) => {
    sut.save(sut.http.object).then( (record) => {
      expect(record).toEqual({id: '1', artist: 'Prince', record: 'Purple Rain', date: '6/24/1984'})
      done();
    })
  })
  it('displays an modal to the user when a record cannot be saved', (done) => {
    sut.http.status = 400
    sut.http.statusText = "Record Not Found"

    spyOn(sut, "popModel")
   
    sut.save(sut.http.object).catch( (error) => { //notice we use a catch here.
      expect(error.status).toEqual(400)
      expect(sut.popModel).toHaveBeenCalledWith("Unable to Save Record", error.statusText)
      done()
    })
  })
})

Using this mocked fetch client we can now test for a number of situations. One situation that I haven't convered is how to test Authorization within the fetch client. I'll cover that in another post!

comments powered by Disqus