Testing Relay Components: From Unit to E2E Tests
Relay is a powerful GraphQL client for React applications, but its unique architecture — built around fragments, queries, and a central store — introduces specific challenges when it comes to testing. Because Relay components rely on a complex data-fetching layer, testing them effectively requires understanding both Relay's mental model and the tools the ecosystem provides. This tutorial walks you through the full testing pyramid for Relay applications, from isolated unit tests of fragment components all the way up to end-to-end tests that exercise your entire GraphQL stack.
Why Testing Relay Components Matters
Relay components are deceptively simple on the surface. A component might just render a few fields from a fragment, but behind the scenes, Relay is doing significant work: resolving fragments from the store, managing subscriptions, handling pagination, and coordinating mutations. Without proper tests, subtle bugs can slip through — a fragment might reference a field that no longer exists in the schema, a pagination component might break when the connection is empty, or a mutation updater might corrupt the store.
Testing Relay components gives you confidence that:
- Your fragments are valid and match the GraphQL schema
- Components render correctly for various data states, including loading and error states
- Mutations update the store as expected
- Pagination and refetch behaviors work correctly
- The full application flow works end to end against a real or mocked GraphQL server
Setting Up Your Testing Environment
Before diving into specific test types, you need a solid testing foundation. Most Relay projects use Jest as the test runner, along with React Testing Library for rendering components. You will also need Relay's own testing utilities, which ship with the relay-runtime and react-relay packages.
Install the necessary dependencies:
npm install --save-dev jest @testing-library/react @testing-library/jest-dom relay-test-utils
Next, configure your Jest setup. Create a setup file that imports Relay's test utilities and configures the environment:
// jest.setup.js
import '@testing-library/jest-dom';
import { createMockEnvironment } from 'relay-test-utils';
// Make createMockEnvironment available globally if needed
global.createMockEnvironment = createMockEnvironment;
Update your jest.config.js to use this setup file:
module.exports = {
preset: 'react',
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jsdom',
transform: {
'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
},
};
You also need to ensure the Relay compiler generates test artifacts. Run the Relay compiler in watch mode during development so that your __generated__ files are always up to date:
npx relay-compiler --watch
Unit Testing Fragment Components
The most common Relay component you will test is a fragment component — a component that declares a fragment and receives data via useFragment. The key to testing these components is the testRenderer utility from relay-test-utils, which lets you render a component with pre-generated mock data for its fragment.
Using the testRenderer Utility
The relay-test-utils package provides a testRenderer function that simplifies rendering fragment components in isolation. It handles wrapping the component in a RelayEnvironmentProvider and resolving the fragment with mock data.
Consider a simple UserProfile component:
// UserProfile.tsx
import { graphql, useFragment } from 'react-relay';
import type { UserProfile_user$key } from './__generated__/UserProfile_user.graphql';
export function UserProfile({ user }: { user: UserProfile_user$key }) {
const data = useFragment(
graphql`
fragment UserProfile_user on User {
id
name
email
avatarUrl
}
`,
user
);
return (
<div>
<img src={data.avatarUrl} alt={data.name} />
<h2>{data.name}</h2>
<p>{data.email}</p>
</div>
);
}
Here is how you would test this component using testRenderer:
// UserProfile.test.tsx
import { testRenderer } from 'relay-test-utils';
import { UserProfile } from './UserProfile';
describe('UserProfile', () => {
it('renders user information correctly', () => {
const { getByText, getByAltText } = testRenderer(
<UserProfile user={null} />,
{
user: {
id: 'user-1',
name: 'Jane Doe',
email: 'jane@example.com',
avatarUrl: 'https://example.com/avatar.png',
},
}
);
expect(getByText('Jane Doe')).toBeInTheDocument();
expect(getByText('jane@example.com')).toBeInTheDocument();
expect(getByAltText('Jane Doe')).toHaveAttribute(
'src',
'https://example.com/avatar.png'
);
});
it('renders without crashing when data is minimal', () => {
const { container } = testRenderer(
<UserProfile user={null} />,
{
user: {
id: 'user-2',
name: 'John',
email: 'john@example.com',
avatarUrl: null,
},
}
);
expect(container).toBeInTheDocument();
});
});
The testRenderer function takes two arguments: the component to render and an object mapping fragment names to their mock data. The fragment name is derived from the fragment declaration — in this case, user corresponds to UserProfile_user. The second argument's key must match the fragment's variable name in the component's props.
Testing Components with Nested Fragments
When a component renders child components that also have fragments, you need to provide mock data for the nested fragments as well. Suppose UserProfile renders a UserStats child component:
// UserStats.tsx
import { graphql, useFragment } from 'react-relay';
import type { UserStats_user$key } from './__generated__/UserStats_user.graphql';
export function UserStats({ user }: { user: UserStats_user$key }) {
const data = useFragment(
graphql`
fragment UserStats_user on User {
postCount
followerCount
}
`,
user
);
return (
<div>
<span>{data.postCount} posts</span>
<span>{data.followerCount} followers</span>
</div>
);
}
When testing a parent component that includes UserStats, provide data for both fragments:
testRenderer(
<UserProfileWithStats user={null} />,
{
user: {
id: 'user-1',
name: 'Jane Doe',
email: 'jane@example.com',
avatarUrl: 'https://example.com/avatar.png',
// Nested fragment data
'...UserStats_user': {
postCount: 42,
followerCount: 1000,
},
},
}
);
Testing Query Components with Mock Environment
While fragment components are tested with testRenderer, query components — those that use useLazyLoadQuery or usePreloadedQuery — require a different approach. These components depend on a Relay environment to resolve queries, so you need to create a mock environment and control its responses.
Creating a Mock Environment
The createMockEnvironment function from relay-test-utils creates a fully functional mock Relay environment. It captures outgoing operations and lets you resolve them with mock data:
// UserList.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { RelayEnvironmentProvider } from 'react-relay';
import { createMockEnvironment, MockPayloadGenerator } from 'relay-test-utils';
import { UserList } from './UserList';
describe('UserList', () => {
let environment;
beforeEach(() => {
environment = createMockEnvironment();
});
it('renders a loading state initially', () => {
render(
<RelayEnvironmentProvider environment={environment}>
<UserList />
</RelayEnvironmentProvider>
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
it('renders users after the query resolves', async () => {
render(
<RelayEnvironmentProvider environment={environment}>
<UserList />
</RelayEnvironmentProvider>
);
// Resolve the pending query with mock data
environment.mock.resolveMostRecentOperation((operation) =>
MockPayloadGenerator.generate(operation, {
User: () => ({
id: 'user-1',
name: 'Jane Doe',
email: 'jane@example.com',
}),
})
);
await waitFor(() => {
expect(screen.getByText('Jane Doe')).toBeInTheDocument();
});
});
});
The MockPayloadGenerator is a powerful tool that automatically generates mock data based on the GraphQL schema. It creates realistic-looking data for every field in the query, so you only need to override the fields that matter for your test. For fields you do not override, it generates sensible defaults — strings become lorem ipsum text, numbers become random integers, and IDs are auto-generated.
Testing Error States
You should also test how your query components handle errors. The mock environment provides a mock.rejectMostRecentOperation method for this purpose:
it('renders an error message when the query fails', async () => {
render(
<RelayEnvironmentProvider environment={environment}>
<UserList />
</RelayEnvironmentProvider>
);
environment.mock.rejectMostRecentOperation(new Error('Network error'));
await waitFor(() => {
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
});
});
Customizing Mock Payload Generators
For more complex scenarios, you can provide custom mock resolvers for specific types. This is especially useful when your component renders lists or has conditional logic based on field values:
environment.mock.resolveMostRecentOperation((operation) =>
MockPayloadGenerator.generate(operation, {
User: () => ({
id: 'user-1',
name: 'Jane Doe',
email: 'jane@example.com',
}),
UserConnection: () => ({
edges: [
{
node: {
id: 'user-1',
name: 'Jane Doe',
email: 'jane@example.com',
},
},
{
node: {
id: 'user-2',
name: 'John Smith',
email: 'john@example.com',
},
},
],
}),
})
);
Testing Mutations
Mutations are a critical part of any Relay application. Testing them involves verifying that the mutation is sent with the correct variables and that the store is updated correctly after the mutation completes.
Basic Mutation Testing
Consider a component that creates a new post:
// CreatePostForm.tsx
import { graphql, useMutation } from 'react-relay';
import type { CreatePostFormMutation } from './__generated__/CreatePostFormMutation.graphql';
export function CreatePostForm() {
const [commit, isInFlight] = useMutation<CreatePostFormMutation>(graphql`
mutation CreatePostFormMutation($input: CreatePostInput!) {
createPost(input: $input) {
post {
id
title
body
}
}
}
`);
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
commit({
variables: {
input: {
title: formData.get('title'),
body: formData.get('body'),
},
},
});
};
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Title" />
<textarea name="body" placeholder="Body" />
<button type="submit" disabled={isInFlight}>Create Post</button>
</form>
);
}
Here is how you test this mutation component:
// CreatePostForm.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { RelayEnvironmentProvider } from 'react-relay';
import { createMockEnvironment, MockPayloadGenerator } from 'relay-test-utils';
import { CreatePostForm } from './CreatePostForm';
describe('CreatePostForm', () => {
let environment;
beforeEach(() => {
environment = createMockEnvironment();
});
it('sends the mutation with correct variables', async () => {
render(
<RelayEnvironmentProvider environment={environment}>
<CreatePostForm />
</RelayEnvironmentProvider>
);
fireEvent.change(screen.getByPlaceholderText('Title'), {
target: { value: 'My New Post' },
});
fireEvent.change(screen.getByPlaceholderText('Body'), {
target: { value: 'This is the post body.' },
});
fireEvent.click(screen.getByText('Create Post'));
// Verify the mutation was sent
const operation = environment.mock.getMostRecentOperation();
expect(operation.fragment.node.name).toBe('CreatePostFormMutation');
expect(operation.request.variables).toEqual({
input: {
title: 'My New Post',
body: 'This is the post body.',
},
});
});
it('disables the submit button while mutation is in flight', async () => {
render(
<RelayEnvironmentProvider environment={environment}>
<CreatePostForm />
</RelayEnvironmentProvider>
);
fireEvent.click(screen.getByText('Create Post'));
expect(screen.getByText('Create Post')).toBeDisabled();
// Resolve the mutation
environment.mock.resolveMostRecentOperation((operation) =>
MockPayloadGenerator.generate(operation)
);
await waitFor(() => {
expect(screen.getByText('Create Post')).not.toBeDisabled();
});
});
});
Testing Mutation Updaters
Relay mutations often include updater functions that modify the store after a mutation completes. Testing these updaters requires verifying that the store is in the correct state after the mutation resolves. Here is an example with an updater that adds a new post to a connection:
// CreatePostFormWithUpdater.tsx
import { graphql, useMutation } from 'react-relay';
import { ConnectionHandler } from 'relay-runtime';
export function CreatePostFormWithUpdater() {
const [commit] = useMutation(graphql`
mutation CreatePostFormWithUpdaterMutation($input: CreatePostInput!) {
createPost(input: $input) {
postEdge {
node {
id
title
body
}
}
}
}
`);
const handleSubmit = (e) => {
e.preventDefault();
const formData = new FormData(e.target);
commit({
variables: {
input: {
title: formData.get('title'),
body: formData.get('body'),
},
},
updater: (store) => {
const payload = store.getRootField('createPost');
const newEdge = payload.getLinkedRecord('postEdge');
const viewer = store.getRoot().getLinkedRecord('viewer');
const connection = ConnectionHandler.getConnection(
viewer,
'PostList_posts'
);
ConnectionHandler.insertEdgeAfter(connection, newEdge);
},
});
};
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Title" />
<textarea name="body" placeholder="Body" />
<button type="submit">Create Post</button>
</form>
);
}
To test the updater, you need to seed the mock environment with initial data, trigger the mutation, and then verify the store was updated:
it('adds the new post to the connection after mutation', async () => {
// Seed the environment with an initial viewer and post connection
environment.mock.queueOperationResolver((operation) =>
MockPayloadGenerator.generate(operation, {
Viewer: () => ({
id: 'viewer-1',
posts: {
edges: [
{
node: { id: 'post-1', title: 'Existing Post', body: 'Old body' },
},
],
},
}),
})
);
render(
<RelayEnvironmentProvider environment={environment}>
<>
<PostList />
<CreatePostFormWithUpdater />
</>
</RelayEnvironmentProvider>
);
// Wait for initial query to resolve
await waitFor(() => {
expect(screen.getByText('Existing Post')).toBeInTheDocument();
});
// Submit the mutation
fireEvent.change(screen.getByPlaceholderText('Title'), {
target: { value: 'New Post' },
});
fireEvent.change(screen.getByPlaceholderText('Body'), {
target: { value: 'New body' },
});
fireEvent.click(screen.getByText('Create Post'));
// Resolve the mutation
environment.mock.resolveMostRecentOperation((operation) =>
MockPayloadGenerator.generate(operation, {
PostEdge: () => ({
node: { id: 'post-2', title: 'New Post', body: 'New body' },
}),
})
);
await waitFor(() => {
expect(screen.getByText('New Post')).toBeInTheDocument();
expect(screen.getByText('Existing Post')).toBeInTheDocument();
});
});
Testing Pagination and Refetch
Components that use usePaginationFragment or useRefetchableFragment add another layer of complexity. You need to test not only the initial render but also the behavior when loading more data or refetching.
Testing Pagination
Here is a paginated post list component:
// PostListPagination.tsx
import { graphql, usePaginationFragment } from 'react-relay';
export function PostListPagination({ posts }) {
const { data, loadNext, isLoadingNext, hasNext } = usePaginationFragment(
graphql`
fragment PostListPagination_posts on Viewer
@argumentDefinitions(
count: { type: "Int", defaultValue: 10 }
cursor: { type: "String" }
)
@refetchable(queryName: "PostListPaginationRefetchQuery") {
posts(first: $count, after: $cursor)
@connection(key: "PostListPagination_posts") {
edges {
node {
id
title
body
}
}
}
}
`,
posts
);
return (
<div>
{data.posts.edges.map(({ node }) => (
<article key={node.id}>
<h3>{node.title}</h3>
<p>{node.body}</p>
</article>
))}
{hasNext && (
<button onClick={() => loadNext(10)} disabled={isLoadingNext}>
{isLoadingNext ? 'Loading...' : 'Load More'}
</button>
)}
</div>
);
}
To test pagination, you first render the component with initial data, then trigger loadNext and resolve the resulting operation:
// PostListPagination.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { RelayEnvironmentProvider } from 'react-relay';
import { createMockEnvironment, MockPayloadGenerator } from 'relay-test-utils';
import { PostListPagination } from './PostListPagination';
describe('PostListPagination', () => {
let environment;
beforeEach(() => {
environment = createMockEnvironment();
});
it('loads more posts when the Load More button is clicked', async () => {
// Seed initial data
environment.mock.queueOperationResolver((operation) =>
MockPayloadGenerator.generate(operation, {
Viewer: () => ({
id: 'viewer-1',
posts: {
edges: Array.from({ length: 10 }, (_, i) => ({
node: {
id: `post-${i}`,
title: `Post ${i}`,
body: `Body ${i}`,
},
})),
pageInfo: {
hasNextPage: true,
endCursor: 'cursor-10',
},
},
}),
})
);
render(
<RelayEnvironmentProvider environment={environment}>
<PostListPagination posts={null} />
</RelayEnvironmentProvider>
);
// Wait for initial render
await waitFor(() => {
expect(screen.getByText('Post 0')).toBeInTheDocument();
});
// Click Load More
fireEvent.click(screen.getByText('Load More'));
// The button should show loading state
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Resolve the pagination operation with more data
environment.mock.resolveMostRecentOperation((operation) =>
MockPayloadGenerator.generate(operation, {
Viewer: () => ({
id: 'viewer-1',
posts: {
edges: Array.from({ length: 10 }, (_, i) => ({
node: {
id: `post-${i + 10}`,
title: `Post ${i + 10}`,
body: `Body ${i + 10}`,
},
})),
pageInfo: {
hasNextPage: false,
endCursor: 'cursor-20',
},
},
}),
})
);
await waitFor(() => {
expect(screen.getByText('Post 10')).toBeInTheDocument();
expect(screen.getByText('Post 19')).toBeInTheDocument();
});
// Load More button should be gone since hasNext is false
expect(screen.queryByText('Load More')).not.toBeInTheDocument();
});
});
Integration Testing with Multiple Components
Integration tests verify that multiple components work together correctly. In a Relay application, this often means testing a page-level component that composes several fragment components and executes a query. The approach is similar to query component testing, but you verify interactions between components.
// UserDashboard.test.tsx
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { RelayEnvironmentProvider } from 'react-relay';
import { createMockEnvironment, MockPayloadGenerator } from 'relay-test-utils';
import { UserDashboard } from './UserDashboard';
describe('UserDashboard Integration', () => {
let environment;
beforeEach(() => {
environment = createMockEnvironment();
});
it('displays user profile and posts together', async () => {
render(
<RelayEnvironmentProvider environment={environment}>
<UserDashboard userId="user-1" />
</RelayEnvironmentProvider>
);
environment.mock.resolveMostRecentOperation((operation) =>
MockPayloadGenerator.generate(operation, {
User: () => ({
id: 'user-1',
name: 'Jane Doe',
email: 'jane@example.com',
avatarUrl: 'https://example.com/jane.png',
posts: {
edges: [
{
node: {
id: 'post-1',
title: 'My First Post',
body: 'Hello world!',
},
},
],
},
}),
})
);
await waitFor(() => {
expect(screen.getByText('Jane Doe')).toBeInTheDocument();
expect(screen.getByText('jane@example.com')).toBeInTheDocument();
expect(screen.getByText('My First Post')).toBeInTheDocument();
});
});
it('allows editing the profile and reflects changes', async () => {
render(
<RelayEnvironmentProvider environment={environment}>
<UserDashboard userId="user-1" />
</RelayEnvironmentProvider>
);
// Resolve initial query
environment.mock.resolveMostRecentOperation((operation) =>
MockPayloadGenerator.generate(operation, {
User: () => ({
id: 'user-1',
name: 'Jane Doe',
email: 'jane@example.com',
avatarUrl: 'https://example.com/jane.png',
}),
})
);
await waitFor(() => {
expect(screen.getByText('Jane Doe')).toBeInTheDocument();
});
// Click edit button
fireEvent.click(screen.getByText('Edit Profile'));
// Change the name
fireEvent.change(screen.getByDisplayValue('Jane Doe'), {
target: { value: 'Jane Smith' },
});
fireEvent.click(screen.getByText('Save'));
// Resolve the mutation
environment.mock.resolveMostRecentOperation((operation) =>
MockPayloadGenerator.generate(operation, {
User: () => ({
id: 'user-1',
name: 'Jane Smith',
email: 'jane@example.com',
avatarUrl: 'https://example.com/jane.png',
}),
})
);
await waitFor(() => {
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
expect(screen.queryByText('Jane Doe')).not.toBeInTheDocument();
});
});
});
End-to-End Testing
End-to-end (E2E) tests verify the entire application flow, from the browser through the GraphQL server and back. These tests catch issues that unit and integration tests might miss, such as schema mismatches, authentication problems, or network-related bugs.
Setting Up Playwright with a Mock GraphQL Server
One common approach is to use Playwright for browser automation and intercept GraphQL requests at the network level. This gives you the realism of E2E testing while keeping tests deterministic:
// e2e/userDashboard.spec.ts
import { test, expect } from '@playwright/test';
test.describe('User Dashboard E2E', () => {
test('displays user profile after login', async ({ page }) => {
// Intercept GraphQL requests
await page.route('**/graphql', async (route) => {
const request = route.request();
const postData = JSON.parse(request.postData());
const operationName = postData.operationName;
if (operationName === 'UserDashboardQuery') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
data: {
user: {
id: 'user-1',
name: 'Jane Doe',
email: 'jane@example.com',
avatarUrl: 'https://example.com/jane.png',
posts: {
edges: [
{
node: {
id: 'post-1',
title: 'My First Post',
body: 'Hello world!',
},
},
],
},
},
},
}),
});
} else {
await route.continue();
}
});
await page.goto('http://localhost:3000/dashboard');
await expect(page.locator('h2')).toHaveText('Jane Doe');
await expect(page.locator('text=jane@example.com')).toBeVisible();
await expect(page.locator('text=My First Post')).toBeVisible();
});
test('handles GraphQL errors gracefully', async ({ page }) => {
await page.route('**/graphql', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
errors: [{ message: 'Unauthorized' }],
}),
});
});
await page.goto('http://localhost:3000/dashboard');
await expect(page.locator('text=/something went wrong/i')).toBeVisible();
});
});
Testing Against a Real GraphQL Server
For maximum confidence, you can run E2E tests against a real GraphQL server. This is typically done in a staging environment or with a locally running server in a CI pipeline. The advantage is that you test the actual schema, resolvers, and database:
// e2e/fullFlow.spec.ts
import { test, expect } from '@playwright/test';
test.describe('Full Application Flow', () => {
test('user can create and view a post', async ({ page }) => {
// Navigate to the app
await page.goto('http://localhost:3000');
// Log in (assuming a login form)
await page.fill('input[name="email"]', 'test@example.com');
await page.fill('input[name="password"]', 'password123');
await page.click('button[type="submit"]');
// Wait for dashboard to load
await expect(page).toHaveURL('http://localhost:3000/dashboard');
// Navigate to create post
await page.click('text=New Post');
// Fill in the form
await page.fill('input[name="title"]', 'E2E Test Post');
await page.fill('textarea[name="body"]', 'This post was created by an E2E test.');
await page.click('button[type="submit"]');
// Verify the post appears in the list
await expect(page.locator('text=E2E Test Post')).toBeVisible();
// Click on the post to view details
await page.click('text=E2E Test Post');
await expect(page.locator('text=This post was created by an E2E test.')).toBeVisible();
});
});
Best Practices
1. Keep the Relay Compiler Running
Your tests depend on generated artifacts (__generated__ files). If these are stale, tests will fail with confusing errors. Always run the Relay compiler before running tests, or better yet, integrate it into your test script:
{
"scripts": {
"test": "relay-compiler && jest"
}
}
2. Use MockPayloadGenerator for Default Data
Resist the temptation to hand-craft every field in your mock data. Use MockPayloadGenerator for default values and only override the fields your test actually asserts on. This keeps tests focused and resilient to schema changes that add non-critical fields.
3. Test All Data States
Every query component has at least three states: loading, success, and error. Make sure you test all three. Additionally, consider edge cases like empty lists, null fields, and very long text.
4. Isolate Fragment Tests from Query Tests
Use testRenderer for fragment components and createMockEnvironment for query components. Mixing the two leads to unnecessary complexity. Fragment components should be testable without any network or environment setup.
5. Assert on Behavior, Not Implementation
Prefer asserting on what the user sees (text, elements, visibility) rather than on internal Relay state. This makes your tests more resilient to refactoring. Use React Testing Library's query methods (getByText, getByRole, etc.) rather than inspecting component internals.
6. Queue Operations for Predictable Resolution
When a test triggers multiple operations (for example, an initial query followed by a mutation), use environment.mock.queueOperationResolver to queue responses in order. This ensures each operation gets the correct response even if they resolve in an unexpected order:
environment.mock.queueOperationResolver((operation) =>
MockPayloadGenerator.generate(operation, {
User: () => ({ id: 'user-1', name: 'Jane Doe' }),
})
);
environment.mock.queueOperationResolver((operation) =>
MockPayloadGenerator.generate(operation, {
Post: () => ({ id: 'post-1', title: 'New Post' }),
})
);
7. Clean Up Between Tests
Always create a fresh mock environment in beforeEach. Reusing environments between tests can lead to stale data leaking between tests, causing flaky and hard-to-debug failures.
8. Use Snapshot Testing Sparingly
Snapshot tests can be useful for Relay components, but they break easily when the generated artifacts change. If you use snapshots, focus on the rendered output rather than the Relay store state, and review snapshot diffs carefully during code review.
Conclusion
Testing Relay components effectively requires understanding the different layers of Relay's architecture and choosing the right tool for each layer. Fragment components are best tested in isolation with testRenderer, which provides a simple way to inject mock data without setting up a full environment. Query components, mutations, and pagination require a mock environment with createMockEnvironment and MockPayloadGenerator, giving you fine-grained control over operation resolution and store state. Integration tests bring multiple components together to verify they compose correctly, while E2E tests with Playwright validate the entire stack against either mocked or real GraphQL endpoints. By following the patterns and best practices outlined in this tutorial, you can build a robust test suite that catches bugs early, documents expected behavior, and gives you the confidence to refactor and extend your Relay application without fear of regressions.