@web/mocks
MSW integration layer for usage with @web/dev-server, @web/test-runner and @web/dev-server-storybook.
Defining mocks
feature-a/demo/mocks.js:
import { http } from '@web/mocks/http.js';
import mocksFromAnotherFeature from 'another-feature/demo/mocks.js';
export default {
default: [http.get('/api/foo', context => Response.json({ foo: 'bar' }))],
error: [http.get('/api/foo', context => new Response('', { status: 400 }))],
custom: [
http.get('/api/users', ({ request }) => {
const searchParams = new URL(request.url).searchParams;
if (searchParams.get('user') === '123') {
return Response.json({ id: '123', name: 'frank' });
}
return Response.json({ id: '456', name: 'bob' });
}),
http.get('/api/users/:id', ({ params }) => {
if (params.id === '123') {
return new Response('', { status: 400 });
}
return Response.json({ id: '456', name: 'bob' });
}),
http.get('/api/abtest', ({ cookies }) => {
return Response.json({ abtest: cookies.segment === 'business' });
}),
],
returnValues: [
http.get('/api/foo', async context => Response.json({ foo: 'bar' })),
http.get(
'/api/foo',
async context => new Response(JSON.stringify({ foo: 'bar' }), { status: 200 }),
),
http.get('/api/foo', context => Response.json({ foo: 'bar' })),
http.get('/api/foo', context => new Response(JSON.stringify({ foo: 'bar' }), { status: 200 })),
],
importedMocks: [
mocksFromAnotherFeature.default,
http.get('/api/foo', () => Response.json({ foo: 'bar' })),
],
};
Context
The context object that gets passed to the handler includes:
http.get('/api/foo', ({ request, cookies, params }) => {
return Response.json({ foo: 'bar' });
});
request the native Request object
cookies an object based on the request cookies
params an object based on the request params
@web/dev-server/@web/dev-server-storybook
feature-a/web-dev-server.config.mjs:
import { storybookPlugin } from '@web/dev-server-storybook';
import { mockPlugin } from '@web/mocks/plugins.js';
export default {
nodeResolve: true,
plugins: [mockPlugin(), storybookPlugin({ type: 'web-components' })],
};
You can also add the mockRollupPlugin to your .storybook/main.cjs config for when you're bundling your Storybook to deploy somewhere; your mocks will be deployed along with your Storybook, and will work in whatever environment you deploy them to.
feature-a/.storybook/main.cjs:
module.exports = {
stories: ['../stories/**/*.stories.{js,md,mdx}'],
+ rollupConfig: async config => {
+ const { mockRollupPlugin } = await import('@web/mocks/plugins.js');
+ config.plugins.push(mockRollupPlugin());
+ return config;
+ },
};
mockRollupPlugin configuration options
The rollup plugin also takes an optional interceptor, which can be useful to handle things like rewriting api. prefixes made in requests, for example:
mockRollupPlugin({
interceptor: `
const domain = window.location.hostname;
const apiDomain = "api." + domain;
const { fetch: originalFetch } = window;
async function fetch(request) {
request = new Request(request);
const resolvedURL = new URL(request.url, window.location);
if (resolvedURL.hostname === apiDomain) {
// rewrite hostname without api.
resolvedURL.hostname = domain;
const init = {};
for (const property in request) {
init[property] = request[property];
}
request = new Request(resolvedURL.href, init);
if (request.body != null) {
request = new Request(request, { body: await request.arrayBuffer() });
}
}
return originalFetch(request);
}
window.fetch = fetch;
`,
});
This can be used to avoid CORS issues when deploying your Storybooks.
And add the addon:
feature-a/.storybook/main.cjs:
module.exports = {
stories: ['../stories/**/*.stories.{js,md,mdx}'],
// for Storybook 7 (@web/storybook-builder)
+ addons: ['@web/mocks/storybook/addon/manager.js'],
// for Storybook 6 (@web/dev-server-storybook)
+ addons: ['@web/mocks/storybook/addon.js'],
rollupConfig: async config => {
const { mockRollupPlugin } = await import('@web/mocks/plugins.js');
config.plugins.push(mockRollupPlugin());
return config;
},
};
feature-a/.storybook/preview.js:
import { withMocks } from '@web/mocks/storybook/addon/decorator.js';
import { withMocks } from '@web/mocks/storybook/decorator.js';
export const decorators = [withMocks];
feature-a/stories/default.stories.js:
import { html } from 'lit';
import { http } from '@web/mocks/http.js';
import mocks from '../demo/mocks.js';
export const Default = () => html`<feature-a></feature-a>`;
Default.story = {
parameters: {
mocks: mocks.default,
mocks: [
mocks.default,
otherMocks.error,
http.get('/api/bar', () => Response.json({ bar: 'bar' })),
http.post('/api/baz', () => new Response('', { status: 400 })),
],
mocks: [
http.get('/api/users/:id', ({ params }) => {
if (params.id === '123') {
return Response.json({ name: 'frank' });
}
return Response.json({ name: 'bob' });
}),
],
},
};
@web/test-runner
The registerMockRoutes function will ensure the service worker is installed, and the mockPlugin takes care of resolving the service worker file, so users don't have to keep this one-time generated service worker file in their own project roots.
feature-a/web-test-runner.config.mjs:
import { mockPlugin } from '@web/mocks/plugins.js';
export default {
nodeResolve: true,
files: ['test/**/*.test.js'],
plugins: [mockPlugin()],
};
feature-a/test/my-test.test.js:
import { registerMockRoutes } from '@web/mocks/browser.js';
import { http } from '@web/mocks/http.js';
import mocks from '../demo/mocks.js';
import featureBmocks from 'feature-b/demo/mocks.js';
describe('feature-a', () => {
it('works', async () => {
registerMockRoutes(http.get('/api/foo', () => Response.json({ foo: 'foo' })));
const response = await fetch('/api/foo').then(r => r.json());
expect(response.foo).to.equal('foo');
});
it('works', () => {
registerMockRoutes(
mocks.default,
featureBmocks.default,
http.get('/api/baz', context => Response.json({ baz: 'baz' })),
);
});
});
Mocking requests in node.js
You can also mock requests in node.js:
import { registerMockRoutes } from '@web/mocks/node.js';
import { http } from '@web/mocks/http.js';
registerMockRoutes(
http.get('/api/foo', () => new Response(JSON.stringify({ foo: 'bar' }), { status: 200 })),
);
const r = await fetch('/api/foo').then(r => r.json());
console.assert(r.foo === 'bar');
Rationale
Why not use MSW directly?
Large applications may have many features, that themself may depend on other features internally. Consider the following example:
feature-a uses feature-b internally. feature-a wants to reuse the mocks of feature-b, but the versions of msw are different.
feature-a uses msw@1.0.0
feature-b uses msw@2.0.0
import mocks from '../demo/mocks.js';
import featureBmocks from 'feature-b/demo/mocks.js';
const Default = () => html`<feature-a></feature-a>`;
Default.story = {
parameters: {
mocks: [
mocks.default,
featureBmocks.default,
],
},
};
msw@2.0.0 may have a different API or it's service worker may expect a different message, event, or data format. In order to ensure forward compatibility, we expose a "middleman" function:
import { http } from '@web/mocks/http.js';
http.get('/api/foo', () => Response.json({ foo: 'bar' }));
The middleware function simply returns an object that looks like:
{
method: 'get',
endpoint: '/api/foo',
handler: () => Response.json({foo: 'bar'})
}
This way we can support multiple versions of msw inside of our integration layer by acting as a bridge of sorts; the function people define mocks with doesn't directly depend on msw itself, it just creates an object with the information we need to pass on to msw.
That way, feature-a's project controls the dependency on msw (via the msw integration layer package), while still being able to use mocks from other projects that may use a different version of msw themself internally.
In the wrapper, we standardize on native Request and Response objects; the handler function receives a Request object, and returns a Response object. For utility, we also pass cookies and params, since those are often used to conditionally return mocks. This means that the wrapper function only depends on standard, browser-native JS, and itself has no other dependencies, which is a good foundation to ensure forward compatibility.
Requests/Responses
The Request and Response objects used are standard JS Request and Response objects. You can read more about them on MDN.