Junit Test Engine for suspendable tests
Corounit can run thousands test cases concurrently using small amount of threads.
- Corounit
- Standard JUnit Test Engine approach
- Corounit Test Engine suspendable test approach
- Test class instance
- Suspendable and non suspendable test methods
- Test beforeAll and afterAll
- Test beforeEach and afterEach
- Corounit plugins and listeners
- Allure integration
- Corounit Properties
- JUnit friendship
- Allure reporting
Standard JUnit test engine execute test classes and their methods within classical Threads.
In order to increase speed of testing phase JUnit engine can be configured to run test clasess in parallel in different threads.
Or even run each test method of same test class in different threads.
See https://junit.org/junit5/docs/snapshot/user-guide/#writing-tests-parallel-execution
Given this configuration JUnit Test Engine will use thread pool of size 4 and run all test classes and test methods in parallel.
# junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.config.strategy=fixed
junit.jupiter.execution.parallel.config.fixed.parallelism=4
junit.jupiter.execution.parallel.mode.default = concurrent
junit.jupiter.execution.parallel.mode.classes.default = concurrent
object MyServiceHttpClient{
fun doAction1() {...}
fun doAction2() {...}
...
fun doAction100(){...}
fun isActionResultAvailable(): Boolean {...}
fun getActionResult(): Result {...}
}
class LongRunningTest {
@Test
fun `action1 test`() {
client.doAction1()
while(!client.isActionResultAvailable()){
Thread.sleep(1_000)
}
client.getActionResult().shouldBe(...)
}
@Test
fun `action2 test`() {
client.doAction2()
while(!client.isActionResultAvailable()){
Thread.sleep(1_000)
}
client.getActionResult().shouldBe(...)
}
...
}
In given example action4 test
will occupy Thread 4
.
The problem here is that action4 test
can spent most of the time waiting for response from tested server.
Due to memory restriction we can not spawn 1000 threads for each class and method of our test suites. So we have to use limited number of threads and can not launch all our test cases simultaneously.
It is common for integration test to spent most of the time waiting for server response. With Kotlin coroutines it is possible do not waste thread resources on waiting. With suspendable test methods we can start thousands of tests simultaneously without OutOfMemoryException. All of them will start almost simultaneously and require only small amount of threads to run.
Default JUnit Engine requires corounit
library in class path in order to be able to start suspendable methods.
You can use default junit-platform.properties
file in test resources to specify limited number of threads that will be used by Corounit Test Engine.
# junit-platform.properties
corounit.execution.parallelism=4
corounit.testinstance.lifecycle.default=per_class
object MyServiceHttpClient{
suspend fun doAction1() {...}
suspend fun doAction2() {...}
suspend fun isActionResultAvailable(): Boolean {...}
suspend fun getActionResult(): Result {...}
}
class LongRunningTest {
@Test
suspend fun `action1 test`() {
client.doAction1()
while(!client.isActionResultAvailable()){
delay(1_000)
}
client.getActionResult().shouldBe(...)
}
@Test
suspend fun `action2 test`() {
client.doAction2()
while(!client.isActionResultAvailable()){
delay(1_000)
}
client.getActionResult().shouldBe(...)
}
...
}
By default corounit uses new test class instance for each method invocation.
As JUnit does.
You can override this behaviour via @TestInstance(PER_CLASS)
annotation.
Or use corounit.testinstance.lifecycle.default=per_class
property.
Corounit scan for suspend
methods annotated with @Test
annotation.
If test class has both types of test methods, corounit will launch suspendable ones and default JUnit Test Engine will launch default test methods.
class TestClass{
//runs by JUnit
@Test
fun `my regular test`(){...}
//runs by Corounit
@Test
suspend fun `my suspend test`(){...}
}
Corounit follows convention over configuration approach.
Methods beforeAll
and afterAll
do not need special annotations @BeforeAll
, @AfterAll
.
They will be invoked before and after all test methods of the test class.
However annotations @BeforeAll
and @AfterAll
works just fine.
@TestInstance(PER_CLASS)
class TestClass{
suspend fun beforeAll(){...}
suspend fun afterAll(){...}
@Test
suspend fun `my suspend test`(){...}
}
You can mark these method by annotations too.
@TestInstance(PER_CLASS)
class TestClass{
@BeforeAll
suspend fun setUp(){...}
@AfterAll
suspend fun tearDown(){...}
@Test
suspend fun `my suspend test`(){...}
}
In case of default @TestInstance(PER_METHOD)
behaviour,
you have to define beforeAll and afterAll methods in companion object.
class TestClass{
companion object{
suspend fun beforeAll(){...}
suspend fun afterAll(){...}
}
@Test
suspend fun `my suspend test`(){...}
}
You can name beforeEach
and afterEach
.
Then they will be invoked before and after each of test methods.
Or you can annotate methods with @BeforeEach
or @AfterEach
annotation.
class TestClass{
suspend fun beforeEach(){...}
suspend fun afterEach(){...}
@Test
suspend fun `my test`(){...}
}
class TestClass{
@BeforeEach
suspend fun createResources(){...}
@AfterEach
suspend fun destroyResources(){...}
@Test
suspend fun `my test`(){...}
}
Corounit Test Engine provide extension point for test execution lifecycle.
This extension point called CorounitPlugin
.
You can use it to Listen for test run events or override coroutine context and behaviour.
There are two options to add extension point into your project:
- Create
CorounitConfig
object in your test module package. - Or use JVM ServiceLoader mechanism.
Define
ru.fix.corounit.engine.CorounitPlugin
entry within META-INF/services. You can tak a look atcorounit-allure
module.
Object CorounitConfig
should implement CorounitPlugin
interface or CorounitPluginsProvider
interface.
Put this object in subpackage of any of the test classes.
Override any methods of CorounitPlugin
that you want to.
Or define list of plugins by impemening CorounitPluginsProvider
interface.
For example, you can start mocking server before all test cases and shutdown after all test cases complete.
package base.pacakge.of.the.project
object CorounitConfig: CorounitPlugin {
override suspend fun beforeAllTestClasses(globalContext: CoroutineContext): CoroutineContext {
// do custom initialization here,
// like starting mock server
// that will be used by several different test classes
return super.beforeAllTestClasses(globalContext)
}
override suspend fun afterAllTestClasses(globalContext: CoroutineContext) {
// shutdow mock server after all test classes completion
super.afterAllTestClasses(globalContext)
}
// CorounitPlugin has other handy test lifecycle methods waiting for you to override.
...
}
You can provide several plugins from single CorounitConfig object by implementing CorounitPluginsProvider
interface.
package base.pacakge.of.the.project
object CorounitConfig: CorounitPluginsProvider {
override suspend fun beforeAllTestClasses(globalContext: CoroutineContext): CoroutineContext {
// do custom initialization here,
// like starting mock server
// that will be used by several different test classes
return super.beforeAllTestClasses(globalContext)
}
override suspend fun afterAllTestClasses(globalContext: CoroutineContext) {
// shutdow mock server after all test classes completion
super.afterAllTestClasses(globalContext)
}
// CorounitPlugin has other handy test lifecycle methods waiting for you to override.
...
}
Example of a listener that logs test execution results
class TestResultLogger: CorounitPlugin{
override suspend fun afterTestMethod(testMethodContext: CoroutineContext, thr: Throwable?) {
val testClass = testMethodContext[TestClassContextElement]!!.testClass
val testMethod = testMethodContext[TestMethodContextElement]!!.testMethod
if(thr == null){
log.info { "Test $testClass : $testMethod passed" }
}else {
log.info { "Test $testClass : $testMethod failed with exception: $thr" }
}
}
override suspend fun skipTestMethod(testMethodContext: CoroutineContext, reason: String) {
val testClass = testMethodContext[TestClassContextElement]!!.testClass
val testMethod = testMethodContext[TestMethodContextElement]!!.testMethod
log.info { "Test $testClass : $testMethod skipped due to $reason" }
}
}
corounit-allure
provides api to enrich test with Allure step description.
There are three options to create Allure steps that will be visible in Allure Report
- Annotate
suspend
method with@Step
annotation. - Annotate class with
@Step
annotation. - Create dynamic subclasses via
AllureAspect.newAspectedInstanceViaSubtyping
- Create step via string extension blocks
"step name"{...}
Be aware that it should be ru.fix.corounit.allure.Step
annotation.
import ru.fix.corounit.allure.Step
@Step
suspend fun `purchase a ticket`(date: LocalDate){
...
}
Be aware that StepAnnotationAspect
from corounit-allure
should be activated during your build.
plugins{
id("io.freefair.aspectj.base") version Vers.freefair_aspectj
id("io.freefair.aspectj.post-compile-weaving") version Vers.freefair_aspectj
}
dependencies{
testAspect("ru.fix:corounit-allure:${Vers.corounit}")
testImplementation(Libs.aspect_weaver)
}
@Step
annotated aproach currently does not works for methods with recursion.
In IntelliJ IDEA ascpects defined this way could be ingored.
In order to launch tests from IDE in gradle project switch executor to gradle:
https://www.jetbrains.com/help/idea/work-with-tests-in-gradle.html
AllureAspect.createStepClassInstance
creates subclass and wrap open
suspend
functions into a step.
val airport = createStepClassInstance<AirportSteps>()
airport.`book flight for person`(person)
String steps allow clarifying test case and will be present in Allure report.
@Test
suspend fun `user travel`(){
"Purchase a flight ticket"{
...
}
"Booking a hotel"{
...
}
}
class TestClass{
@Test
suspend fun `my test`() = parameterized(
row(1, "one"),
row(2, "two"),
row(3, "three")){ number, text ->
...
}
}
You can add corounit properties within default JUnit property file at src/test/resources/junit-platform.properties
:
corounit.execution.parallelism=4
How many threads corounite engine will use to execute tests.corounit.testinstance.lifecycle.default=per_class
By default corounit will create new instance of test class for each method invocation. Default value is calculated based on number of CPU. This property changes that behaviour so corounit will create single test class instance and will use same instance for all method invocation. Explicit behaviour can be set via@TestInstance(PER_CLASS)
test class annotation. Default value isper_method
.
Corounit Engine will look for suspendable
test methods marked with @Test
annotation and run them.
All non suspendable regular methods marked with @Test
will stay untouched.
Default JUnit Engine will launch them after Corounit Engine finish running suspendable tests.
@Tag
annotation works in a same way as in default JUnit Engine.
As in regular gradle junit suite you can configure gradle to receive tag filter from command line.
tasks {
withType<Test> {
useJUnitPlatform(){
val INCLUDE_TAGS = "includeTags"
val EXCLUDE_TAGS = "excludeTags"
if(project.hasProperty(INCLUDE_TAGS)) {
includeTags(project.properties[INCLUDE_TAGS] as String)
}
if(project.hasProperty(EXCLUDE_TAGS)) {
excludeTags(project.properties[EXCLUDE_TAGS] as String)
}
}
maxParallelForks = 10
testLogging {
events(TestLogEvent.PASSED, TestLogEvent.FAILED, TestLogEvent.SKIPPED)
showStandardStreams = true
exceptionFormat = TestExceptionFormat.FULL
}
}
}
gradle -p corounit-example clean test -PincludeTags="slow"
Works in similar way
Works in similar way. Can be declared in Companion object. In corounit annotations are optional. Suspendable methods supported. See details in related paragraph.
You can use parameterized
function from allure
module.
@Test
suspend fun `test with parameters`() = parameterized(
row(1, "one"),
row(2, "two"),
row(3, "three"),
row(4, null)
) { number, text ->
println("number $number is a $text")
}
Corounit provides allure (http://allure.qatools.ru/) reporting integration via corounit-allure
plugin.
Add allure.properties
file in src/test/resources
directory.
allure.results.directory=build/allure-results
Add corounit-allure
dependency to your project.
If you are using gradle we recomend to use io.qameta.allure
plugin.
Take a look at corounit-allure-example
module.