DEV Community

Leonardo Colman Lopes
Leonardo Colman Lopes

Posted on • Edited on

Automated Screenshot Taking in Android using Kotest and Ktor

1. Introduction

Automated screenshot testing is essential in Android development for ensuring UI consistency across updates and devices. Manually verifying UI changes is time-consuming and error-prone, making automation a vital practice.

In this guide, we’ll show how to use Kotest for capturing screenshots and Ktor for uploading them to a local server. This setup streamlines UI validation, improves efficiency, and centralizes screenshot storage for better collaboration and quality assurance.

This article contains a simpler implementation of what's currently in production on Petals App

2. Setup

To get started with automated screenshot testing using Kotest and Ktor, we’ll need to prepare our project by configuring the necessary tools and libraries.

Update our project’s build.gradle.kts file to include the required libraries for testing and screenshot uploads. Below are the key dependencies:

dependencies {
    // Kotest for writing tests
    androidTestImplementation("br.com.colman:kotest-runner-android:${version}")

    // Fuel library for uploading screenshots
    androidTestImplementation("com.github.kittinunf.fuel:fuel:${version}")
}

Enter fullscreen mode Exit fullscreen mode

Here:

  • Kotest provides the framework for writing and running tests.
  • Kotest-Android provides specific bindings for running Android tests.
  • Fuel simplifies HTTP requests for uploading screenshots during tests.

3. Preparing a Local Server to Receive Screenshots

A critical component of automated screenshot testing is a server to store the captured images for validation or archival purposes. We’ll use Ktor, a lightweight Kotlin-based framework, to set up a local server capable of handling screenshot uploads.

Create a Kotlin script file named refresh_screenshots.main.kts. This script will configure and launch a local server using Ktor.

3.1. Disabling Animations

Animations in the Android emulator can affect screenshot consistency. The script disables them using ADB commands:

fun disableAnimations() {
    listOf(
        "adb shell settings put global window_animation_scale 0",
        "adb shell settings put global transition_animation_scale 0",
        "adb shell settings put global animator_duration_scale 0"
    ).forEach { command ->
        try {
            val process = Runtime.getRuntime().exec(command)
            process.waitFor()
        } catch (e: Exception) {
            println("Error while trying to disable animations via ADB: ${e.localizedMessage}")
        }
    }
    println("Animations in the emulator have been disabled")
}
Enter fullscreen mode Exit fullscreen mode

3.2. Starting the Ktor Server

The server listens on port 8081 and handles file uploads. Uploaded screenshots are stored in language-specific directories based on parameters (lang and country) in the request:

fun startServer(): NettyApplicationEngine {
    return embeddedServer(Netty, port = 8081) {
        install(ContentNegotiation) {
            json()
        }
        routing {
            post("/upload") {
                val multipart = call.receiveMultipart()
                val lang = call.parameters["lang"] ?: "unknown"
                val country = call.parameters["country"] ?: "unknown"

                multipart.forEachPart { part ->
                    if (part is PartData.FileItem) {
                        val countryHyphen = if (country.isNotBlank()) "-$country" else ""
                        val dir = File("../fastlane/metadata/android/${lang}${countryHyphen}/images/phoneScreenshots")
                        if (!dir.exists()) {
                            dir.mkdirs()
                        }
                        val file = File(dir, part.originalFileName ?: "screenshot.png")
                        part.streamProvider().use { input -> file.outputStream().buffered().use { input.copyTo(it) } }
                    }
                    part.dispose()
                }
                call.respondText("File uploaded successfully", status = HttpStatusCode.OK)
            }
        }
    }.start(wait = false)
}
Enter fullscreen mode Exit fullscreen mode

3.3. Running Tests and Handling the Server

The script runs Android tests using Gradle and stops the server once the tests are completed:

val server = startServer()

disableAnimations()

// Run Android tests
val process = ProcessBuilder(
    "../gradlew",
    "connectedFdroidDebugAndroidTest",
    "-Pandroid.testInstrumentationRunnerArguments.class=br.com.colman.petals.ScreenshotTakerTest"
).inheritIO().start()
val exitCode = process.waitFor()

server.stop(1000, 10000)

Enter fullscreen mode Exit fullscreen mode

3.4. Full Script

Let's take a look at the final result by merging all the scripts and adding relevant imports:

#!/usr/bin/env kotlin

@file:DependsOn("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.10.1")
@file:DependsOn("io.ktor:ktor-server-core-jvm:2.3.13")
@file:DependsOn("io.ktor:ktor-server-netty-jvm:2.3.13")
@file:DependsOn("io.ktor:ktor-server-content-negotiation-jvm:2.3.13")
@file:DependsOn("io.ktor:ktor-serialization-kotlinx-json-jvm:2.3.13")
@file:DependsOn("io.ktor:ktor-server-host-common-jvm:2.3.13")

import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import java.io.File

// Function to disable animations via ADB
fun disableAnimations() {
  listOf(
    "adb shell settings put global window_animation_scale 0",
    "adb shell settings put global transition_animation_scale 0",
    "adb shell settings put global animator_duration_scale 0"
  ).forEach { command ->
    try {
      val process = Runtime.getRuntime().exec(command)
      process.waitFor()
    } catch (e: Exception) {
      println("Error while trying to disable animations via ADB: ${e.localizedMessage}")
    }
  }
  println("Animations in the emulator have been disabled")
}

