Skip to main content
Testing is essential for ensuring your Viaduct resolvers work correctly. This guide covers unit testing resolvers, integration testing, and best practices.

Unit Testing Resolvers

Viaduct provides test utilities to help you test resolvers in isolation.

Test Base Class

Extend ViaductResolverTestBase for resolver unit tests:
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import viaduct.api.grts.User
import viaduct.tenant.runtime.test.ViaductResolverTestBase

class UserNodeResolverTest : FunSpec(), ViaductResolverTestBase {
    private val userService = mockk<UserService>()
    private val resolver = UserNodeResolver(userService)
    
    init {
        test("should resolve user by id") {
            // Arrange
            val userId = "12345"
            every { userService.getUser(userId) } returns UserData(
                id = userId,
                firstName = "Alice",
                lastName = "Smith",
                email = "[email protected]"
            )
            
            val ctx = contextForResolver(resolver, userId)
            
            // Act
            val result = resolver.resolve(ctx)
            
            // Assert
            result.getFirstName() shouldBe "Alice"
            result.getLastName() shouldBe "Smith"
            result.getEmail() shouldBe "[email protected]"
        }
    }
}

Creating Test Contexts

Use contextForResolver() to create execution contexts:
// For node resolvers
val ctx = contextForResolver(
    resolver = resolver,
    id = "user-123"
)

// For field resolvers
val ctx = contextForResolver(
    resolver = resolver,
    parent = parentObject,
    arguments = arguments
)

Testing Node Resolvers

Simple Node Resolver Test

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.mockk.*
import viaduct.api.grts.Character
import viaduct.tenant.runtime.test.ViaductResolverTestBase

class CharacterNodeResolverTest : StringSpec(), ViaductResolverTestBase {
    private val characterRepository = mockk<CharacterRepository>()
    private val resolver = CharacterNodeResolver(characterRepository)
    
    init {
        "should resolve character by id" {
            // Given
            val characterId = "1"
            val characterData = Character(
                id = characterId,
                name = "Luke Skywalker",
                birthYear = "19BBY",
                height = 172
            )
            
            every { characterRepository.findById(characterId) } returns characterData
            
            val ctx = contextForResolver(resolver, characterId)
            
            // When
            val result = resolver.resolve(ctx)
            
            // Then
            result.getName() shouldBe "Luke Skywalker"
            result.getBirthYear() shouldBe "19BBY"
            result.getHeight() shouldBe 172
            
            verify { characterRepository.findById(characterId) }
        }
        
        "should handle missing character" {
            // Given
            every { characterRepository.findById(any()) } returns null
            
            val ctx = contextForResolver(resolver, "999")
            
            // When/Then
            shouldThrow<IllegalArgumentException> {
                resolver.resolve(ctx)
            }
        }
    }
}

Batch Node Resolver Test

import io.kotest.matchers.collections.shouldHaveSize
import viaduct.api.FieldValue

class CharacterBatchResolverTest : StringSpec(), ViaductResolverTestBase {
    private val characterRepository = mockk<CharacterRepository>()
    private val resolver = CharacterNodeResolver(characterRepository)
    
    init {
        "should batch resolve multiple characters" {
            // Given
            val characterIds = listOf("1", "2", "3")
            val characters = listOf(
                Character(id = "1", name = "Luke Skywalker"),
                Character(id = "2", name = "Leia Organa"),
                Character(id = "3", name = "Han Solo")
            )
            
            every { characterRepository.findByIds(characterIds) } returns characters
            
            val contexts = characterIds.map { id ->
                contextForResolver(resolver, id)
            }
            
            // When
            val results = resolver.batchResolve(contexts)
            
            // Then
            results shouldHaveSize 3
            results.forEach { it.isValue shouldBe true }
            
            results[0].value.getName() shouldBe "Luke Skywalker"
            results[1].value.getName() shouldBe "Leia Organa"
            results[2].value.getName() shouldBe "Han Solo"
        }
        
        "should handle partial failures in batch" {
            // Given
            val characterIds = listOf("1", "2", "999")
            val characters = listOf(
                Character(id = "1", name = "Luke"),
                Character(id = "2", name = "Leia")
            )
            
            every { characterRepository.findByIds(characterIds) } returns characters
            
            val contexts = characterIds.map { 
                contextForResolver(resolver, it)
            }
            
            // When
            val results = resolver.batchResolve(contexts)
            
            // Then
            results shouldHaveSize 3
            results[0].isValue shouldBe true
            results[1].isValue shouldBe true
            results[2].isError shouldBe true
        }
    }
}

Testing Field Resolvers

Simple Field Resolver

class UserDisplayNameResolverTest : StringSpec(), ViaductResolverTestBase {
    private val resolver = UserDisplayNameResolver()
    
