…from JUnit
Choose your pace🔗
TestBalloon can reside with JUnit 4/5/6 tests in the same module(1), running tests side-by-side.
- For Android host-side tests: If you have JUnit Platform and JUnit 4 enabled (e.g. by using JUnit Vintage), please disable TestBalloon on JUnit Platform via
useJUnitPlatform { excludeEngines("de.infix.testBalloon") }. Otherwise, the framework would respond to both integrations and initialize twice, which produces an error.
You can migrate at your pace, and you don't need to migrate code that does not benefit from TestBalloon's capabilities.
Keep your assertions🔗
You can keep your assertion library (most assertion libraries work out of the box, for Kotest Assertions there is a TestBalloon integration for it). Code inside tests can remain unchanged.
What needs to change🔗
Test Classes and Methods🔗
Top-level test classes become top-level suite properties. Test methods become test function invocations:
Class properties, setup and teardown🔗
Keep your code inside tests, and
- wrap JUnit class properties into a fixture, omitting
varandlateinit, - omit
runTest, - co-locate setup code with initialization,
- use the optional
closeWithlambda for tear-down code, - make it a test-level fixture, providing a fresh value to each test:
class JUnitTestLevelFixture {
private lateinit var service: WeatherService
@Before // (1)!
fun setup() = runTest {
service = FakeWeatherService()
service.connect(token = "TestToken")
}
@After // (2)!
fun teardown() = runTest {
service.disconnect()
}
@Test
fun `Temperature in Hamburg is 21_5 °C`() = runTest {
Assert.assertEquals(21.5, service.location("Hamburg").temperature)
}
// more tests...
}
@BeforeEachin JUnit 5+.@AfterEachin JUnit 5+.
val FromJUnitTestLevelFixture by testSuite {
testFixture {
FakeWeatherService().apply {
connect(token = "TestToken") // (1)!
}
} closeWith {
disconnect() // (2)!
} asParameterForEach { // (3)!
test("Temperature in Hamburg is 21.5 °C") { service ->
Assert.assertEquals(21.5, service.location("Hamburg").temperature)
}
// more tests...
}
}
- As the
testFixturelambda is suspending, you can co-locate any setup code here. - A suspending tear-down function.
- Making it a test-level fixture provides a fresh, isolated value as a parameter for each test.
Tip
For a fixture with multiple properties, use an object expression and provide it via asContextForEach to each test.
Sharing state across tests🔗
Keep your code inside tests, and
- wrap JUnit class properties into a fixture, omitting
var,lateinit, and static declarations (companion object), - co-locate setup code with initialization,
- use the optional
closeWithlambda for tear-down code, - make it a suite-level fixture, providing a shared value to all tests:
class JUnitClassLevelFixture {
companion object {
private lateinit var service: WeatherService
@JvmStatic
@BeforeClass // (1)!
fun setup(): Unit = runTest {
service = FakeWeatherService()
service.connect(token = "TestToken")
}
@JvmStatic
@AfterClass // (2)!
fun teardown() = runTest {
service.disconnect()
}
}
@Test
fun `Temperature in Hamburg is 21_5 °C`() = runTest {
Assert.assertEquals(
21.5,
service.location("Hamburg").temperature
)
}
// more tests...
}
@BeforeAllin JUnit 5+.@AfterAllin JUnit 5+.
val FromJUnitClassLevelFixture by testSuite {
testFixture {
FakeWeatherService().apply {
connect(token = "TestToken")
}
} closeWith {
disconnect()
} asParameterForAll { // (1)!
test("Temperature in Hamburg is 21.5 °C") { service ->
Assert.assertEquals(21.5, service.location("Hamburg").temperature)
}
// more tests...
}
}
- Making it a suite-level fixture provides a shared value as a parameter for all tests.
Tip
For a fixture with multiple properties, use an object expression and provide it as via asContextForAll to all tests.
Mixing test-level setup and shared state🔗
Use the test-level fixture as shown above, and use additional shared fixtures for class-level state:
class JUnitMixedFixture {
companion object {
private lateinit var service: WeatherService
@JvmStatic
@BeforeClass // (1)!
fun setup(): Unit = runTest {
service = FakeWeatherService()
}
}
@Before // (2)!
fun setup() = runTest {
service.connect(token = "TestToken")
}
@After // (3)!
fun teardown() = runTest {
service.disconnect()
}
@Test
fun `Temperature in Hamburg is 21_5 °C`() = runTest {
Assert.assertEquals(21.5, service.location("Hamburg").temperature)
}
// more tests...
}
@BeforeAllin JUnit 5+.@BeforeEachin JUnit 5+.@AfterEachin JUnit 5+.
val FromJUnitMixedFixture by testSuite {
val sharedService = testFixture { FakeWeatherService() }
testFixture {
sharedService().apply { // (1)!
connect(token = "TestToken")
}
} closeWith {
disconnect()
} asParameterForEach {
test("Temperature in Hamburg is 21.5 °C") { service ->
Assert.assertEquals(21.5, service.location("Hamburg").temperature)
}
// more tests...
}
}
- Invoking the
sharedServicefixture makes it a suite-level fixture.
JUnit 4🔗
Rules🔗
- Use a fixture as shown before, but put properties into an object deriving from a
JUnit4RulesContext. - Instead of
@Ruleannotations, register rules via arule()function.
class JetpackComposeWithJUnit4 {
@get:Rule
val composeTestRule = createComposeRule()
@get:Rule
val myCustomRule = myCustomRule()
@Test
fun click() {
composeTestRule.setContent {
ComposableUnderTest()
}
composeTestRule.onNodeWithText("Button").performClick()
composeTestRule.onNodeWithText("Success").assertExists()
}
}
val JetpackComposeWithTestBalloon by testSuite {
testFixture {
object : JUnit4RulesContext() { // (1)!
val composeTestRule = rule(createComposeRule()) // (2)!
val myCustomRule = rule(myCustomRule())
}
} asContextForEach {
test("click") {
composeTestRule.setContent {
ComposableUnderTest()
}
composeTestRule.onNodeWithText("Button").performClick()
composeTestRule.onNodeWithText("Success").assertExists()
}
}
}
- Deriving a fixture value from
JUnit4RulesContextenables support for JUnit 4 rules. - Instead of annotations, use the
rule()function to register aTestRule.
Tip
Use only pre-existing rules with TestBalloon, avoid creating new ones. Rules are blocking by nature and do not mesh well with Kotlin's coroutines. Use TestBalloon's TestConfig.aroundEachTest() to wrap code around tests with full coroutine support.
Parameterized tests 🔗
- Drop
@RunWith(Parameterized::class), class properties for parameters, and the companion object. - Use plain Kotlin:
@RunWith(Parameterized::class)
class JUnit4Parameterized(val city: String, val expectedTemperature: Double) {
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0} {1}")
fun data() =
mapOf("Hamburg" to 21.5, "Munich" to 25.0, "Berlin" to 23.5)
.map { (city, expectedTemperature) ->
arrayOf<Any>(city, expectedTemperature)
}
}
private lateinit var service: WeatherService
@Before
fun setup() = runTest {
service = FakeWeatherService()
service.connect(token = "TestToken")
}
@After
fun teardown() = runTest {
service.disconnect()
}
@Test
fun `Location has expected temperature`() = runTest {
Assert.assertEquals(
expectedTemperature,
service.location(city).temperature
)
}
}
val FromJUnit4Parameterized by testSuite {
testFixture {
FakeWeatherService().apply {
connect(token = "TestToken")
}
} closeWith {
disconnect()
} asParameterForEach {
mapOf(
"Hamburg" to 21.5,
"Munich" to 25.0,
"Berlin" to 23.5
).forEach { (city, expectedTemperature) ->
test(
"Temperature in $city is $expectedTemperature °C"
) { service ->
Assert.assertEquals(
expectedTemperature,
service.location(city).temperature
)
}
}
}
}
JUnit 5, JUnit 6🔗
Parameterization🔗
JUnit 5+ offers parameterization via @ParameterizedClass and @MethodSource annotations. These are very similar to JUnit 4 parameterized tests. The same migration techniques apply.
Migrating JUnit 5+ templated tests to TestBalloon follows the same pattern.
Ordering🔗
To run tests in a chosen order, JUnit 5+ requires interventions (like an @Order annotation). In TestBalloon, tests run in the order they appear in the source by default.
Extensions🔗
JUnit 5+ extensions are reusable classes whose methods can run code before, after, or around tests.
TestBalloon's TestConfig builder provides 4 functional mechanisms which achieve the same:
- two universal functions:
aroundEach()andtraversal(), - two convenience variants:
aroundAll()andaroundEachTest().
@ExtendWith(TimingExtension::class)
class JUnit6WithExtension {
@Test
fun `some test`() {
// Code to be timed
}
}
class TimingExtension : InvocationInterceptor {
override fun interceptTestMethod(
invocation: InvocationInterceptor.Invocation<Void?>,
invocationContext: ReflectiveInvocationContext<Method>,
extensionContext: ExtensionContext
) {
val duration = measureTime {
super.interceptTestMethod(
invocation,
invocationContext,
extensionContext
)
}
println(
"TIME: ${extensionContext.requiredTestMethod.name} took $duration."
)
}
}
Other🔗
Nested and dynamic tests are covered as TestBalloon's testSuite functions nest and everything is dynamic by nature.
Most other JUnit 5+ features like disabling tests, conditional execution, tagging, repeated tests have a natural replacement using plain Kotlin, TestBalloon's TestConfig builder, and environment variables (see this example).