Relay Modern Testing: MockPayloadGenerator, relay-test-utils, and Fragment Testing

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:

  1. You test compiled queries, not source GraphQL strings
  2. Fragments are the unit — each component owns its data requirements
  3. MockEnvironment simulates the Relay runtime, not the network
  4. 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/react

Create 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 persists

HelpMeTest runs these tests continuously to catch Relay persisted query mismatches, store corruption, and server schema changes.

Summary

Testing Relay Modern thoroughly requires:

  1. MockEnvironment — simulates the Relay runtime without network calls
  2. MockPayloadGenerator — generates type-safe data from your schema
  3. Fragment testing — test each fragment component with controlled parent queries
  4. Mutation testing — verify optimistic updates, confirmations, and rollbacks
  5. Pagination testingusePaginationFragment with sequential mock resolves
  6. Subscription testing — push events with environment.mock.nextValue
  7. Error boundary testing — verify Relay errors propagate to boundaries correctly
  8. E2E monitoring — HelpMeTest for production Relay behavior verification

Read more