mswjs / msw

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

Question: GraphQL queries are not intercepted in Jest unit test #218

Closed thawsitt closed 4 years ago

thawsitt commented 4 years ago

Issue

I want to use msw in Jest unit tests only. However, GraphQL queries are not being intercepted. Our stack is React + Jest + Apollo GraphQL.

I have already looked at https://github.com/mswjs/examples/tree/master/examples/graphql-react-apollo which is very helpful. Thanks for that.

Environment

I have 2 questions.

1. Do I need any extra setup to make msw work in node (Jest unit tests)?

I believe this command is required to make msw work in browser for development. Do I need to do something similar for node?

$ npx msw init <PUBLIC_DIR>

2. Is there any config needed for different url end point?

Our end point is /prism instead of /graphql. Do I need to change any config to make it work besides setting up apollo client?

Note: I have also tried changing our api url to /graphql but it still doesn't work.

thawsitt commented 4 years ago

Just for debugging purposes, I added this in my Jest unit test file. (I only do this once. I comment this out in other runs)

const fn: any = (url: any, config: any) => {
    console.warn(url);
    console.warn(config);
};
jest.spyOn(window, 'fetch').mockImplementation(fn);

Here is what it prints out in terminal when I run the test.

http://localhost:3000/graphql

{
      method: 'POST',
      headers: { accept: '*/*', 'content-type': 'application/json' },
      credentials: undefined,
      signal: AbortSignal {},
      body: '{"operationName":"GetSourceDetail","variables":{"ids":["f3701660-1c06-4ff5-93e0-5ac772581553","0cab5f37-6050-4009-94ac-40509a40c330"]},"query":"query GetSourceDetail($ids: [String!]!) {\\n  getSourceDetailById(ids: $ids, type: LOGICAL_TABLE) {\\n    id\\n    name\\n    columns {\\n      name\\n      id\\n      dataType\\n      modified\\n      __typename\\n    }\\n    __typename\\n  }\\n}\\n"}'
    }

My handlers.js file

// graphql-handlers.js
import { graphql } from 'msw';

export const handlers = [
    graphql.query('GetSourceDetail', (req, res, ctx) => {
        // Note: This does not get printed. (i.e. the query doesn't get intercepted)
        console.warn('Intercepting graphql query', req.variables);

        return res(
            ctx.data({
                data: [
                    { ... someMockData}
                ],
                error: null,
                loading: false,
            }),
        );
    }),
];

Jest setup file

// jest/jest-graphql-mock-server.js
import { setupServer } from 'msw/node';
import { handlers } from './graphql-handlers';

const server = setupServer(...handlers);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

In jest.config.js

// jest.config.js
module.exports = {
    // ... other settings,
    setupFilesAfterEnv: ['./jest/jest-graphql-mock-server.js'],
};
kettanaito commented 4 years ago

Hey, @thawsitt. Thanks for raising this.

  1. Do I need any extra setup to make msw work in node (Jest unit tests)?

No. Calling setupServer either in a test suite directly, or as a part of test teardown is enough to enable requests interception in a NodeJS process.

  1. Is there any config needed for different url end point?

Mock Service Worker intercepts GraphQL operations based on operation kind. Hostnames and endpoints are ignored during the request matching process. So you don't have to configure where your GraphQL server endpoint is.


I think to understand this issue better, let's analyze how MSW matches GraphQL requests and try to spot what may go wrong in your case.

GraphQL operation flow

Whenever any request happens, and you have a graphql.* request handler attached, that handler parses a captured request to determine:

  1. If it contains a valid GraphQL query, either in the search params of the URL (in case of a GET request), or in req.body.query (in case of a POST request).
  2. If the query is found, the library parses it using graphql JavaScript implementation. Parsing the query allows to get more information about it: its name and variables, for example. I think this is where the issue may be, because I see your query string containing an external type reference LOGICAL_TABLE.
  3. If the query is successfully parsed, the library uses a mocked response you defined as a response for that GraphQL operation.

My guess goes that graphql.parse() fails due to the external type being referenced in a query. It'd be nice to verify that by modifying the original GraphQL example for its query to contain some external type as well.

kettanaito commented 4 years ago

