π§ Testing GraphQL applications with minimal mock data π§
There are three criteria for a great test:
- It's realistic, meaning it will fail in valuable ways
- It's easy to maintain (easy to read, runs fast)
- It's easy to write
Codegen tools & mocking tools in the GraphQL ecosystem have made it possible to write tests that are unbelievably good at #1 and #2. But #3 needs some love. In this blog post I'm gonna show how typechecking can make #3 easy peasy.
An example test
Say you've got a function that takes some data fetched from GraphQL as an argument:
export const GetCardsDocument = graphql("
query GetCards {
cards {
id
abilities {
effect
name
}
}
}
")
export function app(data: ResultOf<typeof GetCardsDocument>) {
return data.cards?.[0]?.abilities?.[0]?.effect
}
and you want to test this function, with something like:
import { expect, test } from 'vitest'
import { app, GetCardsDocument } from './app'
import { createMockData } from 'src/api/mock-data-utils/create-mock-data'
test('app', () => {
const data = magicalFunctionThatCreatesExactlyTheMockDataIWant(GetCardsDocument, "paralyzed")
const result = app(data)
expect(result).toBe("paralyzed")
})
This is a short test! Sure, the example is simplistic, but to recap, this test:
- creates realistic mock data with a specific value that I'm going to test for
- calls a function with that data
- asserts that the function did the right thing with the value I specified (paralyzed)
That's a lot to do in only 3 lines of code--in fact, it's hard to imagine a shorter test; we've got one each for arrange / act / assert). For the three criteria I laid out earlier, it's a 10/10 for each one.
Welp, the whole point of this post is to explain how magicalFunctionThatCreatesExactlyTheMockDataIWant doesn't have to be magical, but it does take some effort to set up.
Implementing magicalFunctionThatCreatesExactlyTheMockDataIWant
We can use the @graphql-tools/mock package to write a function that, given a schema and a document node, outputs mock data:
import { buildASTSchema } from "graphql"
import { addMocksToSchema } from "@graphql-tools/mock"
import SDL from "src/api/graphql/schema.graphql"
const schema = buildASTSchema(SDL)
export function magicalFunctionThatCreatesExactlyTheMockDataIWant(documentNode) {
const executableSchema = addMocksToSchema({ schema })
const result = executeSync({ schema: executableSchema, document: documentNode })
return result.data
}
Alright, let's break down what this function is doing.
- We're importing a schema definition language document (as
SDL
) that describes our schema. In this case it's a schema file I generated by introspecting a public api for pokΓ©mon cards. - Next, we're using
buildASTSchema
to parse the SDL into aGraphQLSchema
which is the format that@graphql-tools/mock
wants. - Now we're ready to implement our function: we're creating an executable schema by feeding our schema to
addMocksToSchema
. Now we can run queries against this mocked schema and get realistic responses: any field we request in a query will receive a mock value of the type described by the schema. ie if we request a field calledname
, and it's typed asString
in the GraphQL API that we used to create our schema, we'll get a response like{ data: { myQuery: { name: "Hello, World" } } }
- Calling
executeSync
with our executable schema and the document node that describes our operation gives us a realistic response!
Straightforward enough, right?
Well, we haven't fulfilled our original promise--there's no way to add specific values to the mock response. Let's add that real quick:
import { buildASTSchema } from "graphql"
import { addMocksToSchema } from "@graphql-tools/mock"
import SDL from "src/api/graphql/schema.graphql"
const schema = buildASTSchema(SDL)
export function magicalFunctionThatCreatesExactlyTheMockDataIWant(documentNode, mocks) {
const executableSchema = addMocksToSchema({ schema, mocks })
const result = executeSync({ schema: executableSchema, document: documentNode })
return result.data
}
Is it really that easy? Kind of. The hard part about this function is typing it!
addMocksToSchema
's mocks
argument is supposed to be a Resolvers object, which would look something like this:
const resolvers = {
Query: () => ({ someQuery: () => ({ someField: () => generateRandomValue() })}),
Mutation: () => ({ someMutation: () => ({ someField: () => generateRandomValue() })})
Entity: () => ({ someField: () => generateRandomValue() })
}
NOTE: anything you don't define in the mock resolvers object is created automatically for you by addMocksToSchema
.
You only need to provide overrides for specific fields in your tests, e.g.
const resolvers = { Entity: () => ({ someField: "paralyzed" }) }
and addMocksToSchema
takes care of the rest. Problem is, we're working on a client.
We don't have resolvers on the client, so how are we supposed to write a type for
magicalFunctionThatCreatesExactlyTheMockDataIWant
's mocks argument?
Luckily, we can use code generation to create a resolvers type, even if no resolvers exist.
import { CodegenConfig } from "@graphql-codegen/cli"
export default {
overwrite: true,
schema: ["src/api/graphql/schema.graphql"],
documents: ["src/**/*.graphql", "!src/api/*", "src/**/*.tsx", "src/**/*.ts"],
generates: {
"src/api/graphql/resolvers.ts": {
plugins: ["typescript", "typescript-resolvers"],
},
},
} satisfies CodegenConfig
This codegen configuration will generate a resolvers type for us! Let's talk about what it's doing:
schema
: this should point to your SDL document (same one we used for created the executable schema), but it could also point to an API introspection endpoint.generates
: each key in this object should be a path to a file you want to generate. In this case, we've got one key (realistic projects will have several) that leads toresolvers.ts
plugins
: for theresolvers.ts
file we want to generate, we're using the typescript plugin first, which translates the SDL types to TypeScript types. Second, we're using the "typescript-resolvers" plugin to generate resolver types (it references the types from the "typescript" plugin to define its own types)
Alright! How do we use the Resolvers
type generated in resolvers.ts
to typecheck our magical function?
We could try naively applying it to the addMocksToSchema
mocks
parameter, but if we look carefully
at addMocksToSchema
's signature, we'll notice something:
export declare function addMocksToSchema<TResolvers = IResolvers>({
schema,
store: maybeStore,
mocks,
typePolicies,
resolvers: resolversOrFnResolvers,
preserveResolvers
}: IMockOptions<TResolvers>): GraphQLSchema;
Ok, this is some fancy footwork--we've got to look at IMockOptions
to understand what mocks
is supposed to be.
type IMockOptions<TResolvers = IResolvers> = {
schema: GraphQLSchema;
store?: IMockStore;
mocks?: IMocks<TResolvers>;
typePolicies?: {
[typeName: string]: TypePolicy;
};
resolvers?: Partial<TResolvers> | ((store: IMockStore) => Partial<TResolvers>);
preserveResolvers?: boolean;
};
Ok, mocks is typed by two types:
IMocks
, which@graphql-tools/mock
defines, andTResolvers
, which is a generic type argument, and in this case that means we're supposed to provide it.
That's how our generated Resolvers
type fits in:
import { buildASTSchema } from "graphql"
import { addMocksToSchema, IMocks } from "@graphql-tools/mock"
import { Resolvers } from "src/api/graphql/resolvers"
import SDL from "src/api/graphql/schema.graphql"
const schema = buildASTSchema(SDL)
export function magicalFunctionThatCreatesExactlyTheMockDataIWant(documentNode, mocks: IMocks<Resolvers>) {
const executableSchema = addMocksToSchema({ schema, mocks })
const result = executeSync({ schema: executableSchema, document })
return result.data
}
Cool! Now we have a type that tells us how to define the mocks
object.
Let's try applying that to our test:
import { expect, test } from 'vitest'
import { app, GetCardsDocument } from './app'
import { createMockData } from 'src/api/mock-data-utils/create-mock-data'
test('app', () => {
const data = magicalFunctionThatCreatesExactlyTheMockDataIWant(GetCardsDocument, { AbilitiesListItem: { effect: () => "paralyzed" } })
const result = app(data)
expect(result).toBe("paralyzed")
})
Holy shit! This actually works, and it's still only three lines (if slightly more complex). Let's break down what's happening:
- We're providing our mock data function with a DocumentNode just as before
- In our
mocks
object, we're defining an entity resolver forAbilitiesListItem
, to ensure itseffect
property has the specific value we're looking for in our test. - When we call that function, it produces an object that looks like this:
{
"cards": [
{
"id": "Hello World",
"abilities": [
{
"effect": "paralyzed",
"name": "Hello World",
"__typename": "AbilitiesListItem"
},
{
"effect": "paralyzed",
"name": "Hello World",
"__typename": "AbilitiesListItem"
}
],
"__typename": "Card"
},
{
"id": "Hello World",
"abilities": [
{
"effect": "paralyzed",
"name": "Hello World",
"__typename": "AbilitiesListItem"
},
{
"effect": "paralyzed",
"name": "Hello World",
"__typename": "AbilitiesListItem"
}
],
"__typename": "Card"
}
]
}
You're probably thinking "whoa, that's a lot of stuff!". It is! Let's also break down why there's so much data here!
- First we see the
cards
array property, which corresponds to thecards
query in the GraphQL API. - The
cards
array has 2 entries in it. Why? This is the default behavior for@graphql-tools/mock
. When it encounters an array field, it produces an array of length 2 by default. You can change this. - We see
"Hello World"
being used a lot--this is the default value that@graphql-tools/mock
uses for anyString
field it encounters. effect
has our"paralyzed"
value just as we wanted!
Hooray! That was a lot work, but we wrote a dream interface for writing tests. Are we done? Kind of.
Let's try writing another test. Remember how I said you can change how @graphql-tools/mock
resolves arrays?
Let's do that:
test('app', () => {
const data = createMockData(GetCardsDocument, { Query: { cards: () => [{ abilities: [{ effect: "paralyzed" }] }] }})
const result = app(data)
expect(result).toBe("paralyzed")
})
Here, we're using the Query
property in the mocks
object to tell @graphql-tools/mock
exactly how many Cards
we want returned in the cards
array. We're also defining a specific value for our card's AbilityListItem
's name,
same as before but through the Query resolver instead of the AbilityListItem
resolver.
Oh no! We have a TypeScript error on cards
:
Type '() => { abilities: { effect: string; }[]; }[]' is not assignable to type 'Resolver<Maybe<Maybe<ResolverTypeWrapper<Card>>[]>, {}, any, Partial<QueryCardsArgs>> | undefined'.
Type '() => { abilities: { effect: string; }[]; }[]' is not assignable to type 'ResolverFn<Maybe<Maybe<ResolverTypeWrapper<Card>>[]>, {}, any, Partial<QueryCardsArgs>>'.
Type '{ abilities: { effect: string; }[]; }[]' is not assignable to type 'Maybe<Maybe<ResolverTypeWrapper<Card>>[]> | Promise<Maybe<Maybe<ResolverTypeWrapper<Card>>[]>>'.
Type '{ abilities: { effect: string; }[]; }[]' is not assignable to type 'Maybe<ResolverTypeWrapper<Card>>[]'.
Type '{ abilities: { effect: string; }[]; }' is not assignable to type 'Maybe<ResolverTypeWrapper<Card>>'.
Type '{ abilities: { effect: string; }[]; }' is missing the following properties from type 'Card': category, id, legal, localId, and 3 more.ts(2322)
Bleh, that's a lot of text. Always remember: TypeScript errors should be read from the bottom up:
Type '{ abilities: { effect: string; }[]; }' is missing the following properties from type 'Card': category, id, legal, localId, and 3 more.ts(2322)
Ok, so the object we're providing in the cards
array is missing some properties.
To fix this TypeScript error, we could add all those properties, but that would have
a disastrous effect on test criteria #2 and #3. Besides, if we needed these properties
for our query, we know @graphql-tools/mock
would provide them automatically for us.
So the type must be wrong! Let's fix it:
import { CodegenConfig } from "@graphql-codegen/cli"
export default {
overwrite: true,
schema: ["src/api/graphql/schema.graphql"],
documents: ["src/**/*.graphql", "!src/api/*", "src/**/*.tsx", "src/**/*.ts"],
generates: {
"src/api/graphql/resolvers.ts": {
config: {
resolverTypeWrapperSignature: "RecursivePartial<T>",
},
plugins: [
"typescript",
"typescript-resolvers",
{ add: { content: "type RecursivePartial<T> = T extends object ? { [K in keyof T]?: RecursivePartial<T[K]> } : T" } },
],
},
},
} satisfies CodegenConfig
Ok this is starting to look a little complicated, but bear with me! Let's break it down:
- We added a
config
property to oursrc/api/graphql/resolvers.ts
object. This lets us define custom behavior. - In that
config
property, we've definedresolverTypeWrapperSignature
, which is supposed to be a TypeScript type that wraps the resolver's return type. In this case we're saying we want the return type to be a recursive partial type (so we won't have to define all the properties;@graphql-tools/mock
does that for us at runtime) - In
plugins
we've added an object that lets us simply add a line to the generatedresolvers.ts
file, and it's a definition forRecursivePartial
. That way when the resolvers' return types are wrapped with ourresolverTypeSignature
, the definition ofRecursivePartial
is available in the file
Now if we run codegen to re-generate resolvers.ts
we find that our mocks object no longer has
a type error! Hooray!
Ok, that was even more work. NOW, are we done? Depending on your perspective, I have either bad news or good news for you. TypeScript types, like great works of art, are never finished, only abandoned.
You could leave well enough alone and use this function in your codebase as it is! However, here are some missing pieces of functionality to consider before you abandon this blog post:
@graphql-tools/mock's IMock type is too permissive
A test like this will fail because the mocks object is defined wrong, but you won't see a TypeScript error:
test('app', () => {
const data = createMockData(GetCardsDocument, { Qeury: { cards: () => [{ abilities: [{ effect: "paralyzed" }] }] }})
const result = app(data)
expect(result).toBe("paralyzed")
})
This is something we can fix! What's going on here is that IMocks
type is an intersection between two types:
export type IMocks<TResolvers = IResolvers> = {
[TTypeName in keyof TResolvers]?: {
[TFieldName in keyof TResolvers[TTypeName]]: TResolvers[TTypeName][TFieldName] extends (args: any) => any ? () => ReturnType<TResolvers[TTypeName][TFieldName]> | ReturnType<TResolvers[TTypeName][TFieldName]> : TResolvers[TTypeName][TFieldName];
};
} & {
[typeOrScalarName: string]: IScalarMock | ITypeMock;
};
The first type is a mapped object type. It's taking the keys of TResolvers
and defining types for each resolver that
make sense for the interface addMocksToSchema
needs. This one isn't the problem.
The second type is an object type, and its English equivalent says something like "you can also define any property you want inside an object typed by IMocks, as long as its key is a string". This is flexible to the point of being unhelpful! If I mistype a top-level resolver property name, I want TypeScript to tell me about it!
You can ensure this happens by copying this code in your codebase and omitting the second type in the intersection:
export type IMocks<TResolvers> = {
[TTypeName in keyof TResolvers]?: {
[TFieldName in keyof TResolvers[TTypeName]]: TResolvers[TTypeName][TFieldName] extends (args: any) => any ? () => ReturnType<TResolvers[TTypeName][TFieldName]> | ReturnType<TResolvers[TTypeName][TFieldName]> : TResolvers[TTypeName][TFieldName];
};
}
I need to mock custom scalar types
Let's say your API has fields that are ISO8601 date strings (accept no substitutes!). Well,
if they're typed as String
in the GraphQL API, our createMockData
function is going to provide values that
make sense for a String
field: "Hello, World!"
.
This is really irritating because your API should be providing you type information to trust; you
shouldn't have to validate date fields in your API's responses.
Here's what you can do: add a custom scalar type to your API like DateTime
, which returns an ISO8601 format string,
and ensure your API follows through with its promise, of course.
Next, we need to provide a scalar mock to our createMockData
function, which ensures that every field typed with DateTime
gets a ISO8601 format string by default:
import { Resolvers } from "src/api/graphql/resolvers"
const globalMocks: MockResolvers = {
DateTime: () => new Date().toISOString()
}
export function magicalFunctionThatCreatesExactlyTheMockDataIWant(documentNode, mocks: IMocks<Resolvers>) {
const executableSchema = addMocksToSchema({ schema, mocks: {...globalMocks, ...mocks} })
const result = executeSync({ schema: executableSchema, document })
return result.data
}
Yay! Our test will pass, but uh-oh! The MockResolvers
type does not like the implementation we provided to our DateTime
resolver.
That's just another case where the Resolvers
type, intended for server-side use, isn't quite a good match for
what addMocksToSchema
really wants. We can use codegen tools to fix this!
First, add a scalar type mapping to your codegen config, which will generate a Scalars type we'll use in the next step:
import { CodegenConfig } from "@graphql-codegen/cli"
export default {
// ...
generates: {
"src/api/graphql/resolvers.ts": {
config: {
// ...
scalars: {
DateTime: "string"
},
},
},
},
} satisfies CodegenConfig
The generated Scalars type looks something like this:
export type Scalars = {
ID: { input: string; output: string; }
String: { input: string; output: string; }
Boolean: { input: boolean; output: boolean; }
Int: { input: number; output: number; }
Float: { input: number; output: number; }
DateTime: { input: string; output: string; } // this is the only custom one; defining the scalars map just makes predefined scalar types explicit
};
Now we can modify our custom IMocks
type to ask for the type of Scalar mock that addMocksToSchema
really wants:
import { Resolvers, Scalars } from "src/api/graphql/resolvers"
type IMocks<TResolvers> = {
[TTypeName in keyof TResolvers]?: TTypeName extends keyof Scalars
? () => Scalars[TTypeName]["output"]
: {
[TFieldName in keyof TResolvers[TTypeName]]: TResolvers[TTypeName][TFieldName] extends (...args: any) => any
? (() => ReturnType<TResolvers[TTypeName][TFieldName]>) | ReturnType<TResolvers[TTypeName][TFieldName]>
: TResolvers[TTypeName][TFieldName];
}
}
In plain English, this type is saying: for each top-level key of the Resolvers type, let's define a new value for the property. If the top-level key also exists in the generated Scalars type, then the value ought to be a function that returns the "output" type for that key's entry in the Scalars type. If top-level key doesn't exist in the Scalars map, carry on as we did before.
Now our globalMocks object is correctly typed!
Ugh, the IMocks type (as well as our custom version of it) permits a mock type that addMocksToSchema
can't actually use!
This part is particularly annoying, I'm not even gonna front. But basically this test fails because addMocksToSchema
doesn't
actually use mock values if at least one of the top 2 nested properties isn't a function:
test('app', () => {
const data = createMockData(GetCardsDocument, { AbilitiesListItem: { effect: "paralyzed" } })
const result = app(data)
expect(result).toBe("paralyzed")
})
My reaction after putting several days' worth of effort into creating a nicely typed interface for tests, only to learn that there's a bizarre edge case:
Luckily, dear reader, I have endured this pain, and can share with you the not-obvious-at-all solution. We can naively start in our custom IMocks type--just make the object mapping portion of the type be a function that returns an object instead of an object:
import type { Scalars } from "src/api/graphql/resolvers"
type IMocks<TResolvers> = {
[TTypeName in keyof TResolvers]?: TTypeName extends keyof Scalars
? () => Scalars[TTypeName]["output"]
: () => {
[TFieldName in keyof TResolvers[TTypeName]]: TResolvers[TTypeName][TFieldName] extends (...args: any) => any
? (() => ReturnType<TResolvers[TTypeName][TFieldName]>) | ReturnType<TResolvers[TTypeName][TFieldName]>
: TResolvers[TTypeName][TFieldName];
}
}
This will lead you to some weird TypeScript errors. Be not afraid. I'll save you the tedium of explaining why (I am getting ready to abandon this blog post), but the way you fix it is, once again, in codegen:
import type { CodegenConfig } from "@graphql-codegen/cli"
export default {
// ...
generates: {
"src/api/graphql/resolvers.ts": {
config: {
// ...
customResolverFn: "TResult | (() => TResult)",
},
},
},
} satisfies CodegenConfig
This has the side effect of disallowing one flavor of mock object type that addMocksToSchema
actually accepts,
but, disallowing things that should be disallowed is of a higher value in my book.
Is this starting to get complicated? A bit! Does it work well? You bet your ass it does.
I wrote this interface (and some more complicated ones for creating Apollo MockedResponse
s about 2 years ago,
and now, when I search for usages of these functions in our codebase, I get 375 results in 58 files
.
Check out a repo with fully-working examples (and tests you can run!) here