Testing
Overview¶
At their core, resolvers are functions: they take inputs and produce outputs. That makes them a natural fit for straightforward JUnit-style unit tests.
The catch is that resolver inputs are highly stylized. Rather than plain method parameters, a resolver receives an ExecutionContext whose shape — which arguments are present, what the parent object looks like, what query values are available — is determined by the resolver's @Resolver annotation and the schema fields it serves. Constructing a valid ExecutionContext by hand is tedious and error-prone.
ResolverTestBase removes that friction. It provides a type-safe DSL for building ExecutionContext values that exactly match what a given resolver expects, so tests stay focused on behavior rather than wiring.
Consider the following schema, where FooLabelResolver derives label from the
parent's name:
A minimal example:
@OptIn(ExperimentalApi::class)
class FooLabelResolverTest : ResolverTestBase() {
@Test
fun `returns label`() = runTest {
val result = runFieldResolver(FooLabelResolver()) {
objectValue = Foo.of(context) { name("bar") }
}
assertEquals("bar", result)
}
}
The sections below cover each resolver type and the full set of available spec properties.
ResolverTestBase (Experimental)¶
Note: These APIs are marked
@ExperimentalApiand may change in future releases.
Setup¶
Extend ResolverTestBase — no additional configuration needed. Resolvers run in isolation with no HTTP, DI framework, or Spring startup. The schema loads automatically from classpath resources.
@OptIn(ExperimentalApi::class)
class FooResolverTest : ResolverTestBase() {
@Test
fun `name of test`() = runTest {
val result = runFieldResolver(FooLabelResolver()) {
objectValue = ...
arguments = ...
}
assertEquals(..., result)
}
}
The spec lambda ({ objectValue = ..., arguments = ... }) is how you supply the resolver's inputs. The next section explains how to construct those values.
Constructing Test Inputs¶
The context: ExecutionContext property is available in every test. Use it with the Type.of(context) { … } DSL to build GRT objects:
The builder form also works but is more verbose:
Fields that take arguments have a generated arguments type, built the same way — see With arguments.
Building GlobalIDs¶
Use globalIDFor directly on the test class — it is a convenience wrapper around context.globalIDFor.
APIs¶
Each method runs a specific resolver type via a typed spec lambda. The compiler enforces that you only set properties that match the resolver's declared types.
| Method | Spec properties |
|---|---|
runFieldResolver |
objectValue, queryValue, arguments, contextQueryValues, referenceSpy |
runFieldBatchResolver |
objectValues, queryValues, referenceSpy |
runNodeResolver |
id (required), referenceSpy |
runNodeBatchResolver |
ids, referenceSpy |
runMutationFieldResolver |
queryValue, arguments, contextQueryValues, contextMutationValues, referenceSpy |
Every spec also has requestContext for seeding header/scope data.
Field resolver¶
Use runFieldResolver and set objectValue to a GRT built with Type.of(context).
@Test
fun `returns label`() = runTest {
val result = runFieldResolver(FooLabelResolver()) {
objectValue = Foo.of(context) { name("bar") }
}
assertEquals("bar", result)
}
With arguments¶
Set arguments alongside objectValue using the generated Type_Field_Arguments.of(context) DSL.
Schema:
@Test
fun `returns uppercase label when flag is set`() = runTest {
val result = runFieldResolver(FooLabelResolver()) {
objectValue = Foo.of(context) { name("bar") }
arguments = Foo_Label_Arguments.of(context) { uppercase(true) }
}
assertEquals("BAR", result)
}
Mocking ctx.query¶
Set contextQueryValues to stub the results of ctx.query(...) calls the resolver
makes during execution. Pass a single Query built with Query.of(context) to return
the same value for every query the resolver issues; a ctx.query(...) call with no
stubbed value throws.
Schema:
@Test
fun `reads label from ctx query`() = runTest {
val queryValue = Query.of(context) {
node(Foo.of(context) { name("bar") })
}
val result = runFieldResolver(FooLabelResolver()) {
contextQueryValues = listOf(queryValue)
}
assertEquals("bar", result)
}
To return different values depending on the selection set the resolver requests, wrap
each Query in a QueryForSelection(selections, query). Lookups match on the rendered
selection set; an unwrapped Query acts as the fallback for any selection. At most one
unwrapped Query may be supplied.
contextQueryValues = listOf(
QueryForSelection("node { id }", queryWithId),
QueryForSelection("node { label }", queryWithLabel),
)
Verifying root field references (ctx.ref)¶
Set referenceSpy to a ReferenceSpy() to record the root field references
the resolver creates during execution with ctx.ref(...). A root field reference points at a
field on a factory (namespace) type reachable from the root — for example LabelFactory.format,
exposed via Query.labelFactory.
References are not stubbed. The resolver receives an unresolved reference, exactly as it would in production, and that reference holds no data — reading a field from it throws. Assertions therefore run after the resolver returns, against the calls the spy recorded.
Schema:
extend type Query {
labelFactory: LabelFactory
}
type LabelFactory @namespaceType {
format(text: String!): FormattedLabel @resolver
}
assertCalledExactly takes the same generated factory calls production code uses, and compares
them in order, including repeats:
@Test
fun `formats the label through the label factory`() = runTest {
val spy = ReferenceSpy()
runFieldResolver(FooLabelResolver()) {
objectValue = Foo.of(context) { name("bar") }
referenceSpy = spy
}
spy.assertCalledExactly(
LabelFactory.format { text("bar") },
)
}
When the expectation cannot name an exact value — a substring of a string the resolver assembled,
say — assert a property of the recorded arguments instead. assertCallArgumentsOf selects every
call to one root field, in call order, and infers the arguments type from the generated field:
spy.assertCallArgumentsOf(LabelFactory.Fields.format) { args ->
args.any { it.text.contains("bar") }
}
assertCallArgumentsOfFirst is the same for a single call, and fails when the resolver created
none:
A resolver that creates a reference without a referenceSpy to record it fails with an error
naming the field it referenced.
Field batch resolver¶
Use runFieldBatchResolver and pass a list of objects as objectValues. Call .get() on each result to extract the value.
Schema:
@Test
fun `resolves label for each foo in batch`() = runTest {
val foos = listOf("a", "b", "c").map { Foo.of(context) { name(it) } }
val results = runFieldBatchResolver(FooLabelBatchResolver()) {
objectValues = foos
}
assertEquals(3, results.size)
results.forEach { fv -> assertNotNull(fv.get()) }
}
objectValues.size must equal queryValues.size when queryValues is provided.
Node resolver¶
Use runNodeResolver and set id using globalIDFor — this property is required.
Schema:
@Test
fun `fetches foo by id`() = runTest {
val result = runNodeResolver(FooNodeResolver()) {
id = globalIDFor(Foo.Reflection, "42")
}
assertEquals("42", result.getIdOrThrow())
}
Node batch resolver¶
Use runNodeBatchResolver and pass a list of GlobalIDs as ids. Call .get() on each result to extract the resolved object.
Schema:
@Test
fun `fetches multiple foos by id`() = runTest {
val ids = listOf("1", "2").map { globalIDFor(Foo.Reflection, it) }
val results = runNodeBatchResolver(FooNodeResolver()) {
this.ids = ids
}
assertEquals(2, results.size)
}
Mutation resolver¶
Use runMutationFieldResolver and build the input and arguments with the generated .of(context) DSL.
Schema:
@Test
fun `creates foo and returns it`() = runTest {
val input = CreateFooInput.of(context) { label("bar") }
val result = runMutationFieldResolver(CreateFooMutation()) {
arguments = Mutation_CreateFoo_Arguments.of(context) { input(input) }
}
assertEquals("bar", result!!.getLabelOrThrow())
}
For worked examples against a real schema, see the Star Wars Testing examples.