Okay, I've tried using an external type in the GraphQL query of the example, and it passed:

const LOG_IN = gql`
  mutation Login($username: String!, $type: INTERNAL_USER_TYPE) {
    user {
      id
      firstName
      lastName
    }
  }
`

The issue must be eslewhere.

kettanaito commented 4 years ago

@thawsitt, could you please post some more info about the test?

  1. Include the actual test suite, so we can see what is happening during the test.
  2. Show where/how the GetSourceDetail GraphQL query is executed.
  3. Could you please run your test with DEBUG=* npm run your-test-command? This will print out additional information about the interception. Please post it here.

Ideally, it would be awesome to have a reproduction repo (you can base it on the Examples repo fork, modifying existing GraphQL usage example). That way I could debug the issue much faster. Thanks.

kettanaito commented 4 years ago

Also worth noticing: ensure your GraphQL query is a valid query. Otherwise, the library will fail in parsing it (using the graphql package), and that request will not be intercepted.

kettanaito commented 4 years ago

I've tried performing this query:

fetch('/graphql', {
    method: 'POST',
    headers: {
        accept: '*/*',
        'content-type': 'application/json'
    },
    credentials: undefined,
    body: '{"operationName":"GetSourceDetail","variables":{"ids":["f3701660-1c06-4ff5-93e0-5ac772581553","0cab5f37-6050-4009-94ac-40509a40c330"]},"query":"query GetSourceDetail($ids: [String!]!) {\\n  getSourceDetailById(ids: $ids, type: LOGICAL_TABLE) {\\n    id\\n    name\\n    columns {\\n      name\\n      id\\n      dataType\\n      modified\\n      __typename\\n    }\\n    __typename\\n  }\\n}\\n"}'
})

Using this setup:

import { setupWorker, graphql } from 'msw'

const worker = setupWorker(
  graphql.query('GetSourceDetail', (req, res, ctx) => {
    return res(ctx.data({ id: 12 }))
  }),
)

worker.start()

And see it being intercepted and mocked in a browser (using setupWorker):

Screen Shot 2020-06-21 at 18 23 37

Let me see how this behaves in NodeJS.

kettanaito commented 4 years ago

I've tried performing that query in a Jest test using the following test suite:

/**
 * @jest-environment node
 */
import { setupServer } from 'msw/node'
import { graphql } from 'msw'
import fetch from 'node-fetch'

const server = setupServer(
  graphql.query('GetSourceDetail', (req, res, ctx) => {
    return res(ctx.data({ id: 12 }))
  }),
)

server.listen()

test('works', async () => {
  const res = await fetch('http://localhost:3000/graphql', {
    method: 'POST',
    headers: {
      accept: '*/*',
      'content-type': 'application/json',
    },
    body:
      '{"operationName":"GetSourceDetail","variables":{"ids":["f3701660-1c06-4ff5-93e0-5ac772581553","0cab5f37-6050-4009-94ac-40509a40c330"]},"query":"query GetSourceDetail($ids: [String!]!) {\\n  getSourceDetailById(ids: $ids, type: LOGICAL_TABLE) {\\n    id\\n    name\\n    columns {\\n      name\\n      id\\n      dataType\\n      modified\\n      __typename\\n    }\\n    __typename\\n  }\\n}\\n"}',
  })
  const json = await res.json()

  expect(json).toEqual({ data: { id: 12 } })
})

This test passes, and the GraphQL query gets intercepted and mocked.


A couple of things I'd suggest:

  1. Ensure you are using the latest version of the library: npm install msw@latest.
  2. Carefully compare those two examples of usage from my side with yours. I hope there's a small detail that sets them apart.
  3. Try using setupFiles in your jest.config.js instead of https://jestjs.io/docs/en/configuration#setupfilesafterenv-array. I see there's a difference of where those modules are executed. This may also be the reason interception fails for you.
penspinner commented 4 years ago

Hello. I am also running into this problem where GraphQL queries are not intercepted in Jest.

I have this line in my setupTests.js:

global.fetch = require('jest-fetch-mock');

that is needed to instantiate a BatchHttpLink from apollo-link-batch-http that gets passed into ApolloClient.

