跳至主要内容
版本:v18.0.0

测试中继组件

摘要

本文档的目的是介绍用于测试中继组件的 Relay API。

本文档内容主要侧重于 jest 单元测试(测试单个组件)和集成测试(测试组件组合)。但这些测试工具可以应用于不同的场景:截图测试、生产冒烟测试、Redbox 测试、模糊测试、e2e 测试等。

编写 jest 测试有哪些好处

  • 总的来说,它提高了系统的稳定性。Flow 帮助捕获各种 Javascript 错误,但仍然可能给组件引入回归。单元测试有助于发现、重现和修复回归,并防止未来出现回归。
  • 它简化了重构过程:当编写得当(测试公共接口,而不是实现)时,测试有助于更改组件的内部实现。
  • 它可以加快和改进开发工作流程。有些人可能称之为测试驱动开发(TM)。但本质上,它只是为组件的公共接口编写测试,然后编写实现这些接口的组件。Jest 的 watch 模式在这种情况下非常出色。
  • 它将简化新开发人员的入职流程。拥有测试有助于新开发人员快速了解新代码库,使他们能够修复错误并交付功能。

需要注意的是:虽然 jest 单元测试和集成测试将有助于提高系统的稳定性,但它们应被视为具有多层自动化测试的更大稳定性基础设施的一部分:Flow、e2e、截图、Redbox、性能测试。

使用中继进行测试

测试使用中继的应用程序可能具有挑战性,因为在实际产品代码之上添加了额外的取数据层。

而且,并非总是容易理解中继背后发生的所有流程的机制,以及如何正确处理与框架的交互。

幸运的是,有一些工具旨在简化为中继组件编写测试的过程,通过提供用于控制请求/响应流的命令式 API 以及用于模拟数据生成的附加 API。

您可以在测试中使用两个主要的中继模块

  • createMockEnvironment(options): RelayMockEnvironment
  • MockPayloadGenerator@relay_test_operation 指令

使用 createMockEnvironment,您将能够创建 RelayMockEnvironment 的实例,这是一个专门用于测试的中继环境。由 createMockEnvironment 创建的实例实现了中继环境接口,并且它还具有一个附加的模拟层,其中包含允许您解析/拒绝和控制操作流(查询/变异/订阅)的方法。

MockPayloadGenerator 的主要目的是改进为测试组件创建和维护模拟数据的过程。

您可能在中继组件测试中看到的一种模式:95% 的测试代码是测试准备——巨大的模拟对象,包含虚拟数据,手动创建,或者只是需要作为网络响应传递的示例服务器响应的副本。其余 5% 是实际的测试代码。结果,人们不会进行太多测试。创建和管理所有这些用于不同用例的虚拟负载很困难。因此,编写测试非常耗时,而且测试有时很难维护。

使用 MockPayloadGenerator@relay_test_operation,我们希望摆脱这种模式,并将开发人员的注意力从测试准备转移到实际测试。

使用 React 和中继进行测试

React 测试库 是一组助手,允许您测试 React 组件而不依赖于它们的实现细节。这种方法使重构变得轻而易举,并且还会引导您采用最佳实践来提高可访问性。尽管它没有提供在不包含子组件的情况下“浅渲染”组件的方法,但像 Jest 这样的测试运行程序允许您通过模拟来实现这一点。

RelayMockEnvironment API 概述

