Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add an option to warn/error on unhandled requests #257

Merged
merged 11 commits into from
Jul 22, 2020
Merged

Add an option to warn/error on unhandled requests #257

merged 11 commits into from
Jul 22, 2020

Conversation

jensmeindertsma
Copy link

@jensmeindertsma jensmeindertsma commented Jul 1, 2020

GitHub

Specification

This specification will be kept up to date as we iterate over the functionality.

An unhandled request is a request that has no corresponding request handler. It's possible to configure MSW to handle such requests differently. For example, MSW could notify the user with a warning or error message when a request has no corresponding request handler.

onUnhandledRequest

Both worker and server instances accept the onUnhanledRequest configuration option. This allows the user to choose a provided functionality or provide their own through a custom function:

type OnUnhandledRequest = 'bypass' | 'warn' | 'error' | (req: MockedRequest) => void

Important: all functionality is opt-in. This means that the default value for onUnhanledRequest is 'bypass'.

Usage example

const worker = setupWorker(...)
const server = setupServer(...)

worker.start({ onUnhandledRequest: 'bypass' })
server.listen({ onUnhandledRequest: 'error' })

Draft

This pull request is a work in progress. That means large portions of functionality have not been implemented yet, and the functionality and specification details are still open for debate.

@jensmeindertsma
Copy link
Author

jensmeindertsma commented Jul 1, 2020

Comment on lines 70 to 89
if (resolvedOptions.onUnhandledRequest === 'warn') {
// Produce a developer-friendly warning
return console.warn(
`A request to ${mockedRequest.url} was detected but not mocked because no request handler matching the URL exists.`,
)
}

if (resolvedOptions.onUnhandledRequest === 'error') {
// Throw an exception

// throw new Error(`A request to ${req.url} was detected but not mocked because no request handler matching the URL exists.`)
return
}

if (typeof resolvedOptions.onUnhandledRequest === 'function') {
resolvedOptions.onUnhandledRequest(mockedRequest)
return
}

// resolvedOptions.onUnhandledRequest === 'bypass'

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would personally avoid duplication of both the error / warn message and the onUnhandledRequest logic, by creating a util, like:

export const onMissingRequestHandler = (
  request: MockedRequest,
  onUnhandledRequest: OnUnhandledRequest,
) => {
  if (typeof onUnhandledRequest === 'function') {
    onUnhandledRequest(request)
    return
  }

  if (['warn', 'error'].includes(onUnhandledRequest)) {
    const message = `[MSW] A request to ${request.url} was detected but not mocked because no request handler matching the URL exists.`

    if (onUnhandledRequest === 'warn') {
      console.warn(message)
    } else {
      throw new Error(message)
    }
    return
  }

  // if onUnhandledRequest is 'bypass' we have nothing to do.
}

I've added [MSW] tag at the beginning of the message and refactored a bit the logic.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome!

Comment on lines 73 to 93
if (options.onUnhandledRequest === 'warn') {
// Produce a developer-friendly warning
console.warn(
`A request to ${req.url} was detected but not mocked because no request handler matching the URL exists.`,
)
return channel.send({ type: 'MOCK_NOT_FOUND' })
}

if (options.onUnhandledRequest === 'error') {
// Throw an exception

// throw new Error(`A request to ${req.url} was detected but not mocked because no request handler matching the URL exists.`)
return channel.send({ type: 'MOCK_NOT_FOUND' })
}

if (typeof options.onUnhandledRequest === 'function') {
options.onUnhandledRequest(req)
return channel.send({ type: 'MOCK_NOT_FOUND' })
}

// options.onUnhandledRequest === 'bypass'

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can avoid channel.send duplication and onUnhandledRequest logic by creating the util described in a previous comment.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure of how much is the effort, but we should test this logic on both worker.listen and server.start

Copy link
Author

@jensmeindertsma jensmeindertsma Jul 2, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Would you be willing to write the tests?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolutely agree, there's no need to duplicate the channel.send() and any other possible logic in case of an unhandled request. You can think of the new feature as:

if (!response) {
  onUnhandledRequest(req, resolvedOptions) // analyzes "resolvedOptions.onUnhandledRequest"
  return channel.send({ type: 'MOCK_NOT_FOUND' })
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can try!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To clarify: I mean exactly what @cloud-walker suggested above: a custom function that encapsulates the logic (checks if the option value is a function, or one of the enums).

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Would you be willing to write the tests?

How can contribute in this scenario? You give fork permissions to me? Or I should do another fork? 🤔

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will give you permission to push to my fork

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've merged the changes made by @kettanaito in the upstream repository so we're all up to date and I've given you access. Can you verify that it works? Haven't done this before.

@kettanaito kettanaito added this to the 0.20.0 milestone Jul 2, 2020
import { OnUnhandledRequest } from '../onUnhandledRequest'

export interface ListenOptions {
onUnhandledRequest?: OnUnhandledRequest
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Considering we will be shipping the same behavior in terms of this option to both Browser and Node, do you find it useful to introduce a concept of "shared" configuration?

interface SharedOptions {
  onUnhandledRequest?: OnUnhandledRequest
}

type WorkerOptions = SharedOptions & {
  serviceWorker: WhatWeHaveNow
  // ...
}

type ServerOptions = SharedOptions

All those interfaces, apart from the SharedOptions, already exist in the respective setupWorker/setupServer declarations.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps we could call it IsomorphicOptions? Doesn't really matter to me.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Shared" seems to describe that the options are shared between multiple units. I'm not sure what "isomorphic" means in relation to those settings.

@@ -10,6 +10,11 @@ import { getResponse } from '../utils/getResponse'
import { parseRequestBody } from '../utils/parseRequestBody'
import { isNodeProcess } from '../utils/isNodeProcess'
import * as requestHandlerUtils from '../utils/requestHandlerUtils'
import { ListenOptions } from './glossary'

const DEFAULT_LISTEN_OPTIONS: DeepRequired<ListenOptions> = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to use DeepRequired, it's one level object (for now).

@jensmeindertsma
Copy link
Author

I added most of the changes that you requested, but there is still one problem: due to the use of Object.assign, onUnhandledRequest still has a possible type of undefined, after combining the passed options with the default options. This causes a type error when passing options.onUnhandledRequest to the function, because it doesn't accept undefined. To fix this, let's check whether options.onUnhandledRequest is defined inside the function, and assign a default value of 'bypass' here if it isn't. Are you OK with this idea @kettanaito ? It will move the default value for this options away from the DEFAULT_OPTIONS object.

@cloud-walker
Copy link

@kettanaito I've no idea on how to run the integration tests, running yarn test:integration all test cases are failing for.. reasons.

You have any advice on how to start? Thanks!

@jensmeindertsma
Copy link
Author

They could be failing because of TypeScript errors?

@jensmeindertsma
Copy link
Author

This change fixes the TypeScript issues and probably also solves the issue with the integration tests for @cloud-walker , but it does make the code a little less DRY. Please give your opinion on this change and whether you have a better suggestion.

@kettanaito
Copy link
Member

You've done an amazing work, fellows! I've rebased the branch and will add some tests to cover this feature. Looks solid!

@jensmeindertsma
Copy link
Author

Awesome!

@kettanaito
Copy link
Member

I'm having troubles testing the "error" scenario, because it's designed to throw an exception. The trick here is that the exception is not thrown as a part of fetch call, but within the process that runs Jest. I've tried various ways to catch that exception, but all had no effect:

  • expect(myFunc).toThrow()
  • expect(myFunc()).rejects.toThrow()
  • expect(myFunc()).rejects.toEqual()
  • process.on('unhandledException')
  • process.on('unhandledRejection')

The culprit is that the "error" value is designed to fail the test, and it does, including the test on the MSW side. That is the expected behavior, so we need to somehow convert that failed test into a "passing" test.

Also having a little in-browser issues while testing, but should figure them out. Manual testing of the feature works great.

@cloud-walker
Copy link

@kettanaito you can share the code of the tests?

@kettanaito
Copy link
Member

Sure. I've pushed the setupServer tests. Note that it would need mswjs/interceptors#29 to be released in order to pass.

There's a trick towards throwing in the request promise: if not handled, that exception will terminate the current running process (i.e. a test). Although it seems expected, I couldn't find a reliable way to assert that in tests. Instead, I've introduced a feature to node-request-interceptor that, if an exception is thrown in a request promise, it imitates the request error (similar to network error). This results into error being thrown by request issuing function (i.e. fetch).

@kettanaito
Copy link
Member

I've pushed browser integration tests. The following tests are failing, I cannot seem to figure why:

  • error.test.ts
  • callback-throws.test.ts

Manual testing produces expected behavior. There must be something on the Puppeteer's/test setup side that fails to assert that behavior properly. If somebody could take a look at those tests, I'd be much thankful!

@cloud-walker
Copy link

I'm not an expert of puppeteer, but I can give an eye 👀

@kettanaito The command to run is npm run test:integration?

@kettanaito
Copy link
Member

@cloud-walker that'd be awesome.

Yeah, to run a specific integration test use:

$ yarn test:integration test/msw-api/setup-worker/start/on-unhandled-request/warn.test.ts

You can also run a specific test scenario in a browser to play around with by this command:

$ yarn test:focused test/msw-api/setup-worker/start/on-unhandled-request/warn.mocks.ts

For some reason both error and callback-throws scenarios fail (they don't produce an error in the console during runtime). Manual testing works as expected. Something must be off.

@cloud-walker
Copy link

I get a console.error about the "msw" dep missing:

image

I should do something else? Like yarn link?

@jensmeindertsma
Copy link
Author

Have you run yarn build yet?

@cloud-walker
Copy link

@kettanaito I get a SIGABRT error (by puppeteer I guess) or a SIGSDEV or SIGBUS at random on all the tests (bypass included). Seems to be in heisenbug..

My guess is that some cleanup of puppeteer is missing, but I'm not an expert of it T_T

@kettanaito
Copy link
Member

Yes, you need to yarn build the package, as the built version is used in the integration tests.
Those exceptions are rather strange. Please, @cloud-walker, which OS are you using?

@cloud-walker
Copy link

I use MacOS mojave

@kettanaito
Copy link
Member

There's one nuance I've noticed: since all requests come through Service Worker and MSW, if you specify onUnhandledRequest: 'error' this, most likely, will break your entire page. Requests to third-party integrations, local assets, and even internal endpoints (like requests that webpack polls for HMR) will error, since they don't have (and shouldn't have?) a corresponding request handler.

I can't say I disagree with such behavior, since the "error" value is quite self-descriptive, but I'd be cautious about this. What do you think, folks?

@jensmeindertsma
Copy link
Author

Perhaps it would be better to include a required RegEx option with onUnhandledRequest where only routes that match the RegEx get "blocked" when using error. Also, the error option could mock a network error as well as throwing a error in the console. What do you think of this?

@cloud-walker
Copy link

There's one nuance I've noticed: since all requests come through Service Worker and MSW, if you specify onUnhandledRequest: 'error' this, most likely, will break your entire page. Requests to third-party integrations, local assets, and even internal endpoints (like requests that webpack polls for HMR) will error, since they don't have (and shouldn't have?) a corresponding request handler.

I can't say I disagree with such behavior, since the "error" value is quite self-descriptive, but I'd be cautious about this. What do you think, folks?

I personally think it's the expected behavior, at least for setupServer. During test development you want the code to fail as early as possible.

For setupWorker I agree, seems a bit odd. But I guess that the majority of users will choose warn here.

@kettanaito
Copy link
Member

kettanaito commented Jul 15, 2020

@jensmeindertsma, I'm not against supporting a RegExp as the value of onUnhandledRequest, but I would start with shipping the current state of the feature first. That is to see if there are more people in need of regular expressions. My concern is to keep this feature small and predictable.

Regarding making the error value behave as a network error, I think this would be confusing. The next release will also include the actual network error emulation via res.networkError() (#253), which you can utilize on per-handled basis. You can achieve a similar behavior to unhandled requests and res.networkError() using a fall-through handler:

// Handled request
rest.get('/user', (req, res) => res()),

// Fall-through handler matching any other request
res.get('*', (req, res) => res.networkError('Any other request raises a network error!')

The error option for unhandled requests is meant to raise an error in your test run, not in the mocked response.

@jensmeindertsma
Copy link
Author

Is there still work to be done to get the current version of this feature shipped?

@kettanaito
Copy link
Member

There are two test suits that fail:

  • error.test.ts
  • callback-throws.test.ts

We need to either construct them better, or make sure they pass in the current state before shipping this.

@cloud-walker
Copy link

@kettanaito you think it's possible to reproduce the bugged scenarios on a dummy repo? Just to remove the real project noise?

@kettanaito
Copy link
Member

I'm not sure, @cloud-walker. That noise may contain a valuable context. Once we bring a potentially solved test scenario in, it may start failing anyway. I'd say it's a good practice to approach tests exactly where they're being run.

This certainly takes more time, but I'm confident it's worth it. Sometimes Puppeteer is tricky, but most of the times it's just me now knowing how to use it. For example, I wonder why it doesn't get the console errors that are clearly there if you run Puppeteer in a headfull mode. The overall page.on('console') listener seems to be flaky at times, yet the error production must be consistent.

I'm a strong believer that no test is a thousand times better than a flaky one. I'll give this one more go, and if I cannot find a reliable way to test certain error scenarios they simply won't be added.

@kettanaito
Copy link
Member

kettanaito commented Jul 22, 2020

After significant amount of effort I conclude error and callback-throws test scenarios as unreliable. The list of intercepted console errors sometimes is an empty array [] received from Puppeteer, and I can't find a reason why.

That being said, those scenarios do produce an expected behavior during manual testing.

I'm removing those tests and checking if the CI passes with the remaining browser and server tests. I've still added the *.mocks.ts files that represent the tested behavior, for manual testing mainly.

@kettanaito kettanaito marked this pull request as ready for review July 22, 2020 08:30
Copy link
Member

@kettanaito kettanaito left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you everybody for participating in developing this feature!
It looks great, let's publish it and give it a try. Approved 🎉

@kettanaito kettanaito merged commit 6001b79 into mswjs:master Jul 22, 2020
@jensmeindertsma
Copy link
Author

🎉

@jensmeindertsma jensmeindertsma deleted the feature/warn-on-unhandled branch July 22, 2020 08:56
s-pic added a commit to FixMyBerlin/fixmy.frontend that referenced this pull request Aug 27, 2020
This enables us to use onUnhandledRequest introduced in mswjs/msw#257
s-pic added a commit to FixMyBerlin/fixmy.frontend that referenced this pull request Oct 10, 2020
* chore(dependencies): bump msw to 0.20.5

This enables us to use onUnhandledRequest introduced in mswjs/msw#257

* chore(msw setup): throw on un-intercepted requests

* fix(reports/apiService): errors during submit are not being detected as such

We could only detect this by stop mocking fetch and letting msw intercept the request on the network level.
Now we know the apiService and the thunk invoking it did not rethrow the error caught by ky.

* test(reports): test successful submit using msw

closes https://github.com/FixMyBerlin/fixmy.platform/issues/250
relates to https://github.com/FixMyBerlin/fixmy.platform/issues/424

* chore(dependencies): update jest and ts.jest to v26

* chore(jest setup): cleanup

* refactor(OverviewMapState unit tests): replace fetch-mock with msw

* refactor(OverviewMapState unit tests): increase readability

* refactor(SubmitReportState unit tests): increase readability

* refactor(survey unit tests): replace fetch-mock with msw

* refactor(api unit tests): replace fetch-mock with msw

* chore(jest setup): only use one setup file

This simplifies our setup as it has been desired.
It makes not much sense to split up the logic running before each test into two files.
It is not 100% clear, but setupFilesAfterEnv seems to offer everything setupFiles does PLUS
globals like `expect` or `beforeAll` can be used.
See https://stackoverflow.com/questions/47587689/whats-the-difference-between-setupfiles-and-setuptestframeworkscriptfile

* Fix article unit test

* Add new fetch error to expected actions

* Revert commenting out code

* fix(OverviewMapState tests)

bugs where introduced in
8a49c12 and
e2583aa

* refactor(SubmitReportState.unit.test): prettier

Co-authored-by: Vincent Ahrend <mail@vincentahrend.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add an option to warn/error on unmocked requests
3 participants