Integration Testing

Write resolver unit tests and HTTP integration tests using Viaduct.

The Star Wars demo shows how to build a Viaduct tenant. This page teaches how to test it—both through end-to-end HTTP calls and with fast, focused resolver unit tests.

  • If you want a gentle overview of what gets tested and why, see Testing Overview.
  • If you want to jump straight to code, start with Use DefaultAbstractResolverTestBase.

What you will learn

  • How to run integration tests against /graphql with @SpringBootTest and TestRestTemplate.
  • How to write unit tests for resolvers using DefaultAbstractResolverTestBase.
  • How to test field, node, query, and batch resolvers.
  • How to pass arguments, build GlobalID values, and (optionally) control selection sets.

Integration tests (HTTP)

Integration tests exercise the full stack by sending GraphQL over HTTP to a real Spring Boot server.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ResolverIntegrationTest {
@Autowired
private lateinit var restTemplate: TestRestTemplate

@LocalServerPort
private var port: Int = 0

private val objectMapper = ObjectMapper()

private fun executeGraphQLQuery(query: String): JsonNode {
    val headers = HttpHeaders()
    headers.contentType = MediaType.APPLICATION_JSON

    val request = mapOf("query" to query)
    val entity = HttpEntity(request, headers)

    val response = restTemplate.postForEntity(
        "http://localhost:$port/graphql",
        entity,
        String::class.java
    )

    return objectMapper.readTree(response.body)
}

@Nested
inner class QueryResolvers {
    // Note: Individual node query tests are covered by StarWarsNodeResolversTest

    @Test
    fun `should resolve allCharacters list`() {

Integration tests are useful for:

  • Verifying end-to-end behavior of the GraphQL API.
  • Catching issues with dependency injection, application startup, and configuration.
  • Testing real HTTP request/response cycles.
  • Smoke tests before a release.

Use DefaultAbstractResolverTestBase (unit testing resolvers)

DefaultAbstractResolverTestBase is the fast path to exercise resolvers in isolation. It:

  • Builds an ExecutionContext and resolver‑specific context objects for you.
  • Lets you call resolver resolve()/batchResolve() without HTTP, DI frameworks, or Spring.
  • Provides helpers to create GlobalIDs and to run field, node, and batch resolvers.

Create a test class that extends the base and overrides getSchema():

@OptIn(ExperimentalCoroutinesApi::class)
class CharacterResolverUnitTests : DefaultAbstractResolverTestBase() {
override fun getSchema(): ViaductSchema =
    SchemaFactory(DefaultCoroutineInterop)
        .fromResources("com.example.starwars", Regex(".*\\.graphqls"))

@Test
fun `DisplayNameResolver returns name correctly`(): Unit =
    runBlocking {
        val resolver = CharacterDisplayNameResolver()

Resources are loaded from the given directory. The regex should match all your *.graphqls files.

Key helpers you can find

  • context: ExecutionContext Use it to build GRT objects via generated *.Builder(context) and to create GlobalIDs with context.globalIDFor(Type.Reflection, "internal-id").

  • runFieldResolver(resolver, objectValue, queryValue, arguments) Executes a field resolver and returns its value.

  • runFieldBatchResolver(resolver, objectValues, queryValues) Executes a batch field resolver and returns a list of FieldValue<T>.

  • runNodeResolver(resolver, id) / runNodeBatchResolver(resolver, ids) Executes a node resolver (single or batch) for one or more GlobalIDs.

Optionally, all runners accept selections and contextQueryValues when you need to customize the selection set or seed the context with root query results.


Field resolver

Example: test a simple field resolver that formats a character’s display name.

@OptIn(ExperimentalCoroutinesApi::class)
class CharacterResolverUnitTests : DefaultAbstractResolverTestBase() {
override fun getSchema(): ViaductSchema =
    SchemaFactory(DefaultCoroutineInterop)
        .fromResources("com.example.starwars", Regex(".*\\.graphqls"))

@Test
fun `DisplayNameResolver returns name correctly`(): Unit =
    runBlocking {
        val resolver = CharacterDisplayNameResolver()

        val result = runFieldResolver(
            resolver = resolver,
            objectValue = Character.Builder(context).name("Leia Organa").build(),
        )

        assertEquals("Leia Organa", result)
    }

@Test
fun `DisplaySummaryResolver returns formatted name and birth year`(): Unit =
    runBlocking {
        val resolver = CharacterDisplaySummaryResolver()

        val result = runFieldResolver(
            resolver = resolver,
            objectValue = Character.Builder(context).name("Darth Vader").birthYear("41.9BBY").build(),
        )

        assertEquals("Darth Vader (41.9BBY)", result)
    }

Notes:

  • objectValue represents the object returned by the resolver’s required selection set.
  • If your resolver takes arguments, build them with the generated ..._Arguments.Builder(context).

Query resolver

Example: test with limit arguments.

@Test
fun `allCharacters respects limit and maps fields`(): Unit =
    runBlocking {
        val limit = 3
        val resolver = AllCharactersQueryResolver()

        val args = Query_AllCharacters_Arguments.Builder(context)
            .limit(limit)
            .build()

        val result = runFieldResolver(
            resolver = resolver,
            arguments = args
        )

        assertNotNull(result)
        assertEquals(limit, result!!.size)
        val ref = CharacterRepository.findAll().first()
        val first = result.first()!!
        assertEquals(ref.name, first.getName())
        assertEquals(ref.birthYear, first.getBirthYear())
    }

Notes:

  • For query resolvers, you usually omit objectValue and queryValue—the runner supplies defaults.

Node resolver

Example: resolve a Film node by GlobalID.

@Test
fun `film by id returns the correct Film using node resolver`(): Unit =
    runBlocking {
        val ref = FilmsRepository.getAllFilms().first()
        val resolver = FilmNodeResolver()

        // Create global ID for the film
        val filmGlobalId = context.globalIDFor(Film.Reflection, ref.id)

        // Use runNodeResolver to fetch film
        val result = runNodeResolver(resolver, filmGlobalId)

        assertNotNull(result)
        assertEquals(ref.title, result.getTitle())

Tips:

  • In this demo, internal ids are small strings (e.g., "4"). Your app will use your own id scheme.

Batch resolver

Example: batch node resolver returning multiple starships.

@Test
fun `CharacterBatchNodeResolver resolves multiple ids`() =
    runBlocking {
        val resolver = CharacterNodeResolver()

        val ids = listOf("1", "2").map {
            context.globalIDFor(Character.Reflection, it)
        }

        val results = runNodeBatchResolver(
            resolver = resolver,
            ids = ids
        )

For batch field resolvers, use runFieldBatchResolver(resolver, objectValues = ..., queryValues = ...).

Header examples

This code shows how to test a field resolver that depends on header values.

private fun executeGraphQLQuery(
    query: String,
    headers: Map<String, String> = emptyMap()
): JsonNode {
    val httpHeaders = HttpHeaders()
    httpHeaders.contentType = MediaType.APPLICATION_JSON

    // Add any custom headers (for scopes, etc.)
    headers.forEach { (key, value) ->
        httpHeaders.set(key, value)
    }

    val request = mapOf("query" to query)
    val entity = HttpEntity(request, httpHeaders)

    val response = restTemplate.postForEntity(
        "http://localhost:$port/graphql",
        entity,
        String::class.java
    )

    return objectMapper.readTree(response.body)

Advanced: controlling selections and context queries (optional)

Most resolvers do not need this. When they do:

  • Selections: pass a SelectionSet to the runner if the resolver branches on selected subfields.
  • Context query values: seed the root query cache with specific results using contextQueryValues. This is rare; prefer straightforward inputs when possible.

When to choose unit vs integration tests

  • Choose unit tests with DefaultAbstractResolverTestBase for:

    • Resolver behavior, argument handling, mapping logic, and edge cases.
    • Fast feedback during development (no HTTP, DB, or Spring).
  • Choose integration tests for:

    • Wiring across modules, startup behavior, and JSON marshalling.
    • Request filters and application security.

In practice, keep most tests unit‑level, plus a few end‑to‑end HTTP tests as smoke tests.

Troubleshooting

  • IllegalArgumentException about query/object sizes When using runFieldBatchResolver, make sure objectValues.size == queryValues.size.

  • Missing schema types Ensure getSchema() loads all *.graphqls files. The regex ".*\.graphqls" is a safe default.

  • GlobalID building Use context.globalIDFor(Type.Reflection, "<your-internal-id>"). Avoid constructing IDs by hand.

Full examples in the repo

See these demo tests in demoapps/starwars/src/test/kotlin/viaduct/demoapp/starwars:

They are small, focused, and make good templates for new tests.

Summary

  • Use integration tests to verify the end‑to‑end server.
  • Use DefaultAbstractResolverTestBase to test resolvers fast and in isolation.
  • Prefer small, targeted tests with clear inputs/outputs and minimal mocking.