blob: f78ea6211dab1aea60d264606daf2c7be8ea22d6 [file] [log] [blame]
/*
* Copyright (C) 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.tools.metalava.model.testsuite
import com.android.tools.lint.checks.infrastructure.TestFile
import com.android.tools.lint.checks.infrastructure.TestFiles
import com.android.tools.metalava.model.AnnotationItem
import com.android.tools.metalava.model.ClassItem
import com.android.tools.metalava.model.Codebase
import com.android.tools.metalava.model.ConstructorItem
import com.android.tools.metalava.model.FieldItem
import com.android.tools.metalava.model.Item
import com.android.tools.metalava.model.MethodItem
import com.android.tools.metalava.model.PackageItem
import com.android.tools.metalava.model.source.SourceCodebase
import java.util.ServiceLoader
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.fail
import org.junit.AssumptionViolatedException
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import org.junit.rules.TestRule
import org.junit.runner.Description
import org.junit.runners.Parameterized
import org.junit.runners.model.Statement
/**
* Base class for tests that verify the behavior of model implementations.
*
* This is parameterized by [TestParameters] as even though the tests are run in different projects
* the test results are collated and reported together. Having the parameters in the test name makes
* it easier to differentiate them.
*
* Note: In the top-level test report produced by Gradle it appears to just display whichever test
* ran last. However, the test reports in the model implementation projects do list each run
* separately. If this is an issue then the [ModelSuiteRunner] implementations could all be moved
* into the same project and run tests against them all at the same time.
*/
abstract class BaseModelTest(parameters: TestParameters) {
/** The [ModelSuiteRunner] that this test must use. */
private val runner = parameters.runner
/**
* The [InputFormat] of the test files that should be processed by this test. It must ignore all
* other [InputFormat]s.
*/
private val inputFormat = parameters.inputFormat
@get:Rule val temporaryFolder = TemporaryFolder()
@get:Rule val baselineTestRule: TestRule = BaselineTestRule(runner)
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun testParameters(): Iterable<TestParameters> {
val loader = ServiceLoader.load(ModelSuiteRunner::class.java)
val runners = loader.toList()
if (runners.isEmpty()) {
fail("No runners found")
}
val list =
runners.flatMap { runner ->
runner.supportedInputFormats
.map { inputFormat -> TestParameters(runner, inputFormat) }
.toList()
}
return list
}
}
/**
* Set of inputs for a test.
*
* Currently, this is limited to one file but in future it may be more.
*/
data class InputSet(
/** The [InputFormat] of the [testFiles]. */
val inputFormat: InputFormat,
/** The [TestFile]s to process. */
val testFiles: List<TestFile>,
)
/**
* Create an [InputSet].
*
* It is an error if [testFiles] is empty or if [testFiles] have different [InputFormat]. That
* means that it is not currently possible to mix Kotlin and Java files.
*/
fun inputSet(vararg testFiles: TestFile): InputSet {
if (testFiles.isEmpty()) {
throw IllegalStateException("Must provide at least one source file")
}
val (htmlFiles, nonHtmlFiles) =
testFiles.partition { it.targetRelativePath.endsWith(".html") }
// Make sure that all the test files are the same InputFormat. Ignore HTML files.
val byInputFormat = nonHtmlFiles.groupBy { InputFormat.fromFilename(it.targetRelativePath) }
val inputFormatCount = byInputFormat.size
if (inputFormatCount != 1) {
throw IllegalStateException(
buildString {
append(
"All files in the list must be the same input format, but found $inputFormatCount different input formats:\n"
)
byInputFormat.forEach { (format, files) ->
append(" $format\n")
files.forEach { append(" $it\n") }
}
}
)
}
val (inputFormat, files) = byInputFormat.entries.single()
return InputSet(inputFormat, files + htmlFiles)
}
/**
* Create a [Codebase] from one of the supplied [inputSets] and then run a test on that
* [Codebase].
*
* The [InputSet] that is selected is the one whose [InputSet.inputFormat] is the same as the
* current [inputFormat]. There can be at most one of those.
*/
private fun createCodebaseFromInputSetAndRun(
vararg inputSets: InputSet,
test: (Codebase) -> Unit,
) {
// Run the input set that matches the current inputFormat, if there is one.
inputSets
.singleOrNull { it.inputFormat == inputFormat }
?.let {
val tempDir = temporaryFolder.newFolder()
runner.createCodebaseAndRun(tempDir, it.testFiles, test)
}
}
private fun testFilesToInputSets(testFiles: Array<out TestFile>): Array<InputSet> {
return testFiles.map { inputSet(it) }.toTypedArray()
}
/**
* Create a [Codebase] from one of the supplied [sources] and then run the [test] on that
* [Codebase].
*
* The [sources] array should have at most one [TestFile] whose extension matches an
* [InputFormat.extension].
*/
fun runCodebaseTest(
vararg sources: TestFile,
test: (Codebase) -> Unit,
) {
runCodebaseTest(
sources = testFilesToInputSets(sources),
test = test,
)
}
/**
* Create a [Codebase] from one of the supplied [sources] [InputSet] and then run the [test] on
* that [Codebase].
*
* The [sources] array should have at most one [InputSet] of each [InputFormat].
*/
fun runCodebaseTest(
vararg sources: InputSet,
test: (Codebase) -> Unit,
) {
createCodebaseFromInputSetAndRun(
*sources,
test = test,
)
}
/**
* Create a [SourceCodebase] from one of the supplied [sources] and then run the [test] on that
* [SourceCodebase].
*
* The [sources] array should have at most one [TestFile] whose extension matches an
* [InputFormat.extension].
*/
fun runSourceCodebaseTest(
vararg sources: TestFile,
test: (SourceCodebase) -> Unit,
) {
runSourceCodebaseTest(
sources = testFilesToInputSets(sources),
test = test,
)
}
/**
* Create a [SourceCodebase] from one of the supplied [sources] [InputSet]s and then run the
* [test] on that [SourceCodebase].
*
* The [sources] array should have at most one [InputSet] of each [InputFormat].
*/
fun runSourceCodebaseTest(
vararg sources: InputSet,
test: (SourceCodebase) -> Unit,
) {
createCodebaseFromInputSetAndRun(
*sources,
) {
test(it as SourceCodebase)
}
}
/** Create a signature [TestFile] with the supplied [contents]. */
fun signature(contents: String): TestFile {
return TestFiles.source("api.txt", contents.trimIndent())
}
/** Get the class from the [Codebase], failing if it does not exist. */
fun Codebase.assertClass(qualifiedName: String): ClassItem {
val classItem = findClass(qualifiedName)
assertNotNull(classItem) { "Expected $qualifiedName to be defined" }
return classItem
}
/** Get the package from the [Codebase], failing if it does not exist. */
fun Codebase.assertPackage(pkgName: String): PackageItem {
val packageItem = findPackage(pkgName)
assertNotNull(packageItem) { "Expected $pkgName to be defined" }
return packageItem
}
/** Get the field from the [ClassItem], failing if it does not exist. */
fun ClassItem.assertField(fieldName: String): FieldItem {
val fieldItem = findField(fieldName)
assertNotNull(fieldItem) { "Expected $fieldName to be defined" }
return fieldItem
}
/** Get the method from the [ClassItem], failing if it does not exist. */
fun ClassItem.assertMethod(methodName: String, parameters: String): MethodItem {
val methodItem = findMethod(methodName, parameters)
assertNotNull(methodItem) { "Expected $methodName($parameters) to be defined" }
return methodItem
}
/** Get the constructor from the [ClassItem], failing if it does not exist. */
fun ClassItem.assertConstructor(parameters: String): ConstructorItem {
val methodItem = findMethod(simpleName(), parameters)
assertNotNull(methodItem) { "Expected ${simpleName()}($parameters) to be defined" }
return assertIs(methodItem)
}
/** Get the annotation from the [Item], failing if it does not exist. */
fun Item.assertAnnotation(parameters: String): AnnotationItem {
val annoItem =
modifiers.annotations().filter { it.qualifiedName == parameters }.firstOrNull()
assertNotNull(annoItem) { "Expected item to be annotated with ($parameters)" }
return assertIs(annoItem)
}
}
private const val GRADLEW_UPDATE_MODEL_TEST_SUITE_BASELINE =
"`scripts/refresh-testsuite-baselines.sh` to update the baseline"
/** A JUnit [TestRule] that uses information from the [ModelTestSuiteBaseline] to ignore tests. */
private class BaselineTestRule(private val runner: ModelSuiteRunner) : TestRule {
/**
* The [ModelTestSuiteBaseline] that indicates whether the tests are expected to fail or not.
*/
private val baseline = ModelTestSuiteBaseline.fromResource
override fun apply(base: Statement, description: Description): Statement {
return object : Statement() {
override fun evaluate() {
val expectedFailure =
baseline.isExpectedFailure(description.className, description.methodName)
try {
// Run the test even if it is expected to fail as a change that fixes one test
// may fix more. Instead, this will just discard any failure.
base.evaluate()
if (expectedFailure) {
// If a test that was expected to fail passes then updating the baseline
// will remove that test from the expected test failures.
System.err.println(
"Test was expected to fail but passed, please run $GRADLEW_UPDATE_MODEL_TEST_SUITE_BASELINE"
)
}
} catch (e: Throwable) {
if (expectedFailure) {
// If this was expected to fail then throw an AssumptionViolatedException
// so it is not treated as either a pass or fail.
throw AssumptionViolatedException(
"Test skipped since it is listed in the baseline file for $runner"
)
} else {
// Inform the developer on how to ignore this failing test.
System.err.println(
"Failing tests can be ignored by running $GRADLEW_UPDATE_MODEL_TEST_SUITE_BASELINE"
)
// Rethrow the error
throw e
}
}
}
}
}
}