Using Apollo Kotlin Data Builders for Testing
All the code described in the article is available here
API
The GraphQL API used for this article is called Countries and returns simple information about continents, countries and languages.
Sandbox: https://studio.apollographql.com/public/countries/explorer
The Query we will be testing:
query Country($code: ID!) {
country(code: $code) {
name
languages {
...LanguageFragment
}
}
}
fragment LanguageFragment on Language {
name
}
Response for CountryQuery("GB"):
{
"data": {
"country": {
"name": "United Kingdom",
"languages": [
{
"name": "English"
}
]
}
}
}
Apollo
Based on the query above, Apollo generates something along these lines:
public data class CountryQuery(
public val code: String,
) : Query<CountryQuery.Data> {
public data class Data(
public val country: Country?,
) : Query.Data
public data class Country(
public val name: String,
public val languages: List<Language>,
)
public data class Language(
public val __typename: String,
public val languageFragment: LanguageFragment,
)
}
public data class LanguageFragment(
public val name: String?,
) : Fragment.Data
The Country data class can be instantiated manually
CountryQuery.Country(
name = "United Kingdom",
languages = listOf(
CountryQuery.Language(
__typename = "Language",
languageFragment = LanguageFragment("English")
)
)
)
However, this involves a lot of boilerplate because there are no default values. Additionally, whenever the query changes the compilation will break (all constructor calls need to be updated)
To mitigate this, Apollo provides Data builders, which provides a nice DSL for the class creation.
Data Builders
The main benefit of builders is that they provide default values for any known types (Unknown custom scalars will be explained later) and they don't care about fragments. Meaning that they don't break so easily when the query is updated.
Unit tests
Usually the Query data will be converted to a domain data structure, in this case it could be:
data class Country(
val name: String,
val language: List<Language>,
)
data class Language(
val name: String
)
Given we have a correctly implemented converter, we could have a test that verifies that the country name is correctly converted:
@Test
fun `The country name is correctly converted`() {
val schema = CountryQuery.Data {
country = buildCountry {
name = "Poland"
}
}.country!!
val result = systemUnderTest.convert(schema)
result.name shouldBe "Poland"
}
The data builder creates the whole Query "Data" that's why we need to access the nullable .country!! at the end.
To avoid repeating the same boilerplate every time, a helper function can be extracted:
@Test
fun `The country name is correctly converted`() {
val schema = createCountry {
name = "Poland"
}
val result = systemUnderTest.convert(schema)
result.name shouldBe "Poland"
}
private fun createCountry(
block: CountryBuilder.() -> Unit
): CountryQuery.Country =
CountryQuery.Data {
country = buildCountry {
block()
}
}.country!!
Thanks to this, the boilerplate is mitigated while still allowing the full usage like:
createCountry {
name = "Poland"
languages = listOf(
buildLanguage {
name = "Polish"
},
)
}
The full converter test is available in the repository.
Custom Scalars
When the data builder encounters a field which was not set inside the DSL, it will use a Resolver to put a default value there. For numbers, it starts at 0 and increments every time it is used, for strings the field name is used.
However, sometimes the GraphQL schema contains a type which Apollo cannot resolve
"""
A custom scalar for presentation purposes
"""
scalar CustomScalar
query CountryWithCustomScalar {
country(code: "GB") {
name
customScalar
}
}
Because Apollo does not know what to do with this type, an error will be thrown if it is not set in the DSL
java.lang.IllegalStateException: Don't know how to instantiate leaf CustomScalar
To help with this, we can define our own resolver
class MyFakeResolver : FakeResolver {
private val delegate = DefaultFakeResolver(__Schema.all)
override fun resolveLeaf(context: FakeResolverContext): Any {
return if (context.mergedField.type.leafType().name == "CustomScalar") {
return ""
} else {
delegate.resolveLeaf(context)
}
}
override fun resolveListSize(context: FakeResolverContext): Int {
return delegate.resolveListSize(context)
}
override fun resolveMaybeNull(context: FakeResolverContext): Boolean {
return delegate.resolveMaybeNull(context)
}
override fun resolveTypename(context: FakeResolverContext): String {
return delegate.resolveTypename(context)
}
}
This resolver uses an empty string for every CustomScalar field and delegates to the default behavior in every other case.
Verification test:
@Test
fun `Does not throw exception with resolver`() {
shouldNotThrowAny {
CountryWithCustomScalarQuery.Data(MyFakeResolver()) {
country = buildCountry {
name = "Custom"
}
}
}
}
@Test
fun `Throws an exception without resolver`() {
shouldThrow<IllegalStateException> {
CountryWithCustomScalarQuery.Data {
country = buildCountry {
name = "Custom"
}
}
}
}
The Resolver and verification test can be found in the repository.
Integration / UI tests
In cases where we want to test our logic along with the network layer, for example through Mock HttpInterceptor or MockServerHandler. The data builder can be used to provide the JSON string.
object Responses {
// {"data":{"country":{"name":"United Kingdom","languages":[{"__typename":"Language","name":"English"}]}}}
val SUCCESS =
CountryQuery.Data {
country = buildCountry {
name = "United Kingdom"
languages = listOf(
buildLanguage {
name = "English"
}
)
}
}.toDataJson()
// {"data":{"country":null}}
val SUCCESS_NULL_SCHEMA = CountryQuery.Data {
country = null
}.toDataJson()
private fun Operation.Data.toDataJson() =
"""{"data":${this.toJsonString()}}"""
private fun CountryQuery.Data.toDataJsonKmm() =
"""{"data":${CountryQuery_ResponseAdapter.Data.obj().toJsonString(this)}}"""
}
The main benefit of using the builder here is that the JSON will always be up-to-date with the latest Query changes and is more resilient to parsing issues.
Sidenote: the JVM Apollo implementation provides a generic Operation.Data.toJsonString() function however on KMM, every query requires its own function (like in the snippet above).
The full integration test code is available here.
Besides Apollo I've also covered Mocking Ktor network requests and In-Memory SQLDelight testing which might interest you. Additionally, I have an article series about what testing mistakes you should avoid if you want to improve your test suite.
GraphQL testing course
Recently I had the pleasure of recording a couple of GraphQL testing course videos which will be a part of the KotlinTesting Unit testing course. Unfortunately, the course is currently only available in Polish, but we're planning to expand it to a more world-wide audience. If you're interested in the English version or have any questions, please let me know.
In the course, I discuss things like:
- How Apollo can be used for building apps using a GraphQL API
- Pros and Cons of different data creation Manual instantiation vs Builders
- Sanity tests for quick prototyping and learning how an API works
- Verifying the system correctness through integration tests which include the network layer
- Different ways of creating Integration tests in Apollo
All the course videos are recorded using a Test Driven Development approach which some might find interesting, along the way I also share some information about my approach to testing.
More information about the course can be found here: