1.x → 2.x

2.0 版迁移指南。

关于发布

¥About the release

版本 2.0 为库带来了自成立以来最大的 API 变化。除了新的 API 之外,它还包括各种功能,例如 ReadableStream 支持、ESM 兼容性和无数的错误修复。本指南将帮助你将应用迁移到 2.0 版。我们强烈建议你从头到尾阅读。

¥Version 2.0 brings the biggest API change to the library since its inception. Alongside the new API, it includes various features, such as ReadableStream support, ESM-compatibility, and countless bug fixes. This guide will help you migrate your application to version 2.0. We highly recommend you read it from start to finish.

如果你遗漏了此版本的官方公告,请务必阅读!

¥Make sure to read the official announcement for this release if you’ve missed it!

Introducing MSW 2.0

Official announcement post.

Codemods

我们在 Codemod.com 的朋友准备了一个很棒的 codemod 集合,可以帮助你迁移到 MSW 2.0。

¥Our friends at Codemod.com have prepared a fantastic collection of codemods that can help you migrate to MSW 2.0.

安装

¥Installation

npm install msw@latest

重大更改

¥Breaking changes

环境

¥Environment

Node.js 版本

¥Node.js version

此版本将支持的最低 Node.js 版本设置为 18.0.0。

¥This release sets the minimal supported Node.js version to 18.0.0.

TypeScript 版本

¥TypeScript version

此版本将支持的最低 TypeScript 版本设置为 4.7。如果你使用的是旧版 TypeScript,请迁移到 4.7 或更高版本以使用 MSW。请考虑,在撰写本文时,TypeScript 4.6 已有近两年的历史。

¥This release sets the minimal supported TypeScript version to 4.7. If you are using an older TypeScript version, please migrate to version 4.7 or later to use MSW. Please consider that at the moment of writing this TypeScript 4.6 is almost two years old.

导入

¥Imports

Worker 导入

¥Worker imports

现在从 msw/browser 入口点导出与浏览器端集成相关的所有内容。这包括 setupWorker 函数和相关类型定义。

¥Everything related to the browser-side integration is now exported from the msw/browser entrypoint. This includes both the setupWorker function and the relevant type definitions.

之前:

¥Before:

import { setupWorker } from 'msw'

之后:

¥After:

import { setupWorker } from 'msw/browser'

响应解析器参数

¥Response resolver arguments

响应解析器函数不再接受 reqresctx 参数。相反,它接受一个参数,该参数是一个包含有关被拦截请求的信息的对象。

¥Response resolver function no longer accepts req, res, and ctx arguments. Instead, it accepts a single argument which is an object containing information about the intercepted request.

之前:

¥Before:

rest.get('/resource', (req, res, ctx) => {})

之后:

¥After:

http.get('/resource', (info) => {})

根据所使用的处理程序命名空间(httpgraphql),info 对象包含不同的属性。你现在可以在 请求更改 中了解如何访问请求信息。

¥Depending on the handler namespace used (http or graphql), the info object contains different properties. You can learn about how to access request information now in the Request changes.

了解有关请求处理程序命名空间的更新调用签名的更多信息:

¥Learn more about the updated call signature of the request handler namespaces:

http

API reference for the `http` namespace.

graphql

API reference for the `graphql` namespace.

请求更改

¥Request changes

请求 URL

¥Request URL

由于拦截的请求现在被描述为 Fetch API Request 实例,其 request.url 属性不再是 URL 实例,而是普通的 string. 之前:

¥Since the intercepted request is now described as a Fetch API Request instance, its request.url property is no longer a URL instance but a plain string. Before:

rest.get('/resource', (req) => {
  const productId = req.url.searchParams.get('id')
})

之后:

¥After:

如果你希望将其作为 URL 实例进行操作,则应首先从 request.url 字符串创建它。

¥If you wish to operate with it as a URL instance, you should create it first from the request.url string.

import { http } from 'msw'
 
