Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add the fetch-mock-jest library to replace the manual mocking of fetch #4020

Merged
merged 3 commits into from May 2, 2022

Conversation

julienw
Copy link
Contributor

@julienw julienw commented Apr 29, 2022

Fixes #3257

This mock mocks the fetch command. fetch can be asserted like a jest mock function (it is a mock function) but its behavior can be configured in a more expressive way.
I considered also using nock like we do in the server, but settled on that library for now for this reason mainly.

The library also comes with custom jest assertion matchers, but I didn't add those to the jest library definition yet, because I didn't use them here. Actually I found that jest's usual matchers work fine already.

Most test files are much simpler, some of them are similar.

Be careful as the changes for receive-profile.test.js is hidden by default by github.

package.json Show resolved Hide resolved
@codecov
Copy link

codecov bot commented Apr 29, 2022

Codecov Report

Merging #4020 (d4a1262) into main (3aed2a9) will decrease coverage by 0.00%.
The diff coverage is n/a.

❗ Current head d4a1262 differs from pull request most recent head acb64c9. Consider uploading reports for the commit acb64c9 to get more accurate results

@@            Coverage Diff             @@
##             main    #4020      +/-   ##
==========================================
- Coverage   87.29%   87.29%   -0.01%     
==========================================
  Files         279      278       -1     
  Lines       24088    24078      -10     
  Branches     6374     6377       +3     
==========================================
- Hits        21027    21018       -9     
+ Misses       2820     2819       -1     
  Partials      241      241              
Impacted Files Coverage Δ
src/components/timeline/TrackThread.js 89.65% <0.00%> (-2.56%) ⬇️
src/components/app/TabBar.js 100.00% <0.00%> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 3aed2a9...acb64c9. Read the comment docs.

