Relay Modern Testing: MockPayloadGenerator, relay-test-utils, and Fragment Testing
Relay Modern is Facebook's production GraphQL client, built for performance and type safety. Its testing approach is unlike Apollo or urql — Relay provides relay-test-utils with MockEnvironment and MockPayloadGenerator that work at the Relay runtime level. This guide covers the full Relay testing toolkit.
Why Relay Testing is Different
Relay's key difference: it uses a compiler that generates optimized artifacts from your GraphQL fragments. This means:
- You test compiled queries, not source GraphQL strings
- Fragments are the unit — each component owns its data requirements
- MockEnvironment simulates the Relay runtime, not the network
- MockPayloadGenerator creates realistic test data automatically
This architecture makes Relay tests extremely fast and deterministic.
Setting Up relay-test-utils
npm install --save-dev relay-test-utils @testing-library/reactCreate a test environment helper:
// test-utils/relay-setup.js
import { createMockEnvironment } from 'relay-test-utils';
import { render } from '@testing-library/react';
import { RelayEnvironmentProvider } from 'react-relay';
export function createTestEnvironment() {
return createMockEnvironment();
}
export function renderWithRelay(component, environment) {
return render(
<RelayEnvironmentProvider environment={environment}>
{component}
</RelayEnvironmentProvider>
);
}Testing Components with MockPayloadGenerator
MockPayloadGenerator generates type-safe mock data from your schema:
import { createMockEnvironment, MockPayloadGenerator } from 'relay-test-utils';
import { renderWithRelay, createTestEnvironment } from '../test-utils/relay-setup';
import ProductCard from '../components/ProductCard';
test('renders product data from query', async () => {
const environment = createTestEnvironment();
renderWithRelay(
<React.Suspense fallback="Loading...">
<ProductCard productId="product-1" />
</React.Suspense>,
environment
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Resolve the pending query with mock data
act(() => {
environment.mock.resolveMostRecentOperation(operation =>
MockPayloadGenerator.generate(operation, {
Product() {
return {
id: 'product-1',
name: 'Trail Running Shoes',
price: 149.99,
inStock: true,
};
},
})
);
});
await waitFor(() => {
expect(screen.getByText('Trail Running Shoes')).toBeInTheDocument();
});
expect(screen.getByText('$149.99')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Add to Cart' })).not.toBeDisabled();
});Testing Fragment Components
Fragments are Relay's primary unit. Test fragment components in isolation using mockFragment:
// ProductDetails uses a fragment:
// fragment ProductDetails_product on Product {
// id
// name
// description
// rating
// reviewCount
// }
import { MockResolver } from 'relay-test-utils';
test('ProductDetails renders fragment data', () => {
const environment = createTestEnvironment();
// Create a parent query that includes our fragment
const query = graphql`
query ProductDetailsTestQuery($id: ID!) {
product(id: $id) {
...ProductDetails_product
}
}
`;
renderWithRelay(
<React.Suspense fallback="Loading...">
<QueryRenderer
environment={environment}
query={query}
variables={{ id: 'prod-1' }}
render={({ props }) => props ? <ProductDetails product={props.product} /> : null}
/>
</React.Suspense>,
environment
);
act(() => {
environment.mock.resolveMostRecentOperation(operation =>
MockPayloadGenerator.generate(operation, {
Product() {
return {
id: 'prod-1',
name: 'Mountain Boots',
description: 'For serious hikers',
rating: 4.8,
reviewCount: 127,
};
},
})
);
});
expect(screen.getByText('Mountain Boots')).toBeInTheDocument();
expect(screen.getByText('4.8 ★ (127 reviews)')).toBeInTheDocument();
});Testing Mutations
Relay mutations have optimistic updates built in. Test both the optimistic state and the confirmed state:
test('mutation applies optimistic update then confirms', async () => {
const environment = createTestEnvironment();
const user = userEvent.setup();
renderWithRelay(
<React.Suspense fallback="Loading...">
<ProductCard productId="prod-1" />
</React.Suspense>,
environment
);
// Load initial data
act(() => {
environment.mock.resolveMostRecentOperation(operation =>
MockPayloadGenerator.generate(operation, {
Product() {
return { id: 'prod-1', name: 'Shoes', inWishlist: false };
},
})
);
});
await waitFor(() => screen.getByText('Shoes'));
// Click wishlist button — triggers mutation
await user.click(screen.getByRole('button', { name: 'Add to Wishlist' }));
// Optimistic update: button should show "Remove from Wishlist" immediately
expect(screen.getByRole('button', { name: 'Remove from Wishlist' })).toBeInTheDocument();
// Confirm the mutation
act(() => {
environment.mock.resolveMostRecentOperation(operation =>
MockPayloadGenerator.generate(operation, {
AddToWishlistPayload() {
return { product: { id: 'prod-1', inWishlist: true } };
},
})
);
});
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Remove from Wishlist' })).toBeInTheDocument();
});
});
test('mutation reverts optimistic update on error', async () => {
const environment = createTestEnvironment();
const user = userEvent.setup();
renderWithRelay(
<React.Suspense fallback="Loading...">
<ProductCard productId="prod-1" />
</React.Suspense>,
environment
);
act(() => {
environment.mock.resolveMostRecentOperation(operation =>
MockPayloadGenerator.generate(operation, {
Product() {
return { id: 'prod-1', name: 'Shoes', inWishlist: false };
},
})
);
});
await waitFor(() => screen.getByText('Shoes'));
await user.click(screen.getByRole('button', { name: 'Add to Wishlist' }));
// Optimistic: shows added
expect(screen.getByRole('button', { name: 'Remove from Wishlist' })).toBeInTheDocument();
// Server rejects mutation
act(() => {
environment.mock.rejectMostRecentOperation(new Error('Unauthorized'));
});
// Optimistic update should revert
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Add to Wishlist' })).toBeInTheDocument();
});
});Testing Pagination with usePaginationFragment
Relay's pagination hook is the standard way to implement load-more:
test('loads more products on scroll', async () => {
const environment = createTestEnvironment();
const user = userEvent.setup();
renderWithRelay(
<React.Suspense fallback="Loading...">
<ProductList categoryId="cat-1" />
</React.Suspense>,
environment
);
// Initial page
act(() => {
environment.mock.resolveMostRecentOperation(operation =>
MockPayloadGenerator.generate(operation, {
ProductConnection() {
return {
edges: Array.from({ length: 10 }, (_, i) => ({
node: { id: `prod-${i}`, name: `Product ${i}` },
cursor: `cursor-${i}`,
})),
pageInfo: { hasNextPage: true, endCursor: 'cursor-9' },
};
},
})
);
});
await waitFor(() => screen.getByText('Product 0'));
expect(screen.getAllByRole('article')).toHaveLength(10);
// Load more
await user.click(screen.getByRole('button', { name: 'Load More' }));
act(() => {
environment.mock.resolveMostRecentOperation(operation =>
MockPayloadGenerator.generate(operation, {
ProductConnection() {
return {
edges: Array.from({ length: 5 }, (_, i) => ({
node: { id: `prod-${i + 10}`, name: `Product ${i + 10}` },
cursor: `cursor-${i + 10}`,
})),
pageInfo: { hasNextPage: false, endCursor: 'cursor-14' },
};
},
})
);
});
await waitFor(() => {
expect(screen.getAllByRole('article')).toHaveLength(15);
});
expect(screen.queryByRole('button', { name: 'Load More' })).not.toBeInTheDocument();
});Testing Subscriptions
Relay subscriptions use the useSubscription hook:
const PRICE_CHANGE_SUBSCRIPTION = graphql`
subscription ProductPriceSubscription($productId: ID!) {
productPriceChanged(productId: $productId) {
product {
id
price
priceHistory {
timestamp
price
}
}
}
}
`;
test('updates price display on subscription event', async () => {
const environment = createTestEnvironment();
renderWithRelay(
<React.Suspense fallback="Loading...">
<ProductPriceTracker productId="prod-1" />
</React.Suspense>,
environment
);
// Load initial data
act(() => {
environment.mock.resolveMostRecentOperation(operation =>
MockPayloadGenerator.generate(operation, {
Product() {
return { id: 'prod-1', price: 99.99 };
},
})
);
});
await waitFor(() => screen.getByText('$99.99'));
// Push subscription event
act(() => {
environment.mock.nextValue(
environment.mock.getMostRecentOperation(),
MockPayloadGenerator.generate(
environment.mock.getMostRecentOperation(),
{
Product() {
return { id: 'prod-1', price: 89.99 };
},
}
)
);
});
await waitFor(() => {
expect(screen.getByText('$89.99')).toBeInTheDocument();
});
});Testing Error Boundaries with Relay
Relay works best with React error boundaries. Test that errors propagate correctly:
class ErrorBoundary extends React.Component {
state = { error: null };
static getDerivedStateFromError(error) { return { error }; }
render() {
if (this.state.error) return <div role="alert">Error: {this.state.error.message}</div>;
return this.props.children;
}
}
test('error boundary catches Relay query errors', async () => {
const environment = createTestEnvironment();
render(
<RelayEnvironmentProvider environment={environment}>
<ErrorBoundary>
<React.Suspense fallback="Loading...">
<ProductCard productId="bad-id" />
</React.Suspense>
</ErrorBoundary>
</RelayEnvironmentProvider>
);
act(() => {
environment.mock.rejectMostRecentOperation(new Error('Product not found'));
});
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Product not found');
});
});Using MockPayloadGenerator for Realistic Data
MockPayloadGenerator can auto-generate data using type name resolvers:
test('generates realistic data for complex nested types', async () => {
const environment = createTestEnvironment();
renderWithRelay(
<React.Suspense fallback="Loading...">
<OrderHistory userId="user-1" />
</React.Suspense>,
environment
);
act(() => {
environment.mock.resolveMostRecentOperation(operation =>
MockPayloadGenerator.generate(operation, {
// Override specific types with controlled values
User() {
return { id: 'user-1', name: 'Test User' };
},
Order({ name, plural, path }) {
// path tells you where in the query graph you are
const orderIndex = path.indexOf('orders');
return {
id: `order-${orderIndex}`,
status: orderIndex === 0 ? 'DELIVERED' : 'PROCESSING',
total: 99.99 * (orderIndex + 1),
};
},
// All other types get auto-generated values
})
);
});
await waitFor(() => {
expect(screen.getByText('DELIVERED')).toBeInTheDocument();
expect(screen.getByText('PROCESSING')).toBeInTheDocument();
});
});E2E Testing with HelpMeTest
Relay MockEnvironment tests cover the React rendering logic, but real Relay production behavior — persisted queries, network retries, store garbage collection — needs E2E coverage:
Navigate to https://your-app.com/products
Verify product list loads within 2 seconds
Scroll to the bottom of the product list
Click "Load More"
Verify 10 additional products appear without full page reload
Click on a product to view details
Verify product detail page loads with correct data
Click "Add to Wishlist"
Verify wishlist icon updates immediately (optimistic UI)
Navigate back to the list
Verify the wishlist indicator persistsHelpMeTest runs these tests continuously to catch Relay persisted query mismatches, store corruption, and server schema changes.
Summary
Testing Relay Modern thoroughly requires:
- MockEnvironment — simulates the Relay runtime without network calls
- MockPayloadGenerator — generates type-safe data from your schema
- Fragment testing — test each fragment component with controlled parent queries
- Mutation testing — verify optimistic updates, confirmations, and rollbacks
- Pagination testing —
usePaginationFragmentwith sequential mock resolves - Subscription testing — push events with
environment.mock.nextValue - Error boundary testing — verify Relay errors propagate to boundaries correctly
- E2E monitoring — HelpMeTest for production Relay behavior verification