http.get('/resource', ({ request }) => {
  const url = new URL(request.url)
  const productId = url.searchParams.get('id')
})

请求参数

¥Request params

路径参数不再在 req.params 下公开。

¥Path parameters are no longer exposed under req.params.

之前:

¥Before:

rest.get('/post/:id', (req) => {
  const { id } = req.params
})

之后:

¥After:

要访问路径参数,请在响应解析器上使用 params 对象。

¥To access path parameters, use the params object on the response resolver.

import { http } from 'msw'
 
http.get('/post/:id', ({ params }) => {
  const { id } = params
})

请求 cookies

¥Request cookies

请求 cookie 不再在 req.cookies. 下公开

¥Request cookies are no longer exposed under req.cookies.

之前:

¥Before:

rest.get('/resource', (req) => {
  const { token } = req.cookies
})

之后:

¥After:

要访问请求 cookie,请在响应解析器上使用 cookies 对象。

¥To access request cookies, use the cookies object on the response resolver.

import { http } from 'msw'
 
http.get('/resource', ({ cookies }) => {
  const { token } = cookies
})

请求正文

¥Request body

你无法再通过 req.body 属性读取被拦截的请求主体。事实上,根据 Fetch API 规范,如果设置了主体,request.body 现在将返回 ReadableStream

¥You can no longer read the intercepted request body via the req.body property. In fact, according to the Fetch API specification, request.body will now return a ReadableStream if the body is set.

之前:

¥Before:

rest.post('/resource', (req) => {
  // The library would assume a JSON request body
  // based on the request's "Content-Type" header.
  const { id } = req.body
})

之后:

¥After:

MSW 将不再假设请求主体类型。相反,你应该使用标准 Request 方法(如 .text().json().arrayBuffer() 等)根据需要读取请求主体。

¥MSW will no longer assume the request body type. Instead, you should read the request body as you wish using the standard Request methods like .text(), .json(), .arrayBuffer(), etc.

import { http } from 'msw'
 
http.post('/user', async ({ request }) => {
  // Read the request body as JSON.
  const user = await request.json()
  const { id } = user
})

响应声明

¥Response declaration

模拟响应不再使用 res() 组合函数声明。我们正在脱离组合方法,转而遵守 Web 标准。

¥Mocked responses are no longer declared using the res() composition function. We are departing from the composition approach in favor of adhering to the web standards.

之前:

¥Before:

rest.get('/resource', (req, res, ctx) => {
  return res(ctx.json({ id: 'abc-123' }))
})

之后:

¥After:

要声明模拟响应,请创建一个 Fetch API Response 实例并从响应解析器返回它。

¥To declare a mocked response, create a Fetch API Response instance and return it from the response resolver.

import { http } from 'msw'
 
http.get('/resource', () => {
  return new Response(JSON.stringify({ id: 'abc-123' }), {
    headers: {
      'Content-Type': 'application/json',
    },
  })
})

为了提供更简洁的接口并支持模拟响应 cookie 等功能,该库现在提供了一个自定义 HttpResponse 类,你可以将其用作原生 Response 类的替代品。

¥To provide a less verbose interface and also support such features as mocking response cookies, the library now provides a custom HttpResponse class that you can use as a drop-in replacement for the native Response class.

import { http, HttpResponse } from 'msw'
 
export const handlers = [
  http.get('/resource', () => {
    return HttpResponse.json({ id: 'abc-123' })
  }),
]

了解有关新 HttpResponse API 的更多信息:

¥Learn more about the new HttpResponse API:

HttpResponse

API reference for the `HttpResponse` class.

req.passthrough()

之前:

¥Before:

rest.get('/resource', (req, res, ctx) => {
  return req.passthrough()
})

之后:

¥After:

import { http, passthrough } from 'msw'
 
export const handlers = [
  http.get('/resource', () => {
    return passthrough()
  }),
]

res.once()

由于 res() 组合 API 已消失,res.once() 一次性请求处理程序声明也已消失。

