Testing
Testing on the JVM is mature, powerful, and surprisingly different from what you’re used to in TypeScript or Go. This module covers the entire testing stack: JUnit 5, MockK, Kotest, Testcontainers, and framework-specific testing for Spring Boot and Ktor.
Testing ecosystem overview
Section titled “Testing ecosystem overview”The JVM testing world is layered: a platform (JUnit Platform) runs engines (JUnit Jupiter, Kotest), and libraries (MockK, Testcontainers) plug into them. Here’s how the pieces map to what you already know:
| Concept | TypeScript | Go | Kotlin/JVM |
|---|---|---|---|
| Test runner | Jest / Vitest | go test | JUnit 5 (JUnit Platform) |
| Assertions | expect(x).toBe(y) | if x != y { t.Errorf(...) } | assertEquals(y, x) / Kotest matchers |
| Mocking | jest.mock() / vi.mock() | interfaces + manual / testify | MockK |
| BDD style | describe/it blocks | subtests t.Run() | Kotest BehaviorSpec / DescribeSpec |
| Property testing | fast-check | rapid / gopter | Kotest property testing |
| Integration (Docker) | testcontainers-node | testcontainers-go | Testcontainers Java/Kotlin |
| Coverage | --coverage flag | go test -cover | JaCoCo |
| Framework testing | supertest | httptest | @SpringBootTest / Ktor testApplication |
Dependency setup (Gradle)
Section titled “Dependency setup (Gradle)”Every Kotlin project using tests needs these in build.gradle.kts:
plugins { kotlin("jvm") version "2.1.0"}
dependencies { // JUnit 5 testImplementation(platform("org.junit:junit-bom:5.11.4")) testImplementation("org.junit.jupiter:junit-jupiter")
// MockK testImplementation("io.mockk:mockk:1.13.13")
// Kotest testImplementation("io.kotest:kotest-runner-junit5:5.9.1") testImplementation("io.kotest:kotest-assertions-core:5.9.1") testImplementation("io.kotest:kotest-property:5.9.1")
// Testcontainers testImplementation(platform("org.testcontainers:testcontainers-bom:1.20.4")) testImplementation("org.testcontainers:junit-jupiter") testImplementation("org.testcontainers:postgresql")
// Coroutine testing testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")}
tasks.test { useJUnitPlatform()}JUnit 5 fundamentals
Section titled “JUnit 5 fundamentals”JUnit 5 is the default test engine on the JVM — the go test / Jest of this world.
Here’s the same two tests (a passing assertion and an expected exception) written
in each ecosystem:
describe('Calculator', () => { test('adds two numbers', () => { expect(add(2, 3)).toBe(5); });
test('throws on division by zero', () => { expect(() => divide(1, 0)).toThrow('Division by zero'); });});func TestAdd(t *testing.T) { result := Add(2, 3) if result != 5 { t.Errorf("Add(2, 3) = %d; want 5", result) }}
func TestDivideByZero(t *testing.T) { _, err := Divide(1, 0) if err == nil { t.Fatal("expected error for division by zero") }}import org.junit.jupiter.api.Testimport org.junit.jupiter.api.assertThrowsimport org.junit.jupiter.api.Assertions.*
class CalculatorTest {
private val calculator = Calculator()
@Test fun `adds two numbers`() { assertEquals(5, calculator.add(2, 3)) }
@Test fun `throws on division by zero`() { val exception = assertThrows<ArithmeticException> { calculator.divide(1, 0) } assertEquals("Division by zero", exception.message) }}Key differences:
- Kotlin test method names can use backticks with spaces:
`adds two numbers`(). - No
describeblocks in JUnit 5 — use@Nestedclasses instead (shown below). - Assertions are static imports:
assertEquals(expected, actual)— note the order. assertThrows<T>is a Kotlin-specific JUnit 5 extension that uses reified generics.
The class under test:
class Calculator { fun add(a: Int, b: Int): Int = a + b
fun divide(a: Int, b: Int): Int { if (b == 0) throw ArithmeticException("Division by zero") return a / b }}Display names
Section titled “Display names”With backtick method names you rarely need @DisplayName, but it exists for when
you want richer descriptions or non-ASCII characters:
import org.junit.jupiter.api.DisplayName
@DisplayName("Calculator")class CalculatorTest {
@Test @DisplayName("should add two positive numbers") fun addPositive() { assertEquals(5, Calculator().add(2, 3)) }
@Test @DisplayName("should handle negative numbers") fun addNegative() { assertEquals(-1, Calculator().add(2, -3)) }}Assertions in depth
Section titled “Assertions in depth”JUnit ships its own assertions, but you can also use Kotest’s matchers inside plain JUnit tests for a more readable, Jest-like style.
JUnit 5 built-in assertions
Section titled “JUnit 5 built-in assertions”import org.junit.jupiter.api.Testimport org.junit.jupiter.api.Assertions.*
class AssertionExamplesTest {
@Test fun `basic assertions`() { // Equality assertEquals(4, 2 + 2) assertEquals(3.14, Math.PI, 0.01) // delta for doubles
// Boolean assertTrue(listOf(1, 2, 3).contains(2)) assertFalse("hello".isEmpty())
// Null val result: String? = findUser("alice") assertNotNull(result) assertNull(findUser("nobody"))
// Same reference val list1 = listOf(1, 2) val list2 = list1 assertSame(list1, list2) }
@Test fun `grouped assertions -- all run even if some fail`() { val user = User("Alice", 30, "alice@example.com")
assertAll( "user properties", { assertEquals("Alice", user.name) }, { assertEquals(30, user.age) }, { assertTrue(user.email.contains("@")) } ) }
@Test fun `exception assertions`() { val exception = assertThrows<IllegalArgumentException> { validateAge(-1) } assertTrue(exception.message!!.contains("negative")) }
@Test fun `timeout assertion`() { assertTimeout(java.time.Duration.ofSeconds(2)) { Thread.sleep(100) // must complete within 2 seconds } }
private fun findUser(name: String): String? = if (name == "alice") "Alice" else null
private fun validateAge(age: Int) { require(age >= 0) { "Age cannot be negative: $age" } }
data class User(val name: String, val age: Int, val email: String)}assertAll is worth highlighting: it runs every assertion even if some fail, then
reports all failures together — handy for checking several properties of one object.
Kotest assertions (more readable)
Section titled “Kotest assertions (more readable)”Kotest provides matchers that read like natural language — similar to Jest’s
expect API. You can use them inside JUnit 5 tests:
import io.kotest.matchers.shouldBeimport io.kotest.matchers.string.shouldContainimport io.kotest.matchers.string.shouldStartWithimport io.kotest.matchers.collections.shouldContainAllimport io.kotest.matchers.collections.shouldHaveSizeimport io.kotest.matchers.nulls.shouldBeNullimport io.kotest.matchers.nulls.shouldNotBeNullimport io.kotest.matchers.types.shouldBeInstanceOfimport org.junit.jupiter.api.Test
class KotestMatchersInJUnitTest {
@Test fun `string matchers`() { val greeting = "Hello, World!" greeting shouldContain "World" greeting shouldStartWith "Hello" greeting.length shouldBe 13 }
@Test fun `collection matchers`() { val numbers = listOf(1, 2, 3, 4, 5) numbers shouldHaveSize 5 numbers shouldContainAll listOf(2, 4) }
@Test fun `null matchers`() { val present: String? = "hello" val absent: String? = null
present.shouldNotBeNull() absent.shouldBeNull() }
@Test fun `type matchers`() { val result: Any = "hello" result.shouldBeInstanceOf<String>() }}Jest expect vs Kotest shouldBe:
| Jest / Vitest | Kotest |
|---|---|
expect(x).toBe(5) | x shouldBe 5 |
expect(x).toContain("a") | x shouldContain "a" |
expect(x).toHaveLength(3) | x shouldHaveSize 3 |
expect(x).toBeNull() | x.shouldBeNull() |
expect(x).toBeInstanceOf(Foo) | x.shouldBeInstanceOf<Foo>() |
expect(x).toThrow() | shouldThrow<Exception> { ... } |
Test lifecycle and organization
Section titled “Test lifecycle and organization”Every framework has setup/teardown hooks. Here’s the lifecycle in each ecosystem:
describe('UserService', () => { let db: Database;
beforeAll(async () => { db = await connectDb(); }); afterAll(async () => { await db.close(); }); beforeEach(() => { db.clear(); }); afterEach(() => { /* cleanup */ });
test('creates user', () => { /* ... */ });});func TestMain(m *testing.M) { // setup db := connectDb() code := m.Run() // teardown db.Close() os.Exit(code)}import org.junit.jupiter.api.*
class UserServiceTest {
companion object { private lateinit var db: Database
@JvmStatic @BeforeAll fun setupAll() { db = Database.connect() }
@JvmStatic @AfterAll fun teardownAll() { db.close() } }
@BeforeEach fun setup() { db.clear() }
@AfterEach fun cleanup() { // per-test cleanup }
@Test fun `creates a user`() { // ... }}Key points:
@BeforeAll/@AfterAllmust be oncompanion objectmethods with@JvmStatic.- Or use
@TestInstance(TestInstance.Lifecycle.PER_CLASS)to avoid companion objects. - By default, JUnit 5 creates a new test class instance per test method (like Go).
The per-class lifecycle is simpler — no companion object needed:
@TestInstance(TestInstance.Lifecycle.PER_CLASS)class UserServiceTest {
private lateinit var db: Database
@BeforeAll fun setupAll() { db = Database.connect() // no companion object needed }
@AfterAll fun teardownAll() { db.close() }
@Test fun `creates a user`() { /* ... */ }}Nested tests (like describe blocks)
Section titled “Nested tests (like describe blocks)”This is the JUnit 5 equivalent of Jest’s nested describe — an inner class
annotated with @Nested:
import org.junit.jupiter.api.Nestedimport org.junit.jupiter.api.DisplayName
@DisplayName("UserService")class UserServiceTest {
private val service = UserService()
@Nested @DisplayName("when creating users") inner class CreateUser {
@Test fun `saves valid user`() { val user = service.create("alice", "alice@test.com") assertNotNull(user.id) }
@Test fun `rejects duplicate email`() { service.create("alice", "alice@test.com") assertThrows<DuplicateEmailException> { service.create("bob", "alice@test.com") } } }
@Nested @DisplayName("when finding users") inner class FindUser {
@Test fun `finds existing user by email`() { service.create("alice", "alice@test.com") val found = service.findByEmail("alice@test.com") assertNotNull(found) }
@Test fun `returns null for unknown email`() { assertNull(service.findByEmail("nobody@test.com")) } }}Disabling and conditional execution
Section titled “Disabling and conditional execution”import org.junit.jupiter.api.Disabledimport org.junit.jupiter.api.condition.*
@Test@Disabled("Waiting for API v2 -- JIRA-1234")fun `handles rate limiting`() { /* ... */ }
@Test@EnabledOnOs(OS.LINUX)fun `uses epoll on Linux`() { /* ... */ }
@Test@EnabledIfEnvironmentVariable(named = "CI", matches = "true")fun `only runs in CI`() { /* ... */ }
@Test@EnabledIfSystemProperty(named = "java.version", matches = "21.*")fun `needs JDK 21`() { /* ... */ }Test execution order
Section titled “Test execution order”import org.junit.jupiter.api.MethodOrdererimport org.junit.jupiter.api.Orderimport org.junit.jupiter.api.TestMethodOrder
@TestMethodOrder(MethodOrderer.OrderAnnotation::class)class OrderedTest {
@Test @Order(1) fun `step 1 -- create resource`() { /* ... */ }
@Test @Order(2) fun `step 2 -- modify resource`() { /* ... */ }
@Test @Order(3) fun `step 3 -- delete resource`() { /* ... */ }}Parameterized tests
Section titled “Parameterized tests”Parameterized tests let you run the same test logic with multiple inputs — like
Jest’s test.each or Go’s table-driven tests.
test.each([ [1, 1, 2], [2, 3, 5], [0, 0, 0],])('add(%i, %i) = %i', (a, b, expected) => { expect(add(a, b)).toBe(expected);});func TestAdd(t *testing.T) { tests := []struct { a, b, want int }{ {1, 1, 2}, {2, 3, 5}, {0, 0, 0}, } for _, tt := range tests { t.Run(fmt.Sprintf("%d+%d", tt.a, tt.b), func(t *testing.T) { if got := Add(tt.a, tt.b); got != tt.want { t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want) } }) }}import org.junit.jupiter.params.ParameterizedTestimport org.junit.jupiter.params.provider.*
class ParameterizedExamplesTest {
// Method 1: @ValueSource -- single parameter @ParameterizedTest @ValueSource(strings = ["hello", "world", "kotlin"]) fun `string is not empty`(input: String) { assertTrue(input.isNotEmpty()) }
// Method 2: @CsvSource -- multiple parameters (like table-driven) @ParameterizedTest(name = "add({0}, {1}) = {2}") @CsvSource( "1, 1, 2", "2, 3, 5", "0, 0, 0", "-1, 1, 0" ) fun `adds two numbers`(a: Int, b: Int, expected: Int) { assertEquals(expected, Calculator().add(a, b)) }
// Method 3: @EnumSource @ParameterizedTest @EnumSource(Status::class) fun `all statuses have a display name`(status: Status) { assertTrue(status.displayName.isNotEmpty()) }
// Method 4: @MethodSource -- complex objects @ParameterizedTest(name = "{0}") @MethodSource("userTestCases") fun `validates user email`(testName: String, email: String, isValid: Boolean) { assertEquals(isValid, EmailValidator.isValid(email)) }
companion object { @JvmStatic fun userTestCases(): List<Arguments> = listOf( Arguments.of("valid email", "user@example.com", true), Arguments.of("missing @", "userexample.com", false), Arguments.of("missing domain", "user@", false), Arguments.of("empty string", "", false), ) }}
enum class Status(val displayName: String) { ACTIVE("Active"), INACTIVE("Inactive"), PENDING("Pending")}@MethodSource with data classes
Section titled “@MethodSource with data classes”This is the closest to Go’s table-driven test pattern — a data class per case:
data class TestCase( val name: String, val input: String, val expected: String)
class SlugifyTest {
@ParameterizedTest(name = "{0}") @MethodSource("testCases") fun `converts string to slug`(case: TestCase) { assertEquals(case.expected, slugify(case.input)) }
companion object { @JvmStatic fun testCases() = listOf( TestCase("simple words", "Hello World", "hello-world"), TestCase("special chars", "Hello, World!", "hello-world"), TestCase("extra spaces", " too many spaces ", "too-many-spaces"), TestCase("already a slug", "hello-world", "hello-world"), TestCase("mixed case", "CamelCaseString", "camelcasestring"), ).map { Arguments.of(it) } }}MockK: Kotlin-native mocking
Section titled “MockK: Kotlin-native mocking”MockK is the standard mocking library for Kotlin. It’s designed for Kotlin’s language features (extension functions, coroutines, companion objects) in a way that Mockito cannot match.
| Feature | Mockito | MockK |
|---|---|---|
| Kotlin-first DSL | No | Yes |
| Extension functions | Partial | Full support |
| Coroutine support | Via mockito-kotlin | Native |
| Companion object mocking | No | Yes |
| Top-level function mocking | No | Yes |
| Relaxed mocks | No | Yes |
Basic mocking
Section titled “Basic mocking”The shape is the same everywhere: build a fake repository, inject it into the
service, assert on behavior. MockK creates the fake from an interface with
mockk<UserRepository>():
const userRepo = { findById: jest.fn().mockResolvedValue({ id: '1', name: 'Alice' }), save: jest.fn().mockResolvedValue(undefined),};
const service = new UserService(userRepo);type mockUserRepo struct { findByIDFunc func(id string) (*User, error)}
func (m *mockUserRepo) FindByID(id string) (*User, error) { return m.findByIDFunc(id)}import io.mockk.*import org.junit.jupiter.api.Testimport org.junit.jupiter.api.Assertions.*
interface UserRepository { fun findById(id: String): User? fun save(user: User): User fun delete(id: String) fun findAll(): List<User>}
data class User(val id: String, val name: String, val email: String)
class UserService(private val repo: UserRepository) { fun getUser(id: String): User = repo.findById(id) ?: throw NoSuchElementException("User not found: $id")
fun createUser(name: String, email: String): User { val user = User(id = java.util.UUID.randomUUID().toString(), name = name, email = email) return repo.save(user) }}
class UserServiceTest {
private val repo = mockk<UserRepository>() private val service = UserService(repo)
@Test fun `returns user when found`() { // Arrange: define behavior val alice = User("1", "Alice", "alice@test.com") every { repo.findById("1") } returns alice
// Act val result = service.getUser("1")
// Assert assertEquals("Alice", result.name)
// Verify the mock was called verify(exactly = 1) { repo.findById("1") } }
@Test fun `throws when user not found`() { every { repo.findById("999") } returns null
assertThrows<NoSuchElementException> { service.getUser("999") } }}Stubbing: every / returns / throws / answers
Section titled “Stubbing: every / returns / throws / answers”The every { … } returns … block is MockK’s core stubbing DSL. Variants cover
exceptions, sequences, custom logic, and void functions:
class MockBehaviorTest {
private val repo = mockk<UserRepository>()
@Test fun `throws an exception`() { every { repo.findById("bad") } throws IllegalArgumentException("Invalid ID") }
@Test fun `returns different values on consecutive calls`() { every { repo.findById("1") } returnsMany listOf( User("1", "Alice", "a@b.com"), User("1", "Alice Updated", "a@b.com") )
assertEquals("Alice", repo.findById("1")!!.name) assertEquals("Alice Updated", repo.findById("1")!!.name) }
@Test fun `custom answer logic`() { every { repo.save(any()) } answers { val user = firstArg<User>() user.copy(id = "generated-id") }
val saved = repo.save(User("", "Bob", "bob@test.com")) assertEquals("generated-id", saved.id) }
@Test fun `void functions -- use just Runs`() { every { repo.delete(any()) } just Runs
repo.delete("1") // no exception
verify { repo.delete("1") } }}Argument matchers and capturing
Section titled “Argument matchers and capturing”Use any() or match { … } to match arguments loosely, and a slot<User>() to
capture exactly what was passed:
@Testfun `argument matchers`() { every { repo.findById(any()) } returns User("x", "Any", "any@test.com") every { repo.findById(match { it.startsWith("user-") }) } returns User("u", "Matched", "m@test.com")
assertEquals("Any", repo.findById("random")!!.name) assertEquals("Matched", repo.findById("user-123")!!.name)}
@Testfun `captures arguments with slot`() { val slot = slot<User>() every { repo.save(capture(slot)) } answers { slot.captured }
service.createUser("Charlie", "charlie@test.com")
// Inspect what was passed to repo.save() assertEquals("Charlie", slot.captured.name) assertEquals("charlie@test.com", slot.captured.email) assertTrue(slot.captured.id.isNotEmpty())}To capture multiple calls, pass a mutableListOf<User>() to capture(...) instead
of a single slot.
Verification
Section titled “Verification”@Testfun `verification options`() { val repo = mockk<UserRepository>() every { repo.findById(any()) } returns null every { repo.findAll() } returns emptyList() every { repo.save(any()) } answers { firstArg() }
repo.findById("1") repo.findById("2") repo.findAll()
// Verify call count verify(exactly = 2) { repo.findById(any()) } verify(exactly = 1) { repo.findAll() } verify(exactly = 0) { repo.save(any()) }
// Verify call order verifyOrder { repo.findById("1") repo.findById("2") repo.findAll() }
// Verify no other calls were made confirmVerified(repo)}Relaxed mocks
Section titled “Relaxed mocks”A relaxed mock returns default values (0, "", empty list, null) for any unstubbed call, instead of throwing. This is useful for tests where you only care about some interactions:
@Testfun `relaxed mock returns defaults`() { val repo = mockk<UserRepository>(relaxed = true)
// These all work without `every` -- they return defaults val user = repo.findById("1") // returns null (nullable return) val users = repo.findAll() // returns emptyList() repo.delete("1") // just runs
assertNull(user) assertTrue(users.isEmpty())}relaxUnitFun = true only relaxes Unit-returning functions: with
mockk<UserRepository>(relaxUnitFun = true), repo.delete("1") works without
stubbing, but repo.findById("1") still throws — it returns a value type.
Spying (spyk)
Section titled “Spying (spyk)”A spy wraps a real object and lets you override specific methods:
const service = new UserService();jest.spyOn(service, 'getUser').mockReturnValue({ id: '1', name: 'Alice' });class EmailService { fun sendWelcome(email: String): Boolean { // Actually sends email -- we don't want this in tests return sendEmail(email, "Welcome!", "Hello!") }
fun sendEmail(to: String, subject: String, body: String): Boolean { println("Sending email to $to") return true }}
@Testfun `spy overrides specific methods`() { val emailService = spyk(EmailService())
// Override sendEmail but let sendWelcome call real logic every { emailService.sendEmail(any(), any(), any()) } returns true
val result = emailService.sendWelcome("alice@test.com")
assertTrue(result) verify { emailService.sendEmail("alice@test.com", "Welcome!", "Hello!") }}Mocking companion objects and coroutines
Section titled “Mocking companion objects and coroutines”MockK can mock things Mockito can’t — companion objects with mockkObject, and
suspending functions with coEvery / coVerify:
class IdGenerator { companion object { fun generate(): String = java.util.UUID.randomUUID().toString() }}
@Testfun `mock companion object`() { mockkObject(IdGenerator.Companion) every { IdGenerator.generate() } returns "fixed-id"
assertEquals("fixed-id", IdGenerator.generate())
unmockkObject(IdGenerator.Companion)}
interface AsyncUserRepository { suspend fun findById(id: String): User? suspend fun save(user: User): User}
@Testfun `mock suspending functions`() = runTest { val repo = mockk<AsyncUserRepository>()
coEvery { repo.findById("1") } returns User("1", "Alice", "a@b.com") coEvery { repo.save(any()) } coAnswers { firstArg() }
val user = repo.findById("1") assertEquals("Alice", user!!.name)
coVerify { repo.findById("1") }}coEvery and coVerify are the suspend equivalents of every and verify.
Kotest: BDD and property testing
Section titled “Kotest: BDD and property testing”Kotest is a Kotlin-native test framework that provides BDD-style specs (like Jest/Mocha) and powerful property-based testing (like fast-check or Go’s rapid).
Spec styles
Section titled “Spec styles”Kotest offers multiple spec styles — pick the one that matches your team’s
preference. StringSpec is the simplest, DescribeSpec reads exactly like Jest,
and BehaviorSpec gives you Given/When/Then BDD:
import io.kotest.core.spec.style.StringSpecimport io.kotest.matchers.shouldBeimport io.kotest.matchers.string.shouldHaveLength
class StringSpecExample : StringSpec({
"string length should be correct" { "hello".length shouldBe 5 }
"string should have expected length" { "kotlin" shouldHaveLength 6 }})import io.kotest.core.spec.style.DescribeSpecimport io.kotest.matchers.shouldBeimport io.kotest.matchers.shouldNotBe
class UserServiceDescribeSpec : DescribeSpec({
// This reads exactly like Jest! describe("UserService") { val service = UserService(InMemoryUserRepo())
describe("createUser") { it("should create a user with valid data") { val user = service.createUser("Alice", "alice@test.com") user.name shouldBe "Alice" user.id shouldNotBe null }
it("should reject empty name") { shouldThrow<IllegalArgumentException> { service.createUser("", "alice@test.com") } } }
describe("getUser") { it("should return null for unknown user") { service.getUser("unknown") shouldBe null } } }})import io.kotest.core.spec.style.BehaviorSpecimport io.kotest.matchers.shouldBe
class ShoppingCartBehaviorSpec : BehaviorSpec({
Given("an empty shopping cart") { val cart = ShoppingCart()
When("adding an item") { cart.add(Item("Widget", 9.99))
Then("cart should have 1 item") { cart.itemCount shouldBe 1 }
Then("total should be item price") { cart.total shouldBe 9.99 } }
When("checking out with no items") { Then("should throw EmptyCartException") { shouldThrow<EmptyCartException> { ShoppingCart().checkout() } } } }})Property-based testing
Section titled “Property-based testing”Property testing generates random inputs to verify that properties hold for all
inputs, not just the ones you think of. This is like fast-check in TypeScript or
rapid in Go:
import fc from 'fast-check';
test('reverse of reverse is identity', () => { fc.assert( fc.property(fc.array(fc.integer()), (arr) => { expect(arr.reverse().reverse()).toEqual(arr); }) );});import io.kotest.core.spec.style.StringSpecimport io.kotest.property.forAllimport io.kotest.property.Arbimport io.kotest.property.arbitrary.*import io.kotest.property.checkAllimport io.kotest.matchers.shouldBe
class PropertyTestExamples : StringSpec({
"reverse of reverse is identity" { forAll<List<Int>> { list -> list.reversed().reversed() == list } }
"string concatenation length" { forAll<String, String> { a, b -> (a + b).length == a.length + b.length } }
"absolute value is always non-negative" { forAll(Arb.int()) { n -> kotlin.math.abs(n.toLong()) >= 0 } }
"parsed email has valid parts" { checkAll(Arb.email()) { email -> val parts = email.split("@") parts.size shouldBe 2 parts[0].isNotEmpty() shouldBe true parts[1].contains(".") shouldBe true } }})
// Custom Arb (Arbitrary) for domain objectsfun Arb.Companion.email(): Arb<String> = Arb.bind( Arb.string(minSize = 1, maxSize = 10, codepoints = Arb.az()), Arb.string(minSize = 1, maxSize = 5, codepoints = Arb.az()), Arb.of("com", "org", "net", "io") ) { local, domain, tld -> "$local@$domain.$tld" }
fun Arb.Companion.az() = Arb.of(('a'..'z').map { Codepoint(it.code) })The key generators are forAll (returns a Boolean property) and checkAll (runs
assertions inside the block). Arb (Arbitrary) describes how to generate values,
and you can compose your own for domain objects:
import io.kotest.property.Arbimport io.kotest.property.arbitrary.*
data class Money(val amount: Long, val currency: String) { init { require(amount >= 0) { "Amount must be non-negative" } }
operator fun plus(other: Money): Money { require(currency == other.currency) { "Currency mismatch" } return Money(amount + other.amount, currency) }}
fun Arb.Companion.money(currency: String = "USD"): Arb<Money> = Arb.long(0L..1_000_000L).map { Money(it, currency) }
class MoneyPropertyTest : StringSpec({
"addition is commutative" { forAll(Arb.money(), Arb.money()) { a, b -> a + b == b + a } }
"addition with zero is identity" { forAll(Arb.money()) { money -> money + Money(0, "USD") == money } }
"addition is associative" { forAll(Arb.money(), Arb.money(), Arb.money()) { a, b, c -> (a + b) + c == a + (b + c) } }})Testcontainers: Docker-based integration tests
Section titled “Testcontainers: Docker-based integration tests”Testcontainers spins up real Docker containers (PostgreSQL, Redis, Kafka, etc.) for your tests. No more mocking databases — test against the real thing. Each container’s lifecycle is bound to the test run:
flowchart LR A["Test starts"] --> B["Docker container starts"] B --> C["Test runs against real DB"] C --> D["Container destroyed"]
For comparison, this is the equivalent in testcontainers-go:
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: testcontainers.ContainerRequest{ Image: "postgres:16", ExposedPorts: []string{"5432/tcp"}, WaitingFor: wait.ForListeningPort("5432/tcp"), }, Started: true,})PostgreSQL testcontainer
Section titled “PostgreSQL testcontainer”The @Testcontainers annotation manages the container lifecycle; @Container on a
@JvmStatic field starts one container shared by all tests in the class:
import org.junit.jupiter.api.Testimport org.junit.jupiter.api.Assertions.*import org.testcontainers.containers.PostgreSQLContainerimport org.testcontainers.junit.jupiter.Containerimport org.testcontainers.junit.jupiter.Testcontainersimport java.sql.DriverManager
@Testcontainersclass PostgresIntegrationTest {
companion object { @Container @JvmStatic val postgres = PostgreSQLContainer("postgres:16-alpine").apply { withDatabaseName("testdb") withUsername("test") withPassword("test") } }
@Test fun `can connect and query`() { val conn = DriverManager.getConnection( postgres.jdbcUrl, postgres.username, postgres.password )
conn.createStatement().execute(""" CREATE TABLE IF NOT EXISTS users ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT UNIQUE NOT NULL ) """)
conn.prepareStatement("INSERT INTO users (name, email) VALUES (?, ?)").apply { setString(1, "Alice") setString(2, "alice@test.com") executeUpdate() }
val rs = conn.createStatement().executeQuery("SELECT name FROM users WHERE email = 'alice@test.com'") assertTrue(rs.next()) assertEquals("Alice", rs.getString("name"))
conn.close() }}Redis and Kafka testcontainers
Section titled “Redis and Kafka testcontainers”Redis uses the GenericContainer with an exposed port; Kafka has a dedicated
KafkaContainer. Both expose connection details via mapped ports:
@Testcontainersclass RedisIntegrationTest {
companion object { @Container @JvmStatic val redis = GenericContainer("redis:7-alpine").apply { withExposedPorts(6379) } }
@Test fun `stores and retrieves values`() { val jedis = Jedis(redis.host, redis.getMappedPort(6379)) jedis.set("greeting", "hello") assertEquals("hello", jedis.get("greeting")) jedis.close() }}@Testcontainersclass KafkaIntegrationTest {
companion object { @Container @JvmStatic val kafka = KafkaContainer("apache/kafka:3.8.1") }
@Test fun `produces and consumes messages`() { val topic = "test-topic"
val producerProps = Properties().apply { put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.bootstrapServers) put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer::class.java.name) put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer::class.java.name) } val producer = KafkaProducer<String, String>(producerProps) producer.send(ProducerRecord(topic, "key", "hello-kafka")).get() producer.close()
val consumerProps = Properties().apply { put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.bootstrapServers) put(ConsumerConfig.GROUP_ID_CONFIG, "test-group") put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest") put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer::class.java.name) put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer::class.java.name) } val consumer = KafkaConsumer<String, String>(consumerProps) consumer.subscribe(listOf(topic))
val records = consumer.poll(Duration.ofSeconds(10)) assertEquals(1, records.count()) assertEquals("hello-kafka", records.first().value())
consumer.close() }}Reusable containers (faster tests)
Section titled “Reusable containers (faster tests)”Starting containers for every test class is slow. Share them across test classes
by holding the container in an object with withReuse(true):
object TestPostgres { val container = PostgreSQLContainer("postgres:16-alpine").apply { withDatabaseName("testdb") withUsername("test") withPassword("test") withReuse(true) // Keep container running between test runs start() }}Then add this to ~/.testcontainers.properties to enable reuse:
testcontainers.reuse.enable=trueSpring Boot testing
Section titled “Spring Boot testing”Spring Boot provides comprehensive test infrastructure with test slices that load only the parts of the application you need:
| Annotation | What it loads | Use for |
|---|---|---|
@SpringBootTest | Full application context | Integration / E2E tests |
@WebMvcTest | Controllers + MVC infra | Controller unit tests |
@DataJpaTest | JPA + embedded DB | Repository tests |
@WebFluxTest | WebFlux controllers | Reactive controller tests |
| None (plain JUnit) | Nothing | Unit tests (preferred) |
Full integration test with @SpringBootTest
Section titled “Full integration test with @SpringBootTest”@SpringBootTest plus @AutoConfigureMockMvc boots the full context and gives you
a MockMvc to drive HTTP requests:
import org.junit.jupiter.api.Testimport org.springframework.beans.factory.annotation.Autowiredimport org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvcimport org.springframework.boot.test.context.SpringBootTestimport org.springframework.http.MediaTypeimport org.springframework.test.web.servlet.MockMvcimport org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
@SpringBootTest@AutoConfigureMockMvcclass TaskControllerIntegrationTest {
@Autowired lateinit var mockMvc: MockMvc
@Test fun `creates and retrieves a task`() { val createResult = mockMvc.perform( post("/api/tasks") .contentType(MediaType.APPLICATION_JSON) .content("""{"title": "Write tests", "description": "Add test coverage"}""") ) .andExpect(status().isCreated) .andExpect(jsonPath("$.title").value("Write tests")) .andReturn()
val id = com.jayway.jsonpath.JsonPath.read<Int>( createResult.response.contentAsString, "$.id" )
mockMvc.perform(get("/api/tasks/$id")) .andExpect(status().isOk) .andExpect(jsonPath("$.title").value("Write tests")) }
@Test fun `returns 404 for unknown task`() { mockMvc.perform(get("/api/tasks/99999")) .andExpect(status().isNotFound) }}Controller unit test with @WebMvcTest
Section titled “Controller unit test with @WebMvcTest”@WebMvcTest loads only the controller layer. You mock the service layer with
@MockkBean (from springmockk):
import io.mockk.everyimport io.mockk.verifyimport com.ninjasquad.springmockk.MockkBeanimport org.junit.jupiter.api.Testimport org.springframework.beans.factory.annotation.Autowiredimport org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTestimport org.springframework.test.web.servlet.MockMvcimport org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
@WebMvcTest(TaskController::class)class TaskControllerUnitTest {
@Autowired lateinit var mockMvc: MockMvc
@MockkBean lateinit var taskService: TaskService
@Test fun `returns task by id`() { val task = Task(id = 1, title = "Test", description = "A task", completed = false) every { taskService.findById(1) } returns task
mockMvc.perform(get("/api/tasks/1")) .andExpect(status().isOk) .andExpect(jsonPath("$.title").value("Test")) .andExpect(jsonPath("$.completed").value(false))
verify(exactly = 1) { taskService.findById(1) } }}Repository test with @DataJpaTest
Section titled “Repository test with @DataJpaTest”@DataJpaTest configures an embedded DB and gives you a TestEntityManager to set
up fixtures:
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTestimport org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager
@DataJpaTestclass TaskRepositoryTest {
@Autowired lateinit var entityManager: TestEntityManager
@Autowired lateinit var repository: TaskRepository
@Test fun `finds tasks by completed status`() { entityManager.persist(TaskEntity(title = "Done", description = "d", completed = true)) entityManager.persist(TaskEntity(title = "Todo", description = "d", completed = false)) entityManager.persist(TaskEntity(title = "Also done", description = "d", completed = true)) entityManager.flush()
val completed = repository.findByCompleted(true) assertEquals(2, completed.size) assertTrue(completed.all { it.completed }) }}Spring Boot + Testcontainers
Section titled “Spring Boot + Testcontainers”The most common integration pattern — use a real PostgreSQL database, wiring the
container’s connection details into Spring with @DynamicPropertySource:
@SpringBootTest@AutoConfigureMockMvc@Testcontainersclass TaskApiIntegrationTest {
companion object { @Container @JvmStatic val postgres = PostgreSQLContainer("postgres:16-alpine").apply { withDatabaseName("testdb") withUsername("test") withPassword("test") }
@JvmStatic @DynamicPropertySource fun configureProperties(registry: DynamicPropertyRegistry) { registry.add("spring.datasource.url") { postgres.jdbcUrl } registry.add("spring.datasource.username") { postgres.username } registry.add("spring.datasource.password") { postgres.password } registry.add("spring.jpa.hibernate.ddl-auto") { "create-drop" } } }
@Autowired lateinit var mockMvc: MockMvc
@Test fun `full CRUD cycle against real PostgreSQL`() { // Create val json = """{"title": "Integration test", "description": "Testing with real DB"}""" val result = mockMvc.perform( post("/api/tasks") .contentType(MediaType.APPLICATION_JSON) .content(json) ) .andExpect(status().isCreated) .andExpect(jsonPath("$.id").exists()) .andReturn()
val id = com.jayway.jsonpath.JsonPath.read<Int>( result.response.contentAsString, "$.id" )
// Read / Update / Delete mockMvc.perform(get("/api/tasks/$id")) .andExpect(status().isOk) .andExpect(jsonPath("$.title").value("Integration test"))
mockMvc.perform( put("/api/tasks/$id") .contentType(MediaType.APPLICATION_JSON) .content("""{"title": "Updated", "description": "Updated desc", "completed": true}""") ) .andExpect(status().isOk) .andExpect(jsonPath("$.completed").value(true))
mockMvc.perform(delete("/api/tasks/$id")) .andExpect(status().isNoContent)
mockMvc.perform(get("/api/tasks/$id")) .andExpect(status().isNotFound) }}To avoid duplicating container setup, extract it into an abstract base class that
your test classes extend:
abstract class IntegrationTestBase {
companion object { @JvmStatic val postgres = PostgreSQLContainer("postgres:16-alpine").apply { withDatabaseName("testdb") withUsername("test") withPassword("test") start() }
@JvmStatic @DynamicPropertySource fun configureProperties(registry: DynamicPropertyRegistry) { registry.add("spring.datasource.url") { postgres.jdbcUrl } registry.add("spring.datasource.username") { postgres.username } registry.add("spring.datasource.password") { postgres.password } registry.add("spring.jpa.hibernate.ddl-auto") { "create-drop" } } }}WebTestClient (alternative to MockMvc)
Section titled “WebTestClient (alternative to MockMvc)”WebTestClient provides a fluent API and works with both MVC and WebFlux:
import org.springframework.boot.test.context.SpringBootTest.WebEnvironmentimport org.springframework.test.web.reactive.server.WebTestClient
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)class TaskApiWebTestClientTest {
@Autowired lateinit var webClient: WebTestClient
@Test fun `creates a task`() { webClient.post() .uri("/api/tasks") .contentType(org.springframework.http.MediaType.APPLICATION_JSON) .bodyValue("""{"title": "Test", "description": "desc"}""") .exchange() .expectStatus().isCreated .expectBody() .jsonPath("$.title").isEqualTo("Test") }}Ktor testing
Section titled “Ktor testing”Ktor provides a built-in test engine that runs your application in-process without
starting a real HTTP server. This is fast and simple — comparable to Go’s
httptest:
req := httptest.NewRequest("GET", "/api/tasks", nil)w := httptest.NewRecorder()handler.ServeHTTP(w, req)assert.Equal(t, 200, w.Code)testApplication
Section titled “testApplication”The testApplication { … } builder configures your app and exposes a client for
making requests:
import io.ktor.client.request.*import io.ktor.client.statement.*import io.ktor.http.*import io.ktor.server.testing.*import org.junit.jupiter.api.Testimport org.junit.jupiter.api.Assertions.*
class TaskRoutesTest {
@Test fun `GET tasks returns empty list`() = testApplication { application { configureSerialization() configureRouting() }
val response = client.get("/api/tasks") assertEquals(HttpStatusCode.OK, response.status) assertEquals("[]", response.bodyAsText()) }
@Test fun `POST creates a task`() = testApplication { application { configureSerialization() configureRouting() }
val response = client.post("/api/tasks") { contentType(ContentType.Application.Json) setBody("""{"title": "Test task", "description": "A test"}""") }
assertEquals(HttpStatusCode.Created, response.status) assertTrue(response.bodyAsText().contains("Test task")) }
@Test fun `GET unknown task returns 404`() = testApplication { application { configureSerialization() configureRouting() }
val response = client.get("/api/tasks/99999") assertEquals(HttpStatusCode.NotFound, response.status) }}Typed clients, auth, and Testcontainers
Section titled “Typed clients, auth, and Testcontainers”Install ContentNegotiation on a custom client to send and receive typed bodies
with body<TaskResponse>():
import io.ktor.client.plugins.contentnegotiation.*import io.ktor.serialization.kotlinx.json.*
@Testfun `create and retrieve task with typed client`() = testApplication { application { configureSerialization() configureRouting() }
val jsonClient = createClient { install(ContentNegotiation) { json() } }
val created = jsonClient.post("/api/tasks") { contentType(ContentType.Application.Json) setBody(CreateTaskRequest(title = "Learn Ktor", description = "Testing module")) } assertEquals(HttpStatusCode.Created, created.status)
val task = created.body<TaskResponse>() assertEquals("Learn Ktor", task.title)}Authenticated endpoints are tested by sending the Authorization header, and a
database-backed app can wire in a Testcontainers Postgres:
@Testfun `protected endpoint requires auth`() = testApplication { application { configureSerialization() configureAuth() configureRouting() }
val unauthed = client.get("/api/admin/users") assertEquals(HttpStatusCode.Unauthorized, unauthed.status)
val authed = client.get("/api/admin/users") { header(HttpHeaders.Authorization, "Bearer ${generateTestToken()}") } assertEquals(HttpStatusCode.OK, authed.status)}Coroutine testing
Section titled “Coroutine testing”Testing coroutines requires special support to control virtual time and
concurrency. The problem: regular runBlocking waits real time for delay()
calls. runTest skips delays automatically.
runTest basics
Section titled “runTest basics”import kotlinx.coroutines.test.runTestimport kotlinx.coroutines.delayimport org.junit.jupiter.api.Testimport org.junit.jupiter.api.Assertions.*
class CoroutineTestExamples {
suspend fun fetchWithRetry(fetch: suspend () -> String, retries: Int = 3): String { repeat(retries) { attempt -> try { return fetch() } catch (e: Exception) { if (attempt == retries - 1) throw e delay(1000L * (attempt + 1)) // Exponential backoff } } throw IllegalStateException("Unreachable") }
@Test fun `retries on failure then succeeds`() = runTest { var attempts = 0 val result = fetchWithRetry( fetch = { attempts++ if (attempts < 3) throw RuntimeException("fail") "success after retries" } ) assertEquals("success after retries", result) assertEquals(3, attempts) // Note: delays were skipped -- this test runs instantly }
@Test fun `throws after all retries exhausted`() = runTest { assertThrows<RuntimeException> { fetchWithRetry( fetch = { throw RuntimeException("always fails") }, retries = 3 ) } }}Here the function under test takes a suspend () -> String lambda — the retry
delays are skipped by runTest, so the test completes instantly.
TestScope and virtual time
Section titled “TestScope and virtual time”runTest gives you a TestScope with virtual-time controls: advanceTimeBy,
runCurrent, and advanceUntilIdle:
import kotlinx.coroutines.test.*import kotlinx.coroutines.delayimport kotlinx.coroutines.launch
class VirtualTimeTest {
@Test fun `controls virtual time`() = runTest { var value = 0
launch { delay(1000) value = 1 delay(1000) value = 2 }
assertEquals(0, value) // time hasn't advanced yet
advanceTimeBy(1000) runCurrent() assertEquals(1, value)
advanceTimeBy(1000) runCurrent() assertEquals(2, value) }
@Test fun `advanceUntilIdle completes all work`() = runTest { val results = mutableListOf<Int>()
launch { delay(100); results.add(1) } launch { delay(200); results.add(2) } launch { delay(300); results.add(3) }
advanceUntilIdle()
assertEquals(listOf(1, 2, 3), results) }}Testing flows
Section titled “Testing flows”Flows collect inside runTest like any other suspending code. For infinite flows,
combine take(n) with toList():
import kotlinx.coroutines.flow.*import kotlinx.coroutines.test.runTest
class FlowTestExamples {
fun numberFlow(): Flow<Int> = flow { emit(1); emit(2); emit(3) }
@Test fun `collects all values from flow`() = runTest { val values = numberFlow().toList() assertEquals(listOf(1, 2, 3), values) }
@Test fun `tests flow transformations`() = runTest { val doubled = numberFlow() .map { it * 2 } .filter { it > 2 } .toList() assertEquals(listOf(4, 6), doubled) }}Injecting test dispatchers
Section titled “Injecting test dispatchers”When your code uses specific dispatchers, inject a TestDispatcher so virtual time
controls it:
import kotlinx.coroutines.*import kotlinx.coroutines.test.*
class UserService(private val dispatcher: CoroutineDispatcher = Dispatchers.IO) { suspend fun loadUser(id: String): User = withContext(dispatcher) { delay(1000) // simulate slow I/O User(id, "User $id") }}
class UserServiceTest {
@Test fun `loads user with test dispatcher`() = runTest { val testDispatcher = StandardTestDispatcher(testScheduler) val service = UserService(dispatcher = testDispatcher)
val deferred = async { service.loadUser("1") } advanceUntilIdle()
val user = deferred.await() assertEquals("User 1", user.name) }}Test organization and code coverage
Section titled “Test organization and code coverage”A common convention is to split fast unit tests from slow integration tests by directory:
Directorysrc/
Directorymain/kotlin/com/example/
Directorymodel/
- …
Directoryrepository/
- …
Directoryservice/
- …
Directorycontroller/
- …
Directorytest/kotlin/com/example/
Directoryunit/ Fast, no external deps
Directoryservice/
- UserServiceTest.kt
- TaskServiceTest.kt
Directoryintegration/ Testcontainers, real DB
Directoryrepository/
- UserRepositoryTest.kt
Directoryapi/
- TaskApiIntegrationTest.kt
Directorytestutil/ Shared test helpers
- IntegrationTestBase.kt
- TestFixtures.kt
Separating unit and integration tests with Gradle
Section titled “Separating unit and integration tests with Gradle”Create a separate source set and Gradle task so integration tests run on demand:
sourceSets { create("integrationTest") { kotlin.srcDir("src/integrationTest/kotlin") resources.srcDir("src/integrationTest/resources") compileClasspath += sourceSets["main"].output + sourceSets["test"].output runtimeClasspath += sourceSets["main"].output + sourceSets["test"].output }}
configurations["integrationTestImplementation"].extendsFrom(configurations["testImplementation"])configurations["integrationTestRuntimeOnly"].extendsFrom(configurations["testRuntimeOnly"])
tasks.register<Test>("integrationTest") { description = "Runs integration tests." group = "verification" testClassesDirs = sourceSets["integrationTest"].output.classesDirs classpath = sourceSets["integrationTest"].runtimeClasspath useJUnitPlatform() shouldRunAfter(tasks.test)}
tasks.check { dependsOn(tasks.named("integrationTest"))}Tagging tests (alternative to separate source sets)
Section titled “Tagging tests (alternative to separate source sets)”Tag classes with @Tag(...), then include or exclude tags at run time:
import org.junit.jupiter.api.Tag
@Tag("unit")class UserServiceTest { /* ... */ }
@Tag("integration")class UserApiIntegrationTest { /* ... */ }
@Tag("slow")class FullE2ETest { /* ... */ }./gradlew test -PincludeTags=unit # only unit tests./gradlew test -PexcludeTags=slow # skip slow teststasks.test { useJUnitPlatform { val includeTags = project.findProperty("includeTags") as String? val excludeTags = project.findProperty("excludeTags") as String? includeTags?.let { includeTags(it) } excludeTags?.let { excludeTags(it) } }}JaCoCo code coverage
Section titled “JaCoCo code coverage”JaCoCo is the JVM’s coverage tool (the equivalent of go test -cover or Jest’s
--coverage). Apply the plugin and wire reports onto the test task:
plugins { jacoco}
tasks.test { useJUnitPlatform() finalizedBy(tasks.jacocoTestReport)}
tasks.jacocoTestReport { dependsOn(tasks.test) reports { xml.required.set(true) // For CI tools html.required.set(true) // For humans csv.required.set(false) }}
tasks.jacocoTestCoverageVerification { violationRules { rule { limit { minimum = "0.80".toBigDecimal() } // 80% line coverage } rule { element = "CLASS" excludes = listOf("*.Application*", "*.config.*") limit { counter = "BRANCH" minimum = "0.70".toBigDecimal() } } }}./gradlew test jacocoTestReport./gradlew jacocoTestCoverageVerification# Fails build if coverage is below thresholdTest fixtures and factories
Section titled “Test fixtures and factories”Centralize test-data construction in factory functions with sensible defaults — the
name: String = "Test User" defaults let each test override only what it cares
about:
object TestFixtures { fun user( id: String = "user-${java.util.UUID.randomUUID()}", name: String = "Test User", email: String = "$id@test.com" ) = User(id = id, name = name, email = email)
fun task( id: Long = 0, title: String = "Test Task", description: String = "A test task", completed: Boolean = false ) = Task(id = id, title = title, description = description, completed = completed)}Summary
Section titled “Summary”| Concept | TypeScript | Go | Kotlin/JVM |
|---|---|---|---|
| Test framework | Jest / Vitest | go test | JUnit 5 |
| Assertions | expect().toBe() | if/t.Error | assertEquals / Kotest shouldBe |
| Mocking | jest.mock() | interfaces + manual | MockK mockk<T>() |
| BDD style | describe/it | t.Run() subtests | Kotest DescribeSpec / BehaviorSpec |
| Table tests | test.each | []struct{} loop | @ParameterizedTest / @MethodSource |
| Property testing | fast-check | rapid | Kotest forAll / checkAll |
| Docker tests | testcontainers-node | testcontainers-go | Testcontainers @Container |
| Controller tests | supertest | httptest | MockMvc / testApplication |
| Coroutine tests | — | — | runTest / TestScope |
| Coverage | --coverage | go test -cover | JaCoCo |
Practice
Section titled “Practice”Put the testing stack to work — write a full suite against a real database, then prove invariants with generated inputs.