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}")
}
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")
}
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)
}
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)
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)
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)
}
}
}
})
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() }
}
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)
}
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()
}
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)