¥Since the res() composition API is gone, so is the res.once() one-time request handler declaration.

之前:

¥Before:

rest.get('/resource', (req, res, ctx) => {
  return res.once(ctx.text('Hello world!'))
})

之后:

¥After:

要声明一次性请求处理程序,请提供一个对象作为它的第三个参数,并将该对象的 once 属性设置为 true

¥To declare a one-time request handler, provide an object as the third argument to it, and set the once property of that object to true.

import { http, HttpResponse } from 'msw'
 
http.get(
  '/resource',
  () => {
    return new HttpResponse('Hello world!')
  },
  { once: true }
)

res.networkError()

要模拟网络错误,请调用 HttpResponse.error() 静态方法并从响应解析器返回它。

¥To mock a network error, call the HttpResponse.error() static method and return it from the response resolver.

之前:

¥Before:

rest.get('/resource', (req, res, ctx) => {
  return res.networkError('Custom error message')
})

之后:

¥After:

import { http, HttpResponse } from 'msw'
 
http.get('/resource', () => {
  return HttpResponse.error()
})

请注意,Response.error() 不接受自定义错误消息。以前,MSW 会尽最大努力将你提供的自定义错误消息强制传递给底层请求客户端,但它从未可靠地工作过,因为这取决于请求客户端是否处理或忽略网络错误消息。

¥Note that the Response.error() doesn’t accept a custom error message. Previously, MSW did its best to coerce the custom error message you provided to the underlying request client but it never worked reliably because it’s up to the request client to handle or disregard the network error message.

上下文实用程序

¥Context utilities

在此版本中,我们将弃用 ctx 实用程序对象。相反,使用 HttpResponse 类来声明模拟响应属性,如状态、标题或正文。

¥With this release we are deprecating the ctx utilities object. Instead, use the HttpResponse class to declare mocked response properties, like status, headers, or body.

ctx.status()

之前:

¥Before:

rest.get('/resource', (req, res, ctx) => {
  return res(ctx.status(201))
})

之后:

¥After:

import { http, HttpResponse } from 'msw'
 
http.get('/resource', () => {
  return new HttpResponse(null, {
    status: 201,
  })
})

ctx.set()

之前:

¥Before:

rest.get('/resource', (req, res, ctx) => {
  return res(ctx.set('X-Custom-Header', 'foo'))
})

之后:

¥After:

import { http, HttpResponse } from 'msw'
 
http.get('/resource', () => {
  return new HttpResponse(null, {
    headers: {
      'X-Custom-Header': 'foo',
    },
  })
})

了解标准 Headers API

¥Learn about the standard Headers API.

ctx.cookie()

之前:

¥Before:

rest.get('/resource', (req, res, ctx) => {
  return res(ctx.cookie('token', 'abc-123'))
})

之后:

¥After:

import { http, HttpResponse } from 'msw'
 
http.get('/resource', () => {
  return new HttpResponse(null, {
    headers: {
      'Set-Cookie': 'token=abc-123',
    },
  })
})

库能够检测到你何时通过 HttpResponse 类模拟响应 cookie。如果你希望模拟响应 cookie,则必须使用该类,因为在设置响应 cookie 后,无法在原生 Response 类上读取它们。

¥The library is able to detect whenever you are mocking response cookies via the HttpResponse class. If you wish to mock response cookies, you must use that class, since response cookies cannot be read on the native Response class after they are set.

ctx.body()

之前:

¥Before:

rest.get('/resource', (req, res, ctx) => {
  return res(ctx.body('Hello world'), ctx.set('Content-Type', 'text/plain'))
})

之后:

¥After:

import { http, HttpResponse } from 'msw'
 
http.get('/resource', (req, res, ctx) => {
  return new HttpResponse('Hello world')
})

ctx.text()

之前:

¥Before:

rest.get('/resource', (req, res, ctx) => {
  return res(ctx.text('Hello world!'))
})

之后:

¥After:

import { http, HttpResponse } from 'msw'
 
