基于架构的测试
使用 createTestSchema 和相关 API
本文描述了使用 v3.10 中发布的实验性测试实用工具编写集成测试的最佳实践。这些测试工具允许开发人员对配置了模拟解析器和默认标量值的架构执行查询,以测试整个 Apollo 客户端应用程序,包括 UI 到网络层再到 UI 的整个过程。解析器 和默认标量值,以便测试整个Apollo 客户端应用程序,包括链接链.
指导原则
Kent C. Dodds 说得好:
你的测试越接近你的软件的使用方式,它就能给你提供越多的信心。
当涉及到测试使用 Apollo 客户端构建的应用程序时,这意味着验证用户请求将从 UI 到网络层再到 UI 的代码路径。
使用 MockedProvider 进行的单元样式测试可以用于测试单个组件,甚至整个页面或 React 子树,通过模拟预期的单个操作响应数据。然而,测试您组件与网络层之间的集成同样重要。这就是 schema-driven 测试发挥作用的地方。MockedProvider
。但是,也要测试您的组件与网络层的集成。这就是 schema-driven 测试的作用。
本页深受出色的 Redux 文档的启发;同样的原则也适用于 Apollo Client。
createTestSchema
和 createSchemaFetch
安装
首先,请确保您已安装 Apollo Client v3.10 或更高版本。然后,安装以下并行依赖项:
npm i @graphql-tools/merge @graphql-tools/schema @graphql-tools/utils undici --save-dev
考虑一个使用 GraphQL 服务器获取产品列表的 React 应用程序:
现在,让我们使用createTestSchema
实用工具创建的测试架构来编写一些测试,这个架构可以与createSchemaFetch
一同使用,以创建模拟的fetch实现。
配置测试环境
首先,为了使JSDOM测试能够正确运行,需要为一些Node.js全局变量进行polyfill。创建一个名为,例如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.*/const { TextDecoder, TextEncoder } = require("node:util");const { ReadableStream } = require("node:stream/web");const { clearImmediate } = require("node:timers");const { performance } = require("node:perf_hooks");Object.defineProperties(globalThis, {TextDecoder: { value: TextDecoder },TextEncoder: { value: TextEncoder },ReadableStream: { value: ReadableStream },performance: { value: performance },clearImmediate: { value: clearImmediate },});const { Blob, File } = require("node:buffer");const { fetch, Headers, FormData, Request, Response } = require("undici");Object.defineProperties(globalThis, {fetch: { value: fetch, writable: true },Response: { value: Response },Blob: { value: Blob },File: { value: File },Headers: { value: Headers },FormData: { value: FormData },Request: { value: Request },});// Note: if your environment supports it, you can use the `using` keyword// but must polyfill Symbol.dispose here with Jest versions <= 29// where Symbol.dispose is not defined//// Jest bug: https://github.com/jestjs/jest/issues/14874// Fix is available in https://github.com/jestjs/jest/releases/tag/v30.0.0-alpha.3if (!Symbol.dispose) {Object.defineProperty(Symbol, "dispose", {value: Symbol("dispose"),});}if (!Symbol.asyncDispose) {Object.defineProperty(Symbol, "asyncDispose", {value: Symbol("asyncDispose"),});}
现在,在jest.config.ts
或jest.config.js
文件中,添加以下配置:
import type { Config } from "jest";const config: Config = {globals: {"globalThis.__DEV__": JSON.stringify(true),},testEnvironment: "jsdom",setupFiles: ["./jest.polyfills.js"],// You may also have an e.g. setupTests.ts file heresetupFilesAfterEnv: ["<rootDir>/setupTests.ts"],// If you're using MSW, opt out of the browser export condition for MSW tests// For more information, see: https://github.com/mswjs/msw/issues/1786#issuecomment-1782559851testEnvironmentOptions: {customExportConditions: [""],},// If you plan on importing .gql/.graphql files in your tests, transform them with @graphql-tools/jest-transformtransform: {"\\.(gql|graphql)$": "@graphql-tools/jest-transform",},};export default config;
下述示例文件setupTests.ts
中,导入了@testing-library/jest-dom
以允许使用自定义的jest-dom
匹配器(更多信息请参阅@testing-library/jest-dom
)并禁用了fragment警告,这些警告可能会污染测试输出:
import "@testing-library/jest-dom";import { gql } from "@apollo/client";gql.disableFragmentWarnings();
使用MSW进行测试
现在,让我们使用MSW编写对Products
组件的测试。
MSW是一个强大的拦截网络流量并模拟响应的工具。更多关于其设计和理念请参阅这里。
MSW有处理程序的概念,允许拦截网络请求。让我们创建一个处理程序来拦截所有GraphQL操作:
import { graphql, HttpResponse } from "msw";import { execute } from "graphql";import type { ExecutionResult } from "graphql";import type { ObjMap } from "graphql/jsutils/ObjMap";import { gql } from "@apollo/client";import { createTestSchema } from "@apollo/client/testing/experimental";import { makeExecutableSchema } from "@graphql-tools/schema";import graphqlSchema from "../../../schema.graphql";// First, create a static schema...const staticSchema = makeExecutableSchema({ typeDefs: graphqlSchema });// ...which is then passed as the first argument to `createTestSchema`// along with mock resolvers and default scalar values.export let testSchema = createTestSchema(staticSchema, {resolvers: {Query: {products: () => [{id: "1",title: "Blue Jays Hat",},],},},scalars: {Int: () => 6,Float: () => 22.1,String: () => "string",},});export const handlers = [// Intercept all GraphQL operations and return a response generated by the// test schema. Add additional handlers as needed.graphql.operation<ExecutionResult<ObjMap<unknown>, ObjMap<unknown>>>(async ({ query, variables, operationName }) => {const document = gql(query);const result = await execute({document,operationName,schema: testSchema,variableValues: variables,});return HttpResponse.json(result);}),];
MSW可用于浏览器中,在Node.js和React Native中。由于此示例使用Jest和JSDOM在Node.js环境中运行测试,让我们根据Node.js集成指南进行服务器配置:
import { setupServer } from "msw/node";import { handlers } from "./handlers";// This configures a request mocking server with the given request handlers.export const server = setupServer(...handlers);
最后,在之前的步骤中创建的setupTests.ts
文件中进行服务器搭建和解拆:
import "@testing-library/jest-dom";import { gql } from "@apollo/client";gql.disableFragmentWarnings();beforeAll(() => server.listen({ onUnhandledRequest: "error" }));afterAll(() => server.close());afterEach(() => server.resetHandlers());
最后,让我们编写一些测试 🎉
import { Suspense } from "react";import { render as rtlRender, screen } from "@testing-library/react";import {ApolloClient,ApolloProvider,NormalizedCacheObject,} from "@apollo/client";import { testSchema } from "./handlers";import { Products } from "../products";// This should be a function that returns a new ApolloClient instance// configured just like your production Apollo Client instance - see the FAQ.import { makeClient } from "../client";const render = (renderedClient: ApolloClient<NormalizedCacheObject>) =>rtlRender(<ApolloProvider client={renderedClient}><Suspense fallback="Loading..."><Products /></Suspense></ApolloProvider>);describe("Products", () => {test("renders", async () => {render(makeClient());await screen.findByText("Loading...");// This is the data from our initial mock resolver in the test schema// defined in the handlers file 🎉expect(await screen.findByText(/blue jays hat/i)).toBeInTheDocument();});test("allows resolvers to be updated via .add", async () => {// Calling .add on the test schema will update the resolvers// with new datatestSchema.add({resolvers: {Query: {products: () => {return [{id: "2",title: "Mets Hat",},];},},},});render(makeClient());await screen.findByText("Loading...");// Our component now renders the new data from the updated resolverawait screen.findByText(/mets hat/i);});test("handles test schema resetting via .reset", async () => {// Calling .reset on the test schema will reset the resolverstestSchema.reset();render(makeClient());await screen.findByText("Loading...");// The component now renders the initial data configured on the test schemaawait screen.findByText(/blue jays hat/i);});});
使用createSchemaFetch
模拟fetch进行测试
首先,从新的@apollo/client/testing
入口点导入createSchemaFetch
和createTestSchema
。接下来,导入你本地图示的方案:Jest应该配置为将.gql
或.graphql
文件转换为@graphql-tools/jest-transform
(请参阅上面的jest.config.ts
示例配置。)
以下是测试文件最初的可能设置
import {createSchemaFetch,createTestSchema,} from "@apollo/client/testing/experimental";import { makeExecutableSchema } from "@graphql-tools/schema";import { render as rtlRender, screen } from "@testing-library/react";import graphqlSchema from "../../../schema.graphql";// This should be a function that returns a new ApolloClient instance// configured just like your production Apollo Client instance - see the FAQ.import { makeClient } from "../../client";import { ApolloProvider, NormalizedCacheObject } from "@apollo/client";import { Products } from "../../products";import { Suspense } from "react";// First, let's create an executable schema...const staticSchema = makeExecutableSchema({ typeDefs: graphqlSchema });// which is then passed as the first argument to `createTestSchema`.const schema = createTestSchema(staticSchema, {// Next, let's define mock resolversresolvers: {Query: {products: () =>Array.from({ length: 5 }, (_element, id) => ({id,mediaUrl: `https://example.com/image${id}.jpg`,})),},},// ...and default scalar valuesscalars: {Int: () => 6,Float: () => 22.1,String: () => "default string",},});// This `render` helper function would typically be extracted and shared between// test files.const render = (renderedClient: ApolloClient<NormalizedCacheObject>) =>rtlRender(<ApolloProvider client={renderedClient}><Suspense fallback="Loading..."><Products /></Suspense></ApolloProvider>);
现在我们来写一些测试 🎉
首先, createSchemaFetch
可以使用来进行全局 fetch
的模拟,它能使用从测试模式生成的有效负载来解决网络请求。
describe("Products", () => {it("renders", async () => {using _fetch = createSchemaFetch(schema).mockGlobal();render(makeClient());await screen.findByText("Loading...");// title is rendering the default string scalarconst findAllByText = await screen.findAllByText(/default string/);expect(findAllByText).toHaveLength(5);// the products resolver is returning 5 productsawait screen.findByText(/0/);await screen.findByText(/1/);await screen.findByText(/2/);await screen.findByText(/3/);await screen.findByText(/4/);});});
关于使用
和显式资源管理的说明
您可能已经注意到上述测试第一行中有一个新的关键字: using
。
using
是提议的新语言功能的一部分,目前处于TC39提议过程第3阶段。
如果您使用TypeScript 5.2或更高版本,或使用Babel的@babel/plugin-proposal-explicit-resource-management
插件,您可以使用using
关键字在_fetch
超出作用域时自动执行一些清理。在我们的案例中,这是在测试完成后,这意味着在每次测试后自动将全局fetch
函数恢复到其原始状态。
如果您的环境不支持显式资源管理,您会发现调用mockGlobal()
会返回一个恢复函数,您可以在每个测试的末尾手动调用它:
describe("Products", () => {it("renders", async () => {const { restore } = createSchemaFetch(schema).mockGlobal();render(makeClient());// make assertions against the rendered DOM outputrestore();});});
使用testSchema.add
和testSchema.fork
修改测试模式
如果在模式创建后需要更改其行为,可以使用testSchema.add
方法向模式添加新的解析器或覆盖现有的解析器。这在测试场景中需要更改模式行为时非常有用。
describe("Products", () => {it("renders", async () => {const { restore } = createSchemaFetch(schema).mockGlobal();render(makeClient());// make assertions against the rendered DOM output// Here we want to change the return value of the `products` resolver// for the next outgoing query.testSchema.add({resolvers: {Query: {products: () =>Array.from({ length: 5 }, (_element, id) => ({// we want to return ids starting from 5 for the second requestid: id + 5,mediaUrl: `https://example.com/image${id + 5}.jpg`,})),},},});// trigger a new query with a user interactionuserEvent.click(screen.getByText("Fetch more"));// make assertions against the rendered DOM outputrestore();testSchema.reset();});});```Alternatively, you can use `testSchema.fork` to create a new schema with the same configuration as the original schema,but with the ability to make changes to the new isolated schema without affecting the original schema.This can be useful if you just want to mock the global fetch function with a different schema for each test withouthaving to care about resetting your original testSchema.You could also write incremental tests where each test builds on the previous one.If you use MSW, you will probably end up using `testSchema.add`, as MSW needs to be set up with a single schema for all tests.If you are going the `createSchemaFetch` route, you can use `testSchema.fork` to create a new schema for each test,and then use `forkedSchema.add` to make changes to the schema for that test.```tsxconst baseSchema = createTestSchema(staticSchema, {resolvers: {// ...},scalars: {// ...},});test("a test", () => {const forkedSchema = baseSchema.fork();const { restore } = createSchemaFetch(forkedSchema).mockGlobal();// make assertions against the rendered DOM outputforkedSchema.add({// ...});restore();// forkedSchema will just be discarded, and there is no need to reset it});
常见问题解答
我应该何时使用createSchemaFetch
而不是 MSW?
使用MSW有很多好处:它是一个功能强大的工具,具有一组优秀的API。更多关于其理念和好处 在此处。
在可能的情况下,使用MSW:它通过拦截请求来启用更真实的测试,可以捕获更多错误。应用将请求发出后。MSW还支持REST和GraphQL处理程序,因此如果您的应用程序使用了组合(例如从第三方端点获取数据),MSW将比createSchemaFetch
提供更大的灵活性,而createSchemaFetch
是一个更轻量级的解决方案。
测试之间应该共享同一个ApolloClient
实例吗?
不应该;请在每个测试中创建一个新的ApolloClient
实例。即使缓存在测试之间被重置,客户端仍然保持一些不会重置的内部状态。这可能会产生一些意想不到的后果。例如,ApolloClient
实例可能有挂起的查询,这可能会导致以下测试的查询默认进行去重。
相反,创建一个makeClient
函数或类似的工具,这样每个测试都可以使用与您的生产客户端相同的客户端配置,但没有任何两个测试共享相同的客户端实例。以下是一个例子:
这样,每个测试都可以使用makeClient
创建新的客户端实例,同时您仍然可以在您的生产代码中使用client
。
可以使用这些测试工具与Vitest一起使用吗?
遗憾的是,目前还不能。这是由于与graphql
包以及默认捆绑ESM的工具(称为双重包风险。
请参阅此问题以跟踪graphql/graphql-js
存储库中的相关讨论。
沙箱示例
要查看一个示例,演示如何使用Testing Library和Mock Service Worker通过createTestSchema
编写集成测试,请查看CodeSandbox上的此项目: