Ktor: Crafting a Stock Portfolio Endpoint – Part 3

Continuing from our detour in Part 2.5, we now turn our focus to today's topic: Unit Testing. This post is part of a series aiming to build a single endpoint with Ktor. Specifically, we're wrapping the AlphaVantage Quote Endpoint. I highly recommend at least skimming through the previous posts if you haven't read them yet, as it will provide useful context. Here are the links to those previous posts: Part 1 (setup & introduction), Part 2 (external endpoint call), Part 2.5 (dependency injection). Having said that, if you're already caught up with the series, let's unit-test our server.
Preparation
For demonstration purposes, we're adding some logic to our endpoint to make it more testable. Currently, the server echoes the AlphaVantage Endpoint's output, as shown below.
{
"Global Quote": {
"01. symbol": "IBM",
"02. open": 172.9,
"03. high": 174.02,
"04. low": 172.48,
"05. price": 173.94,
"06. volume": 3983461,
"07. latest trading day": "2024-01-23",
"08. previous close": 172.83,
"09. change": 1.11,
"10. change percent": "0.6422%"
}
}
An example of the current response
In this section, we'll have the AlphaVantageApi.getQuote
function simplify the JSON structure and only retain the key fields: price
, symbol
, change
, and changePercent
. The streamlined response appears as follows.
{
"price": 173.94,
"symbol": "IBM",
"change": 1.11,
"changePercent": "0.6422%"
}
An example of the simplified response
So, we'll introduce SimpleStockQuote
, a new model mirroring this simplified response.
@Serializable
data class SimpleStockQuote(
val price: Double,
val symbol: String,
val change: Double,
val changePercent: String,
)
SimpleStockQuote.kt
Response Simplification
The logic to convert from AlphaVantageQuote
to SimpleStockQuote
is straightforward and can be done inside the AlphaVantageApiImpl.kt
. However, as we add more AlphaVantage endpoints or advanced transformations in the future, the class could become unwieldy. Such expansion can lead to significant clutter and complexity. The scalable approach is to create a mapper class that maps to SimpleStockQuote
, which makes it a lot more maintainable and testable.
//...
interface SimpleStockQuoteMapper {
fun map(quote: AlphaVantageQuote): SimpleStockQuote
}
class SimpleStockQuoteMapperImpl : SimpleStockQuoteMapper {
override fun map(quote: AlphaVantageQuote): SimpleStockQuote {
val globalQuote = quote.globalQuote
return SimpleStockQuote(
price = globalQuote.price,
symbol = globalQuote.symbol,
change = globalQuote.change,
changePercent = globalQuote.changePercent,
)
}
}
SimpleStockQuoteMapper.kt
The SimpleStockQuoteMapper
is also created using the Bridge design pattern similar to how we created the AlphaVantageApi
in Part 2. The design pattern enables the class to be injected easily and tested separately. Now, we can update the AlphaVantageApi
to return our new object, SimpleStockQuote
.
//...
interface AlphaVantageApi {
suspend fun getQuote(
symbol: String,
apiKey: String,
// This is the only line that changed
): SimpleStockQuote
}
AlphaVantageApi.kt
//...
class AlphaVantageApiImpl(
private val simpleStockQuoteMapper: SimpleStockQuoteMapper,
) : AlphaVantageApi {
override suspend fun getQuote(
symbol: String,
apiKey: String,
): SimpleStockQuote {
//...
httpClient.close()
return simpleStockQuoteMapper.map(
quote = response.body<AlphaVantageQuote>(),
)
}
}
AlphaVantageApiImpl.kt
As you can see, the code in AlphaVantageApiImpl
is clean and readable, thanks to the creation of SimpleStockQuoteMapper
, but there is still a problem. Remember how AlphaVantageApiImpl
was injected in Part 2.5? It does not know how to instantiate simpleStockQuoteMapper
yet. There are 2 ways to configure the applicationModule
to achieve that.
// Option 1
val applicationModule = module {
single<AlphaVantageApi> {
AlphaVantageApiImpl(SimpleStockQuoteMapperImpl())
}
}
// Option 2
val applicationModule = module {
single<SimpleStockQuoteMapper> {
SimpleStockQuoteMapperImpl()
}
single<AlphaVantageApi> {
AlphaVantageApiImpl(get())
}
}
ApplicationModule.kt
There are 2 reasons that Option 2 is preferable. First, if we need to inject SimpleStockQuoteMapper
into some other classes in the future, we don't have to configure that again. Second, the SimpleStockQuoteMapper
is provided as a singleton, which means it doesn't require extra resources to instantiate another mapper.
This strategic setup of SimpleStockQuoteMapper
aligns perfectly with our next focus: diving into the intricacies of Unit Testing. With our preparations complete, let's explore how to effectively test our server's functionality to ensure reliability and robustness.
Unit Testing
First, we need to add the dependencies for the testing infrastructure as usual.
val mockitoVersion: String by project
//...
dependencies {
//...
// For managing dependency injection in tests
implementation("io.insert-koin:koin-test:$koinVersion")
// For mocking object in tests
testImplementation("org.mockito.kotlin:mockito-kotlin:$mockitoVersion")
}
build.gradle.kt
//...
mockitoVersion=5.2.1 // Note: The latest stable version of Koin might differ when you read this
gradle.properties
Let's write our first test for the application. The code below contains comments explaining the reasons behind each code block.
// The class extends KoinTest for built-in functionality to help
// with dependency injection
class ApplicationTest : KoinTest {
private lateinit var api: AlphaVantageApi
private val apiKey = "apiKey"
private val expectedQuote = SimpleStockQuote(
symbol = "symbol",
price = 69.96,
change = 34.98,
changePercent = "100%",
)
// The annotation makes the function setUp run before each test
@BeforeTest
fun setUp() {
// Mocking enables us to simulate its behavior without making
// actual external calls, ensuring our tests are reliable and
// independent of external factors.
api = mock()
// Allows us to use its dependency injection features to
// provide mock dependencies
startKoin {
modules(
module {
single<AlphaVantageApi> { api }
}
)
}
}
// The annotation makes the function tearDown run after each test
@AfterTest
fun tearDown() {
// Ensures that each test starts with a clean slate,
// preventing interference from previous tests.
stopKoin()
}
@Test
fun `when the api returns stock data, then return the corresponding simplified quote`() = testApplication {
// With the api mocked, we can tell it to return the
// expectedQuote if the apiKey matches
whenever(api.getQuote(symbol = any(), apiKey = eq(apiKey)))
.thenReturn(expectedQuote)
// Set up the environment variables
environment {
config = MapApplicationConfig("ktor.environment" to apiKey)
}
val json = Json { prettyPrint = true }
// Running the application with this specific route
val response = client.get("/")
val actualQuote = json.decodeFromString<SimpleStockQuote>(response.bodyAsText())
// Assertions: this is what the test is about
assertEquals(
expected = HttpStatusCode.OK,
actual = response.status,
)
assertEquals(
expected = expectedQuote,
actual = actualQuote,
)
}
}
ApplicationTest.kt
Despite the accomplishment of writing our first test, we encountered an issue: it doesn't work yet. The error says A Koin Application has already been started
. This is because our server already startKoin
automatically when we call install(Koin)
in configureKoin
. Thus, in the test, it runs one more time when we explicitly call startKoin
in the setUp
function. One workaround is to avoid running configureKoin
in a test. Hence, we need to set that up in the Application.kt
by adding isProduction
flag to determine the environment.
//...
fun Application.module(isProduction: Boolean = true) {
if (isProduction) {
configureKoin()
}
//...
}
Application.kt
The isProduction
flag in Application.module
allows us to control the application's behavior depending on the environment. Setting it to false
in tests bypasses production configurations (e.g. configuring Koin), which can interfere with our test setup.
//...
class ApplicationTest : KoinTest {
@Test
fun `when the api returns stock data, then return the corresponding simplified quote`() = testApplication {
application {
module(isProduction = false)
}
//...
}
}
ApplicationTest.kt

Congratulations! You have officially built a server with unit tests. The test above is the most complicated test we can have for our server. Following this, you can write another test when the server fails to get the apiKey
and one for the SimpleStockQuoteMapper
. For further exploration and to see how these principles are applied, feel free to check out my GitHub repo after attempting to write those on your own.
Conclusion
Building a side project with good design patterns and unit tests can distinguish you from other interns applying for the same job. These skills are highly valued in the industry. I hope this post has demonstrated how easy it is to unit-test a Ktor project. Don’t forget to share your thoughts or questions below, and make sure to subscribe to our newsletter for Part 4 (member-only), in which our server has an actual endpoint instead of printing the response to the browser!
Reference



GitHub Repository
Previous Posts



Comments ()