http.get('/resource', () => {
  return new HttpResponse('Hello world!')
})

ctx.json()

之前:

¥Before:

rest.get('/resource', (req, res, ctx) => {
  return res(ctx.json({ id: 'abc-123' }))
})

之后:

¥After:

import { http, HttpResponse } from 'msw'
 
http.get('/resource', () => {
  return HttpResponse.json({ id: 'abc-123' })
})

请注意,使用静态 HttpResponse 方法(如 HttpResponse.text()HttpResponse.json() 等)时,你不必明确指定 Content-Type 响应标头。

¥Note that you don’t have to explicitly specify the Content-Type response header when using static HttpResponse methods like HttpResponse.text(), HttpResponse.json(), and others.

ctx.xml()

之前:

¥Before:

rest.get('/resource', (req, res, ctx) => {
  return res(ctx.xml('<foo>bar</foo>'))
})

之后:

¥After:

import { http, HttpResponse } from 'msw'
 
http.get('/resource', () => {
  return HttpResponse.xml('<foo>bar</foo>')
})

ctx.data()

之前:

¥Before:

graphql.query('GetUser', (req, res, ctx) => {
  return res(
    ctx.data({
      user: {
        firstName: 'John',
      },
    })
  )
})

之后:

¥After:

graphql 处理程序命名空间不再得到特殊处理。相反,你应该直接声明标准 JSON 响应。

¥The graphql handler namespace no longer gets a special treatment. Instead, you should declare standard JSON responses directly.

为了使 GraphQL 操作的模拟响应定义更舒适,请使用 HttpResponse.json() 静态方法:

¥To make the mocked response definition for GraphQL operations more comfortable, use the HttpResponse.json() static method:

import { graphql, HttpResponse } from 'msw'
 
graphql.query('GetUser', () => {
  return HttpResponse.json({
    data: {
      user: {
        firstName: 'John',
      },
    },
  })
})

使用 HttpResponse,你必须在响应中明确包含根级 data 属性。

¥Using HttpResponse, you have to explicitly include the root-level data property on the response.

ctx.errors()

之前:

¥Before:

graphql.mutation('Login', (req, res, ctx) => {
  const { username } = req.variables
 
  return res(
    ctx.errors([
      {
        message: `Failed to login:  user "${username}" does not exist`,
      },
    ])
  )
})

之后:

¥After:

import { graphql, HttpResponse } from 'msw'
 
graphql.mutation('Login', ({ variables }) => {
  const { username } = variables
 
  return HttpResponse.json({
    errors: [
      {
        message: `Failed to login:  user "${username}" does not exist`,
      },
    ],
  })
})

使用 HttpResponse,你必须在响应中明确包含 errors 根级属性。

¥Using HttpResponse, you have to explicitly include the errors root-level property on the response.

ctx.extensions()

之前:

¥Before:

graphql.query('GetUser', (req, res, ctx) => {
  return res(
    ctx.data({
      user: {
        firstName: 'John',
      },
    }),
    ctx.extensions({
      requestId: 'abc-123',
    })
  )
})

之后:

¥After:

import { graphql, HttpResponse } from 'msw'
 
graphql.query('GetUser', () => {
  return HttpResponse.json({
    data: {
      user: {
        firstName: 'John',
      },
    },
    extensions: {
      requestId: 'abc-123',
    },
  })
})

ctx.delay()

之前:

¥Before:

rest.get('/resource', (req, res, ctx) => {
  return res(ctx.delay(500), ctx.text('Hello world'))
})

之后:

¥After:

库现在导出返回超时 Promisedelay() 函数。你可以在响应解析器中的任何位置等待它以模拟服务器端延迟。

¥The library now exports the delay() function that returns a timeout Promise. You can await it anywhere in your response resolvers to emulate server-side delay.

import { http, HttpResponse, delay } from 'msw'
 
http.get('/resource', async () => {
  await delay(500)
  return HttpResponse.text('Hello world')
})

delay() 函数的调用签名与之前的 ctx.delay() 相同。

