mswjs / msw

Seamless REST/GraphQL API mocking library for browser and Node.js.
https://mswjs.io
MIT License
15.69k stars 503 forks source link

Question: Cypress MSW started, but not intercepted #374

Closed deshiknaves closed 3 years ago

deshiknaves commented 4 years ago

I'm not sure where the best place to ask questions, so I'm asking them here. This is a simple example and I'll abstract it more when I can get the simple example working. What I'm trying to do is load MSW as part of the Cypress tests and not part of the application itself. This will allow me use use MSW to mock any part of the application — just like fixtures do in Cypress. The benefit being that MSW allows me to inspect the request before responding, something Cypress doesn't allow at the moment.

Environment

Name Version
msw ^0.20.5
browser 85.0.4183.83
OS macOS 10.15.6

Request handlers

// App.js in CRA
function App() {
  const [message, setMessage] = useState()

  useEffect(() => {
    setTimeout(() => {
      fetch('https://jsonplaceholder.typicode.com/todos/1')
        .then(response => response.json())
        .then(json => console.log(json))
    }, 1000)
  }, [])

  return (
    <div className="App">
      <h1>{message}</h1>
      <div>
        <button type="button" onClick={() => setMessage('First')}>
          First
        </button>
        <button type="button" onClick={() => setMessage('Second')}>
          Second
        </button>
        <button type="button" onClick={() => setMessage('Third')}>
          Third
        </button>
      </div>
    </div>
  )
}
// In Cypress test
import { setupWorker, rest } from 'msw'

describe('First', () => {
  let worker

  beforeEach(() => {
    cy.visit('/', {
      onBeforeLoad(win) {
        worker = setupWorker(
          rest.get(
            'https://jsonplaceholder.typicode.com/todos/1',
            (req, res, ctx) => {
              return res(
                ctx.json({
                  userId: 1,
                  id: 1,
                  title: 'Lord of the rings',
                  completed: false,
                }),
              )
            },
          ),
        )
        worker.start()
        win.msw = { worker, rest }
      },
    })
  })
  it('should be able to set the first message', () => {
    cy.window().then(win => {
      console.log(win.msw)
    })
    cy.findByRole('button', { name: /first/i }).click()
    cy.findByRole('heading', { name: /first/i }).should('be.visible')
  })
})

Actual request

// Example of making a request. Provide your code here.
fetch('https://jsonplaceholder.typicode.com/todos/1')
        .then(response => response.json())
        .then(json => console.log(json))

Current behavior

I can see from the console that MSW was started and that it is available on the window. The request says it went though the serviceworker, but it actually get the response back from the API and not MSW.

Expected behavior

I was hoping it would be intercepted by MSW and the response sent back. Obviously something about the manner in which I'm doing this doesn't work. It would be great to get some input as to why, maybe there is some other way I can do this.

Response should be

{
  "userId": 1,
  "id": 1,
  "title": "Lord of the rings",
  "completed": false,
}

Screenshots

image image
msutkowski commented 4 years ago

I haven't had a chance to look at this issue in its entirety, but I recently did some troubleshooting on another cypress-related project. It may help you out - https://github.com/msutkowski/msw-cypress-repro. If you don't get a better answer by tomorrow afternoon, I'll look into it :)

deshiknaves commented 4 years ago

@msutkowski that example expects it to be loaded as part of the application (index.tsx). It works if I do that. However, what I want to achieve is to make msw not a dependency of the application, but add it in as part of Cypress. It's adding it in onBeforeLoad, this way, I can make a global server in Cypress, then add custom commands to set up routes just like fixtures work in Cypress. It is getting loaded and available in the window, but the route doesn't get intercepted.

deshiknaves commented 4 years ago

Also https://github.com/deshiknaves/cypress-playground/tree/feature/msw-cypress that branch currently has the proof of concept.

msutkowski commented 4 years ago

Ah, there is another issue where @kettanaito had an idea about making it a command. I'm not 100% sure on the Cypress execution context & timing there, but you're possibly running into race conditions even when using onBeforeLoad. I'd have to read up more on the Cypress-specific behavior here. When I made the repro I linked, the tests still seemed to be flaky with Cypress no matter what I did.

deshiknaves commented 4 years ago

I've tried putting a massive delay and it still doesn't pick it up. I'll also dig into this a bit more. In my example, the it's definitely available on the window. And for me it's not flaky, it never gets intercepted.

marcosvega91 commented 4 years ago

Hi guys, the problem here is that there are multiple clients to same worker.

1) Cypress is a client 2) React app is another client

Under the hood MSW save registered clients to mock requests only for them.

The problem in this case for this approach is that request are sent by react while the only "real" client is cypress

deshiknaves commented 4 years ago

Yep, I just noticed this by logging MOCK_ACTIVATE action and it's id is 4ebd2cd3-3a0b-46c8-8513-d5d0b354c85b while later on it's 96a4641b-f87c-4fbd-9f11-39ca3f5fc1e6.

Any ideas on what can be done to make something like this possible? IMHO this would make it a lot more useful.

deshiknaves commented 4 years ago

This is entirely a hack and I haven't understood the implications yet. But what I've done is cached the connected clientId during MOCK_ACTIVATE. Then change the code when getting the client:

const client = await event.target.clients.get(
        connectedClientId || clientId,
      )

      if (
        // Bypass mocking when no clients active
        !client ||
        // Bypass mocking if the current client has mocking disabled
        !clients[connectedClientId] ||
        // Bypass mocking for navigation requests
        request.mode === 'navigate'
      ) {
        return resolve(getOriginalResponse())
      }

Now it works exactly as I expect it to.

I will spend more time understanding everything that goes on in this file. But is there a way to either flag to the SW that this is the client to use or anything else that makes sense?

