GraphQL resolvers are the bridge between your GraphQL schema and your data sources, and testing them in isolation is crucial for a robust API.
Let’s see how a simple user query might look in action. Imagine you have a GraphQL schema like this:
type User {
id: ID!
name: String!
email: String
}
type Query {
user(id: ID!): User
}
And a corresponding resolver function in your backend code (e.g., Node.js with Apollo Server):
const resolvers = {
Query: {
user: (parent, { id }, context, info) => {
// In a real app, this would fetch from a database or external service
const users = {
'1': { id: '1', name: 'Alice', email: 'alice@example.com' },
'2': { id: '2', name: 'Bob', email: 'bob@example.com' },
};
return users[id];
},
},
};
Now, let’s test this user resolver using Jest. We’ll use the graphql-tools library to create a mock schema and execute queries against our resolvers directly.
First, install the necessary packages:
npm install --save-dev jest graphql graphql-tools
Then, set up your Jest test file (e.g., resolver.test.js):
import { makeExecutableSchema } from 'graphql-tools';
const typeDefs = `
type User {
id: ID!
name: String!
email: String
}
type Query {
user(id: ID!): User
}
`;
const resolvers = {
Query: {
user: (parent, { id }, context, info) => {
const users = {
'1': { id: '1', name: 'Alice', email: 'alice@example.com' },
'2': { id: '2', name: 'Bob', email: 'bob@example.com' },
};
return users[id];
},
},
};
const schema = makeExecutableSchema({ typeDefs, resolvers });
describe('User Resolver', () => {
it('should return a user by ID', async () => {
const query = `
query GetUser($userId: ID!) {
user(id: $userId) {
id
name
email
}
}
`;
const variables = { userId: '1' };
const result = await schema.execute({
document: { kind: 'Document', definitions: [{ kind: 'OperationDefinition', operation: 'query', selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'user' }, arguments: [{ kind: 'Argument', name: { kind: 'Name', value: 'id' }, value: { kind: 'Variable', name: { kind: 'Name', value: 'userId' } } }], selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'id' } }, { kind: 'Field', name: { kind: 'Name', value: 'name' } }, { kind: 'Field', name: { kind: 'Name', value: 'email' } }] } }] }] },
variableValues: variables,
});
expect(result.errors).toBeUndefined();
expect(result.data.user).toEqual({
id: '1',
name: 'Alice',
email: 'alice@example.com',
});
});
it('should return null if user ID not found', async () => {
const query = `
query GetUser($userId: ID!) {
user(id: $userId) {
id
}
}
`;
const variables = { userId: '99' };
const result = await schema.execute({
document: { kind: 'Document', definitions: [{ kind: 'OperationDefinition', operation: 'query', selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'user' }, arguments: [{ kind: 'Argument', name: { kind: 'Name', value: 'id' }, value: { kind: 'Variable', name: { kind: 'Name', value: 'userId' } } }], selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'id' } }] } }] }] },
variableValues: variables,
});
expect(result.errors).toBeUndefined();
expect(result.data.user).toBeNull();
});
});
This setup allows you to test your resolvers without spinning up a full server. You’re essentially executing your resolver logic directly against a mocked schema. The makeExecutableSchema function from graphql-tools is key here; it takes your typeDefs (the schema definition language) and resolvers and compiles them into an executable schema object. Then, schema.execute allows you to run GraphQL queries against this executable schema, simulating how the GraphQL engine would process a request.
For mutations, the principle is identical. You define your mutation in the schema, write the corresponding resolver function, and then use schema.execute with a mutation document and variables to test it.
Consider this mutation:
type Mutation {
createUser(name: String!, email: String): User
}
And its resolver:
// ... (previous resolvers)
const resolvers = {
Query: { /* ... */ },
Mutation: {
createUser: (parent, { name, email }, context, info) => {
// In a real app, this would create a user in the database
const newUser = { id: Date.now().toString(), name, email };
// For testing, we might just return it or add it to a mock store
return newUser;
},
},
};
// ... (makeExecutableSchema)
Your Jest test for this mutation would look like:
describe('User Mutation', () => {
it('should create a new user', async () => {
const mutation = `
mutation CreateUser($name: String!, $email: String) {
createUser(name: $name, email: $email) {
id
name
email
}
}
`;
const variables = { name: 'Charlie', email: 'charlie@example.com' };
const result = await schema.execute({
document: { kind: 'Document', definitions: [{ kind: 'OperationDefinition', operation: 'mutation', selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'createUser' }, arguments: [{ kind: 'Argument', name: { kind: 'Name', value: 'name' }, value: { kind: 'Variable', name: { kind: 'Name', value: 'name' } } }, { kind: 'Argument', name: { kind: 'Name', value: 'email' }, value: { kind: 'Variable', name: { kind: 'Name', value: 'email' } } }], selectionSet: { kind: 'SelectionSet', selections: [{ kind: 'Field', name: { kind: 'Name', value: 'id' } }, { kind: 'Field', name: { kind: 'Name', value: 'name' } }, { kind: 'Field', name: { kind: 'Name', value: 'email' } }] } }] }] },
variableValues: variables,
});
expect(result.errors).toBeUndefined();
expect(result.data.createUser).toHaveProperty('id');
expect(result.data.createUser.name).toBe('Charlie');
expect(result.data.createUser.email).toBe('charlie@example.com');
});
});
The schema.execute method takes a document (the GraphQL query or mutation as an Abstract Syntax Tree, which graphql-tools can parse from a string) and variableValues. It returns a promise that resolves with the execution result, including data and errors. This direct execution bypasses network overhead and server setup, making tests fast and reliable.
When you pass context to schema.execute, it becomes available within your resolver functions. This is how you’d typically pass database connections, authentication information, or other necessary dependencies to your resolvers during testing. For instance, if your user resolver needed to query a mock database, you’d pass that mock database object in the context:
const mockDb = {
users: {
'1': { id: '1', name: 'Alice', email: 'alice@example.com' },
},
};
// ... in your test
const result = await schema.execute({
document: { ... }, // your query AST
variableValues: { userId: '1' },
contextValue: { db: mockDb }, // Pass mockDb in context
});
// Then in your resolver:
// user: (parent, { id }, { db }, info) => {
// return db.users[id];
// },
This allows you to isolate the testing of your business logic within resolvers, ensuring they behave as expected with various inputs and dependencies.
The most surprising thing about testing GraphQL resolvers in isolation is how little you actually need to mock. You’re not mocking the GraphQL engine itself, but rather the dependencies of your resolvers – like databases or external APIs.
The graphql-tools library’s makeExecutableSchema function is a powerful abstraction. It takes your schema definition and resolvers and creates an executable schema object. This object is the GraphQL execution engine, simplified for direct invocation. When you call schema.execute, you’re not sending a request over HTTP; you’re directly invoking the resolver functions in the correct order as dictated by the GraphQL query and schema, passing your provided arguments and context. This makes testing incredibly fast because you’re running pure JavaScript functions, not dealing with network latency or server startup times. The info object, often overlooked, contains valuable metadata about the execution, such as the fieldNodes which represent the selection set of the query, allowing for more advanced resolver logic if needed.
The next step after testing resolvers is often integrating them into a full Apollo Server (or similar) setup and testing the end-to-end API responses.