¥The call signature of the delay() function remains identical to the previous ctx.delay().

delay

API reference for the `delay` function.

ctx.fetch()

之前:

¥Before:

rest.get('/resource', async (req, res, ctx) => {
  const originalResponse = await ctx.fetch(req)
  const originalJson = await originalResponse.json()
 
  return res(
    ctx.json({
      ...originalJson,
      mocked: true,
    })
  )
})

之后:

¥After:

要在处理程序中执行其他请求,请使用从 msw 导出的新 bypass 函数。此函数封装任何给定的 Request 实例,将其标记为 MSW 在拦截请求时应忽略的实例。

¥To perform an additional request within the handler, use the new bypass function exported from msw. This function wraps any given Request instance, marking it as the one MSW should ignore when intercepting requests.

import { http, HttpResponse, bypass } from 'msw'
 
http.get('/resource', async ({ request }) => {
  const originalResponse = await fetch(bypass(request))
  const originalJson = await originalResponse.json()
 
  return HttpResponse.json({
    ...originalJson,
    mocked: true,
  })
})

bypass

API reference for the `bypass` function.

printHandlers()

worker/server 上的 .printHandlers() 方法已被删除,取而代之的是新的 .listHandlers() 方法。

¥The .printHandlers() method on worker/server has been removed in favor of the new .listHandlers() method.

之前:

¥Before:

worker.printHandlers()

之后:

¥After:

新的 .listHandlers() 方法返回当前活动的请求处理程序的只读数组。

¥The new .listHandlers() method returns a read-only array of currently active request handlers.

worker.listHandlers().forEach((handler) => {
  console.log(handler.info.header)
})

onUnhandledRequest

onUnhandledRequestrequest 参数已从抽象请求对象更改为 Fetch API Request 实例。在访问其属性(如 request.url)时请考虑到这一点。

¥The request argument of the onUnhandledRequest has changed from being an abstract request object to be a Fetch API Request instance. Take that into account when accessing its properties, like request.url.

之前:

¥Before:

server.listen({
  onUnhandledRequest(request, print) {
    const url = request.url
 
    if (url.pathname.includes('/assets/')) {
      return
    }
 
    print.warning()
  },
})

之后:

¥After:

request 参数是 Request 的一个实例,这使得它的 url 属性成为 string

¥The request argument is an instance of Request, which makes its url property a string.

server.listen({
  onUnhandledRequest(request, print) {
    // Create a new URL instance manually.
    const url = new URL(request.url)
 
    if (url.pathname.includes('/assets/')) {
      return
    }
 
    print.warning()
  },
})

生命周期事件

¥Life-cycle events

此版本对 生命周期事件 监听器的调用签名进行了更改。

¥This release brings changes to the Life-cycle events listeners’ call signature.

之前:

¥Before:

server.events.on('request:start', (request, requestId) => {})

之后:

¥After:

每个生命周期事件监听器现在都接受一个对象参数。

¥Every life-cycle event listener now accepts a single argument being an object.

server.events.on('request:start', ({ request, requestId }) => {})

新 API

¥New API

除了重大更改之外,此版本还引入了一系列新 API。它们中的大多数都专注于提供与已弃用功能的兼容性。

¥In addition to the breaking changes, this release introduces a list of new APIs. Most of them are focused on providing compatibility with the deprecated functionality.

常见问题

¥Frequent issues

Request/Response/TextEncoder 未定义(Jest)

¥Request/Response/TextEncoder is not defined (Jest)

此问题是由于你的环境由于某种原因没有 Node.js 全局变量而导致的。这通常发生在 Jest 中,因为它故意剥夺了你的 Node.js 全局变量,并且无法完全重新添加它们。因此,你必须自己明确添加它们。

¥This issue is caused by your environment not having the Node.js globals for one reason or another. This commonly happens in Jest because it intentionally robs you of Node.js globals and fails to re-add them in their entirely. As the result, you have to explicitly add them yourself.