RelayMockEnvironment 是 Relay 环境的特殊版本,它具有用于控制操作流的附加 API 方法:解析和拒绝操作、为订阅提供增量有效负载、使用缓存。

  • 用于查找在环境上执行的操作的方法
    • getAllOperations() - 获取当前时间之前测试期间执行的所有操作
    • findOperation(findFn => boolean) - 在所有已执行的操作列表中查找特定操作,此方法将在操作不可用时抛出异常。可能在同时执行多个操作时用于查找特定操作
    • getMostRecentOperation() - 返回最近的操作,如果在此调用之前未执行任何操作,此方法将抛出异常。
  • 用于解析或拒绝操作的方法
    • nextValue(request | operation, data) - 为操作(请求)提供有效负载,但不是完整的请求。在测试增量更新和订阅时非常有用
    • complete(request | operation) - 完成操作,此操作不再需要其他有效负载,完成时。
    • resolve(request | operation, data) - 使用提供的 GraphQL 响应解析请求。本质上,它是 nextValue(...) 和 complete(...)
    • reject(request | operation, error) - 使用特定错误拒绝请求
    • resolveMostRecentOperation(operation => data) - resolve 和 getMostRecentOperation 协同工作
    • rejectMostRecentOperation(operation => error) - reject 和 getMostRecentOperation 协同工作
    • queueOperationResolver(operation => data | error) - 将 OperationResolver 函数添加到队列中。传递的解析器将用于解析/拒绝出现的操作
    • queuePendingOperation(query, variables) - 为了使 usePreloadedQuery 钩子不挂起,必须调用这些函数
      • queueOperationResolver(resolver)
      • queuePendingOperation(query, variables)
      • preloadQuery(mockEnvironment, query, variables) 使用传递给 queuePendingOperation 的相同 queryvariablespreloadQuery 必须在 queuePendingOperation 之后调用。
  • 其他实用方法
    • isLoading(request | operation) - 如果操作尚未完成,将返回 true
    • cachePayload(request | operation, variables, payload) - 将有效负载添加到 QueryResponse 缓存中
    • clearCache() - 将清除 QueryResponse 缓存

模拟负载生成器和 @relay_test_operation 指令

MockPayloadGenerator 可以极大地简化为您测试创建和维护模拟数据的过程。MockPayloadGenerator 可以为操作中的选择生成虚拟数据。有一个 API 用于修改生成的数据——模拟解析器。使用模拟解析器,您可以根据需要调整数据。模拟解析器定义为一个对象,其中**键是 GraphQL 类型(IDStringUserComment 等)的名称**,值是返回该类型默认数据的函数。

简单模拟解析器示例

{
ID() {
// Return mock value for a scalar filed with type ID
return 'my-id';
},
String() {
// Every scalar field with type String will have this default value
return "Lorem Ipsum"
}
}

可以为对象类型定义更多解析器

{
// This will be the default values for User object in the query response
User() {
return {
id: 4,
name: "Mark",
profile_picture: {
uri: "http://my-image...",
},
};
},
}

模拟解析器上下文

模拟解析器的第一个参数是一个包含模拟解析器上下文的对象。可以根据上下文返回模拟解析器的动态值——例如,字段的名称或别名、选择中的路径、参数或父类型。

{
String(context) {
if (context.name === 'zip') {
return '94025';
}
if (context.path != null && context.path.join('.') === 'node.actor.name') {
return 'Current Actor Name';
}
if (context.parentType === 'Image' && context.name === 'uri') {
return 'http://my-image.url';
}
}
}

ID 生成

模拟解析器的第二个参数是一个函数,该函数将生成一系列整数,这对于在测试中生成唯一 ID 很有用

{
// will generate strings "my-id-1", "my-id-2", etc.
ID(_, generateId) {
return `my-id-${generateId()}`;
},
}

浮点数、整数、布尔值等...

请注意,对于生产查询,我们没有标量字段的完整类型信息——比如布尔值、整数、浮点数。在模拟解析器中,它们映射到字符串。您可以使用 context 根据字段名称、别名等调整返回值。

@relay_test_operation

在 Relay 运行时,大多数 GraphQL 类型信息不可用于选择中的特定字段。默认情况下,Relay 无法获取选择中标量字段的类型信息,或对象的接口类型。

使用 @relay_test_operation 指令的操作将具有包含操作选择中字段的 GraphQL 类型信息的附加元数据。它将提高生成数据的质量。您还可以为标量(不仅仅是 ID 和 String)和抽象类型定义模拟解析器

