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

測試 Relay 元件

摘要

本文檔的目的是涵蓋用於測試 Relay 元件的 Relay API。

內容主要集中在 jest 單元測試(測試個別元件)和整合測試(測試元件的組合)。但這些測試工具可能應用於不同的情況:螢幕截圖測試、生產環境冒煙測試、「Redbox」測試、模糊測試、端對端測試等。

編寫 jest 測試的好處是什麼

  • 總體而言,它提高了系統的穩定性。Flow 有助於捕捉各種 JavaScript 錯誤,但仍有可能將回歸引入元件。單元測試有助於發現、重現和修復回歸,並防止它們在未來發生。
  • 它簡化了重構過程:當編寫正確(測試公開介面,而不是實作)時,測試有助於更改元件的內部實作。
  • 它可以加速並改善開發工作流程。有些人可能會稱之為測試驅動開發(TDD)。但本質上,它只是為元件的公開介面編寫測試,然後編寫實作這些介面的元件。Jest 的 —watch 模式在這方面表現出色。
  • 它將簡化新開發人員的入門過程。進行測試有助於新開發人員快速了解新的程式碼庫,使他們能夠修復錯誤並交付功能。

要注意的一點:雖然 jest 單元和整合測試有助於提高系統的穩定性,但它們應被視為具有多層自動化測試的更大穩定性基礎結構的一部分:flow、e2e、螢幕截圖、「Redbox」、效能測試。

使用 Relay 進行測試

由於額外的資料提取層包裹著實際的產品程式碼,因此測試使用 Relay 的應用程式可能具有挑戰性。

而且,要了解 Relay 背後發生的所有過程的機制,以及如何正確處理與框架的互動,並不容易。

幸運的是,有一些工具旨在簡化為 Relay 元件編寫測試的過程,方法是提供用於控制請求/回應流程的命令式 API,以及用於模擬資料產生的額外 API。

您可以在測試中使用兩個主要的 Relay 模組

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

使用 createMockEnvironment,您將能夠建立 RelayMockEnvironment 的實例,這是一個專門用於您的測試的 Relay 環境。由 createMockEnvironment 建立的實例實作了 Relay 環境介面,並且它還具有額外的模擬層,其中包含允許您解析/拒絕和控制操作(查詢/變更/訂閱)流程的方法。

MockPayloadGenerator 的主要目的是改善為測試元件建立和維護模擬資料的過程。

您可能會在 Relay 元件的測試中看到的一種模式:95% 的測試程式碼是測試準備工作,也就是具有虛擬資料、手動建立的龐大模擬物件,或者只是需要作為網路回應傳遞的範例伺服器回應的副本。剩下的 5% 是實際的測試程式碼。結果,人們沒有測試太多。為不同的情況建立和管理所有這些虛擬酬載很困難。因此,編寫測試既耗時,而且有時很難維護。

透過 MockPayloadGenerator@relay_test_operation,我們希望擺脫這種模式,並將開發人員的注意力從測試的準備轉移到實際測試。

使用 React 和 Relay 進行測試

React Testing Library 是一組協助程式,可讓您測試 React 元件,而無需依賴其實作細節。這種方法使重構變得輕而易舉,也促使您採用可存取性的最佳實務。儘管它沒有提供一種「淺層」呈現元件而不呈現其子項的方法,但像 Jest 這樣的測試執行器可讓您透過模擬來執行此操作。

RelayMockEnvironment API 概述

RelayMockEnvironment 是 Relay 環境的一個特殊版本,它具有用於控制操作流程的其他 API 方法:解析和拒絕操作、為訂閱提供增量酬載、處理快取。

  • 用於尋找在環境中執行的操作的方法
    • getAllOperations() - 取得目前時間測試期間執行的所有操作
    • findOperation(findFn => boolean) - 在所有已執行操作的清單中尋找特定的操作,如果操作不可用,此方法將拋出例外。當同時執行多個操作時,可能用於尋找特定操作
    • getMostRecentOperation() - 傳回最近的操作,如果在此呼叫之前沒有執行任何操作,此方法將拋出例外。
  • 用於解析或拒絕操作的方法
    • nextValue(request | operation, data) - 為操作 (request) 提供酬載,但不是完成請求。在測試增量更新和訂閱時非常有用
    • complete(request | operation) - 完成操作,完成操作後,此操作不再預期有更多酬載。
    • resolve(request | operation, data) - 使用提供的 GraphQL 回應解析請求。本質上,它是 nextValue(...) 和 complete(...)
    • reject(request | operation, error) - 使用特定錯誤拒絕請求
    • resolveMostRecentOperation(operation => data) - 解析和 getMostRecentOperation 一起使用
    • rejectMostRecentOperation(operation => error) - 拒絕和 getMostRecentOperation 一起使用
    • queueOperationResolver(operation => data | error) - 將 OperationResolver 函數新增至佇列。傳遞的解析器將用於在操作出現時解析/拒絕操作
    • queuePendingOperation(query, variables) - 為了使 usePreloadedQuery hook 不會暫停,必須呼叫這些函數
      • 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 可以修改產生的資料 - 模擬解析器。使用模擬解析器,您可以根據您的需求調整資料。模擬解析器定義為一個物件,其中 key 是 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...",
},
};
},
}

模擬解析器內容

MockResolver 的第一個引數是包含模擬解析器內容的物件。可以根據內容從模擬解析器傳回動態值,例如欄位的名稱或別名、選取中的路徑、引數或父類型。

{
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()}`;
},
}

Float、Integer、Boolean 等...

請注意,對於生產查詢,我們沒有純量欄位(如 Boolean、Integer、Float)的完整類型資訊。在 MockResolvers 中,它們對應到字串。您可以使用 context 來根據欄位名稱、別名等調整傳回值。

@relay_test_operation

在 Relay 執行期間,選取中特定欄位的大部分 GraphQL 類型資訊都不可用。預設情況下,Relay 無法取得選取中純量欄位的類型資訊,或物件的介面類型。

使用 @relay_test_operation 指令的操作將會包含額外的元數據,其中包含操作中選取欄位的 GraphQL 類型資訊。這將會提升產生資料的品質。您也可以為純量類型 (不僅限於 ID 和 String) 以及抽象類型定義 Mock 解析器。

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

範例

Relay 元件測試

使用 createMockEnvironmentMockPayloadGenerator 可以為使用 Relay hooks 的元件撰寫簡潔的測試。這兩個模組都可以從 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 片段的查詢產生資料時,您可能也想要產生延遲的資料。為此,您可以傳遞 generateDeferredPayload 選項,使用 MockPayloadGenerator.generateWithDefer

// 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、Containers 或 Renderers 測試元件。當撰寫包含 usePreloadedQuery hook 的測試時,請同時參閱上面的 queuePendingOperation 注意事項。

toMatchSnapshot(...)

即使在這裡的所有範例中您都可以看到使用 toMatchSnapshot() 的斷言,我們這樣做只是為了使範例簡潔。但這並不是測試元件的建議方法。

更多範例

範例測試的最佳來源在 relay-experimental 套件中。

測試是好的。您絕對應該這樣做。


這個頁面有用嗎?

透過以下方式幫助我們讓網站變得更好 回答幾個快速問題.