msw-integration-layer

0.9.2 • Public • Published

msw-integration-layer

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 { rest } from 'msw-integration-layer/rest.js';
import mocksFromAnotherFeature from 'another-feature/demo/mocks.js';
 
/**
 * Define mock scenarios
 */
export default {
  /**
   * Return an object from the handler
   */
  default: [
    rest.get('/api/foo', (context) => Response.json({foo: 'bar'}))
  ],
  /**
   * Return native `Response` object from the handler
   */
  error: [
    rest.get('/api/foo', (context) => new Response('', {status: 400}))
  ],
  /**
   * Handle additional custom logic in the handler, based on url, searchparams, whatever
   */
  custom: [
    /**
     * Customize based on searchParams
     */
    rest.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' });
    }),

    /**
     * Customize based on params
     */
    rest.get('/api/users/:id', ({params}) => {
      if(params.id === '123') {
        return new Response('', {status: 400});
      }
 
      return Response.json({ id: '456', name: 'bob' });
    }),

    /**
     * Customize based on cookies
     */
    rest.get('/api/abtest', ({cookies}) => {
      return Response.json({ abtest: cookies.segment === 'business' });
    })
  ],
  /**
   * Provide an async fn, a fn returning an object, a fn returning a Response, or just an object
   */
  returnValues: [
    rest.get('/api/foo', async (context) => Response.json({foo: 'bar'})),
    rest.get('/api/foo', async (context) => new Response(JSON.stringify({foo: 'bar'}), {status: 200})),
    rest.get('/api/foo', (context) => Response.json({foo: 'bar'})),
    rest.get('/api/foo', (context) => new Response(JSON.stringify({foo: 'bar'}), {status: 200})),
  ],
  importedMocks: [
    mocksFromAnotherFeature.default,
    rest.get('/api/foo', () => Response.json({foo: 'bar'}))
  ]
}

Context

The context object that gets passed to the handler includes:

rest.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 'msw-integration-layer/node.js';

export default {
  nodeResolve: true,
  plugins: [
    mockPlugin(),
    storybookPlugin({ type: 'web-components' }),
  ],
};

You can also add the mswRollupPlugin 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 { mswRollupPlugin } = await import('msw-integration-layer/node.js');
    config.plugins.push(mswRollupPlugin());
    return config;
  },
};

feature-a/stories/default.stories.js:

import { html } from 'lit';
import { rest } from 'msw-integration-layer/rest.js';
import mocks from '../demo/mocks.js';

export const Default = () => html`<feature-a></feature-a>`;
Default.story = {
  parameters: {
    mocks: mocks.default,
    // or
    mocks: [
      mocks.default,
      otherMocks.error,
      rest.get('/api/bar', () => Response.json({bar: 'bar'})),
      rest.post('/api/baz', () => new Response('', {status: 400})),
    ],
    // or
    mocks: [
      rest.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 'msw-integration-layer/node.js';

export default {
  nodeResolve: true,
  files: ['test/**/*.test.js'],
  plugins: [
    mockPlugin(),
  ],
};

feature-a/test/my-test.test.js:

import { registerMockRoutes, rest } from 'msw-integration-layer';
import mocks from '../demo/mocks.js';
import featureBmocks from 'feature-b/demo/mocks.js';
 
describe('feature-a', () => {
  it('works', async () => {
    registerMockRoutes(rest.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(
      // Current project's mocks
      mocks.default, // is an array, arrays get flattened in the integration layer
 
      // Third party project's mocks, that uses a different version of MSW internally
      featureBmocks.default,
 
      // Additional mocks
      rest.get('/api/baz', (context) => Response.json({baz: 'baz'}))
    )
  });
});

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>`; // uses `feature-b` internally
Default.story = {
  parameters: {
    mocks: [
      mocks.default, // uses MSW@1.0.0
      featureBmocks.default // ❌ uses MSW@2.0.0, incompatible mocks -> MSW@2.0.0 may expect a different service worker, or different API!
    ]
  }
}

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 { rest } from 'msw-integration-layer/rest.js';

rest.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.

Readme

Keywords

none

Package Sidebar

Install

npm i msw-integration-layer

Weekly Downloads

0

Version

0.9.2

License

ISC

Unpacked Size

21.6 kB

Total Files

11

Last publish

Collaborators

  • koddsson