在你的 jest.config.js 旁边创建一个 jest.polyfills.js 文件,内容如下:

¥Create a jest.polyfills.js file next to your jest.config.js with the following content:

// jest.polyfills.js
/**
 
 * @note The block below contains polyfills for Node.js globals
 
 * required for Jest to function when running JSDOM tests.
 
 * These HAVE to be require's and HAVE to be in this exact
 
 * order, since "undici" depends on the "TextEncoder" global API.
 
 *  * Consider migrating to a more modern test runner if
 
 * you don't want to deal with this.
 */
 
const { TextDecoder, TextEncoder } = require('node:util')
 
Object.defineProperties(globalThis, {
  TextDecoder: { value: TextDecoder },
  TextEncoder: { value: TextEncoder },
})
 
const { Blob, File } = require('node:buffer')
const { fetch, Headers, FormData, Request, Response } = require('undici')
 
Object.defineProperties(globalThis, {
  fetch: { value: fetch, writable: true },
  Blob: { value: Blob },
  File: { value: File },
  Headers: { value: Headers },
  FormData: { value: FormData },
  Request: { value: Request },
  Response: { value: Response },
})

确保安装 undici。它是 Node.js 中的官方获取实现。

¥Make sure to install undici. It’s the official fetch implementation in Node.js.

然后,将 jest.config.js 中的 setupFiles 选项设置为指向新创建的 jest.polyfills.js:

¥Then, set the setupFiles option in jest.config.js to point to the newly created jest.polyfills.js:

// jest.config.js
module.exports = {
  setupFiles: ['./jest.polyfills.js'],
}

如果你发现此设置很麻烦,请考虑迁移到现代测试框架,例如 Vitest,它没有任何 Node.js 全局变量问题并提供开箱即用的原生 ESM 支持。

¥If you find this setup cumbersome, consider migrating to a modern testing framework, like Vitest, which has none of the Node.js globals issues and provides native ESM support out of the box.

找不到模块 ‘msw/node’ (JSDOM)

¥Cannot find module ‘msw/node’ (JSDOM)

此错误由你的测试运行器抛出,因为 JSDOM 默认使用 browser 导出条件。这意味着当你导入任何第三方包(如 MSW)时,JSDOM 会强制将其 browser 导出用作入口点。这是不正确且危险的,因为 JSDOM 仍在 Node.js 中运行,并且无法保证完全的浏览器兼容性。

¥This error is thrown by your test runner because JSDOM uses the browser export condition by default. This means that when you import any third-party packages, like MSW, JSDOM forces its browser export to be used as the entrypoint. This is incorrect and dangerous because JSDOM still runs in Node.js and cannot guarantee full browser compatibility by design.

要解决此问题,请将 jest.config.js 中的 testEnvironmentOptions.customExportConditions 选项设置为 ['']

¥To fix this, set the testEnvironmentOptions.customExportConditions option in your jest.config.js to ['']:

// jest.config.js
module.exports = {
  testEnvironmentOptions: {
    customExportConditions: [''],
  },
}

这将强制 JSDOM 在导入 msw/node 时使用默认导出条件,从而实现正确的导入。

¥This will force JSDOM to use the default export condition when importing msw/node, resulting in correct imports.

multipart/form-data is not supported Node.js 中的错误

¥multipart/form-data is not supported Error in Node.js

早期版本的 Node.js,如 v18.8.0,没有对 request.formData() 的官方支持。请升级到最新的 Node.js 18.x,其中已添加此类支持。

¥Earlier versions of Node.js, like v18.8.0, didn’t have official support for request.formData(). Please upgrade to the latest Node.js 18.x where such a support has been added.

需要帮助吗?

¥Need help?

这是一个巨大的变化。如果你在迁移过程中感到迷茫或有疑问,请随时通过 Discord 联系我们。

¥This is a massive change. If you ever feel lost or have questions while migrating, don’t hesitate to reach out to use on Discord.

MSW Discord

Official MSW Discord server.