Testing GraphQL Federation: Subgraphs, Supergraph, and Gateway
GraphQL Federation (Apollo Federation v2, Hive Federation, Cosmo) splits a GraphQL schema across multiple microservices called subgraphs. Testing federated GraphQL requires a layered approach: unit test subgraphs independently, validate schema composition, and integration-test the full supergraph.
The Three Layers of Federation Testing
- Subgraph tests: test each subgraph as a standalone GraphQL service
- Schema composition tests: verify subgraphs compose into a valid supergraph
- Gateway integration tests: test queries that span multiple subgraphs
Skipping layer 2 leads to runtime composition errors that are hard to debug.
Layer 1: Testing Subgraphs Independently
Each subgraph is a normal Apollo Server (or equivalent). Test it like any GraphQL service.
// users-subgraph/schema.js
const { buildSubgraphSchema } = require('@apollo/subgraph');
const typeDefs = gql`
extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"])
type User @key(fields: "id") {
id: ID!
email: String!
name: String!
}
type Query {
user(id: ID!): User
me: User
}
`;
const resolvers = {
User: {
__resolveReference(ref, { dataSources }) {
return dataSources.usersAPI.getUser(ref.id);
}
},
Query: {
user: (_, { id }, { dataSources }) => dataSources.usersAPI.getUser(id),
me: (_, __, { currentUser }) => currentUser
}
};// users-subgraph/test/schema.test.js
const { ApolloServer } = require('@apollo/server');
const { buildSubgraphSchema } = require('@apollo/subgraph');
const { executeOperation } = require('@apollo/server/testing');
let server;
beforeAll(async () => {
server = new ApolloServer({
schema: buildSubgraphSchema({ typeDefs, resolvers }),
});
await server.start();
});
afterAll(async () => await server.stop());
test('query user by ID', async () => {
const { body } = await server.executeOperation(
{
query: `query GetUser($id: ID!) { user(id: $id) { id email name } }`,
variables: { id: 'u1' }
},
{
contextValue: {
dataSources: {
usersAPI: {
getUser: jest.fn().mockResolvedValue({
id: 'u1',
email: 'alice@example.com',
name: 'Alice'
})
}
}
}
}
);
expect(body.kind).toBe('single');
expect(body.singleResult.data?.user).toEqual({
id: 'u1',
email: 'alice@example.com',
name: 'Alice'
});
});
test('resolves reference for federation', async () => {
// Test __resolveReference — called when gateway resolves cross-subgraph references
const { body } = await server.executeOperation(
{
query: `
query ($representations: [_Any!]!) {
_entities(representations: $representations) {
... on User { id email }
}
}
`,
variables: {
representations: [{ __typename: 'User', id: 'u1' }]
}
},
{
contextValue: {
dataSources: {
usersAPI: {
getUser: jest.fn().mockResolvedValue({
id: 'u1',
email: 'alice@example.com',
name: 'Alice'
})
}
}
}
}
);
expect(body.singleResult.data?._entities[0].email).toBe('alice@example.com');
});The _entities query test is critical — it's how the gateway fetches related entities across subgraph boundaries.
Layer 2: Schema Composition Tests
Schema composition can fail in ways that aren't caught at runtime until a query hits the failing path. Use @apollo/composition to validate composition in CI.
// test/composition.test.js
const { composeServices } = require('@apollo/composition');
const { parse } = require('graphql');
const usersSubgraph = {
name: 'users',
typeDefs: parse(fs.readFileSync('./users-subgraph/schema.graphql', 'utf8'))
};
const ordersSubgraph = {
name: 'orders',
typeDefs: parse(fs.readFileSync('./orders-subgraph/schema.graphql', 'utf8'))
};
const productsSubgraph = {
name: 'products',
typeDefs: parse(fs.readFileSync('./products-subgraph/schema.graphql', 'utf8'))
};
test('all subgraphs compose without errors', () => {
const result = composeServices([usersSubgraph, ordersSubgraph, productsSubgraph]);
if (result.errors?.length > 0) {
const errorMessages = result.errors.map(e => e.message).join('\n');
throw new Error(`Composition failed:\n${errorMessages}`);
}
expect(result.supergraphSdl).toBeTruthy();
});
test('composed schema includes all expected types', () => {
const { supergraphSdl } = composeServices([usersSubgraph, ordersSubgraph, productsSubgraph]);
expect(supergraphSdl).toContain('type User');
expect(supergraphSdl).toContain('type Order');
expect(supergraphSdl).toContain('type Product');
});
test('User type is owned by users subgraph', () => {
const result = composeServices([usersSubgraph, ordersSubgraph, productsSubgraph]);
// No errors means the @key directives are consistent
expect(result.errors).toBeUndefined();
});Run this test every time a subgraph schema changes. Composition failures are caught before deployment.
Layer 3: Gateway Integration Tests
Integration tests verify that cross-subgraph queries work end-to-end.
// test/integration/gateway.test.js
const { ApolloGateway, IntrospectAndCompose } = require('@apollo/gateway');
const { ApolloServer } = require('@apollo/server');
let gateway;
let server;
beforeAll(async () => {
// Use locally running subgraphs for integration tests
gateway = new ApolloGateway({
supergraphSdl: new IntrospectAndCompose({
subgraphs: [
{ name: 'users', url: 'http://localhost:4001/graphql' },
{ name: 'orders', url: 'http://localhost:4002/graphql' },
{ name: 'products', url: 'http://localhost:4003/graphql' }
]
})
});
server = new ApolloServer({ gateway });
await server.start();
});
afterAll(async () => await server.stop());
test('cross-subgraph query: user with orders', async () => {
// Orders subgraph extends User type with orders field
const { body } = await server.executeOperation({
query: `
query UserWithOrders($userId: ID!) {
user(id: $userId) {
id
name
orders {
id
total
status
}
}
}
`,
variables: { userId: 'u1' }
});
expect(body.singleResult.errors).toBeUndefined();
expect(body.singleResult.data?.user.orders).toBeInstanceOf(Array);
});Integration tests require running subgraphs. In CI, start them with docker-compose or use jest-globalSetup to start processes before the test suite.
Testing Schema Composition Breakage
Write tests that catch common composition mistakes before they reach production.
// These should fail composition — test that you catch them
test('fails composition when two subgraphs own the same field', () => {
const conflictingUsers = {
name: 'users2',
typeDefs: parse(`
extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"])
type User @key(fields: "id") {
id: ID!
email: String! # also defined in users subgraph — conflict
}
`)
};
const result = composeServices([usersSubgraph, conflictingUsers]);
expect(result.errors?.length).toBeGreaterThan(0);
expect(result.errors[0].message).toMatch(/field.*conflict/i);
});
test('fails composition when @key references a nonexistent field', () => {
const badSubgraph = {
name: 'bad',
typeDefs: parse(`
extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"])
type Product @key(fields: "nonExistentField") {
id: ID!
}
`)
};
const result = composeServices([badSubgraph]);
expect(result.errors?.length).toBeGreaterThan(0);
});Snapshot Testing for the Supergraph SDL
Lock your supergraph SDL in a snapshot to catch unintended schema changes.
test('supergraph SDL matches snapshot', () => {
const { supergraphSdl } = composeServices([usersSubgraph, ordersSubgraph, productsSubgraph]);
expect(supergraphSdl).toMatchSnapshot();
});When you intentionally change a subgraph schema, update the snapshot with jest --updateSnapshot. Any unintended SDL changes will fail this test.
CI Pipeline for Federation Testing
# .github/workflows/graphql-federation.yml
jobs:
test-subgraphs:
strategy:
matrix:
subgraph: [users, orders, products]
steps:
- run: cd ${{ matrix.subgraph }}-subgraph && npm test
test-composition:
needs: test-subgraphs
steps:
- run: npm run test:composition
test-integration:
needs: test-composition
services:
users-subgraph:
image: my-registry/users-subgraph:latest
orders-subgraph:
image: my-registry/orders-subgraph:latest
steps:
- run: npm run test:integrationRun subgraph tests in parallel. Only run integration tests if composition passes. This order prevents slow integration test runs when a schema is already broken at the composition level.