{
Float() {
return 123.456;
},
Boolean(context) {
if (context.name === 'can_edit') {
return true;
}
return false;
},
Node() {
return {
__typename: 'User',
id: 'my-user-id',
};
}
}

示例

中继组件测试

使用 createMockEnvironmentMockPayloadGenerator 允许为使用中继钩子的组件编写简洁的测试。这两个模块都可以从 relay-test-utils 中导入

// Say you have a component with the useLazyLoadQuery or a QueryRenderer
const MyAwesomeViewRoot = require('MyAwesomeViewRoot');
const {
createMockEnvironment,
MockPayloadGenerator,
} = require('relay-test-utils');
const {act, render} = require('@testing-library/react');

// Relay may trigger 3 different states
// for this component: Loading, Error, Data Loaded
// Here is examples of tests for those states.
test('Loading State', async () => {
const environment = createMockEnvironment();
const renderer = render(
<MyAwesomeViewRoot environment={environment} />,
);

// Here we just verify that the spinner is rendered
expect(await renderer.findByTestId('spinner')).toBeDefined();
});

test('Data Render', async () => {
const environment = createMockEnvironment();
const renderer = render(
<MyAwesomeViewRoot environment={environment} />,
);

// Wrapping in act will ensure that components
// are fully updated to their final state.
act(() => {
environment.mock.resolveMostRecentOperation(operation =>
MockPayloadGenerator.generate(operation),
);
});

// At this point operation will be resolved
// and the data for a query will be available in the store
expect(await renderer.findByTestId('myButton')).toBeDefined();
});

test('Error State', async () => {
const environment = createMockEnvironment();
const renderer = render(
<MyAwesomeViewRoot environment={environment} />,
);

// Wrapping in act will ensure that components
// are fully updated to their final state.
act(() => {
// Error can be simulated with `rejectMostRecentOperation`
environment.mock.rejectMostRecentOperation(new Error('Uh-oh'));
});

expect(await renderer.findByTestId('errorMessage')).toBeDefined();
});

使用延迟片段的组件测试

当使用 MockPayloadGenerator 为具有带有 @defer 的片段的查询生成数据时,您可能也希望生成延迟数据。为此,您可以使用 MockPayloadGenerator.generateWithDefer 并传递 generateDeferredPayload 选项

// Say you have a component with useFragment
const ChildComponent = (props: {user: ChildComponentFragment_user$key}) => {
const data = useFragment(graphql`
fragment ChildComponentFragment_user on User {
name
}
`, props.user);
return <View>{data?.name}</View>;
};

// Say you have a parent component that fetches data with useLazyLoadQuery and `@defer`s the data for the ChildComponent.
const ParentComponent = () => {
const data = useLazyLoadQuery(graphql`
query ParentComponentQuery {
user {
id
...ChildComponentFragment_user @defer
}
}
`, {});
return (
<View>
{id}
<Suspense fallback={null}>
{data?.user && <ChildComponent user={data.user} />}
</Suspense>
</View>
);
};

const {
createMockEnvironment,
MockPayloadGenerator,
} = require('relay-test-utils');
const {act, render} = require('@testing-library/react');

test('Data Render with @defer', () => {
const environment = createMockEnvironment();
const renderer = render(
<RelayEnvironmentProvider environment={environment}>
<ParentComponent />,
</RelayEnvironmentProvider>
);

// Wrapping in ReactTestRenderer.act will ensure that components
// are fully updated to their final state.
act(() => {
const operation = environment.mock.getMostRecentOperation();
const mockData = MockPayloadGenerator.generateWithDefer(operation, null, {generateDeferredPayload: true});
environment.mock.resolve(mockData);

// You may need this to make sure all payloads are retrieved
jest.runAllTimers();
});

// At this point operation will be resolved
// and the data for a query will be available in the store
expect(renderer.container.textContent).toEqual(['id', 'name']);
});

