Skip to content

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:

type Foo {
  name: String
  label: String @resolver
}

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 @ExperimentalApi and 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:

val foo = Foo.of(context) { name("bar") }

The builder form also works but is more verbose:

val foo = Foo.Builder(context).name("bar").build()

Fields that take arguments have a generated arguments type, built the same way — see With arguments.

Building GlobalIDs

val id = globalIDFor(Foo.Reflection, "some-internal-id")

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:

type Foo {
  name: String
  label(uppercase: Boolean): String @resolver
}
@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:

type Query {
  node(id: ID!): Node @resolver
}
@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:

spy.assertCallArgumentsOfFirst(LabelFactory.Fields.format) { it.text == "bar" }

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:

type Foo {
  name: String
  label: String @resolver(isBatching: true)
}
@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:

type Foo implements Node @resolver {
  id: ID!
  name: String
  label: String
}
@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:

type Foo implements Node @resolver(isBatching: true) {
  id: ID!
  name: String
  label: String
}
@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:

type Mutation {
  createFoo(input: CreateFooInput!): Foo @resolver
}
@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.