yarn.lock Show resolved Hide resolved
beforeEach(function () {
// Install fetch and fetch-related objects globally.
// Using the sandbox ensures that parallel tests run properly.
global.fetch = fetchMock.sandbox();
Copy link
Contributor Author

@julienw julienw Apr 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't strictly the recommended way to setup the mock, but the examples are either "in node" or "in browsers", and we're in-between because we're "in node in a jsdom environment".
I initially tried using the cross-fetch library that installs node-fetch as a global directly, and try to apply the mock from here, but couldn't make it work properly. Maybe I didn't try it properly.

Anyway this seems to work fine so I think we can revisit later.
Especially when moving to node v18 and fetch will be available globally in node, we might need to revisit the approach.

Comment on lines +46 to +47
// Do the same with fetch mocks
fetchMock.mockReset();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this is strictly needed because we add a new version of the sandbox for each test. But better be safe than sorry :)

Also I'm not deleting the fetch property from the global. This seems to work fine for now but we might need to adjust in the future.

@@ -210,8 +188,8 @@ describe('AppLocalizationProvider', () => {

expect(await screen.findByText(translatedText('de'))).toBeInTheDocument();
expect(document.documentElement).toHaveAttribute('lang', 'de');
expect(window.fetch).toBeCalledWith('/locales/de/app.ftl');
expect(window.fetch).toBeCalledWith('/locales/en-US/app.ftl');
expect(window.fetch).toBeCalledWith('/locales/de/app.ftl', undefined);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like fetch is always called with 2 arguments now, even if we don't specify the second argument in code, because of how mock-fetch-jest works (it installs another proxy on top of the mock). Therefore we need to match it too in the jest matcher.

});
(window: any).fetch = fetch;
window.fetch
.catch(404) // catchall
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default for catch is to return an empty 200 response. That's why I specify it everywhere,

However whenever a request goes to the catch there's a warning to the console (this can be disabled). So I tried to specify the rules for every tests so that catch isn't even reached.

While changing this I found the warning useful, that's why I decided to keep it. But we could possibly configure a global catch that would throw instead of returning 200.

statusText: 'Method not allowed',
window.fetch
.catch(404) // Catchall
.mock(endpointUrl, (urlString, options) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is mostly similar to the previous code, due to wheresrhys/fetch-mock#636

(window: any).fetch = fetch;
window.fetch
.catch(404) // catchall
.get(url, profile);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

much smaller 😁

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely! ✨

"arrayBuffer": [Function],
"headers": Object {
"get": [Function],
Response {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Reponse object is different so these snapshots reflect that change

Comment on lines +990 to +993
.get(expectedUrl, makeProfileSerializable(_getSimpleProfile()), {
overwriteRoutes: false,
});
Copy link
Contributor Author

@julienw julienw Apr 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See wheresrhys/fetch-mock#637 for the background on using { overwriteRoutes }.

I don't find this very intuitive to be honest. We can also make overwriteRoutes: false the default in the setup. I didn't do it yet but if we find the behavior is better for us this is possible.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah, this looks interesting. Thanks for filing an issue.

window.fetch.get(
expectedUrl,
compress(serializeProfile(_getSimpleProfile())),
{ sendAsJson: false }
Copy link
Contributor Author

@julienw julienw Apr 29, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sendAsJson: true is the default behavior. This means that if we send something that's not a normal object (like array buffers here), we need to specify sendAsJson: false. We might want to do the opposite: set sendAsJson false by default, but set it to true when needed. This might be more intuitive. We can also do it in a file setup so that it's global to a test file only.

I decided to wait a bit before changing the default, to see which one would be better.

Note: that's also why you see I replaced encode(serializeProfile(...)) with makeProfileSerializable in a lot of places, to let the library to the JSON converting. But maybe it would be better in this file to always do the conversion ourselves. I guess we can wait and see.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm yeah, I don't feel strongly about either solution. Waiting and seeing works for me.

isJSON?: true,
arrayBuffer?: () => Promise<Uint8Array>,
json?: () => Promise<mixed>,
content: 'generated-zip' | 'generated-json' | Uint8Array,
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: a bigger change, to better account that isZipped / isJSON / arrayBuffer were mutually exclusive. Also json wasn't used.

statusText: 'Method not allowed',
});
}
window.fetch
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly to the test for ListOfPublishedProfiles.test.js this isn't wildly different than what it was before.

}

const payload = JSON.parse(body);
return responseFromRequestPayload(payload);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't use the functionality to have a response depending on the request body, but I decided to keep it still.

Copy link
Member

@canova canova left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me! It looks like it the new mock makes things a lot easier and shorter 🎉

(window: any).fetch = fetch;
window.fetch
.catch(404) // catchall
.get(url, profile);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely! ✨

@@ -1,55 +0,0 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉

beforeEach(function () {
window.TextDecoder = TextDecoder;
window.fetch = jest.fn().mockResolvedValue(fetch403Response);
window.fetch.catch(403);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really easier to follow with the next get calls and everything!

src/test/store/receive-profile.test.js Show resolved Hide resolved
Comment on lines +990 to +993
.get(expectedUrl, makeProfileSerializable(_getSimpleProfile()), {
overwriteRoutes: false,
});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah, this looks interesting. Thanks for filing an issue.

@@ -1069,7 +1050,7 @@ describe('actions/receive-profile', function () {

it('fails in case the fetch returns a server error', async function () {
const hash = 'c5e53f9ab6aecef926d4be68c84f2de550e2ac2f';
window.fetch = jest.fn().mockResolvedValue(fetch500Response);
window.fetch.any(500);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It took me a while why we needed to use any here (because we have another .catch call in beforeEach block. I was going to recommend using catch but then realized this one. Maybe it could be good to add a comment that explains this here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fundamental difference between catch and any is that when catch is reached a warning will be emitted (which is configurable). Ideally I'd like to avoid that, so ideally it would be good to have routes for every request that's supposed to happen in a test. I think that in the future I'd rather add a .catch({ throws: new Error('No route has been defined for this request') }) in setup.js...

any is different: this is an expected request (even if it catches everything). I guess I was a bit lazy at the end of the day, and could have used a get with the full expected hash... I'm changing this :-)

window.fetch.get(
expectedUrl,
compress(serializeProfile(_getSimpleProfile())),
{ sendAsJson: false }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm yeah, I don't feel strongly about either solution. Waiting and seeing works for me.

@julienw julienw enabled auto-merge May 2, 2022 12:44
@julienw julienw merged commit 7f522e4 into firefox-devtools:main May 2, 2022
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Replace our fetch and Response mocking with fetch-mock-jest
2 participants