片段组件测试

从本质上讲,在上面的示例中,resolveMostRecentOperation 将为所有子片段容器(分页、重新获取)生成数据。但是,通常根组件可能有很多子片段组件,您可能希望练习使用 useFragment 的特定组件。解决此问题的方案是用 useLazyLoadQuery 组件包装您的片段容器,该组件呈现一个查询,该查询从您的片段组件中扩展片段

test('Fragment', () => {
const environment = createMockEnvironment();
const TestRenderer = () => {
const data = useLazyLoadQuery(
graphql`
query TestQuery @relay_test_operation {
myData: node(id: "test-id") {
# Spread the fragment you want to test here
...MyFragment
}
}
`,
{},
);
return <MyFragmentComponent myData={data.myData} />
};

const renderer = render(
<RelayEnvironmentProvider environment={environment}>
<Suspense fallback="Loading...">
<TestRenderer />
</Suspense>
</RelayEnvironmentProvider>
);

// Wrapping in act will ensure that components
// are fully updated to their final state.
act(() => {
environment.mock.resolveMostRecentOperation(operation =>
MockPayloadGenerator.generate(operation),
);
});

expect(renderer).toMatchSnapshot();
});

分页组件测试

从本质上讲,对分页组件(例如使用 usePaginationFragment)的测试与片段组件测试没有区别。但在这里我们可以做更多的事情,我们可以实际看到分页是如何工作的 - 我们可以在执行分页(加载更多、重新获取)时断言组件的行为。

// Pagination Example
test('`Pagination` Container', async () => {
const environment = createMockEnvironment();
const TestRenderer = () => {
const data = useLazyLoadQuery(
graphql`
query TestQuery @relay_test_operation {
myConnection: node(id: "test-id") {
connection {
# Spread the pagination fragment you want to test here
...MyConnectionFragment
}
}
}
`,
{},
);
return <MyPaginationContainer connection={data.myConnection.connection} />
};

const renderer = render(
<RelayEnvironmentProvider environment={environment}>
<Suspense fallback="Loading...">
<TestRenderer />
</Suspense>
</RelayEnvironmentProvider>
);

// Wrapping in act will ensure that components
// are fully updated to their final state.
act(() => {
environment.mock.resolveMostRecentOperation(operation =>
MockPayloadGenerator.generate(operation, {
ID(_, generateId) {
// Why we're doing this?
// To make sure that we will generate a different set of ID
// for elements on first page and the second page.
return `first-page-id-${generateId()}`;
},
PageInfo() {
return {
has_next_page: true,
};
},
}),
);
});

// Let's find a `loadMore` button and click on it to initiate pagination request, for example
const loadMore = await renderer.findByTestId('loadMore');
expect(loadMore.props.disabled).toBe(false);
loadMore.props.onClick();

// Wrapping in act will ensure that components
// are fully updated to their final state.
act(() => {
environment.mock.resolveMostRecentOperation(operation =>
MockPayloadGenerator.generate(operation, {
ID(_, generateId) {
// See, the second page IDs will be different
return `second-page-id-${generateId()}`;
},
PageInfo() {
return {
// And the button should be disabled, now. Probably.
has_next_page: false,
};
},
}),
);
});

expect(loadMore.props.disabled).toBe(true);
});

重新获取组件

在这里,我们可以使用类似的方法,将组件包装在一个查询中。为了完整起见,我们将在下面添加一个示例