fun startServer(): NettyApplicationEngine {
  return embeddedServer(Netty, port = 8081) {
    install(ContentNegotiation) {
      json()
    }
    routing {
      post("/upload") {
        val multipart = call.receiveMultipart()
        val lang = call.parameters["lang"] ?: "unknown"
        val country = call.parameters["country"] ?: "unknown"

        multipart.forEachPart { part ->
          if (part is PartData.FileItem) {
            val countryHyphen = if (country.isNotBlank()) "-$country" else ""
            val dir = File("../fastlane/metadata/android/${lang}${countryHyphen}/images/phoneScreenshots")
            if (!dir.exists()) {
              dir.mkdirs()
            }
            val file = File(dir, part.originalFileName ?: "screenshot.png")
            part.streamProvider().use { input -> file.outputStream().buffered().use { input.copyTo(it) } }
          }
          part.dispose()
        }
        call.respondText("File uploaded successfully", status = HttpStatusCode.OK)
      }
    }
  }.start(wait = false)
}


// Start the server
val server = startServer()

// Disable animations
disableAnimations()

// Run Android tests
val process = ProcessBuilder(
  "../gradlew",
  "connectedFdroidDebugAndroidTest",
  "-Pandroid.testInstrumentationRunnerArguments.class=br.com.colman.petals.ScreenshotTakerTest"
).inheritIO().start()
val exitCode = process.waitFor()

// Stop the server
server.stop(1000, 10000)

Enter fullscreen mode Exit fullscreen mode

4. Writing a Screenshot Test in Kotest

This section demonstrates how to create comprehensive screenshot tests using Kotest in an Android project. The provided code integrates Jetpack Compose testing with screenshot capture and upload functionality, supporting multiple locales and organized storage on a local server.

4.1. Key Features of the Screenshot Tests

1. Multi-Locale Testing
The tests iterate over a list of language (lang) and country (country) codes, ensuring the UI is tested across different locales.

2. Screenshot Capture
Screenshots are taken directly from the UI during tests using Jetpack Compose's testing utilities.

3. Automated Uploads
Screenshots are uploaded to a local server via HTTP, organized into directories by language and country.

4.2. Multi-Locale Screenshot Tests

Below is the main test class, which runs multiple tests and captures screenshots at different points:

class ScreenshotTakerTest : FunSpec({

    val locales = listOf(
        "de" to "DE",
        "en" to "US",
        "es" to "ES",
        "fr" to "FR",
        "it" to "IT",
        "pt" to "BR",
        "ru" to "RU",
        "tr" to "TR",
        "uk" to ""
    )

    test("Screenshot for Main Page") {
        runAndroidComposeUiTest<MainActivity> {
            locales.forEach { (lang, country) ->
                activity?.setLocale(Locale(lang, country))
                waitForIdle()

                // Interact with UI
                onNodeWithTag("UsageMainColumn").performTouchInput { swipeUp() }
                waitForIdle()

                // Capture and upload screenshot
                takeScreenshot("main_page.png", lang, country)
            }
        }
    }
})

Enter fullscreen mode Exit fullscreen mode

4.3. Helper Functions

The implementation relies on utility functions for common operations.

Setting the App Locale Updates the app’s locale dynamically during tests:

private fun MainActivity.setLocale(locale: Locale) {
    val resources = baseContext.resources
    Locale.setDefault(locale)
    val config = resources.configuration
    config.setLocale(locale)
    resources.updateConfiguration(config, resources.displayMetrics)
    runOnUiThread { recreate() }
}
Enter fullscreen mode Exit fullscreen mode

Capturing a Screenshot Uses Jetpack Compose's captureToImage() to capture the current UI state:

private fun AndroidComposeUiTest<*>.takeScreenshot(file: String, lang: String, country: String) {
    sleep(3000) // Wait for animations to settle
    val bitmap = onRoot().captureToImage().asAndroidBitmap()
    uploadScreenshot(bitmap, file, lang, country)
}
Enter fullscreen mode Exit fullscreen mode

Uploading the Screenshot Sends the captured screenshot to the local server:

private fun uploadScreenshot(bitmap: Bitmap, fileName: String, lang: String, country: String) {
    val tempFile = File.createTempFile(fileName, null).apply {
        outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
    }
    Fuel.upload("http://10.0.2.2:8081/upload?lang=$lang&country=$country")
        .add(FileDataPart(tempFile, name = "file", filename = fileName))
        .response()
}
Enter fullscreen mode Exit fullscreen mode

Note: 10.0.2.2 is the ip address of the computer running the emulator. It's an special address

5. Conclusion

Automating screenshot testing in Android using Kotest and Ktor streamlines the UI validation process and ensures consistency across multiple locales. By combining Jetpack Compose testing utilities with an efficient local server, developers can easily capture, organize, and analyze screenshots during automated tests.

This approach offers several benefits:

  • Improved Efficiency: Automates tedious manual testing tasks.
  • Comprehensive Coverage: Validates UI behavior across different languages, countries, and configurations.
  • Centralized Storage: Screenshots are uploaded to a server and organized systematically for future analysis.

By adopting this solution, teams can reduce testing overhead, improve collaboration, and ensure a polished, reliable user interface. This setup can further be extended with CI/CD pipelines, enabling seamless integration into modern development workflows.

Start implementing automated screenshot testing today to enhance your app’s quality and streamline your development process.

Top comments (0)