deshiknaves commented 4 years ago

In the same branch that I mentioned above, I have a working example of this being abstracted into a command using the modified serviceworker.

deshiknaves commented 4 years ago

There are a couple of things that I have questions on. If there is a solution that works after that, I'd be happy to work on a PR to make this work:

Would love to hear your thoughts as you have a lot more context on this.

zapplebee commented 4 years ago

However, what I want to achieve is to make msw not a dependency of the application, but add it in as part of Cypress.

This totally makes sense. The primary reason we are using msw is to use the same mocks in our unit and e2e tests, but integrating them into source just doesn't make sense as a pattern to me.

There's a gotcha that hasn't been called out here too, worker.start() returns a promise. As far as I can tell, there is no way to await a promise in onBeforeLoad.

One possible solution to this would be a babel macro to conditionally add them to the app. This breaks some of the advantages to the approach you have here though, co-location, totally separated from the application source code, etc.

deshiknaves commented 4 years ago

Could there be any possibility to add options to setupWorker or worker.start in a unified|host mode where this would act as the client?/host for all clients connected? It would be useful in scenarios like testing.

As for worker.start() not being awaited. In the example repo this is happening in a before() in support/index.js and not in every test. So it happens before everything and is awaitable.

before(async () => {
  worker = setupWorker()
  await worker.start()
})
zapplebee commented 4 years ago

One possible solution to this would be a babel macro to conditionally add them to the app.

I ended up creating something that could enable this.

https://github.com/zapplebee/perenv.macro

This allows you to use environmental variables to conditionally import things in your app.

So it:

  1. Keeps imports out of source at build time
  2. Enables CLI specification of what mocks to import.

As an example:

- index.js
- mocks/
  - first_set.js
  - second_set.js
- cypress/
  - integration/
    - spec1.js
    - spec2.js

index.js

import { loadPerEnv } from "./perEnv.macro";
loadPerEnv('./mocks/first_set.js', 'MSW_MOCKS', 'FIRST');
loadPerEnv('./mocks/second_set.js', 'MSW_MOCKS', 'SECOND');

bootstrapApp()

package.json

{
  "scripts" : {
    "start:mocks1": "MSW_MOCKS=FIRST react-scripts start",
    "test:mocks1": "cypress run --spec \"cypress/integration/spec1.js\"",
    "e2e:mocks1": "start-server-and-test start:mocks1 http://localhost:3000 test:mocks1",
    "start:mocks2": "MSW_MOCKS=SECOND react-scripts start",
    "test:mocks2": "cypress run --spec \"cypress/integration/spec2.js\"",
    "e2e:mocks2": "start-server-and-test start:mocks1 http://localhost:3000 test:mocks1",
  }
}

Even writing this out, I know this is not ideal, but it does work around the need to modify msw in any way.

deshiknaves commented 4 years ago

Yep, that would work — I can already do this if it's included as part of the application. I think there should be a way to add msw as part of Cypress without modifying the host application in any way. There are many reasons for this. The app can have its only handlers that it wants to use as part of the development experience. It would be great if Cypress can load it's own instance and mock the requests per each test without the app having any knowledge of it.

deshiknaves commented 4 years ago

I'll code up a solution this weekend and get everyone's opinion on that take.

zapplebee commented 4 years ago

I wonder if Cypress can do anything to control the clientId, or at least know the clientId of the JS application.

If msw exposed an API to register a client (either as yourself or on behalf of another client), that would make this possible too.

Another approach might be to use cy.stub() and stub fetch or XHR request. That way, the calls could be lifted up into the Cypress context instead of the application context.

deshiknaves commented 4 years ago

The problem with that approach is now you're modifying the application code. The goal is to leave the application as it is and test it as such just mocking its response, not the mechanism.

The clientId isn't readily available from what I can see, and also requires an API change.

I think by spinning up an interceptor to be the host for all clients connected to it could be a simple and elegant solution.

MarkLyck commented 3 years ago

any news on this? @deshiknaves

kettanaito commented 3 years ago

I believe the implementation that solves this issue is under review. It's been in that state for quite some time as we don't have the capacity to tackle all the issues, so some end up postponed.

We do wish for a better Cypress integration, but we also wish to retain a clear and clean internal implementation. That often requires a scrupulous code review. We have not forgotten about this issue and the associated pull request, and how to look into it in the nearest future.

If you need this merged sooner, I strongly encourage you to review the pull request, understand what is the suggested solution, voice your concerns. A team behind MSW is small, so we need each bit of help we can get in pushing the improvements like this forward. Hope for your understanding on this matter.

deshiknaves commented 3 years ago

I've been a little bit busy lately, but I will make sometime on this one. I need a bit of input from the the team. I'll wait to hear back on some of the questions, and then tackle some of the suggestions from @kettanaito

kettanaito commented 3 years ago

Update: we kicked off a solution to properly intercept requests issued from iframes (nested clients). In the current state of the pull request, MSW is able to intercept and mock a response to a request that originates from an iframe on the page. With @deshiknaves help we found out that in the case of Cypress there may be multiple nested clients involved, which we still need to ensure in a form of an integration test.

bchenSyd commented 2 years ago

Also https://github.com/deshiknaves/cypress-playground/tree/feature/msw-cypress that branch currently has the proof of concept.

@deshiknaves I checked out your branch and it didn't work

image

deshiknaves commented 2 years ago

Also https://github.com/deshiknaves/cypress-playground/tree/feature/msw-cypress that branch currently has the proof of concept.

@deshiknaves I checked out your branch and it didn't work

image

@bochen2014 thats really old. https://github.com/deshiknaves/cypress-msw-interceptor – has a lot of what was being discussed here.