    init {
        "should combine first and last name" {
            // Given
            val user = User.Builder(mockContext)
                .firstName("Alice")
                .lastName("Smith")
                .build()
            
            val ctx = contextForResolver(
                resolver = resolver,
                parent = user
            )
            
            // When
            val result = resolver.resolve(ctx)
            
            // Then
            result shouldBe "Alice Smith"
        }
        
        "should handle null first name" {
            // Given
            val user = User.Builder(mockContext)
                .firstName(null)
                .lastName("Smith")
                .build()
            
            val ctx = contextForResolver(resolver, user)
            
            // When
            val result = resolver.resolve(ctx)
            
            // Then
            result shouldBe "Smith"
        }
    }
}

Field Resolver with Arguments

class UserPostsResolverTest : StringSpec(), ViaductResolverTestBase {
    private val postService = mockk<PostService>()
    private val resolver = UserPostsResolver(postService)
    
    init {
        "should resolve user posts with pagination" {
            // Given
            val userId = "user-1"
            val posts = listOf(
                PostData(id = "1", title = "First Post"),
                PostData(id = "2", title = "Second Post")
            )
            
            every { 
                postService.getUserPosts(
                    userId = userId,
                    offset = 0,
                    limit = 11,
                    status = null
                )
            } returns posts
            
            val user = User.Builder(mockContext)
                .id(globalIdFor(User.Reflection, userId))
                .build()
            
            val args = User_Posts_Arguments.Builder()
                .first(10)
                .build()
            
            val ctx = contextForResolver(
                resolver = resolver,
                parent = user,
                arguments = args
            )
            
            // When
            val result = resolver.resolve(ctx)
            
            // Then
            result.getEdges().size shouldBe 2
            verify {
                postService.getUserPosts(userId, 0, 11, null)
            }
        }
    }
}

Testing Mutations

class CreateUserMutationTest : StringSpec(), ViaductResolverTestBase {
    private val userService = mockk<UserService>()
    private val resolver = CreateUserMutation(userService)
    
    init {
        "should create user with valid input" {
            // Given
            val input = CreateUserInput.Builder()
                .name("Alice Smith")
                .email("[email protected]")
                .bio("Developer")
                .build()
            
            val userId = "new-user-id"
            every { 
                userService.createUser(
                    name = "Alice Smith",
                    email = "[email protected]",
                    bio = "Developer"
                )
            } returns userId
            
            val args = Mutation_CreateUser_Arguments.Builder()
                .input(input)
                .build()
            
            val ctx = contextForResolver(
                resolver = resolver,
                arguments = args
            )
            
            // When
            val result = resolver.resolve(ctx)
            
            // Then
            result.getId().internalID shouldBe userId
            verify {
                userService.createUser("Alice Smith", "[email protected]", "Developer")
            }
        }
        
        "should reject invalid email" {
            // Given
            val input = CreateUserInput.Builder()
                .name("Alice")
                .email("invalid-email")
                .build()
            
            every { userService.createUser(any(), any(), any()) } throws 
                IllegalArgumentException("Invalid email")
            
            val args = Mutation_CreateUser_Arguments.Builder()
                .input(input)
                .build()
            
            val ctx = contextForResolver(resolver, args)
            
            // When/Then
            shouldThrow<IllegalArgumentException> {
                resolver.resolve(ctx)
            }
        }
    }
}

Integration Testing

Test your GraphQL API end-to-end:

With Ktor

import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe

class GraphQLIntegrationTest : StringSpec({
    "should execute GraphQL query" {
        testApplication {
            application {
                configureViaduct()
            }
            
            val response = client.post("/graphql") {
                contentType(ContentType.Application.Json)
                setBody("""
                    {
                        "query": "{ user(id: \"User:1\") { id name email } }"
                    }
                """)
            }
            
            response.status shouldBe HttpStatusCode.OK
            
            val body = response.bodyAsText()
            body shouldContain "\"name\":\"Alice Smith\""
        }
    }
})

With Micronaut

import io.kotest.core.spec.style.StringSpec
import io.kotest.matchers.shouldBe
import io.micronaut.http.HttpRequest
import io.micronaut.http.client.HttpClient
import io.micronaut.http.client.annotation.Client
import io.micronaut.test.extensions.kotest.annotation.MicronautTest
import jakarta.inject.Inject

@MicronautTest
class GraphQLIntegrationTest : StringSpec() {
    @Inject
    @field:Client("/")
    lateinit var client: HttpClient
    
    init {
        "should query user by id" {
            val query = """
                query GetUser {
                    user(id: "User:1") {
                        id
                        name
                        email
                    }
                }
            """
            
            val request = HttpRequest.POST("/graphql", mapOf(
                "query" to query
            ))
            
            val response = client.toBlocking().retrieve(request, Map::class.java)
            val data = response["data"] as Map<*, *>
            val user = data["user"] as Map<*, *>
            
            user["name"] shouldBe "Alice Smith"
        }
    }
}

Test Fixtures

Create reusable test data builders:
object TestFixtures {
    fun createUser(
        id: String = "test-user",
        firstName: String = "Test",
        lastName: String = "User",
        email: String = "[email protected]"
    ) = UserData(
        id = id,
        firstName = firstName,
        lastName = lastName,
        email = email
    )
    
