mswjs / msw

Industry standard API mocking for JavaScript.
https://mswjs.io
MIT License
16.02k stars 526 forks source link

Add example how to use with Cypress #1560

Closed TeemuKoivisto closed 8 months ago

TeemuKoivisto commented 1 year ago

Scope

Improves an existing behavior

Compatibility

Feature description

Hi, I just recently faced a really bizarre bug where my window.location.pathname would not be updated to correct path when I had msw enabled.

Turns out, since there was a home page outside the main Vue app that loaded without msw and inner app with msw there was no time for msw to completely load.

I had to add following checks to ensure it had loaded:

    cy.get(".space-container", { timeout: 10000 }).should("be.visible");
    cy.window().then(async (win) => {
      await win.msw;
      expect(win).has.property("msw");
    });

So my small request is, could this be added to some Cypress example in how to use msw? I'm pretty sure I won't be the only one stumbling across this and scratching their head for a long while since to cause is not so obvious.

Showing that you should load msw first with eg:

    if (process.env.UI_USE_BACKEND_MOCKS === "1") {
      window.msw = new Promise(async (resolve) => {
        const { worker } = await import("@org/testing");
        const started = await worker.start();
        resolve(started);
      })
    }

And then say add a command:

Cypress.Commands.add("waitForMsw", () => {
  cy.window().its('msw').should('not.be.undefined')
});

With your cypress/support/index.d.ts looking like:

// eslint-disable-next-line @typescript-eslint/triple-slash-reference
/// <reference types="cypress" />

declare namespace Cypress {
  interface Chainable {
    waitForMsw: () => Cypress.Chainable<void>,
  }
  interface ApplicationWindow {
    msw: Promise<any | undefined> | undefined
  }
}

And use it at the start of your test:

    cy.waitForMsw();

Thanks for the library though!

EDIT: Okay, it broke again. I thought I had it working but apparently when msw is enabled something goes wrong with vue-router. If I call history.pushState manually it works again.

EDIT EDIT: Cypress really doesn't go along well with msw. Here's an example I got working https://github.com/TeemuKoivisto/sveltekit-monorepo-template

kettanaito commented 1 year ago

Hey, @TeemuKoivisto. That's a good suggestion.

What we should have done long time ago is showcased how to correctly use MSW with Cypress. Currently, we have a minimal usage example which will be fine for some percentage of use cases but it is not fully correct in terms of awaiting MSW's worker while running tests in Cypress.

I recall someone from the community posting a fantastic example of how they use a custom Cypress plugin (or a different terminology) that allows them to import and await MSW as a part of their Cypress run vs exposing MSW on window globally. I believe that is the right direction to take.

That example is somewhat similar to yours above but it didn't rely on window, to my best recollection.

TeemuKoivisto commented 1 year ago

@kettanaito great and thanks! Yeah it's a little complicated so I wager I'd be nice to have a solid example to make people less hesitant to use msw. It'd cool to see their setup and how they managed to set msw as part of their Cypress run. Hmm yeah there are setup hooks that are run before the tests themselves. Probably one of those.

Capocaccia commented 1 year ago

@TeemuKoivisto This is a blog post I wrote awhile back but its a little outdated at this point. I could potentially re-write it to deal with Cypress v10 and above if you need it. Hope it helps.

https://www.capocaccia.dev/posts/cypressMsw

kettanaito commented 1 year ago

The blog post from @Capocaccia is one of the best integration examples I've seen. Posting here for clarity:

//cypress/support/index.js
import { worker } from '../../src/mocks/browser';

Cypress.on('test:before:run:async', async () => {
   await worker.start();
});

The idea they propose here is to start the worker in Cypress' internal hook. I think this is marvelous. Give it a try.

The original article also mentions worker deduplication but MSW ships with that built-in. Subsequent calls to worker.start() while the worker is registering/registered have no effect.

TeemuKoivisto commented 1 year ago

Just as an update, I had to remove msw and switch to cy.intercept. I am not sure why but for me, starting msw in 'test:before:run:async' didn't seem to execute properly. I have an interesting edge-case where I am using web workers to execute fetch in parallel, which fails miserably in Cypress as my workers will be starved and unresponsive while Cypress' workers load all the assets et cetera.

