ENT-3444 create test-db module (#5093)

* ENT-3444 define RequiresDB annotation and junit5 extension

* Move to internal

* info to trace

* Limit exposure of gradle imports

* Enable annotation inheritance, and multiple SQL scripts per class or method

* Get the test context class globally for all groups

* usingRemoteDatabase flag
This commit is contained in:
Dominic Fox 2019-06-18 10:57:20 +01:00 committed by Rick Parker
parent df19b444dd
commit f01f8a129e
13 changed files with 489 additions and 1 deletions

View File

@ -396,6 +396,7 @@ bintrayConfig {
'corda-node-api',
'corda-test-common',
'corda-test-utils',
'corda-test-db',
'corda-jackson',
'corda-webserver-impl',
'corda-webserver',

View File

@ -36,10 +36,11 @@ include 'jdk8u-deterministic'
include 'test-common'
include 'test-cli'
include 'test-utils'
include 'test-db'
include 'smoke-test-utils'
include 'node-driver'
// Avoid making 'testing' a project, and allow build.gradle files to refer to these by their simple names:
['test-common', 'test-utils', 'test-cli', 'smoke-test-utils', 'node-driver'].each {
['test-common', 'test-utils', 'test-cli', 'test-db', 'smoke-test-utils', 'node-driver'].each {
project(":$it").projectDir = new File("$settingsDir/testing/$it")
}
include 'tools:explorer'

View File

@ -0,0 +1,23 @@
apply plugin: 'net.corda.plugins.publish-utils'
apply plugin: 'net.corda.plugins.api-scanner'
apply plugin: 'com.jfrog.artifactory'
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8"
implementation "org.junit.jupiter:junit-jupiter-api:${junit_jupiter_version}"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:${junit_jupiter_version}"
testRuntimeOnly "org.junit.platform:junit-platform-launcher:${junit_platform_version}"
testImplementation "org.assertj:assertj-core:$assertj_version"
testImplementation "org.slf4j:slf4j-api:$slf4j_version"
testRuntimeOnly "org.apache.logging.log4j:log4j-slf4j-impl:$log4j_version"
}
jar {
baseName 'corda-test-db'
}
publish {
name jar.baseName
}

View File

@ -0,0 +1,96 @@
package net.corda.testing.internal.db
import org.junit.jupiter.api.extension.*
import java.lang.reflect.AnnotatedElement
/***
* A JUnit 5 [Extension] which invokes a [TestDatabaseContext] to manage database state across three scopes:
*
* * Test run (defined across multiple classes)
* * Test suite (defined in a single class)
* * Test instance (defined in a single method)
*
* A test class will not ordinarily register this extension directly: instead, it is registered for any class having the [RequiresDb]
* annotation (or an annotation which is itself annotated with [RequiresDb]), which it consults to discover which group of tests the
* test class belongs to (`"default"`, if not stated).
*
* The class of the [TestDatabaseContext] used is selected by a system property, `test.db.context` If this system property is not set, the
* class name defaults to the [RequiresDb.defaultContextClassName] stated in the annotation, which in turn defaults to the class of
* [NoOpTestDatabaseContext].
*
* When [BeforeAllCallback.beforeAll] is called prior to executing any test methods in a given class, the [ExtensionContext.Store] of the
* root extension context is used to look up the [TestDatabaseContext] for the class's declared `groupName`, creating and initialising it
* if it does not already exist. This ensures that a [TestDatabaseContext] is created exactly once during each test run for every named
* group of tests using this extension. This context will be closed with a call to [ExtensionContext.Store.CloseableResource.close] once
* the test run completes, tearing down the database state created at the beginning.
*
* For each test suite and test instance, this extension looks at the corresponding class or method to see if it is annotated with
* [RequiresSql] (or any annotations which are themselves annotated with [RequiresSql]), indicating that further SQL setup/teardown is required
* around the current scope. Calls are then made to [TestDatabaseContext.beforeClass], [TestDatabaseContext.beforeTest],
* [TestDatabaseContext.afterTest] and [TestDatabaseContext.afterClass], passing through the names of any SQL scripts to be run.
*
* (Note that the same name is used for setup and teardown, and it is up to the [TestDatabaseContext] to map this to the appropriate SQL
* script for each case).
*/
class DBRunnerExtension : Extension, BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback {
override fun beforeAll(context: ExtensionContext?) {
val required = context?.testClass?.orElse(null)?.requiredSql ?: return
getDatabaseContext(context)?.beforeTest(required)
}
override fun afterAll(context: ExtensionContext?) {
val required = context?.testClass?.orElse(null)?.requiredSql ?: return
getDatabaseContext(context)?.afterClass(required.asReversed())
}
override fun beforeEach(context: ExtensionContext?) {
val required = context?.testMethod?.orElse(null)?.requiredSql ?: return
getDatabaseContext(context)?.beforeTest(required)
}
override fun afterEach(context: ExtensionContext?) {
val required = context?.testMethod?.orElse(null)?.requiredSql ?: return
getDatabaseContext(context)?.afterTest(required.asReversed())
}
private fun getDatabaseContext(context: ExtensionContext?): TestDatabaseContext? {
val rootContext = context?.root ?: return null
val testClass = context.testClass.orElse(null) ?: return null
val annotation = testClass.requiredDb ?:
throw IllegalStateException("Test run with DBRunnerExtension is not annotated with @RequiresDb")
val groupName = annotation.group
val defaultContextClassName = annotation.defaultContextClassName
val store = rootContext.getStore(ExtensionContext.Namespace.create(DBRunnerExtension::class.java.simpleName, groupName))
return store.getOrComputeIfAbsent(
TestDatabaseContext::class.java.simpleName,
{ createDatabaseContext(groupName, defaultContextClassName) },
TestDatabaseContext::class.java)
}
private fun createDatabaseContext(groupName: String, defaultContextClassName: String): TestDatabaseContext {
val className = System.getProperty("test.db.context") ?: defaultContextClassName
val ctx = Class.forName(className).newInstance() as TestDatabaseContext
ctx.initialize(groupName)
return ctx
}
private val Class<*>.requiredDb: RequiresDb? get() = findAnnotations(RequiresDb::class.java).firstOrNull()
private val AnnotatedElement.requiredSql: List<String> get() = findAnnotations(RequiresSql::class.java).map { it.name }.toList()
private fun <T : Any> AnnotatedElement.findAnnotations(annotationClass: Class<T>): Sequence<T> = declaredAnnotations.asSequence()
.filterNot { it.isInternal }
.flatMap { annotation ->
if (annotationClass.isAssignableFrom(annotation::class.java))sequenceOf(annotationClass.cast(annotation))
else annotation.annotationClass.java.findAnnotations(annotationClass)
}
private val Annotation.isInternal: Boolean get() = annotationClass.java.name.run {
startsWith("java.lang") ||
startsWith("org.junit") ||
startsWith("kotlin")
}
}

View File

@ -0,0 +1,20 @@
package net.corda.testing.internal.db
/**
* An implementation of [TestDatabaseContext] which does nothing.
*/
class NoOpTestDatabaseContext : TestDatabaseContext {
override fun initialize(groupName: String) {}
override fun beforeClass(setupSql: List<String>) {}
override fun afterClass(teardownSql: List<String>) {}
override fun beforeTest(setupSql: List<String>) {}
override fun afterTest(teardownSql: List<String>) {}
override fun close() {}
}

View File

@ -0,0 +1,32 @@
package net.corda.testing.internal.db
import org.junit.jupiter.api.extension.ExtendWith
/**
* An annotation which is applied to test classes to indicate that they belong to a group of tests which require a common database
* environment, which is initialized before any of the tests in any of the classes in that group are run, and cleaned up after all of them
* have completed.
*
* @param group The name of the group of tests to which the annotated test belongs, or `"default"` if unstated.
* @param defaultContextClassName The class name of the [TestDatabaseContext] which should be instantiated to manage the database
* environment for these tests, if none is given in the system property `test.db.context./groupName/`. This defaults to the class name of
* [NoOpTestDatabaseContext].
*/
@ExtendWith(DBRunnerExtension::class)
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
annotation class RequiresDb(
val group: String = "default",
val defaultContextClassName: String = "net.corda.testing.internal.db.NoOpTestDatabaseContext")
/**
* An annotation which is applied to test classes and methods to indicate that the corresponding test suite / instance requires SQL scripts
* to be run against its database as part of its setup / teardown.
*
* @param name The name of the SQL script to run. The same name will be used for setup and teardown: it is up to the [TestDatabaseContext] to
* select the actual SQL script based on the context, e.g. `"specialSql"` may be translated to `"/groupName/-specialSql-setup.sql"` or to
* `"/groupName/-specialSql-teardown.sql"` depending on which operation is being performed.
*/
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
annotation class RequiresSql(val name: String)

View File

@ -0,0 +1,66 @@
package net.corda.testing.internal.db
import org.junit.jupiter.api.extension.ExtensionContext
/**
* Interface which must be implemented by any class offering to manage test database environments for tests annotated with [RequiresDb],
* or with annotations which are themselves annotated with [@RequiresDb].
*
* A separate instance of [TestDatabaseContext] will be created and initialised for each group of tests, identified by [RequiresDb.group].
*
* Once all tests in the group have been run, [ExtensionContext.Store.CloseableResource.close] will be called; implementations should use
* this method to tear down the database context.
*/
interface TestDatabaseContext : ExtensionContext.Store.CloseableResource {
companion object {
private val _usingRemoteDatabase = ThreadLocal<Boolean>()
/**
* A flag that an instantiating class can set to indicate to tests that a remote database is in use.
*/
var usingRemoteDatabase: Boolean
get() = _usingRemoteDatabase.get() ?: false
set(value) = _usingRemoteDatabase.set(value)
}
/**
* Called once when the context is first instantiated, i.e. at the start of the test run, before any tests at all have been executed.
*
* @param groupName The name of the group of tests whose database environment is to be managed by this context, as indicated by a
* [RequiresDb] annotation (or annotations which are themselves annotated with [@RequiresDb]) on each test class in this group.
*/
fun initialize(groupName: String)
/**
* Called once before a suite of tests is executed.
*
* @param setupSql The names of any SQL scripts to be run prior to running the suite of tests, as indicated by a [RequiresSql] annotation
* (or annotations which are themselves annotated with [RequiresSql]), on the class containing the test suite. May be empty.
*/
fun beforeClass(setupSql: List<String>)
/**
* Called once after a suite of tests is executed.
*
* @param teardownSql The names of any SQL scripts to be run after running the suite of tests, as indicated by a [RequiresSql] annotation
* (or annotations which are themselves annotated with [RequiresSql]), on the class containing the test suite. May be empty.
*/
fun afterClass(teardownSql: List<String>)
/**
* Called once before a given test is executed.
*
* @param setUpSql The names of any SQL scripts to be run before running the test, as indicated by a [RequiresSql] annotation
* (or annotations which are themselves annotated with [RequiresSql]), on the method defining the test. May be empty.
*/
fun beforeTest(setupSql: List<String>)
/**
* Called once after a given test is executed.
*
* @param teardownSql The names of any SQL scripts to be run after running the test, as indicated by a [RequiresSql] annotation
* (or annotations which are themselves annotated with [RequiresSql]), on the method defining the test. May be empty.
*/
fun afterTest(teardownSql: List<String>)
}

View File

@ -0,0 +1,61 @@
package net.corda.testing.internal.db
import org.assertj.core.api.Assertions.assertThat
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.lang.IllegalStateException
class AssertingTestDatabaseContext : TestDatabaseContext {
companion object {
private val logger: Logger = LoggerFactory.getLogger(AssertingTestDatabaseContext::class.java)
private val expectations = mutableMapOf<String, List<String>>()
fun addExpectations(groupName: String, vararg scripts: String) {
expectations.compute(groupName) { _, expected ->
(expected ?: emptyList()) + scripts.toList()
}
}
}
private lateinit var groupName: String
private val scriptsRun = mutableListOf<String>()
override fun initialize(groupName: String) {
this.groupName = groupName
scriptsRun += "${groupName}-db-setup.sql"
}
override fun beforeClass(setupSql: List<String>) {
scriptsRun += setupSql.map { "$groupName-$it-setup.sql" }
}
override fun afterClass(teardownSql: List<String>) {
scriptsRun += teardownSql.map { "$groupName-$it-teardown.sql" }
}
override fun beforeTest(setupSql: List<String>) {
scriptsRun += setupSql.map { "$groupName-$it-setup.sql" }
}
override fun afterTest(teardownSql: List<String>) {
scriptsRun += teardownSql.map { "$groupName-$it-teardown.sql" }
}
override fun close() {
scriptsRun += "${groupName}-db-teardown.sql"
logger.info("SQL scripts run for group $groupName:\n" + scriptsRun.joinToString("\n"))
val expectedScripts = (listOf("db-setup") + (expectations[groupName] ?: emptyList()) + listOf("db-teardown"))
.map { "$groupName-$it.sql" }
.toTypedArray()
try {
assertThat(scriptsRun).containsExactlyInAnyOrder(*expectedScripts)
} catch (e: AssertionError) {
throw IllegalStateException("Assertion failed: ${e.message}")
}
}
}

View File

@ -0,0 +1,21 @@
package net.corda.testing.internal.db
import org.junit.jupiter.api.Test
@GroupA
class GroupAMoreTests {
@Test
fun setExpectations() {
AssertingTestDatabaseContext.addExpectations("groupA",
"specialSql1-setup", "specialSql2-setup", "specialSql2-teardown", "specialSql1-teardown")
}
@Test
@SpecialSql1
@SpecialSql2
fun moreSpecialSqlRequired() {
}
}

View File

@ -0,0 +1,26 @@
package net.corda.testing.internal.db
import org.junit.jupiter.api.Test
@RequiresDb("groupA", "net.corda.testing.internal.db.AssertingTestDatabaseContext")
@GroupASql
class GroupATests {
@Test
fun setExpectations() {
AssertingTestDatabaseContext.addExpectations("groupA",
"forClassGroupATests-setup", "specialSql1-setup", "specialSql1-teardown", "forClassGroupATests-teardown")
}
@Test
fun noSpecialSqlRequired() {
}
@Test
@SpecialSql1
fun someSpecialSqlRequired() {
}
}

View File

@ -0,0 +1,25 @@
package net.corda.testing.internal.db
import org.junit.jupiter.api.Test
@GroupB
class GroupBTests {
@Test
fun setExpectations() {
AssertingTestDatabaseContext.addExpectations("groupB",
"forClassGroupBTests-setup", "specialSql1-setup", "specialSql1-teardown", "forClassGroupBTests-teardown")
}
@Test
fun noSpecialSqlRequired() {
}
@Test
@SpecialSql1
fun someSpecialSqlRequired() {
}
}

View File

@ -0,0 +1,27 @@
package net.corda.testing.internal.db
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
@RequiresDb("groupA", "net.corda.testing.internal.db.AssertingTestDatabaseContext")
annotation class GroupA
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS)
@RequiresDb("groupB", "net.corda.testing.internal.db.AssertingTestDatabaseContext")
@RequiresSql("forClassGroupBTests")
annotation class GroupB
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
@RequiresSql("specialSql1")
annotation class SpecialSql1
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
@RequiresSql("specialSql2")
annotation class SpecialSql2
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
@RequiresSql("forClassGroupATests")
annotation class GroupASql

View File

@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="info" packages="net.corda.common.logging">
<Properties>
<Property name="log-path">${sys:log-path:-logs}</Property>
<Property name="log-name">node-${hostName}</Property>
<Property name="archive">${log-path}/archive</Property>
<Property name="defaultLogLevel">${sys:defaultLogLevel:-info}</Property>
</Properties>
<ThresholdFilter level="trace"/>
<Appenders>
<Console name="Console-Appender" target="SYSTEM_OUT">
<PatternLayout>
<ScriptPatternSelector defaultPattern="%highlight{[%level{length=5}] %date{HH:mm:ss,SSS} [%t] %c{2}.%method - %msg%n}{INFO=white,WARN=red,FATAL=bright red}">
<Script name="MDCSelector" language="javascript"><![CDATA[
result = null;
if (!logEvent.getContextData().size() == 0) {
result = "WithMDC";
} else {
result = null;
}
result;
]]>
</Script>
<PatternMatch key="WithMDC" pattern="%highlight{[%level{length=5}] %date{HH:mm:ss,SSS} [%t] %c{2}.%method - %msg %X%n}{INFO=white,WARN=red,FATAL=bright red}"/>
</ScriptPatternSelector>
</PatternLayout>
<ThresholdFilter level="trace"/>
</Console>
<!-- Required for printBasicInfo -->
<Console name="Console-Appender-Println" target="SYSTEM_OUT">
<PatternLayout pattern="%msg%n" />
</Console>
<!-- Will generate up to 100 log files for a given day. During every rollover it will delete
those that are older than 60 days, but keep the most recent 10 GB -->
<RollingRandomAccessFile name="RollingFile-Appender"
fileName="${log-path}/${log-name}.log"
filePattern="${archive}/${log-name}.%date{yyyy-MM-dd}-%i.log.gz">
<PatternLayout pattern="[%-5level] %date{ISO8601}{UTC}Z [%t] %c{2}.%method - %msg %X%n"/>
<Policies>
<TimeBasedTriggeringPolicy/>
<SizeBasedTriggeringPolicy size="10MB"/>
</Policies>
<DefaultRolloverStrategy min="1" max="100">
<Delete basePath="${archive}" maxDepth="1">
<IfFileName glob="${log-name}*.log.gz"/>
<IfLastModified age="60d">
<IfAny>
<IfAccumulatedFileSize exceeds="10 GB"/>
</IfAny>
</IfLastModified>
</Delete>
</DefaultRolloverStrategy>
</RollingRandomAccessFile>
<Rewrite name="Console-ErrorCode-Appender">
<AppenderRef ref="Console-Appender"/>
<ErrorCodeRewritePolicy/>
</Rewrite>
<Rewrite name="Console-ErrorCode-Appender-Println">
<AppenderRef ref="Console-Appender-Println"/>
<ErrorCodeRewritePolicy/>
</Rewrite>
<Rewrite name="RollingFile-ErrorCode-Appender">
<AppenderRef ref="RollingFile-Appender"/>
<ErrorCodeRewritePolicy/>
</Rewrite>
</Appenders>
<Loggers>
<Root level="info">
<AppenderRef ref="Console-ErrorCode-Appender"/>
</Root>
<Logger name="net.corda" level="${defaultLogLevel}" additivity="false">
<AppenderRef ref="Console-ErrorCode-Appender"/>
<AppenderRef ref="RollingFile-ErrorCode-Appender" />
</Logger>
</Loggers>
</Configuration>