Retrying Android Tests and Clearing the Database Between the Runs

Intro
While great minds are arguing with each other about whether it is better to disable flaky tests or to retry them, I’d like to show you the way how you can achieve the latter.
This is for the brave, so do it at your own risk! 🤠
Implementing retry strategy
Let’s create two classes:
Retry
- this one will be able to retry any specific testRetryRule
- this one will be responsible for the whole test suite
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.model.Statement
/** Annotation to retry a specific failed test. **/
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
public annotation class Retry(val count: Int)
/** Rule to retry all failed tests. **/
public class RetryRule(private val count: Int) : TestRule {
override fun apply(base: Statement, description: Description): Statement = statement(base, description)
private fun statement(base: Statement, description: Description): Statement {
return object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
val retryAnnotation: Retry? = description.getAnnotation(Retry::class.java)
val retryCount = retryAnnotation?.count ?: count
var caughtThrowable: Throwable? = null
for (i in 0 until retryCount) {
try {
System.err.println("${description.displayName}: run #${(i + 1)} started.")
base.evaluate()
return
} catch (t: Throwable) {
System.err.println("${description.displayName}: run #${(i + 1)} failed.")
caughtThrowable = t
}
}
throw caughtThrowable ?: IllegalStateException()
}
}
}
}
Then let’s create two simple test cases that will hopelessly try to assert the inequality of two different numbers:
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
abstract class BaseTestClass {
@get:Rule
val retryRule = RetryRule(count = 3)
}
class SampleTest : BaseTestClass() {
@Test // <~~~~~~ This test have 3 attempts to pass
fun testNewFancyRetryRule() {
assertEquals(33, 42)
}
@Retry(count = 4)
@Test // <~~~~~~ This test have 4 attempts to pass
fun testNewFancyRetryAnnotation() {
assertEquals(42, 33)
}
}
Clearing database between retries
More than likely, you would like the environment to be as clean as possible for each retry.
To achieve this, let’s implement the DatabaseOperations
class, which will be responsible for clearing the database between each retry of the test:
import android.database.sqlite.SQLiteDatabase
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
public open class DatabaseOperations {
public open fun clearDatabases() {
val databaseOperations = DatabaseOperations()
val dbFiles = databaseOperations.getAllDatabaseFiles().filterNot { shouldIgnoreFile(it.path) }
dbFiles.forEach { clearDatabase(it, databaseOperations) }
}
private fun shouldIgnoreFile(path: String): Boolean {
val ignoredSuffixes = arrayOf("-journal", "-shm", "-uid", "-wal")
return ignoredSuffixes.any { path.endsWith(it) }
}
private fun clearDatabase(dbFile: File, dbOperations: DatabaseOperations) {
dbOperations.openDatabase(dbFile).use { database ->
val tablesToClear = dbOperations.getTableNames(database).filterNot { it == "room_master_table" }
tablesToClear.forEach { dbOperations.deleteTableContent(database, it) }
}
}
private fun getAllDatabaseFiles(): List<File> {
return InstrumentationRegistry.getInstrumentation().targetContext.let { context ->
context.databaseList().map { context.getDatabasePath(it) }
}
}
private fun openDatabase(databaseFile: File): SQLiteDatabase {
return SQLiteDatabase.openDatabase(databaseFile.absolutePath, null, 0)
}
private fun getTableNames(sqLiteDatabase: SQLiteDatabase): List<String> {
sqLiteDatabase.rawQuery("SELECT name FROM sqlite_master WHERE type IN (?, ?)", arrayOf("table", "view"))
.use { cursor ->
val tableNames = ArrayList<String>()
while (cursor.moveToNext()) {
tableNames.add(cursor.getString(0))
}
return tableNames
}
}
private fun deleteTableContent(sqLiteDatabase: SQLiteDatabase, tableName: String) {
sqLiteDatabase.delete(tableName, null, null)
}
}
Thus, the statement
function of the RetryRule
class will undergo some changes:
private fun statement(base: Statement, description: Description): Statement {
return object : Statement() {
@Throws(Throwable::class)
override fun evaluate() {
val retryAnnotation: Retry? = description.getAnnotation(Retry::class.java)
val retryCount = retryAnnotation?.count ?: count
val databaseOperations = DatabaseOperations() // <~~~~~~ new line
var caughtThrowable: Throwable? = null
for (i in 0 until retryCount) {
try {
System.err.println("${description.displayName}: run #${(i + 1)} started.")
base.evaluate()
return
} catch (t: Throwable) {
System.err.println("${description.displayName}: run #${(i + 1)} failed.")
databaseOperations.clearDatabases() // <~~~~~~ new line
caughtThrowable = t
}
}
throw caughtThrowable ?: IllegalStateException()
}
}
}
TLDR
To retry, or not to retry, that is the question. Even though it remains unanswered, you can find the full sample code here 🙂