test('Refetch Container', async () => {
const environment = createMockEnvironment();
const TestRenderer = () => {
const data = useLazyLoadQuery(
graphql`
query TestQuery @relay_test_operation {
myData: node(id: "test-id") {
# Spread the pagination fragment you want to test here
...MyRefetchableFragment
}
}
`,
{},
);
return <MyRefetchContainer data={data.myData} />
};

const renderer = render(
<RelayEnvironmentProvider environment={environment}>
<Suspense fallback="Loading...">
<TestRenderer />
</Suspense>
</RelayEnvironmentProvider>
);

act(() => {
environment.mock.resolveMostRecentOperation(operation =>
MockPayloadGenerator.generate(operation),
);
});

// Assuming we have refetch button in the Container
const refetchButton = await renderer.findByTestId('refetch');

// This should trigger the `refetch`
refetchButton.props.onClick();

act(() => {
environment.mock.resolveMostRecentOperation(operation =>
MockPayloadGenerator.generate(operation, {
// We can customize mock resolvers, to change the output of the refetch query
}),
);
});

expect(renderer).toMatchSnapshot();
});

变异

变异本身是操作,因此我们可以独立地测试它们(单元测试)以获取特定的变异,或者与调用此变异的视图结合起来。

注意

useMutation API 比直接调用 commitMutation 有所改进。

// Say, you have a mutation function
function sendMutation(environment, onCompleted, onError, variables)
commitMutation(environment, {
mutation: graphql`...`,
onCompleted,
onError,
variables,
});
}

// Example test may be written like so
test('it should send mutation', () => {
const environment = createMockEnvironment();
const onCompleted = jest.fn();
sendMutation(environment, onCompleted, jest.fn(), {});
const operation = environment.mock.getMostRecentOperation();

act(() => {
environment.mock.resolve(
operation,
MockPayloadGenerator.generate(operation)
);
});

expect(onCompleted).toBeCalled();
});

订阅

useSubscription API 比直接调用 requestSubscription 有所改进。

我们可以像测试变异一样测试订阅。

// Example subscribe function
function subscribe(environment, onNext, onError, variables)
requestSubscription(environment, {
subscription: graphql`...`,
onNext,
onError,
variables,
});
}

// Example test may be written like so
test('it should subscribe', () => {
const environment = createMockEnvironment();
const onNext = jest.fn();
subscribe(environment, onNext, jest.fn(), {});
const operation = environment.mock.getMostRecentOperation();

act(() => {
environment.mock.nextValue(
operation,
MockPayloadGenerator.generate(operation)
);
});

expect(onNext).toBeCalled();
});

使用 queueOperationResolver 的示例

使用 queueOperationResolver,可以为将在环境上执行的操作定义响应

// Say you have a component with the QueryRenderer
const MyAwesomeViewRoot = require('MyAwesomeViewRoot');
const {
createMockEnvironment,
MockPayloadGenerator,
} = require('relay-test-utils');

test('Data Render', async () => {
const environment = createMockEnvironment();
environment.mock.queueOperationResolver(operation =>
MockPayloadGenerator.generate(operation),
);

const renderer = render(
<MyAwesomeViewRoot environment={environment} />,
);

// At this point operation will be resolved
// and the data for a query will be available in the store
expect(await renderer.findByTestId('myButton')).toBeDefined();
});

test('Error State', async () => {
const environment = createMockEnvironment();
environment.mock.queueOperationResolver(() =>
new Error('Uh-oh'),
);
const renderer = render(
<MyAwesomeViewRoot environment={environment} />,
);

expect(await renderer.findByTestId('myButton')).toBeDefined();
});

使用 Relay Hooks

本指南中的示例应该适用于使用 Relay Hooks、容器或渲染器的组件测试。在编写涉及 usePreloadedQuery 挂钩的测试时,请参阅上面的 queuePendingOperation 说明。

toMatchSnapshot(...)

即使在所有示例中,您都可以看到使用 toMatchSnapshot() 进行断言,但我们保留这种方式只是为了使示例简明扼要。但这并不是测试组件的推荐方法。

更多示例

示例测试的最佳来源是 relay-experimental 包 中。

测试很重要。您应该一定要进行测试。


此页面有用吗?

通过 回答几个简短的问题.