I have the following (simplified) React component.
class SalesView extends Component<{}, State> { state: State = { salesData: null }; componentDidMount() { this.fetchSalesData(); } render() { if (this.state.salesData) { return <SalesChart salesData={this.state.salesData} />; } else { return <p>Loading</p>; } } async fetchSalesData() { let data = await new SalesService().fetchSalesData(); this.setState({ salesData: data }); } }
When mounting, I fetch data from an API, which I have abstracted away in a class called SalesService
. This class I want to mock, and for the method fetchSalesData
I want to specify the return data (in a promise).
This is more or less how I want my test case to look like:
- predefine test data
- import SalesView
- mock SalesService
setup mockSalesService to return a promise that returns the predefined test data when resolved
create the component
- await
- check snapshot
Testing the looks of SalesChart is not part of this question, I hope to solve that using Enzyme. I have been trying dozens of things to mock this asynchronous call, but I cannot seem to get this mocked properly. I have found the following examples of Jest mocking online, but they do not seem to cover this basic usage.
- Hackernoon: Does not use asychronous calls
- Wehkamp tech blog: Does not use asynchronous calls
- Agatha Krzywda: Does not use asynchronous calls
- GitConnected: Does not use a class with a function to mock
- Jest tutorial An Async Example: Does not use a class with a function to mock
- Jest tutorial Testing Asynchronous Code: Does not use a class with a function to mock
- SO question 43749845: I can't connect the mock to the real implementation in this way
- 42638889: Is using dependency injection, I am not
- 46718663: Is not showing how the actual mock Class is implemented
My questions are:
- How should the mock class look like?
- Where should I place this mock class?
- How should I import this mock class?
- How do I tell that this mock class replaces the real class?
- How do set up the mock implementation of a specific function of the mock class?
- How do I wait in the test case for the promise to be resolved?
One example that I have that does not work is given below. The test runner crashes with the error throw err;
and the last line in the stack trace is at process._tickCallback (internal/process/next_tick.js:188:7)
# __tests__/SalesView-test.js import React from 'react'; import SalesView from '../SalesView'; jest.mock('../SalesService'); const salesServiceMock = require('../SalesService').default; const weekTestData = []; test('SalesView shows chart after SalesService returns data', async () => { salesServiceMock.fetchSalesData.mockImplementation(() => { console.log('Mock is called'); return new Promise((resolve) => { process.nextTick(() => resolve(weekTestData)); }); }); const wrapper = await shallow(<SalesView/>); expect(wrapper).toMatchSnapshot(); });
3 Answers
Answers 1
Sometimes, when a test is hard to write, it is trying to tell us that we have a design problem.
I think a small refactor could make things a lot easier - make SalesService
a collaborator instead of an internal.
By that I mean, instead of calling new SalesService()
inside your component, accept the sales service as a prop by the calling code. If you do that, then the calling code can also be your test, in which case all you need to do is mock the SalesService
itself, and return whatever you want (using sinon or any other mocking library, or even just creating a hand rolled stub).
Answers 2
One "ugly" way I've used in the past is to do a sort of poor-man's dependency injection.
It's based on the fact that you might not really want to go about instantiating SalesService
every time you need it, but rather you want to hold a single instance per application, which everybody uses. In my case, SalesService
required some initial configuration which I didn't want to repeat every time.[1]
So what I did was have a services.ts
file which looks like this:
/// In services.ts let salesService: SalesService|null = null; export function setSalesService(s: SalesService) { salesService = s; } export function getSalesService() { if(salesService == null) throw new Error('Bad stuff'); return salesService; }
Then, in my application's index.tsx
or some similar place I'd have:
/// In index.tsx // initialize stuff const salesService = new SalesService(/* initialization parameters */) services.setSalesService(salesService); // other initialization, including calls to React.render etc.
In the components you can then just use getSalesService
to get a reference to the one SalesService
instance per application.
When it comes time to test, you just need to do some setup in your mocha
(or whatever) before
or beforeEach
handlers to call setSalesService
with a mock object.
Now, ideally, you'd want to pass in SalesService
as a prop to your component, because it is an input to it, and by using getSalesService
you're hiding this dependency and possibly causing you grief down the road. But if you need it in a very nested component, or if you're using a router or somesuch, it's becomes quite unwieldy to pass it as a prop.
You might also get away with using something like context, to keep everything inside React as it were.
The "ideal" solution for this would be something like dependency injection, but that's not an option with React AFAIK.
[1] It can also help in providing a single point for serializing remote-service calls, which might be needed at some point.
Answers 3
You could potentially abstract the new
keyword away using a SalesService.create()
method, then use jest.spyOn(object, methodName) to mock the implementation.
import SalesService from '../SalesService '; test('SalesView shows chart after SalesService returns data', async () => { const mockSalesService = { fetchSalesData: jest.fn(() => { return new Promise((resolve) => { process.nextTick(() => resolve(weekTestData)); }); }) }; const spy = jest.spyOn(SalesService, 'create').mockImplementation(() => mockSalesService); const wrapper = await shallow(<SalesView />); expect(wrapper).toMatchSnapshot(); expect(spy).toHaveBeenCalled(); expect(mockSalesService.fetchSalesData).toHaveBeenCalled(); spy.mockReset(); spy.mockRestore(); });
0 comments:
Post a Comment