Unit Testing Resolvers
Viaduct provides test utilities to help you test resolvers in isolation.Test Base Class
ExtendViaductResolverTestBase 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
UsecontextForResolver() 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
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
}
Use test fixtures
Create reusable test data:
object TestData {
fun user(name: String = "Test") = UserData(...)
}
Mock external dependencies
Mock services, databases, and HTTP clients:
private val userService = mockk<UserService>()
private val httpClient = mockk<HttpClient>()
Test edge cases
Cover error scenarios:
"should handle null values" { }
"should handle empty lists" { }
"should handle missing data" { }
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
Recommended Libraries
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