← Back to DevBytes

Testing Relay Components: From Unit to E2E Tests

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:

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.

— Ad —

Google AdSense will appear here after approval

← Back to all articles