So I added a hacky if-else like this:

  let worker: DriveWorker;
  // @ts-ignore
  if (window.Cypress) {
    // Don't use workers inside Cypress since Cypress uses workers itself heavily which deprioritizes
    // our workers, taking forever to run
    const postWorkerMsg = (ev: WorkerEvent) => {
      worker.onmessage({ data: ev });
    };
    const workerSelf = {
      onmessage: onmessage({ postMessage: postWorkerMsg }),
      postMessage: postWorkerMsg,
    };
    worker = {
      onmessage: (ev: { data: WorkerEvent }) => {},
      postMessage(msg: ClientEvent) {
        workerSelf.onmessage({ data: msg });
      },
      terminate() {},
    };
  } else {
    worker = new Worker(
      // @ts-ignore
      new URL("../../workers/driveWorker", import.meta.url),
      { type: "module" },
    ) as DriveWorker;
  }

to switch back to single-threaded execution while in Cypress. Something similar probably happens with msw where the service worker is somehow starved by Cypress.

Now that I added this if-else hack, I perhaps could await msw properly to intercept the fetch requests. But since this got quite convoluted already, I'll probably skip using workers altogether in Cypress - seems just too flaky. I'll definitely keep my eye on this if the situation improves.

kettanaito commented 1 year ago

I stand by that the simplest and most straightforward integration is just following the Browser integration for your entire app, enable it conditionally during the Cypress run (env variables are your friends) and get client-side request interception out of the box.

Since you await responses anyway, you are unlikely to deal with race conditions as long as you write correct assertions, which you should be doing regardless of the API mocking library of choice.

I will try to add a Cypress usage example to the new examples repo to have a single point of reference and close this issue afterward.

TeemuKoivisto commented 1 year ago

@kettanaito I concur that using similar conditional env I was able to launch the app and use msw in Cypress. It was only my other workers which then failed. Probably with that new hack & reverting to using env for msw it could work but it just got too much at that point.

abrahamgr commented 1 year ago

I think the easiest way to implement MSW within Cypress without exposing worker to the window is the following: I just extracted what I need from cypress-msw-interceptor and you can adjust as you need.

Also it works with OOB Intercept by Cypress but this way you can reuse you handlers if you already implement them for your app.

Note: Just make sure to expose the file mockServiceWorker.js generated by msw in you public folder where you serve your app.

in support folder add a msw.ts file:

import { setupWorker, type SetupWorker, type RequestHandler } from 'msw'

declare global {
  namespace Cypress {
    interface Chainable {
      interceptRequest(...handlers: RequestHandler[]): void
    }
  }
}

let worker: SetupWorker

before(() => {
  worker = setupWorker()
  cy.wrap(worker.start({ onUnhandledRequest: 'bypass' }), { log: false })
})

Cypress.on('test:before:run', () => {
  if (!worker) return

  worker.resetHandlers()
})

Cypress.Commands.add('interceptRequest', (...handlers: RequestHandler[]) => {
  worker.use(...handlers)
})

Import into e2e.ts file:

import './msw'

Now you can use worker to mock requests:

import { rest } from 'msw'

it('visit app', () => {
  const handler = rest.post('/api/items', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json([
        { name: 'apple' },
        { name: 'orange' },
      ]))
  })
  cy.interceptRequest(handler)
  // your app inside will fetch '/api/items'
  cy.visit('/')
 })

I hope it helps!

ghost commented 1 year ago

great

I think the easiest way to implement MSW within Cypress without exposing worker to the window is the following: I just extracted what I need from cypress-msw-interceptor and you can adjust as you need.

Also it works with OOB Intercept by Cypress but this way you can reuse you handlers if you already implement them for your app.

Note: Just make sure to expose the file mockServiceWorker.js generated by msw in you public folder where you serve your app.

in support folder add a msw.ts file:

import { setupWorker, type SetupWorker, type RequestHandler } from 'msw'

declare global {
  namespace Cypress {
    interface Chainable {
      interceptRequest(...handlers: RequestHandler[]): void
    }
  }
}

let worker: SetupWorker

before(() => {
  worker = setupWorker()
  cy.wrap(worker.start({ onUnhandledRequest: 'bypass' }), { log: false })
})

Cypress.on('test:before:run', () => {
  if (!worker) return

  worker.resetHandlers()
})

Cypress.Commands.add('interceptRequest', (...handlers: RequestHandler[]) => {
  worker.use(...handlers)
})

Import into e2e.ts file:

import './msw'

Now you can use worker to mock requests:

import { rest } from 'msw'

it('visit app', () => {
  const handler = rest.post('/api/items', (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json([
        { name: 'apple' },
        { name: 'orange' },
      ]))
  })
  cy.interceptRequest(handler)
  // your app inside will fetch '/api/items'
  cy.visit('/')
 })

I hope it helps!

Great example! can you show an repo?

abrahamgr commented 1 year ago

Great example! can you show an repo?

There you have msw-cypress it's just simple.