    fun createPost(
        id: String = "test-post",
        title: String = "Test Post",
        content: String = "Test content",
        authorId: String = "test-user"
    ) = PostData(
        id = id,
        title = title,
        content = content,
        authorId = authorId
    )
}

// Usage
class SomeResolverTest : StringSpec() {
    init {
        "should do something" {
            val user = TestFixtures.createUser(
                firstName = "Alice",
                email = "[email protected]"
            )
            // ...
        }
    }
}

Mocking Services

Use MockK for service mocking:
import io.mockk.*

class UserResolverTest : StringSpec() {
    private val userService = mockk<UserService>()
    
    init {
        beforeEach {
            clearMocks(userService)
        }
        
        "should call service with correct parameters" {
            // Setup mock
            every { userService.getUser("123") } returns UserData(
                id = "123",
                name = "Alice"
            )
            
            // Test code...
            
            // Verify calls
            verify(exactly = 1) { userService.getUser("123") }
        }
        
        "should handle service errors" {
            // Mock exception
            every { userService.getUser(any()) } throws RuntimeException("Service down")
            
            shouldThrow<RuntimeException> {
                // Test code...
            }
        }
    }
}

Testing with GlobalIDs

class PostResolverTest : StringSpec(), ViaductResolverTestBase {
    private val postService = mockk<PostService>()
    private val resolver = PostAuthorResolver(postService)
    
    init {
        "should resolve author from GlobalID" {
            // Create GlobalID
            val authorId = globalIdFor(User.Reflection, "author-123")
            
            val post = Post.Builder(mockContext)
                .authorId(authorId)
                .build()
            
            val ctx = contextForResolver(resolver, post)
            
            // When
            val result = resolver.resolve(ctx)
            
            // Then
            result.getId() shouldBe authorId
        }
    }
}

Testing Pagination

class UserPostsConnectionTest : StringSpec(), ViaductResolverTestBase {
    private val postService = mockk<PostService>()
    private val resolver = UserPostsResolver(postService)
    
    init {
        "should return connection with correct pagination" {
            // Given
            val posts = (1..11).map { i ->
                PostData(id = "$i", title = "Post $i")
            }
            
            every {
                postService.getUserPosts(
                    userId = "user-1",
                    offset = 0,
                    limit = 11,
                    status = null
                )
            } returns posts
            
            val args = User_Posts_Arguments.Builder()
                .first(10)
                .build()
            
            val ctx = contextForResolver(resolver, args)
            
            // When
            val result = resolver.resolve(ctx)
            
            // Then
            result.getEdges().size shouldBe 10
            result.getPageInfo().getHasNextPage() shouldBe true
            result.getPageInfo().getHasPreviousPage() shouldBe false
        }
    }
}

Best Practices

1

Test resolver logic, not framework

Focus on testing your business logic:
// Good - tests business logic
"should calculate total from items" {
    val items = listOf(10, 20, 30)
    result.getTotal() shouldBe 60
}

// Bad - tests framework behavior
"should call node resolver" {
    // Don't test Viaduct's internal behavior
}
2

Use test fixtures

Create reusable test data:
object TestData {
    fun user(name: String = "Test") = UserData(...)
}
3

Mock external dependencies

Mock services, databases, and HTTP clients:
private val userService = mockk<UserService>()
private val httpClient = mockk<HttpClient>()
4

Test edge cases

Cover error scenarios:
"should handle null values" { }
"should handle empty lists" { }
"should handle missing data" { }
5

Verify service calls

Check that services are called correctly:
verify { userService.getUser("123") }
verify(exactly = 1) { postService.create(any()) }

Real-World Test Example

@Resolver
class CharacterFilmCountResolver @Inject constructor(
    private val filmRepository: FilmRepository
) : CharacterResolvers.FilmCount() {
    override suspend fun batchResolve(
        contexts: List<Context>
    ): List<FieldValue<Int>> {
        val characterIds = contexts.map { 
            it.objectValue.getId().internalID 
        }
        
        val filmCounts = filmRepository.getFilmCountsByCharacters(characterIds)
        
        return contexts.map { ctx ->
            val characterId = ctx.objectValue.getId().internalID
            FieldValue.ofValue(filmCounts[characterId] ?: 0)
        }
    }
}

Testing Tools

Kotest

Powerful Kotlin testing framework with multiple styles

MockK

Mocking library designed for Kotlin

AssertJ

Fluent assertion library

JUnit 5

Industry-standard testing platform

Dependencies

build.gradle.kts
dependencies {
    testImplementation(libs.viaduct.test.fixtures)
    testImplementation(libs.kotest.runner.junit)
    testImplementation(libs.kotest.assertions.core)
    testImplementation(libs.mockk)
    testImplementation(libs.assertj.core)
}

Next Steps

Project Setup

Review project structure and setup

Writing Resolvers

Learn resolver implementation patterns

Further Reading

Build docs developers (and LLMs) love