Testing
When building a web application it's important to be able to test the behaviour of your system to ensure it does what you expect. In this guide we'll show you how to use @keystone-6/core/testing
and Jest to write tests to check the behaviour of your GraphQL API.
Running tests
In order to run tests using @keystone-6/core/testing
, we recommend adding the following script to your package.json
file.
"scripts": {"test": "jest"}
This will let you run your tests with the command
yarn test
It is important to use --runInBand
when running your tests. This tells Jest not to run your tests in parallel. Each test shares the same database, so it's important that multiple tests aren't trying to manipulate the data at the same time.
Test runner
The first step to writing a test for your Keystone system is to setup a test runner with setupTestRunner
. You can then use this runner to wrap your test functions.
import { setupTestRunner } from '@keystone-6/core/testing';import config from './keystone';const runner = setupTestRunner({ config });test('Keystone test',runner(() => {// Write your test here}));
The test runner does a number of things for you here. It starts by creating a connection to the database and dropping all the data. This ensures that all tests are run in a known state.
The test runner then sets up a partial Keystone system for you, including an Apollo server to handle GraphQL requests. The system does not include an Admin UI, and does not open a network port to listen for requests.
Finally, the runner sets up three APIs for you to use in your test. The first is a KeystoneContext
object, which lets you use any of the functions in the context API. The second is a graphQLRequest
function, which lets you run GraphQL requests over HTTP using the supertest
library. The third is an express.Express
value named app
which lets you access any of the endpoints of the Express server using supertest
.
The test runner will drop all data in your database on each run. Make sure you do not run your tests against a system with live data.
Writing tests
In general you will want to run tests which check the behaviour of any custom code that you write as part of your Keystone system. This includes things like access control, hooks, virtual fields, and GraphQL API extensions.
import { getContext } from '@keystone-6/core/context';import { resetDatabase } from '@keystone-6/core/testing';import * as PrismaModule from '.prisma/client';import baseConfig from './keystone';const dbUrl = `file:./test-${process.env.JEST_WORKER_ID}.db`;const prismaSchemaPath = path.join(__dirname, 'schema.prisma');const config = { ...baseConfig, db: { ...baseConfig.db, url: dbUrl } };beforeEach(async () => {await resetDatabase(dbUrl, prismaSchemaPath);});const context = getContext(config, PrismaModule);test('Your unit test', async () => {// ...});
We're setting the database url as file:./test-${process.env.JEST_WORKER_ID}.db
so that our tests can use one database per Jest worker thread and run each test suite in parallel
Context API
The context API lets you easily manipulate data in your system. We can use the Query API to ensure that we can do basic CRUD operations.
const person = await context.query.Person.createOne({data: { name: 'Alice', email: 'alice@example.com', password: 'super-secret' },query: 'id name email password { isSet }',});expect(person.name).toEqual('Alice');expect(person.email).toEqual('alice@example.com');expect(person.password.isSet).toEqual(true);
This API works well when we expect an operation to succeed. If we expect an operation to fail we can use the context.graphql.raw
operation to check that both the data
and errors
returned by a query are what we expect.
// Create user without the required `name` fieldconst { data, errors } = await context.graphql.raw({query: `mutation {createPerson(data: { email: "alice@example.com", password: "super-secret" }) {id name email password { isSet }}}`,});expect(data.createPerson).toBe(null);expect(errors).toHaveLength(1);expect(errors[0].path).toEqual(['createPerson']);expect(errors[0].message).toEqual('You provided invalid data for this operation.\n - Person.name: Name must not be empty');
The context.withSession()
function can be used to run queries as if you were logged in as a particular user. This can be useful for testing the behaviour of your access control rules. In the example below, the access control only allows users to update their own tasks.
// Create some usersconst [alice, bob] = await context.query.Person.createMany({data: [{ name: 'Alice', email: 'alice@example.com', password: 'super-secret' },{ name: 'Bob', email: 'bob@example.com', password: 'super-secret' },],});// Create a task assigned to Aliceconst task = await context.query.Task.createOne({data: {label: 'Experiment with Keystone',priority: 'high',isComplete: false,assignedTo: { connect: { id: alice.id } },},});// Check that we can't update the task when logged in as Bobconst { data, errors } = await context.withSession({ itemId: bob.id, data: {} }).graphql.raw({query: `mutation update($id: ID!) {updateTask(where: { id: $id }, data: { isComplete: true }) {id}}`,variables: { id: task.id },});expect(data!.updateTask).toBe(null);expect(errors).toHaveLength(1);expect(errors![0].path).toEqual(['updateTask']);expect(errors![0].message).toEqual(`Access denied: You cannot perform the 'update' operation on the item '{"id":"${task.id}"}'. It may not exist.`);
graphQLRequest API
While the context
API will cover most use cases, if you need to test specific HTTP related behaviour, you can use the graphQLRequest
API. This API lets you control details such as the HTTP headers sent with your request, and returns the full HTTP response, including return codes. The function graphQLRequest
accepts an object { query, variables, operationName }
and returns a supertest
test object.
runner(async ({ graphQLRequest }) => {const response = await graphQLRequest({query: `mutation {createPerson(data: { name: "Alice", email: "alice@example.com", password: "super-secret" }) {id name email password { isSet }}}`,}).set('X-Example-Header', 'header-value').expect(200);const person = response.body.data.createPerson;expect(person.name).toEqual('Alice');expect(person.email).toEqual('alice@example.com');expect(person.password.isSet).toEqual(true);})
See the supertest
docs for full details on the methods available with graphQLRequest
.
Express app
There are some situations where you might want to directly interact with specific endpoints of the Express server. The underlying Express application is exposed as app
, and you can use supertest
to interact with it. For example, if you wanted to check the /_healthcheck
endpoint, you could do the following:
runner(async ({ app }) => {const { text } = await supertest(app).get('/_healthcheck').set('Accept', 'application/json').expect('Content-Type', /json/).expect(200);expect(JSON.parse(text)).toMatchObject({ status: 'pass' });})
Test environment
The test runner function resets the database to a clean state for every test. This ensures that changes to the state of the data in one test won't interfere with any other tests.
Resetting the database for every test can become expensive if you need to do a large amount of data-seeding for every test. In these cases you will want to run multiple tests which share database state, without resetting it between each test. This can be achieved with setupTestEnv
.
The function setupTestEnv
will initialise your system, drop all the data from the database, and then return an object which allows you to control how your tests are run. The returned value contains connect
and disconnect
functions, which you will generally call in the beforeAll
and afterAll
blocks of your test group. It also returns testArgs
, which contains the same arguments provided to tests by the test runner function.
The context API can be used after calling connect()
in the beforeAll()
block to initialise the database into a state which will then be used by all the tests in the test block.
Be careful of sharing database state across tests. Avoid relying on changes of state from one test in subsequent tests.
import { setupTestEnv, TestEnv } from '@keystone-6/core/testing';import { KeystoneContext } from '@keystone-6/core/types';describe('Example tests using test environment', () => {let testEnv: TestEnv;let context: KeystoneContext;beforeAll(async () => {testEnv = await setupTestEnv({ config });context = testEnv.testArgs.context;await testEnv.connect();// Initialise database state here});afterAll(async () => {await testEnv.disconnect();});test('Test 1', async () => {...});test('Test 2', async () => {...});});
Related resources
Example Project: Testing →
Shows you how to write tests against the GraphQL API to your Keystone system. Builds on the Authentication example project.
Context API Reference →
The API for run-time functionality in your Keystone system. Use it to write business logic for access control, hooks, testing, GraphQL schema extensions, and more.
Query API Reference →
A programmatic API for running CRUD operations against your GraphQL API. For each list in your system you get an API at context.query.<listName>
.