45

The Problem:

I have a simple React component I'm using to learn to test components with Jest and Enzyme. As I'm working with props, I added the prop-types module to check for properties in development. prop-types uses console.error to alert when mandatory props are not passed or when props are the wrong data type.

I wanted to mock console.error to count the number of times it was called by prop-types as I passed in missing/mis-typed props.

Using this simplified example component and test, I'd expect the two tests to behave as such:

  1. The first test with 0/2 required props should catch the mock calling twice.
  2. The second test with 1/2 required props should catch the mock called once.

Instead, I get this:

  1. The first test runs successfully.
  2. The second test fails, complaining that the mock function was called zero times.
  3. If I swap the order of the tests, the first works and the second fails.
  4. If I split each test into an individual file, both work.
  5. console.error output is suppressed, so it's clear it's mocked for both.

I'm sure I am missing something obvious, like clearing the mock wrong or whatever.

When I use the same structure against a module that exports a function, calling console.error some arbitrary number of times, things work.

It's when I test with enzyme/react that I hit this wall after the first test.

Sample App.js:

import React, { Component } from 'react';
import PropTypes from 'prop-types';

export default class App extends Component {

  render(){
    return(
      <div>Hello world.</div>
    );
  }
};

App.propTypes = {
  id : PropTypes.string.isRequired,
  data : PropTypes.object.isRequired
};

Sample App.test.js

import React from 'react';
import { mount } from 'enzyme';
import App from './App';

console.error = jest.fn();

beforeEach(() => {
  console.error.mockClear();
});

it('component logs two errors when no props are passed', () => {
  const wrapper = mount(<App />);
  expect(console.error).toHaveBeenCalledTimes(2);
});

it('component logs one error when only id is passed', () => {
  const wrapper = mount(<App id="stringofstuff"/>);
  expect(console.error).toHaveBeenCalledTimes(1);
});

Final note: Yeah, it's better to write the component to generate some user friendly output when props are missing, then test for that. But once I found this behavior, I wanted to figure out what I'm doing wrong as a way to improve my understanding. Clearly, I'm missing something.

Matthew Bakaitis
  • 11,600
  • 7
  • 43
  • 53

5 Answers5

48

I ran into a similar problem, just needed to cache the original method

const original = console.error

beforeEach(() => {
  console.error = jest.fn()
  console.error('you cant see me')
})

afterEach(() => {
  console.error('you cant see me')
  console.error = original
  console.error('now you can')
})
random-forest-cat
  • 33,652
  • 11
  • 120
  • 99
32

Given the behavior explained by @DLyman, you could do it like that:

describe('desc', () => {
    beforeAll(() => {
        jest.spyOn(console, 'error').mockImplementation(() => {});
    });

    afterAll(() => {
        console.error.mockRestore();
    });

    afterEach(() => {
        console.error.mockClear();
    });

    it('x', () => {
        // [...]
    });

    it('y', () => {
        // [...]
    });

    it('throws [...]', () => {
        shallow(<App />);
        expect(console.error).toHaveBeenCalled();
        expect(console.error.mock.calls[0][0]).toContain('The prop `id` is marked as required');
    });
});
cmcculloh
  • 47,596
  • 40
  • 105
  • 130
  • 1
    When I used this method in TypeScript I get "console.error.mockClear is not a function". If anyone else runs into this problem, writing it differently as seen here works: https://github.com/facebook/jest/issues/6777#issuecomment-409076898 – jsonp Apr 28 '22 at 13:23
  • Try using `as`: `expect((console.error) as jest.Mock).mock.calls[0][0]).toContain('The prop `id` is marked as required');` – toakleaf Aug 04 '22 at 18:08
  • In my case `console.error.mock.calls[0][0]` is an array. So instead of 'The prop `id` is marked as required' I would get: ['The prop `%s` is marked as required', id, stacktrace] :/ – David Feb 08 '23 at 11:29
16

What guys wrote above is correct. I've encoutered similar problem and here's my solution. It takes also into consideration situation when you're doing some assertion on the mocked object:

beforeAll(() => {
    // Create a spy on console (console.log in this case) and provide some mocked implementation
    // In mocking global objects it's usually better than simple `jest.fn()`
    // because you can `unmock` it in clean way doing `mockRestore` 
    jest.spyOn(console, 'log').mockImplementation(() => {});
  });
afterAll(() => {
    // Restore mock after all tests are done, so it won't affect other test suites
    console.log.mockRestore();
  });
afterEach(() => {
    // Clear mock (all calls etc) after each test. 
    // It's needed when you're using console somewhere in the tests so you have clean mock each time
    console.log.mockClear();
  });
Papi
  • 741
  • 6
  • 8
  • 1
    I found useful calling `mockImplementationOnce` instead to avoid having to restore the mock. – Emi Nov 26 '19 at 09:43
  • @Emi - I would guess that `jest.spyOn` mutates the underlying console object so `mockImplationOnce` may not work as a full restoration of console – random-forest-cat Aug 09 '23 at 16:38
7

You didn't miss anything. There is a known issue (https://github.com/facebook/react/issues/7047) about missing error/warning messages.

If you switch your test cases ('...when only id is passed' - the fisrt, '...when no props are passed' - the second) and add such console.log('mockedError', console.error.mock.calls); inside your test cases, you can see, that the message about missing id isn't triggered in the second test.

DLyman
  • 96
  • 3
1

For my solutions I'm just wrapping original console and combine all messages into arrays. May be someone it will be needed.

const mockedMethods = ['log', 'warn', 'error']
export const { originalConsoleFuncs, consoleMessages } = mockedMethods.reduce(
  (acc: any, method: any) => {
    acc.originalConsoleFuncs[method] = console[method].bind(console)
    acc.consoleMessages[method] = []

    return acc
  },
  {
    consoleMessages: {},
    originalConsoleFuncs: {}
  }
)

export const clearConsole = () =>
  mockedMethods.forEach(method => {
    consoleMessages[method] = []
  })

export const mockConsole = (callOriginals?: boolean) => {
  const createMockConsoleFunc = (method: any) => {
    console[method] = (...args: any[]) => {
      consoleMessages[method].push(args)
      if (callOriginals) return originalConsoleFuncs[method](...args)
    }
  }

  const deleteMockConsoleFunc = (method: any) => {
    console[method] = originalConsoleFuncs[method]
    consoleMessages[method] = []
  }

  beforeEach(() => {
    mockedMethods.forEach((method: any) => {
      createMockConsoleFunc(method)
    })
  })

  afterEach(() => {
    mockedMethods.forEach((method: any) => {
      deleteMockConsoleFunc(method)
    })
  })
}


Sergey Volkov
  • 871
  • 11
  • 17