Skip to content

…from JUnit

Choose your pace🔗

TestBalloon can reside with JUnit 4/5/6 tests in the same module(1), running tests side-by-side.

  1. 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 JUnitBasics {
    @Test
    fun expected_to_pass() {
        Assert.assertEquals(4, 2 + 2)
    }

    @Test
    fun expected_to_fail() {
        Assert.assertEquals(5, 2 + 2)
    }
}
val FromJUnitBasics by testSuite {
    test("expected to pass") {
        Assert.assertEquals(4, 2 + 2)
    }

    test("expected to fail") {
        Assert.assertEquals(5, 2 + 2)
    }
}

Class properties, setup and teardown🔗

Keep your code inside tests, and

  1. wrap JUnit class properties into a fixture, omitting var and lateinit,
  2. omit runTest,
  3. co-locate setup code with initialization,
  4. use the optional closeWith lambda for tear-down code,
  5. 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...
}
  1. @BeforeEach in JUnit 5+.
  2. @AfterEach in 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...
    }
}
  1. As the testFixture lambda is suspending, you can co-locate any setup code here.
  2. A suspending tear-down function.
  3. 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

  1. wrap JUnit class properties into a fixture, omitting var, lateinit, and static declarations (companion object),
  2. co-locate setup code with initialization,
  3. use the optional closeWith lambda for tear-down code,
  4. 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...
}
  1. @BeforeAll in JUnit 5+.
  2. @AfterAll in 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...
    }
}
  1. 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...
}
  1. @BeforeAll in JUnit 5+.
  2. @BeforeEach in JUnit 5+.
  3. @AfterEach in 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...
    }
}
  1. Invoking the sharedService fixture makes it a suite-level fixture.

JUnit 4🔗

Rules🔗

  1. Use a fixture as shown before, but put properties into an object deriving from a JUnit4RulesContext.
  2. Instead of @Rule annotations, register rules via a rule() 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()
        }
    }
}
  1. Deriving a fixture value from JUnit4RulesContext enables support for JUnit 4 rules.
  2. Instead of annotations, use the rule() function to register a TestRule.

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 🔗

  1. Drop @RunWith(Parameterized::class), class properties for parameters, and the companion object.
  2. 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() and traversal(),
  • two convenience variants: aroundAll() and aroundEachTest().
@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."
        )
    }
}
val FromJUnit6WithExtension by testSuite(testConfig = TestConfig.timed()) {
    test("some test") {
        // Code to be timed
    }
}

fun TestConfig.timed() = aroundEachTest { action ->
    val duration = measureTime {
        action()
    }
    println("TIME: $testElementPath 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).