Just wondering if setting that fetch value in setupTests.js has anything to do with this issue.

kettanaito commented 4 years ago

Hey, @Penspinner. I don't think that using any kind of fetch mock/polyfill would affect requests interception. Any polyfill uses either a native http/https module, its abstraction (i.e. request), or an underlying polyfill (like whatwg-fetch uses XMLHttpRequest polyfilled by jsdom).

Please, if you have a reproduction repository, could you push it so we could take a look at it? There's definitely some factor at place, but we can find it once we are close to the context you are in. Thanks.

HugoLiconV commented 4 years ago

Does anyone know how to wait for results, before, when I was using MockedProvider I used to do this:

   jest.useFakeTimers()
  ...
   render(
      <MockedProvider mocks={mocks}>
        <App />
      </MockedProvider>
    );
    expect(screen.getByText("Loading")).toBeInTheDocument();
    act(() =>  {
      jest.runAllTimers()
    })
    expect(
      screen.getByText(`${country.name} - ${country.code}`)
   ).toBeInTheDocument();

but now I don't know how to change the state, because the app just keeps in a loading state.

marcosvega91 commented 4 years ago

If I understood your case, before using MSW you used to mock your API using something like setTimeout, now using MSW you don't know when loading is finished right ?

HugoLiconV commented 4 years ago

If I understood your case, before using MSW you used to mock your API using something like setTimeout, now using MSW you don't know when loading is finished right ?

Yes, I was using runAllTimers to wait for the response. The documentation of Apollo uses wait but that didn't use to work for me, so I started using jest.runAllTimers() to wait for the response to finish.

// This snippet is from Apollo and `wait` on its own didn't use to work for me
const wait = require('waait');

it('should render dog', async () => {
  const dogMock = {
    request: {
      query: GET_DOG_QUERY,
      variables: { name: 'Buck' },
    },
    result: {
      data: { dog: { id: 1, name: 'Buck', breed: 'poodle' } },
    },
  };

  const component = renderer.create(
    <MockedProvider mocks={[dogMock]} addTypename={false}>
      <Dog name="Buck" />
    </MockedProvider>,
  );

  await wait(0); // wait for response

  const p = component.root.findByType('p');
  expect(p.children).toContain('Buck is a poodle');
});

Fortunately, I was able to solve it. Playing around, I got to this and it worked.

Before, using MockedProvider

expect(screen.getByText("Loading")).toBeInTheDocument();
act(() =>  {
  jest.runAllTimers() // wait for response
})
expect(
  screen.getByText(`${country.name} - ${country.code}`)
).toBeInTheDocument();

using MSW

expect(screen.getByText("Loading")).toBeInTheDocument();
await act(async () => {
  await wait(10); // wait for response
});
expect(
  screen.getByText(`${country.name} - ${country.code}`)
).toBeInTheDocument();
marcosvega91 commented 4 years ago

ok, so you can do in this way

expect(screen.getByText("Loading")).toBeInTheDocument();
await waitForElementToBeRemoved(() => screen.getByText("Loading"));
expect(
  screen.getByText(`${country.name} - ${country.code}`)
).toBeInTheDocument();

waitForElementToBeRemoved

kettanaito commented 4 years ago

The reproduction wasn't reported for some time now, I'm treating this as a resolved then. Feel free to discuss and include any vital reproduction repositories so we could come back to this issue, if you still experiencing it. Thank you.

priley86 commented 4 years ago

For anyone else following this issue and landing here, using jest-fetch-mock in your setupTests.ts file can break MSW within a Node.js / Jest setting. I discovered this after a lot of trial and error today, and can't really identify why, but removing jest-fetch-mock fixed the problem for me here.

priley86 commented 4 years ago

setupTest.ts file before:

import '@testing-library/jest-dom/extend-expect';

import { UIBreakpoints } from '@cx-labs/cisco-ui-react';
import { GlobalWithFetchMock } from 'jest-fetch-mock';

import { createMatchMedia } from './matchMedia.mock';

// Globally set default timeout to 30 seconds
jest.setTimeout(30000);

const customGlobal: GlobalWithFetchMock = global as GlobalWithFetchMock;
customGlobal.fetch = require('jest-fetch-mock');
customGlobal.fetchMock = customGlobal.fetch;

