Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Expose test filters as standalone tool #2055

Merged
merged 4 commits into from
Jul 1, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ include(
":corellium:sandbox",

":tool:apk",
":tool:filter",
":tool:shard",
":tool:shard:calculate",
":tool:shard:obfuscate",
Expand Down
9 changes: 9 additions & 0 deletions tool/filter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Filtering test targets

Tool for filtering test cases using predefined test targets.

### References

* Module type - [tool](../../../docs/architecture.md#tool)
* Dependency type - [static](../../../docs/architecture.md#static_dependencies)
* Public API - [Filter.kt](./src/main/kotlin/flank/filter/Filter.kt)
19 changes: 19 additions & 0 deletions tool/filter/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
kotlin(Plugins.Kotlin.PLUGIN_JVM)
}

repositories {
jcenter()
mavenCentral()
maven(url = "https://kotlin.bintray.com/kotlinx")
}

tasks.withType<KotlinCompile> { kotlinOptions.jvmTarget = "1.8" }

dependencies {
implementation(Dependencies.KOTLIN_COROUTINES_CORE)

testImplementation(Dependencies.JUNIT)
}
13 changes: 13 additions & 0 deletions tool/filter/src/main/kotlin/flank/filter/Filter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package flank.filter

import flank.filter.internal.fromTestTargets

/**
* Factory for creating [ShouldRun] function from given [targets].
*/
fun createTestCasesFilter(targets: List<String>): ShouldRun =
fromTestTargets(targets).shouldRun

typealias ShouldRun = (Target) -> Boolean

typealias Target = Pair<String, List<String>>
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package flank.filter.internal

import flank.filter.Target
import flank.filter.internal.factory.allOf
import flank.filter.internal.factory.anyOf
import flank.filter.internal.factory.fromTestFile
import flank.filter.internal.factory.not
import flank.filter.internal.factory.withAnnotation
import flank.filter.internal.factory.withClassName
import flank.filter.internal.factory.withPackageName
import flank.filter.internal.factory.withSize

internal fun fromTestTargets(testTargets: List<String>): Test.Filter {
val parsedFilters: List<Test.Filter> = testTargets
.asSequence()
.map(String::trim)
.map { parseTarget(it).createFilter() }
.toList()

// select test method name filters and short circuit if they match ex: class a.b#c
val annotationFilters = parsedFilters.filter { it.isAnnotation }.toTypedArray()
val otherFilters = parsedFilters.filterNot { it.isAnnotation }
val exclude = otherFilters.filter { it.describe.startsWith("not") }.toTypedArray()
val include = otherFilters.filterNot { it.describe.startsWith("not") }.toTypedArray()

return allOf(*annotationFilters, *exclude, anyOf(*include))
}

private fun parseTarget(target: String): Target =
target.split(" ", limit = 2).run {
require(size == 2) { "Invalid argument: $target" }
first() to last().split(",").map(String::trim).apply {
require(isNotEmpty()) { "Empty args parsed from $target" }
}
}

private fun Target.createFilter(): Test.Filter =
when (type) {
Test.Target.Type.TEST_CLASS -> withClassName(args)
Test.Target.Type.NOT_TEST_CLASS -> not(withClassName(args))
Test.Target.Type.TEST_PACKAGE -> withPackageName(args)
Test.Target.Type.NOT_TEST_PACKAGE -> not(withPackageName(args))
Test.Target.Type.ANNOTATION -> withAnnotation(args)
Test.Target.Type.NOT_ANNOTATION -> not(withAnnotation(args))
Test.Target.Type.TEST_FILE -> fromTestFile(args)
Test.Target.Type.NOT_TEST_FILE -> not(fromTestFile(args))
Test.Target.Type.TEST_SIZE -> withSize(args)
else -> throw IllegalArgumentException("Filtering option $type not supported")
}

private val Target.type get() = first
private val Target.args get() = second
52 changes: 52 additions & 0 deletions tool/filter/src/main/kotlin/flank/filter/internal/Test.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package flank.filter.internal

import flank.filter.ShouldRun

internal object Test {

/**
* TestFilter similar to https://junit.org/junit4/javadoc/4.12/org/junit/runner/manipulation/Filter.html
*
* Annotations are tracked as all annotation filters must match on a test.
*
* @property describe - description of the filter
* @property shouldRun - lambda that returns if a TestMethod should be included in the test run
* **/
data class Filter(
val describe: String,
val shouldRun: ShouldRun,
val isAnnotation: Boolean = false
)

/**
* Supports arguments defined in androidx.test.internal.runner.RunnerArgs
*
* Multiple annotation arguments will result in the intersection.
* https://developer.android.com/reference/android/support/test/runner/AndroidJUnitRunner
* https://cloud.google.com/sdk/gcloud/reference/firebase/test/android/run
*/
internal object Target {

object Type {
const val TEST_CLASS = "class"
const val NOT_TEST_CLASS = "notClass"

const val TEST_SIZE = "size"

const val ANNOTATION = "annotation"
const val NOT_ANNOTATION = "notAnnotation"

const val TEST_PACKAGE = "package"
const val NOT_TEST_PACKAGE = "notPackage"

const val TEST_FILE = "testFile"
const val NOT_TEST_FILE = "notTestFile"
}

object Size {
const val LARGE = "large"
const val MEDIUM = "medium"
const val SMALL = "small"
}
}
}
12 changes: 12 additions & 0 deletions tool/filter/src/main/kotlin/flank/filter/internal/factory/AllOf.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package flank.filter.internal.factory

import flank.filter.internal.Test

internal fun allOf(vararg filters: Test.Filter) = Test.Filter(
describe = "allOf ${filters.map { it.describe }}",
shouldRun = { testMethod ->
filters.isEmpty() || filters.all { filter ->
filter.shouldRun(testMethod)
}
}
)
10 changes: 10 additions & 0 deletions tool/filter/src/main/kotlin/flank/filter/internal/factory/AnyOf.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package flank.filter.internal.factory

import flank.filter.internal.Test

internal fun anyOf(vararg filters: Test.Filter) = Test.Filter(
describe = "anyOf ${filters.map { it.describe }}",
shouldRun = { testMethod ->
filters.isEmpty() || filters.any { filter -> filter.shouldRun(testMethod) }
}
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package flank.filter.internal.factory

import flank.filter.internal.Test
import java.io.IOException
import java.nio.file.Files
import java.nio.file.Paths

internal fun fromTestFile(args: List<String>): Test.Filter {
require(args.size == 1) { "Invalid file path" }
val path = Paths.get(args[0])
try {
val lines = Files.readAllLines(path)
// this is really an implementation detail:
// being the package name most generic one, it is able to filter properly if you pass the package name,
// the fully qualified class name or the fully qualified method name.
return withPackageName(lines)
} catch (e: IOException) {
throw IllegalArgumentException("Unable to read testFile", e)
}
}
11 changes: 11 additions & 0 deletions tool/filter/src/main/kotlin/flank/filter/internal/factory/Not.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package flank.filter.internal.factory

import flank.filter.internal.Test

internal fun not(filter: Test.Filter) = Test.Filter(
describe = "not (${filter.describe})",
shouldRun = { testMethod ->
filter.shouldRun(testMethod).not()
},
isAnnotation = filter.isAnnotation
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package flank.filter.internal.factory

import flank.filter.internal.Test

internal fun withAnnotation(filter: List<String>) = Test.Filter(
describe = "withAnnotation (${filter.joinToString(", ")})",
shouldRun = { (_, annotations) ->
annotations.any { it in filter }
},
isAnnotation = true
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package flank.filter.internal.factory

import flank.filter.internal.Test

internal fun withClassName(classNames: List<String>): Test.Filter {
// splits foo.bar.TestClass1#testMethod1 into [foo.bar.TestClass1, testMethod1]
val classFilters = classNames.map { it.extractClassAndTestNames() }

return Test.Filter(
describe = "withClassName (${classNames.joinToString(", ")})",
shouldRun = { (name, _) ->
name.extractClassAndTestNames().matchFilters(classFilters)
}
)
}

private fun String.extractClassAndTestNames() = split("#")

private fun List<String>.matchFilters(classFilters: List<List<String>>): Boolean {
fun List<String>.className() = first()
fun List<String>.methodName() = last()
return classFilters.any { filter ->
// When filter.size == 1 all test methods from the class should run therefore we do not compare method names
// When filter.size != 1 only particular test from the class should be launched and we need to compare method names as well
className() == filter.className() && (filter.size == 1 || methodName() == filter.methodName())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package flank.filter.internal.factory

import flank.filter.internal.Test

internal fun withPackageName(packageNames: List<String>) = Test.Filter(
describe = "withPackageName (${packageNames.joinToString(", ")})",
shouldRun = { (name, _) ->
packageNames.any { packageName ->
name.startsWith(packageName)
}
}
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package flank.filter.internal.factory

import flank.filter.internal.Test

internal fun withSize(args: List<String>): Test.Filter {
val filter = args.map(sizeAnnotations::getValue)
return Test.Filter(
describe = "withSize (${filter.joinToString(", ")})",
shouldRun = { (_, annotations) ->
// Ensure that all annotation with a name matching this size are detected, like
// https://developer.android.com/reference/android/support/test/filters/LargeTest or
// https://developer.android.com/reference/androidx/test/filters/LargeTest
annotations.any { it.split(".").last() in filter }
},
isAnnotation = true
)
}

private val sizeAnnotations: Map<String, String> =
setOf(
Test.Target.Size.LARGE,
Test.Target.Size.MEDIUM,
Test.Target.Size.SMALL,
).associateWith { size ->
size.replaceFirstChar { char -> char.uppercaseChar() } + "Test"
}.withDefault { size ->
throw IllegalArgumentException("Unknown size $size")
}
Loading