adamstambouli commented 11 months ago

Thanks all for sharing! I just integrated MSW into Cypress with a small tweak since cypress/support/index.js is now cypress/support/e2e.{js,jsx,ts,tsx} as of cypress 10.0.0

2023-12-12 at 13 47 27@2x
// cypress/support/e2e.{js,jsx,ts,tsx}
import { worker } from '../../src/mocks/browser';

Cypress.on('test:before:run:async', async () => {
   await worker.start();
});
kettanaito commented 8 months ago

Can someone please open a pull request to mswjs/examples repo and migrate the existing Cypress setups to use Cypress.on('test:before:run:async')? I think that's the way to go and I'd like to recommend that to our users.

One question: does this approach allow one to reference the worker instance in tests to append runtime handlers with worker.use()? If yes, how?

Examples using Cypress

abrahamgr commented 8 months ago

Can someone please open a pull request to mswjs/examples repo and migrate the existing Cypress setups to use Cypress.on('test:before:run:async')? I think that's the way to go and I'd like to recommend that to our users.

One question: does this approach allow one to reference the worker instance in tests to append runtime handlers with worker.use()? If yes, how?

Examples using Cypress

I'll take a look

reintroducing commented 7 months ago

@kettanaito I ran up against this the other day and went ahead and put together a small, easily reproducible example using the latest Cypress and MSW that shows the constant restart issue very clearly.

vite-project.zip

Grab that zip, run npm i and then npm start. Load up http://localhost:3000/ and if you click the buttons, you will see the responses populating correctly in the UI from each.

Now, stop that process and run npm run cy:open and select Component Testing. Load it up in Chrome and select App.cy.tsx. You'll see that its just in a crazy loop and keeps trying to restart.

Open up cypress/support/component.ts and you will see line 42 where I added the test:before:run:async event where I'm trying to start the worker. I also had to change the serviceWorker url to what you see there otherwise it would error on that.

What is even crazier is that i had this same setup at a prior employer with msw 1.3.2 and I had it all working correctly. I even tried to duplicate that in this example repo (separately) with that version of msw and now I can't even get that working as well, so not sure how it ever worked previously :\ That project had a much more elaborate setup but I tried to boil it down to what I would think would be sufficient and still could not get it to work in the older version of msw.

MattyBalaam commented 5 months ago

I’m also seeing this issue with the Cypress test runner getting in a crazy infinite loop after trying to move from a webpack config to vite.

@reintroducing small tip, you can use ${import.meta.env.BASE_URL}/mockServiceWorker.js to get the URL

I feel like because this worked before in Webpack this is very likely a vite-related Cypress regression. Have you raised there?

reintroducing commented 5 months ago

@MattyBalaam i previously had this working in Vite so not exactly sure where the problem is, unfortunately.

MattyBalaam commented 5 months ago

@reintroducing Was that vite 4, rather than vite 5?

reintroducing commented 5 months ago

@MattyBalaam good call, it was Vite 4.4.4 and msw 1.3.2

MattyBalaam commented 5 months ago

OK, I think I got it. There is a badly documented devServerPublicPathRoute config setting for Cypress which has helped for your minimal example at least. It will show as an error for not being a valid config property but seems to fix the issue.

https://github.com/cypress-io/cypress/issues/28347#issuecomment-2111054407

export default defineConfig({
    component: {
        devServer: {
            framework: "react",
            bundler: "vite",
        },
        devServerPublicPathRoute: "",
    },
    retries: 0,
});
reintroducing commented 5 months ago

@MattyBalaam ooooohhhh, thats promising. so this isn't an msw issue after all? at this point i don't even know where i'd log a bug for this :\

MattyBalaam commented 5 months ago

Yeah, it’s more a documentation and error handling issue really I think. Possibly there could be something MSW could do here if it is retiggering a reload as I see that in some of the code… but could also be more in Cypress’s court to handle that better. @kettanaito any thoughts here?

Minozzzi commented 3 months ago

OK, acho que entendi. Há uma devServerPublicPathRouteconfiguração mal documentada para o Cypress que ajudou pelo menos no seu exemplo mínimo. Ela será exibida como um erro por não ser uma propriedade de configuração válida, mas parece corrigir o problema.

cypress-io/cypress#28347 (comentário)

export default defineConfig({
  component: {
      devServer: {
          framework: "react",
          bundler: "vite",
      },
      devServerPublicPathRoute: "",
  },
  retries: 0,
});

Is there something like this for nextjs?

If you also know something about how to intercept requests made by the nextjs middleware (server side request) it would help a lot, I'm having a lot of problems with this