/**
 * Default the UI Breakpoint to ExtraLarge, this can be overriden
 * by reassigning window.matchMedia in an individual test
 */
window.matchMedia = createMatchMedia(UIBreakpoints.ExtraLarge);

/**
 * Popper.js mock needed to fire React Bootstrap Dropdown events in JS DOM
 * https://github.com/popperjs/popper.js/issues/478#issuecomment-341506071
 */
jest.mock('popper.js', () => {
  const PopperJS = jest.requireActual('popper.js');

  return class {
    static placements = PopperJS.placements;

    constructor() {
      return {
        destroy: () => {},
        scheduleUpdate: () => {},
      };
    }
  };
});

after (removed):

import '@testing-library/jest-dom/extend-expect';

import { UIBreakpoints } from '@cx-labs/cisco-ui-react';

import { createMatchMedia } from './matchMedia.mock';

// Globally set default timeout to 30 seconds
jest.setTimeout(30000);

/**
 * Default the UI Breakpoint to ExtraLarge, this can be overriden
 * by reassigning window.matchMedia in an individual test
 */
window.matchMedia = createMatchMedia(UIBreakpoints.ExtraLarge);

/**
 * Popper.js mock needed to fire React Bootstrap Dropdown events in JS DOM
 * https://github.com/popperjs/popper.js/issues/478#issuecomment-341506071
 */
jest.mock('popper.js', () => {
  const PopperJS = jest.requireActual('popper.js');

  return class {
    static placements = PopperJS.placements;

    constructor() {
      return {
        destroy: () => {},
        scheduleUpdate: () => {},
      };
    }
  };
});
kettanaito commented 4 years ago

That's a good comment. MSW is the replacement for jest-fetch-mock. Using both doesn't guarantee any reliability and doesn't make much sense. Thanks for highlighting that.

rrogowski commented 3 years ago

For anyone coming to this in the future, I did have trouble intercepting requests made via Apollo's BatchHttpLink (from apollo-link-batch-http). Keeping everything else the same, using createHttpLink (from apollo-link-http), the requests were intercepted just fine.

One notable difference is that the request body is normally an object, but BatchHttpLink sends an array. @kettanaito Perhaps the library is not prepared to handle this body type?

Screen Shot 2021-05-06 at 3 28 13 PM
kettanaito commented 3 years ago

MSW doesn’t currently support batched GraphQL queries. See the support progress in #513.

dagadbm commented 1 year ago

For me: the real problem was that I was using cross-fetch/polyfill in the jest setup :

import cross-fetch/polyfill Deleting cross-fetch worked.

on the apollo client you still need to do this:

const httpLink = new HttpLink({
  uri: '...',
  fetch: window.fetch,
});

so if you are using anything to mock or support fetch in node js environment. DONT.

My current understanding is that MSW already does all of this stuff for you. so you really only need to tell apollo to use window.fetch, which MSW is intercepting, if you add cross-fetch or anything that is similarly related MSW will not get the request.

kettanaito commented 1 year ago

@dagadbm, to clarify, MSW doesn't provide any polyfills for you. Most of the issues arise from people forgetting to include a polyfill or doing so incorrectly.

In the context of Apollo, it depends on how you're setting up your tests. If you're using something like JSDOM, you need to consult Apollo's best practices on how to configure it on that testing environment. What I did in the past is: included a fetch polyfill, configured Apollo to always use window.fetch (which in JSDOM will point to my polyfill).

dagadbm commented 1 year ago

But I dont have any polyfill and its working. Im so confused. I thought msw did this out of the box. How is this working then ?

kettanaito commented 1 year ago

@dagadbm, perhaps you're using a modern Node (17+) that ships with global fetch?

dagadbm commented 1 year ago

Node 16.19.0

dagadbm commented 1 year ago

Ok I am trying to run this with DEBUG=*

If I have this setup the graphQL requests get intercepted:

on apolloClient file:

const httpLink = new HttpLink({
  uri: '/api/graphql',
  fetch: window.fetch,
});

in jest.setup.js

console.log('fetch', window.fetch);
// import msw stuff

