Ktor: Crafting a Stock Portfolio Endpoint – Part 3

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

Definitions | Koin
By using Koin, you describe definitions in modules. In this section we will see how to declare, organize & link your modules.
API Documentation | Alpha Vantage
API Documentation for Alpha Vantage. Alpha Vantage offers free JSON APIs for realtime and historical stock market data with over 50 technical indicators. Supports intraday, daily, weekly, and monthly stock quotes and technical analysis with charting-ready time series.
What is Unit Testing? Definition from WhatIs.com
Unit tests evaluate the smallest testable parts of an application. Explore how to unit test, manual vs. automated testing, advantages and disadvantages.

GitHub Repository

GitHub - trinhan-nguyen/ktor-ferreter
Contribute to trinhan-nguyen/ktor-ferreter development by creating an account on GitHub.

Previous Posts

Ktor: Crafting a Stock Portfolio Endpoint – Part 1
Let’s build a simple server for a stock market-related app using Ktor - a lightweight Kotlin framework. Part 1 only shows how to set up a Ktor project and return a type-safe response.
Ktor: Crafting a Stock Portfolio Endpoint – Part 2
This is the second post in the series of posts that aim to build a single endpoint with Ktor by wrapping the AlphaVantage Quote Endpoint.
Ktor: Crafting a Stock Portfolio Endpoint – Part 2.5
Before we delve into Part 3, which focuses on unit testing, it’s essential to address one key preparation step to improve the testability and reusability of the code: dependency injection.