4
votes

I'm using jest+nock+jsdom modules to test my React\Redux application. I need to test this async action function:

export function updateUserPhoto (file, token) {
  const data = new FormData()
  data.append('file', file)

  return dispatch => {
    dispatch(userPutPhotoRequest())
    return axios({
      method: 'PUT',
      headers: {
        'x-access-token': token
      },
      data: data,
      url: API_URL + '/user/photo'
    })
      .then(res => dispatch(userPutPhotoSuccess(res.data)))
      .catch(err => dispatch(userPutPhotoFilure(err)))
  }
}

So i'm using jsdom to provide FormData and File objects into tests:

const {JSDOM} = require('jsdom')

const jsdom = (new JSDOM(''))
global.window = jsdom.window
global.document = jsdom.window.document
global.FormData = jsdom.window.FormData
const File = jsdom.window.File
global.File = jsdom.window.File

And this is the method to test "upload photo" function:

it('creates USER_UPDATE_SUCCESS when updating user photo has been done', () => {
    const store = mockStore(Map())

    const file = new File([''], 'filename.txt', {
      type: 'text/plain',
      lastModified: new Date()
    })

    const expectedFormData = new FormData()
    expectedFormData.append('file', file)

    nock(API_URL, {
      reqheaders: {
        'x-access-token': token
      }
    }).put('/user/photo', expectedFormData)
      .reply(200, {body: {}})

    const expectedActions = [
      {
        type: ActionTypes.USER_PUT_PHOTO_REQUEST
      },
      {
        type: ActionTypes.USER_PUT_PHOTO_SUCCESS,
        response: {
          body: {}
        }
      }
    ]

    return store.dispatch(actions.updateUserPhoto(file, token))
      .then(() => {
        // return of async actions
        expect(store.getActions()).toEqual(expectedActions)
      })
  })

Where i'm using nock to mock axios requests, redux-mock-store to mock Redux store. Creating File and FormData objects to compare it with response from axios. And then i'm calling action function passing file and token as parameters.

In production action function works and dispatch action success fine. But in testing i'm receiving error:

Error: Data after transformation must be a string, an ArrayBuffer, a Buffer, or a Stream

When i pass into axios empty object as data test passes, so problem in FormData object. How can i mock FormData object for axios in an appropriate way to make this test work ?

2
Did you figure this out? I'm trying to do something similar, but I get FormData is not a constructor. Need to somehow mock FormData as well.shoke
Unfortunately i did not found a solution of this problem. So all I could do was comment on this test.Vlad Morzhanov

2 Answers

3
votes

This answer is coming way too late, but I was looking to do something similar and I wanted to post a solution here that someone else might stumble across and find useful.

The main problem here is that nock mocks network requests and not Javascript libraries. FormData is a Javascript object that eventually gets transformed to text when making network requests. By the time the FormData object makes it to nock, its been converted to a string or a Buffer, hence the error you see. nock is unable to use the FormData object for comparison.

You have a few options:

1. Easiest solution

Just don't match against the data in the PUT request. The reason you are mocking is because you don't want a real HTTP request to go out but you want a fake response back. nock only mocks the request once, so if you mock all PUT requests to /user/photo nock will catch it but only for that test:

nock(API_URL, {
  reqheaders: {
    'x-access-token': token
  }
}).put('/user/photo')
  .reply(200, {body: {}})

Before you implement the test this way, think about what your test is trying to verify. Are you trying to verify that the file is sent in the HTTP request? If yes, then this is a poor option. Your code could send a completely different file than the one dispatched and still pass this test. If however you have another test to verify the file is being put in the HTTP request properly then this solution might save you some time.

2. Easy solution for getting nock to fail on not matching the request

If you do want the test to fail if your code passed a corrupted or wrong file, then he simplest solution would be to test for the filename. Since your file is empty there is no need to match the content, but we can match on the filename:

nock(API_URL, {
  reqheaders: {
    'x-access-token': token
  }
}).put('/user/photo', /Content-Disposition\s*:\s*form-data\s*;\s*name="file"\s*;\s*filename="filename.txt"/i)
  .reply(200, {body: {}})

This should match the simple case where you have one file uploading.

3. Matching the content of form data fields

Say you have additional fields to be added to your request

export function updateUserPhoto (file, tags, token) {
  const data = new FormData()
  data.append('file', file)
  data.append('tags', tags)
  ...

OR you have fake content in the file that you want to match on

const file = new File(Array.from('file contents'), 'filename.txt', {
  type: 'text/plain',
  lastModified: new Date()
})

This is where things get a bit complex. Essentially what you need to do is to parse the form data text back into an object and then write your own matching logic.

parse-multipart-data is a fairly simple parser that you could use:

https://www.npmjs.com/package/parse-multipart-data

Using that package your test might look something like this

it('creates USER_UPDATE_SUCCESS when updating user photo has been done', () => {
    const store = mockStore(Map())

    const file = new File(Array.from('file content'), 'filename.txt', {
      type: 'text/plain',
      lastModified: new Date()
    })

    nock(API_URL, {
      reqheaders: {
        'x-access-token': token
      }
    }).put('/user/photo', function (body) { /* You cannot use a fat-arrow function since we need to access the request headers */
        // Multipart Data has a 'boundary' that works as a delimiter.
        // You need to extract that
        const boundary = this.headers['content-disposition']
          .match(/boundary="([^"]+)"/)[1];

        const parts = multipart.Parse(Buffer.from(body),boundary);

        // return true to indicate a match
        return parts[0].filename === 'filename.txt'
          && parts[0].type === 'text/plain'
          && parts[0].data.toString('utf8') === 'file contents'
          && parts[1].name === 'tags[]'
          && parts[1].data.toString('utf8') === 'tag1'
          && parts[2].name === 'tags[]'
          && parts[2].data.toString('utf8') === 'tag2';
      })
      .reply(200, {body: {}})

    const expectedActions = [
      {
        type: ActionTypes.USER_PUT_PHOTO_REQUEST
      },
      {
        type: ActionTypes.USER_PUT_PHOTO_SUCCESS,
        response: {
          body: {}
        }
      }
    ]

    return store.dispatch(actions.updateUserPhoto(file, ['tag1', 'tag2'], token))
      .then(() => {
        // return of async actions
        expect(store.getActions()).toEqual(expectedActions)
      })
  })
3
votes

I was dealing with the same issue, the problem was that axios was setting http as the default adapter. And xhr is the one you need.

// axios/lib/defaults.js
function getDefaultAdapter() {
  var adapter;
  // Only Node.JS has a process variable that is of [[Class]] process
  if (typeof process !== 'undefined' && Object.prototype.toString.call(process) === '[object process]') {
    // For node use HTTP adapter
    adapter = require('./adapters/http');
  } else if (typeof XMLHttpRequest !== 'undefined') {
    // For browsers use XHR adapter
    adapter = require('./adapters/xhr');
  }
  return adapter;
}

So, setting the xhr adapter explicitly on the axios calls worked for me.

Something like:

export function updateUserPhoto (file, token) {
  const data = new FormData()
  data.append('file', file)

  return dispatch => {
    dispatch(userPutPhotoRequest())
    return axios({
      method: 'PUT',
      headers: {
        'x-access-token': token
      },
      adapter: require('axios/lib/adapters/xhr'),
      data: data,
      url: API_URL + '/user/photo'
    })
      .then(res => dispatch(userPutPhotoSuccess(res.data)))
      .catch(err => dispatch(userPutPhotoFilure(err)))
  }
}

Also, I had issues with nock and CORS, so, if you have same issue, you can add access-control-allow-origin header

nock(API_URL, {
  reqheaders: {
    'x-access-token': token
  }
})
.defaultReplyHeaders({ 'access-control-allow-origin': '*' })
.put('/user/photo', expectedFormData)
.reply(200, {body: {}})