This is the important output:

    console.log
    fetch undefined

      at Object.<anonymous> (client/app/test-utils/jest-setup.ts:4:9)

  http constructing the interceptor... +0ms
  xhr constructing the interceptor... +0ms
  setup-server constructing the interceptor... +0ms
  http:on adding "request" event listener:  +0ms
  async-event-emitter:on adding "request" listener... +0ms
  xhr:on adding "request" event listener:  +0ms
  async-event-emitter:on adding "request" listener... +0ms
  http:on adding "response" event listener:  +0ms
  async-event-emitter:on adding "response" listener... +0ms
  xhr:on adding "response" event listener:  +0ms
  async-event-emitter:on adding "response" listener... +0ms
  setup-server:apply applying the interceptor... +0ms
  async-event-emitter:activate set state to: ACTIVE +0ms
  setup-server:apply activated the emiter! ACTIVE +0ms
  setup-server retrieved global instance: undefined +3s
  setup-server:apply no running instance found, setting up a new instance... +0ms
  setup-server:setup applying all 2 interceptors... +0ms
  setup-server:setup applying "ClientRequestInterceptor" interceptor... +1ms
  http:apply applying the interceptor... +0ms
  async-event-emitter:activate set state to: ACTIVE +0ms
  http:apply activated the emiter! ACTIVE +0ms
  http retrieved global instance: undefined +3s
  http:apply no running instance found, setting up a new instance... +0ms
  http:setup native "http" module patched! +0ms
  http:setup native "https" module patched! +0ms
  http set global instance! http +0ms
  setup-server:setup adding interceptor dispose subscription +0ms
  setup-server:setup applying "XMLHttpRequestInterceptor" interceptor... +0ms
  xhr:apply applying the interceptor... +0ms
  async-event-emitter:activate set state to: ACTIVE +0ms
  xhr:apply activated the emiter! ACTIVE +0ms
  xhr retrieved global instance: undefined +3s
  xhr:apply no running instance found, setting up a new instance... +0ms
  xhr:setup patching "XMLHttpRequest" module... +0ms
  xhr:setup native "XMLHttpRequest" module patched! XMLHttpRequestOverride +0ms
  xhr set global instance! xhr +0ms
  setup-server:setup adding interceptor dispose subscription +0ms
  setup-server set global instance! setup-server +1ms
  xhr:request POST /api/graphql open {
  method: 'POST',
  url: '/api/graphql',
  async: true,
  user: undefined,
  password: undefined
} +0ms
  xhr:request POST /api/graphql reset +1ms
  xhr:request POST /api/graphql readyState change 0 -> 1 +0ms
  xhr:request POST /api/graphql triggering readystate change... +0ms
  xhr:request POST /api/graphql trigger "readystatechange" (1) +0ms
  xhr:request POST /api/graphql resolve listener for event "readystatechange" +0ms
  xhr:request POST /api/graphql set request header "accept" to "*/*" +0ms
  xhr:request POST /api/graphql set request header "content-type" to "application/json" +0ms
  xhr:request POST /api/graphql send POST /api/graphql +0ms
  xhr:request POST /api/graphql request headers HeadersPolyfill {
  [Symbol(normalizedHeaders)]: { accept: '*/*', 'content-type': 'application/json' },
  [Symbol(rawHeaderNames)]: Map(2) { 'accept' => 'accept', 'content-type' => 'content-type' }
} +1ms
  xhr:request POST /api/graphql emitting the "request" event for 1 listener(s)... +0ms
  async-event-emitter:emit emitting "request" event... +0ms
  async-event-emitter:openListenerQueue opening "request" listeners queue... +0ms
  async-event-emitter:openListenerQueue no queue found, creating one... +0ms
  async-event-emitter:emit appending a one-time cleanup "request" listener... +0ms
  async-event-emitter:on adding "request" listener... +0ms
  async-event-emitter:openListenerQueue opening "request" listeners queue... +0ms
  async-event-emitter:openListenerQueue returning an exising queue: [] +0ms
  async-event-emitter:on awaiting the "request" listener... +3s
  async-event-emitter:openListenerQueue opening "request" listeners queue... +0ms
  async-event-emitter:openListenerQueue returning an exising queue: [
  {
    args: [ [InteractiveIsomorphicRequest] ],
    done: Promise { <pending> }
  }
] +0ms
  async-event-emitter:on awaiting the "request" listener... +1ms
  xhr:request POST /api/graphql awaiting mocked response... +1ms
  async-event-emitter:on "request" listener has resolved! +8ms
  async-event-emitter:on "request" listener has resolved! +10ms
  xhr:request POST /api/graphql all request listeners have been resolved! +9ms
  xhr:request POST /api/graphql event.respondWith called with: {
  status: 200,
  statusText: 'OK',
  headers: { 'x-powered-by': 'msw', 'content-type': 'application/json' },
// .... as you can see it is catching requests

Now if I do the recommended approach:

import 'cross-fetch/polyfill';
console.log('fetch', window.fetch);

This is the log:

  console.log
    fetch [Function: bound fetch] { polyfill: true }

      at Object.<anonymous> (client/app/test-utils/jest-setup.ts:3:9)

  http constructing the interceptor... +0ms
  xhr constructing the interceptor... +0ms
  setup-server constructing the interceptor... +0ms
  http:on adding "request" event listener:  +0ms
  async-event-emitter:on adding "request" listener... +0ms
  xhr:on adding "request" event listener:  +0ms
  async-event-emitter:on adding "request" listener... +0ms
  http:on adding "response" event listener:  +0ms
  async-event-emitter:on adding "response" listener... +0ms
  xhr:on adding "response" event listener:  +0ms
  async-event-emitter:on adding "response" listener... +0ms
  setup-server:apply applying the interceptor... +0ms
  async-event-emitter:activate set state to: ACTIVE +0ms
  setup-server:apply activated the emiter! ACTIVE +0ms
  setup-server retrieved global instance: undefined +2s
  setup-server:apply no running instance found, setting up a new instance... +0ms
  setup-server:setup applying all 2 interceptors... +0ms
  setup-server:setup applying "ClientRequestInterceptor" interceptor... +0ms
  http:apply applying the interceptor... +0ms
  async-event-emitter:activate set state to: ACTIVE +0ms
  http:apply activated the emiter! ACTIVE +0ms
  http retrieved global instance: undefined +2s // => should this be NOT undefined here ?
  http:apply no running instance found, setting up a new instance... +0ms
  http:setup native "http" module patched! +0ms
  http:setup native "https" module patched! +0ms
  http set global instance! http +1ms
  setup-server:setup adding interceptor dispose subscription +1ms
  setup-server:setup applying "XMLHttpRequestInterceptor" interceptor... +0ms
  xhr:apply applying the interceptor... +0ms
  async-event-emitter:activate set state to: ACTIVE +0ms
  xhr:apply activated the emiter! ACTIVE +0ms
  xhr retrieved global instance: undefined +2s
  xhr:apply no running instance found, setting up a new instance... +0ms
  xhr:setup patching "XMLHttpRequest" module... +0ms
  xhr:setup native "XMLHttpRequest" module patched! XMLHttpRequestOverride +0ms
  xhr set global instance! xhr +0ms
  setup-server:setup adding interceptor dispose subscription +0ms
  setup-server set global instance! setup-server +1ms
[15:19:16.133][emittery:emit][undefined] Event Name: test-case-result
// notice there is no catch of any request, and hence the test fails

I have tried multiple strategies with different packages and the result is always the same. Here are some of the examples I have tried:

// same result

import fetch from 'cross-fetch';
global.fetch = globalThis.fetch = window.fetch = fetch;
console.log('fetch', window.fetch);

// segmentation fault (probably this libraries fault)

import fetch from 'isomorphic-unfetch';
global.fetch = globalThis.fetch = window.fetch = fetch;
console.log('fetch', window.fetch);

// same result

import fetch from 'isomorphic-fetch';
global.fetch = globalThis.fetch = window.fetch = fetch;
console.log('fetch', window.fetch);

But this is very weird because I have other tests that are mocking REST api requests and they work! (irregardless if polyfill is enabled or not)

dagadbm commented 1 year ago

I noticed you did a new release with some support for node 17 so I have updated just in case but the result is still the same i'm afraid.