Emitting an error in my jest test doesn't trigger the error handler as expected

Viewed 679

My download code relies on listening on listening for events to determine when to fire callbacks, and whether the promise it's in should be resolved or rejected:

async function downloadMtgJsonZip() {
  const path = Path.resolve(__dirname, 'resources', fileName);
  const writer = Fs.createWriteStream(path);

  console.info('...connecting...');
  const { data, headers } = await axios({
    url,
    method: 'GET',
    responseType: 'stream',
  });
  return new Promise((resolve, reject) => {
    const timeout = 20000;
    const timer = setTimeout(() => {
      console.log('timed out'); // debug log
      writer.close();
      reject(new Error(`Promise timed out after ${timeout} ms`));
    }, timeout);
    let error = null;
    const totalLength = headers['content-length'];
    const progressBar = getProgressBar(totalLength);
    console.info('...starting download...');
    // set up data and writer listeners
    data.on('data', (chunk) => progressBar.tick(chunk.length));
    data.on('error', (err) => { // added this to see if it would be triggered - it is not
      console.log(`did a data error: ${error}`);
      error = err;
      clearTimeout(timer);
      writer.close();
      reject(err);
    });
    writer.on('error', (err) => {
      console.log(`did a writer error: ${error}`);
      error = err;
      clearTimeout(timer);
      writer.close();
      reject(err);
    });
    writer.on('close', () => {
      const now = new Date();
      console.log(`close called: ${now}`);
      console.log(`error is: ${error}`);
      console.info(
        `Completed in ${(now.getTime() - progressBar.start) / 1000} seconds`,
      );
      clearTimeout(timer);
      console.log(`time cleared: ${timer}`);
      if (!error) resolve(true);
      // no need to call the reject here, as it will have been called in the
      // 'error' stream;
    });
    // finally call data.pipe with our writer
    data.pipe(writer);
  });
}

I had some issues writing my tests, but I managed to get something that worked, despite feeling slightly messy, based on this advice:

Here is my test, with the relevant bits of my set up:

describe('fetchData', () => {
  let dataChunkFn;
  let dataErrorFn;
  let dataOnFn;
  let writerCloseFn;
  let writerErrorFn;
  let writerOnFn;
  let pipeHandler;
  beforeEach(() => {
    // I've left all the mocking in place,
    // to give an idea of what I've set up
    const mockWriterEventHandlers = {};
    const mockDataEventHandlers = {};

    dataChunkFn = jest.fn((chunk) => mockDataEventHandlers.data(chunk));
    dataErrorFn = jest.fn((chunk) => mockDataEventHandlers.data(chunk));
    dataOnFn = jest.fn((e, cb) => {
      mockDataEventHandlers[e] = cb;
    });

    writerCloseFn = jest.fn(() => mockWriterEventHandlers.close());
    writerErrorFn = jest.fn(() => mockWriterEventHandlers.error());
    writerOnFn = jest.fn((e, cb) => {
      mockWriterEventHandlers[e] = cb;
    });
    const getMockData = (pipe) => ({
      status: 200,
      data: {
        pipe,
        on: dataOnFn,
      },
      headers: { 'content-length': 100 },
    });

    axios.mockImplementationOnce(() => getMockData(pipeHandler));
    fs.createWriteStream.mockImplementationOnce(() => ({
      on: writerOnFn,
      close: writerCloseFn,
    }));
    jest.spyOn(console, 'info').mockImplementation(() => {});
    jest.spyOn(console, 'log').mockImplementation(() => {});
  });
  it.only('handles errors from the writer', async (done) => {
    console.log('writer error');
    expect.assertions(1);
    pipeHandler = (writer) => writer.emit('error', new Error('bang'));
    try {
      await downloadMtgJsonZip();
      done.fail('ran without error');
    } catch (exception) {
      // expect(dataErrorFn).toHaveBeenCalled(); // neither of these are called
      expect(writerErrorFn).toHaveBeenCalled();
    }
  });

I would have expected, that when data(pipe) ran, and the writer emitted a new error, it would have triggered at least one of the error listeners.

The code runs as expected, and it even handles the timeout (which I initially set too low), but this last test doesn't run.

As I commented above, neither of the functions above are called, so the expect.assertions(1); code fails the test.

It's possible I need to fundamentally change how I've written the tests, but I'm not sure how I would do that.

Why doesn't that last test pass?

2 Answers

When the code invokes data.pipe(writer), it's running your pipeHandler function defined in the test. This function takes a given writer object and calls writer.emit(...). I believe the issue is that the writer object being passed in is the one mocked out for fs.createWriteStream(), which doesn't have an emit method defined, so nothing is happening in response to that call. It is likely throwing an error, which you may be able to see in your catch block.

I believe what you want is to invoke the handlers saved by the writerOnFn. One way to do so would be to add a property to the object returned by your mock of fs.createWriteStream named emit and define it as a function that invokes the appropriate handler from inside mockWriterEventHandlers. I haven't tested this code but it would look something like the following

const writerEmitFn = (event, arg) => {
  mockWriterEventHandlers[event](arg);
}

fs.createWriteStream.mockImplementationOnce(() => ({
  on: writerOnFn,
  close: writerCloseFn,
  emit: writerEmitFn,
}));

My guess is that jest is gobbling up the error.

In order to continue running in the case of exceptions, jest could be guarding against ever having to run try and throw.

You could try expecting an error to have been thrown using jest's API.

Related