Skip to main content

Kotlin Testing Guidelines

Overview

This guide covers comprehensive Kotlin testing strategies including JUnit 5 for unit tests, Kotest for expressive BDD-style tests, MockK for idiomatic Kotlin mocking, coroutine testing with test dispatchers, Flow testing with Turbine, and integration testing patterns. Testing is essential for maintaining code quality, catching regressions early, and enabling confident refactoring. Kotlin's testing ecosystem provides powerful tools specifically designed to work with Kotlin's language features like coroutines, nullable types, and data classes.


Core Principles

  1. Test Pyramid: Many unit tests, some integration tests, few E2E tests
  2. Coroutine Testing: Use test dispatchers and test scope
  3. Flow Testing: Turbine for collecting and asserting Flow emissions
  4. MockK: Idiomatic Kotlin mocking library
  5. JUnit 5: Modern testing framework with parameterized tests
  6. Kotest: Expressive assertions and BDD-style specs
  7. TestContainers: Real dependencies for integration tests
  8. Isolation: Each test independent and repeatable
  9. Fast Feedback: Unit tests complete in milliseconds
  10. Readable Tests: Tests as living documentation

JUnit 5 Basics

JUnit 5 (also called JUnit Jupiter) is the latest version of the Java testing framework, fully redesigned to leverage modern Java features and provide better extensibility. It works seamlessly with Kotlin, supporting features like backtick function names and default parameters.

Basic Test Structure

JUnit 5 uses @Test to mark test methods. Lifecycle methods like @BeforeEach and @AfterEach run before/after each test. The @BeforeAll and @AfterAll annotations mark methods that run once per test class. These lifecycle methods enable proper test isolation - each test starts with fresh state.

Why test isolation matters: Tests must be independent. If one test modifies shared state, it can cause other tests to fail or pass incorrectly. Isolating tests with @BeforeEach setup ensures each test starts from a known state. This makes tests deterministic - they produce the same result every time, regardless of execution order.

Kotlin's lateinit modifier works well with test fields. You initialize them in @BeforeEach, and the compiler verifies they're initialized before use. For nullable fields, you can use regular nullable types with initialization to null.

// GOOD: Clear test structure with JUnit 5
class PaymentServiceTest {

// lateinit for non-nullable types initialized in setup
private lateinit var paymentService: PaymentService
private lateinit var repository: PaymentRepository
private lateinit var gateway: PaymentGateway

@BeforeEach
fun setup() {
// Fresh instances for each test
repository = mockk()
gateway = mockk()
paymentService = PaymentService(repository, gateway)
}

@Test
fun `should process payment successfully`() {
// Given (Arrange) - set up test conditions
val payment = Payment(
id = "123",
amount = BigDecimal("100.00"),
currency = "USD",
status = PaymentStatus.PENDING
)
every { gateway.process(payment) } returns PaymentResult.Success("txn-456")
every { repository.save(any()) } returns payment

// When (Act) - execute the behavior being tested
val result = paymentService.processPayment(payment)

// Then (Assert) - verify the outcome
assertThat(result).isInstanceOf(PaymentResult.Success::class.java)
verify { gateway.process(payment) }
verify { repository.save(any()) }
}

@AfterEach
fun tearDown() {
// Clean up after each test
clearAllMocks()
}

// GOOD: Using @BeforeAll for expensive setup
companion object {
@JvmStatic
@BeforeAll
fun setupOnce() {
// Runs once before all tests in this class
// Use for expensive initialization shared across tests
}

@JvmStatic
@AfterAll
fun tearDownOnce() {
// Runs once after all tests complete
// Use for cleaning up shared resources
}
}
}

The three-phase structure (Given-When-Then or Arrange-Act-Assert) makes tests readable. Comments separating phases help, but the structure should be obvious even without them. For more on test structure patterns, see our testing strategy guide.

Parameterized Tests

Parameterized tests allow running the same test logic with different inputs. Instead of writing repetitive tests that differ only in input data, you write one test and provide multiple input sets. This reduces duplication and makes it easy to add new test cases.

Test sources: @CsvSource for inline CSV data, @MethodSource for complex objects from a method, @ValueSource for simple single values, @EnumSource for enums, and @ArgumentsSource for custom argument providers.

Parameterized tests are ideal for validation logic, boundary testing, or any scenario where behavior depends on input values. Each input combination runs as a separate test, with clear reporting of which inputs passed or failed.

// GOOD: Parameterized tests for multiple scenarios
class PaymentValidatorTest {

// CSV source for simple value combinations
@ParameterizedTest
@CsvSource(
"0.00, false", // Zero amount should be invalid
"0.01, true", // Smallest positive amount should be valid
"100.00, true", // Normal amount should be valid
"-50.00, false" // Negative amount should be invalid
)
fun `should validate payment amount`(amount: BigDecimal, expected: Boolean) {
val payment = Payment(
id = "123",
amount = amount,
currency = "USD",
status = PaymentStatus.PENDING
)

val result = PaymentValidator.isValid(payment)

assertThat(result).isEqualTo(expected)
}

// Method source for complex test objects
@ParameterizedTest
@MethodSource("provideInvalidPayments")
fun `should reject invalid payments`(payment: Payment, expectedError: String) {
val result = PaymentValidator.validate(payment)

assertThat(result.isFailure).isTrue()
assertThat(result.exceptionOrNull()?.message).contains(expectedError)
}

companion object {
// Method providing test data
@JvmStatic
fun provideInvalidPayments() = listOf(
Arguments.of(
Payment(id = "", amount = BigDecimal("100"), currency = "USD", status = PaymentStatus.PENDING),
"ID cannot be empty"
),
Arguments.of(
Payment(id = "123", amount = BigDecimal.ZERO, currency = "USD", status = PaymentStatus.PENDING),
"Amount must be positive"
),
Arguments.of(
Payment(id = "123", amount = BigDecimal("100"), currency = "", status = PaymentStatus.PENDING),
"Invalid currency"
)
)
}

// GOOD: ValueSource for simple values
@ParameterizedTest
@ValueSource(strings = ["USD", "EUR", "GBP", "JPY"])
fun `should accept valid currency codes`(currency: String) {
val payment = Payment(id = "123", amount = BigDecimal("100"), currency = currency, status = PaymentStatus.PENDING)
assertThat(PaymentValidator.isValid(payment)).isTrue()
}

// GOOD: EnumSource for testing all enum values
@ParameterizedTest
@EnumSource(PaymentStatus::class)
fun `should handle all payment statuses`(status: PaymentStatus) {
val payment = Payment(id = "123", amount = BigDecimal("100"), currency = "USD", status = status)
// Test that validator handles all status values without throwing
assertDoesNotThrow { PaymentValidator.validate(payment) }
}
}

Parameterized tests make it easy to thoroughly test edge cases and boundary conditions. When you find a bug, add a failing test case to the parameter list to ensure it doesn't regress.

Nested Tests

Nested tests organize related tests into hierarchical groups using inner classes. Each inner class can have its own setup and teardown methods, allowing you to build up context progressively. This is particularly useful for testing state machines, workflows, or scenarios with complex preconditions.

Benefits: Clearer test organization (tests are grouped by context), reduced duplication (shared setup in outer class), better test reports (hierarchical structure reflects the scenarios being tested), and easier to find related tests.

// GOOD: Nested tests for organized test structure
class PaymentProcessorTest {

private lateinit var processor: PaymentProcessor
private lateinit var repository: PaymentRepository

@BeforeEach
fun setup() {
// Common setup for all tests
repository = mockk()
processor = PaymentProcessor(repository)
}

@Nested
@DisplayName("When processing valid payment")
inner class ValidPayment {

private lateinit var validPayment: Payment

@BeforeEach
fun setupValidPayment() {
// Additional setup specific to valid payment tests
validPayment = Payment(
id = "123",
amount = BigDecimal("100.00"),
currency = "USD",
status = PaymentStatus.PENDING
)
every { repository.validate(validPayment) } returns true
}

@Test
fun `should validate payment`() {
// Test validation with valid payment
assertThat(processor.validate(validPayment)).isTrue()
}

@Test
fun `should call gateway`() {
// Test gateway call with valid payment
processor.process(validPayment)
verify { repository.save(validPayment) }
}

@Test
fun `should save to database`() {
// Test database save with valid payment
processor.process(validPayment)
verify(exactly = 1) { repository.save(any()) }
}
}

@Nested
@DisplayName("When payment fails validation")
inner class InvalidPayment {

private lateinit var invalidPayment: Payment

@BeforeEach
fun setupInvalidPayment() {
// Setup specific to invalid payment tests
invalidPayment = Payment(
id = "123",
amount = BigDecimal.ZERO, // Invalid amount
currency = "USD",
status = PaymentStatus.PENDING
)
every { repository.validate(invalidPayment) } returns false
}

@Test
fun `should throw validation exception`() {
// Test validation failure
assertThrows<ValidationException> {
processor.process(invalidPayment)
}
}

@Test
fun `should not call gateway`() {
// Verify gateway not called on invalid payment
try {
processor.process(invalidPayment)
} catch (e: ValidationException) {
// Expected exception
}
verify(exactly = 0) { repository.save(any()) }
}
}
}

Nested tests create a readable test suite that mirrors the structure of your code's behavior. The @DisplayName annotation provides human-readable descriptions that appear in test reports.


MockK for Mocking

MockK is a mocking library designed specifically for Kotlin, providing idiomatic support for Kotlin features like coroutines, extension functions, and top-level functions. Unlike Java mocking libraries (like Mockito), MockK fully embraces Kotlin's language features and doesn't require workarounds for final classes, suspend functions, or other Kotlin-specific constructs.

Why MockK Over Java Mocking Libraries

Kotlin classes and methods are final by default, which creates problems for Java-based mocking libraries that rely on inheritance. Mockito requires the mockito-inline dependency and extra configuration to mock final classes. MockK uses bytecode manipulation to mock any Kotlin class or function without restrictions.

MockK provides first-class support for coroutines - you use coEvery to stub suspend functions and coVerify to verify they were called. This is cleaner than Mockito's workarounds for coroutines. MockK also supports mocking extension functions, top-level functions, objects, and constructors - all common in Kotlin but challenging or impossible with Java mocking libraries.

The API is Kotlin-friendly with infix functions and DSL-style configuration. You write every { repository.getUser() } returns user instead of Mockito's when(repository.getUser()).thenReturn(user). MockK integrates naturally with Kotlin's null safety, requiring explicit nullability declarations. For mocking strategies in tests, see our testing strategy guide.

Basic Mocking

// GOOD: MockK for Kotlin-friendly mocking
class PaymentServiceTest {

@Test
fun `should process payment with mocked dependencies`() {
// Create mocks
val repository = mockk<PaymentRepository>()
val gateway = mockk<PaymentGateway>()
val service = PaymentService(repository, gateway)

val payment = Payment(...)

// Define behavior
every { repository.findById("123") } returns payment
every { gateway.process(payment) } returns PaymentResult.Success("txn-456")
every { repository.save(any()) } returns payment

// Execute
val result = service.processPayment("123")

// Verify
assertThat(result).isInstanceOf(PaymentResult.Success::class.java)
verify(exactly = 1) { gateway.process(payment) }
verify { repository.save(match { it.status == PaymentStatus.COMPLETED }) }
}
}

Argument Matchers

// GOOD: Flexible argument matching
@Test
fun `should save payment with correct status`() {
val repository = mockk<PaymentRepository>()

// Match any Payment with COMPLETED status
every {
repository.save(match { it.status == PaymentStatus.COMPLETED })
} returns mockk()

// Match amount range
every {
repository.save(match { it.amount in BigDecimal("100")..BigDecimal("1000") })
} returns mockk()

// Capture argument
val slot = slot<Payment>()
every { repository.save(capture(slot)) } returns mockk()

service.processPayment(payment)

// Assert captured value
assertThat(slot.captured.status).isEqualTo(PaymentStatus.COMPLETED)
}

Relaxed Mocks

// GOOD: Relaxed mocks for convenience
@Test
fun `should handle optional dependencies`() {
// Relaxed mock returns default values
val auditService = mockk<AuditService>(relaxed = true)
val notificationService = mockk<NotificationService>(relaxed = true)

val service = PaymentService(
repository = mockk(),
gateway = mockk(),
auditService = auditService,
notificationService = notificationService
)

// No need to define behavior for every method
service.processPayment(payment)

// Can still verify calls
verify { auditService.log(any()) }
}

Spy for Partial Mocking

// GOOD: Spy for partial mocking
@Test
fun `should use real implementation for some methods`() {
val realService = PaymentService(repository, gateway)
val spyService = spyk(realService)

// Override specific method
every { spyService.validatePayment(any()) } returns true

// Other methods use real implementation
val result = spyService.processPayment(payment)

verify { spyService.validatePayment(payment) }
}

Coroutine Testing

Testing coroutines requires special support because coroutines introduce asynchronicity, dispatchers, and delays. The kotlinx-coroutines-test library provides test utilities that give you control over coroutine execution, allowing tests to run synchronously and deterministically.

Understanding Coroutine Testing Challenges

Coroutines present several testing challenges. Real dispatchers execute code asynchronously on various thread pools, making tests non-deterministic and slow. Delays in production code (e.g., delay(5000) for polling) would make tests take forever. Coroutines might not complete before test assertions run, causing flaky tests.

The solution is test dispatchers that provide virtual time. Test dispatchers don't use real threads or real time. Instead, they execute coroutines immediately (or when you tell them to) on the test thread, and advance virtual time instantly. This makes tests fast, deterministic, and controllable. You can skip delays instantly, advance time precisely, and verify state at exact moments.

StandardTestDispatcher is the default test dispatcher. It doesn't execute coroutines immediately - you must explicitly advance time with advanceUntilIdle() or advanceTimeBy(). This gives precise control over when coroutines execute. UnconfinedTestDispatcher executes eagerly, starting coroutines immediately. It's useful when you want immediate execution without manual advancing.

The runTest function creates a test coroutine scope with a test dispatcher and virtual time. It automatically waits for all coroutines to complete before finishing, preventing incomplete coroutines from causing test failures. For more on coroutine fundamentals, see our Kotlin coroutines guide.

Test Dispatcher

// GOOD: Using test dispatcher for coroutines
@OptIn(ExperimentalCoroutinesApi::class)
class PaymentViewModelTest {

private lateinit var viewModel: PaymentViewModel
private lateinit var repository: PaymentRepository

// Test dispatcher for controlling coroutine execution
private val testDispatcher = StandardTestDispatcher()

@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
repository = mockk()
viewModel = PaymentViewModel(repository)
}

@After
fun tearDown() {
Dispatchers.resetMain()
}

@Test
fun `should load payments successfully`() = runTest {
// Given
val payments = listOf(Payment(...), Payment(...))
coEvery { repository.getPayments() } returns payments

// When
viewModel.loadPayments()

// Advance time to execute coroutines
advanceUntilIdle()

// Then
val state = viewModel.uiState.value
assertThat(state).isInstanceOf(PaymentUiState.Success::class.java)
assertThat((state as PaymentUiState.Success).payments).isEqualTo(payments)
}
}

RunTest for Coroutine Tests

// GOOD: runTest for testing suspend functions
@Test
fun `should process payment asynchronously`() = runTest {
// Given
val payment = Payment(...)
coEvery { gateway.process(payment) } coAnswers {
delay(1000) // Simulated delay
PaymentResult.Success("txn-123")
}

// When
val result = service.processPayment(payment)

// Then (delay automatically skipped in test)
assertThat(result).isInstanceOf(PaymentResult.Success::class.java)
}

Testing Concurrent Operations

// GOOD: Testing concurrent coroutines
@Test
fun `should process multiple payments concurrently`() = runTest {
val payments = listOf(Payment(...), Payment(...), Payment(...))

coEvery { gateway.process(any()) } coAnswers {
delay(100)
PaymentResult.Success("txn-${Random.nextInt()}")
}

// Launch concurrent operations
val results = payments.map { payment ->
async { service.processPayment(payment) }
}.awaitAll()

// Verify all succeeded
assertThat(results).hasSize(3)
assertThat(results).allMatch { it is PaymentResult.Success }
}

Testing Error Handling

// GOOD: Testing exception handling in coroutines
@Test
fun `should handle network error gracefully`() = runTest {
// Given
coEvery { repository.getPayments() } throws NetworkException("Connection failed")

// When
viewModel.loadPayments()
advanceUntilIdle()

// Then
val state = viewModel.uiState.value
assertThat(state).isInstanceOf(PaymentUiState.Error::class.java)
assertThat((state as PaymentUiState.Error).message).contains("Connection failed")
}

Flow Testing with Turbine

Testing Flows presents unique challenges because Flows emit multiple values over time. You need to collect emissions, assert on each value, verify the emission order, and handle completion or errors. Turbine is a testing library that simplifies Flow testing by providing an intuitive API for collecting and asserting on Flow emissions.

Why Turbine for Flow Testing

Without Turbine, testing Flows is cumbersome. You must manually collect emissions into a list, handle threading and timing issues, and write verbose assertion code. Turbine provides a clean DSL with a test extension function that creates a test turbine, collects emissions, and provides methods like awaitItem(), awaitComplete(), and awaitError() for assertions.

Turbine integrates with coroutine testing - it respects virtual time from test dispatchers. When your Flow uses delay(), Turbine works with runTest to skip delays instantly. Turbine also handles Flow cancellation automatically: when the test block finishes, the Flow is cancelled, preventing leaks. If a Flow emits more items than you assert on, Turbine fails the test, catching unintended emissions.

The key methods are awaitItem() to get the next emission (suspends if not yet available), awaitComplete() to assert the Flow completes successfully, awaitError() to assert the Flow throws an exception, and expectNoEvents() to assert no emissions occur within a timeout. For patterns on using Flow in production code, see our Kotlin Flow guide.

Basic Flow Testing

// GOOD: Using Turbine for Flow testing
@Test
fun `should emit payment updates`() = runTest {
val payments = listOf(Payment(...), Payment(...))
coEvery { repository.observePayments() } returns flowOf(payments)

// Test Flow emissions with Turbine
repository.observePayments().test {
// Await first emission
val emission = awaitItem()
assertThat(emission).isEqualTo(payments)

// Verify Flow completes
awaitComplete()
}
}

Testing Multiple Emissions

// GOOD: Testing Flow with multiple emissions
@Test
fun `should emit loading then success states`() = runTest {
val payments = listOf(Payment(...))

viewModel.paymentState.test {
// Initial state
assertThat(awaitItem()).isEqualTo(PaymentState.Idle)

// Trigger loading
viewModel.loadPayments()

// Verify state transitions
assertThat(awaitItem()).isEqualTo(PaymentState.Loading)
val successState = awaitItem() as PaymentState.Success
assertThat(successState.payments).isEqualTo(payments)
}
}

Testing Flow Transformations

// GOOD: Testing Flow operators
@Test
fun `should filter and map payments`() = runTest {
val payments = listOf(
Payment(id = "1", amount = BigDecimal("50"), status = PaymentStatus.COMPLETED),
Payment(id = "2", amount = BigDecimal("150"), status = PaymentStatus.PENDING),
Payment(id = "3", amount = BigDecimal("200"), status = PaymentStatus.COMPLETED)
)

val flow = flowOf(payments)
.map { list -> list.filter { it.status == PaymentStatus.COMPLETED } }
.map { list -> list.map { it.amount } }

flow.test {
val amounts = awaitItem()
assertThat(amounts).containsExactly(
BigDecimal("50"),
BigDecimal("200")
)
awaitComplete()
}
}

Testing Flow Errors

// GOOD: Testing Flow error handling
@Test
fun `should handle Flow errors`() = runTest {
val errorFlow = flow<List<Payment>> {
emit(listOf(Payment(...)))
throw NetworkException("Connection lost")
}

errorFlow.test {
// First emission succeeds
val payments = awaitItem()
assertThat(payments).hasSize(1)

// Then error occurs
val error = awaitError()
assertThat(error).isInstanceOf(NetworkException::class.java)
assertThat(error.message).isEqualTo("Connection lost")
}
}

Testing StateFlow

// GOOD: Testing StateFlow updates
@Test
fun `should update StateFlow value`() = runTest {
val viewModel = PaymentViewModel(repository)

viewModel.payments.test {
// Initial value
assertThat(awaitItem()).isEmpty()

// Trigger update
viewModel.loadPayments()
advanceUntilIdle()

// New value
val payments = awaitItem()
assertThat(payments).isNotEmpty()
}
}

Kotest for BDD-Style Tests

Kotest is a flexible Kotlin testing framework that provides multiple testing styles (specs), powerful assertions, property-based testing, and excellent Kotlin support. Unlike JUnit, Kotest embraces Kotlin idioms and provides a more expressive syntax. It's particularly popular for BDD-style (Behavior-Driven Development) testing.

Why Kotest

Kotest offers several advantages over JUnit: Multiple specs for different testing styles (StringSpec, DescribeSpec, FunSpec, etc.), No annotations required (uses lambdas instead), Powerful matchers with natural language assertions (shouldBe, shouldContain, etc.), Property-based testing built-in, Test lifecycle hooks with scoping, and Data-driven testing without separate annotations.

The spec-based approach makes tests more readable. Instead of annotated methods, tests are defined as strings or structured blocks. This feels more natural in Kotlin and produces tests that read like documentation. Kotest also integrates seamlessly with coroutines - no special setup required.

When to use Kotest vs JUnit: Use JUnit when you need broad tooling support, team familiarity with JUnit, or integration with Java-centric frameworks. Use Kotest for greenfield Kotlin projects, when you want expressive BDD-style tests, or when property-based testing is valuable.

String Spec

StringSpec is the simplest Kotest spec - each test is a string mapped to a lambda. This is perfect for straightforward unit tests where you don't need complex nesting. The string describes what the test does, and the lambda contains the test code. No annotations, no boilerplate.

// GOOD: Kotest StringSpec for readable tests
class PaymentServiceSpec : StringSpec({

// Setup shared across all tests
val repository = mockk<PaymentRepository>()
val gateway = mockk<PaymentGateway>()
val service = PaymentService(repository, gateway)

// Each test is a string with a lambda
"should process payment successfully" {
val payment = Payment(...)
coEvery { gateway.process(payment) } returns PaymentResult.Success("txn-123")

val result = service.processPayment(payment)

// Kotest matchers - more readable than JUnit assertions
result shouldBe instanceOf<PaymentResult.Success>()
}

"should handle payment failure" {
val payment = Payment(...)
coEvery { gateway.process(payment) } returns PaymentResult.Failure("INSUFFICIENT_FUNDS")

val result = service.processPayment(payment)

result shouldBe instanceOf<PaymentResult.Failure>()
}

"should retry on network error" {
val payment = Payment(...)
coEvery { gateway.process(payment) } throws NetworkException("Timeout")

shouldThrow<NetworkException> {
service.processPayment(payment)
}
}
})

StringSpec is ideal for simple, flat test suites. If you need grouping or hierarchy, use DescribeSpec or FunSpec instead. The string-based test names are more flexible than method names - you can include spaces, punctuation, and detailed descriptions.

Describe Spec

DescribeSpec provides a hierarchical BDD structure with describe, context, and it blocks. This mimics RSpec/Jasmine syntax from Ruby/JavaScript and is excellent for organizing tests by context. describe blocks group related tests, context blocks specify conditions, and it blocks contain individual assertions.

Structure: describe is for the subject being tested (usually a class or function). context is for different scenarios or states. it is for specific behaviors. This creates tests that read like specifications: "Describe PaymentProcessor, when payment is valid, it should process payment."

// GOOD: Kotest DescribeSpec for nested structure
class PaymentProcessorSpec : DescribeSpec({

describe("PaymentProcessor") {
// Describe the subject
val processor = PaymentProcessor()

context("when payment is valid") {
// Context describes the scenario
val validPayment = Payment(amount = BigDecimal("100"))

it("should validate successfully") {
// Specific behavior assertion
processor.validate(validPayment) shouldBe true
}

it("should process payment") {
val result = processor.process(validPayment)
result.shouldBeTypeOf<PaymentResult.Success>()
}

it("should record transaction") {
processor.process(validPayment)
processor.transactionCount shouldBe 1
}
}

context("when payment amount is zero") {
val invalidPayment = Payment(amount = BigDecimal.ZERO)

it("should fail validation") {
processor.validate(invalidPayment) shouldBe false
}

it("should not process payment") {
shouldThrow<ValidationException> {
processor.process(invalidPayment)
}
}
}

context("when processor is disabled") {
beforeEach {
processor.disable()
}

it("should reject all payments") {
val payment = Payment(amount = BigDecimal("100"))
shouldThrow<ProcessorDisabledException> {
processor.process(payment)
}
}

afterEach {
processor.enable()
}
}
}
})

DescribeSpec's hierarchical structure makes it easy to see relationships between tests. Each level of nesting adds context, and lifecycle hooks (beforeEach, afterEach) can be scoped to specific contexts. This creates highly organized test suites for complex behaviors.

Kotest Assertions

// GOOD: Expressive Kotest assertions
@Test
fun `should have correct payment properties`() {
val payment = Payment(
id = "123",
amount = BigDecimal("100.00"),
currency = "USD",
status = PaymentStatus.COMPLETED
)

payment.shouldNotBeNull()
payment.id shouldBe "123"
payment.amount shouldBe BigDecimal("100.00")
payment.status shouldBe PaymentStatus.COMPLETED

// Collection assertions
val payments = listOf(payment)
payments shouldHaveSize 1
payments shouldContain payment
payments.map { it.amount } shouldContainExactly listOf(BigDecimal("100.00"))

// Exception assertions
shouldThrow&lt;ValidationException> {
PaymentValidator.validate(Payment(amount = BigDecimal.ZERO))
}.message shouldContain "Amount must be positive"
}

Integration Testing

Integration tests verify that multiple components work together correctly. Unlike unit tests that isolate a single class with mocks, integration tests use real implementations of dependencies (or close approximations) to test interactions between components. This catches issues that unit tests miss, like SQL query errors, serialization problems, or incorrect API contracts.

Why Integration Tests Matter

Unit tests with mocks verify that your code calls dependencies correctly, but they don't verify that dependencies actually work as expected. A unit test might pass because you mocked the database to return data, but the real database query could be broken. Integration tests catch these issues by using real databases, real HTTP clients, or real file systems.

The tradeoff is speed and complexity. Integration tests are slower than unit tests because they involve real I/O operations. They also require more setup - databases need to be initialized with schema, test data needs to be managed, and cleanup is necessary between tests. Tools like Room's in-memory databases and MockWebServer help by providing fast, isolated test environments without external dependencies.

Test pyramid: Most of your tests should be fast unit tests, a moderate number of integration tests, and a few end-to-end tests. Integration tests sit in the middle - slower than unit tests but faster than E2E tests, covering critical integration points without testing the entire system. For more on the test pyramid, see our testing strategy guide.

Repository Integration Tests

// GOOD: Integration test with in-memory database
@OptIn(ExperimentalCoroutinesApi::class)
class PaymentRepositoryIntegrationTest {

private lateinit var database: PaymentDatabase
private lateinit var paymentDao: PaymentDao
private lateinit var repository: PaymentRepository

@Before
fun setup() {
// In-memory database for testing
database = Room.inMemoryDatabaseBuilder(
ApplicationProvider.getApplicationContext(),
PaymentDatabase::class.java
).build()

paymentDao = database.paymentDao()
repository = PaymentRepositoryImpl(paymentDao)
}

@After
fun tearDown() {
database.close()
}

@Test
fun `should save and retrieve payment`() = runTest {
// Given
val payment = Payment(
id = "123",
amount = BigDecimal("100.00"),
currency = "USD",
status = PaymentStatus.PENDING
)

// When
repository.save(payment)
val retrieved = repository.findById("123")

// Then
assertThat(retrieved).isNotNull()
assertThat(retrieved?.id).isEqualTo("123")
assertThat(retrieved?.amount).isEqualTo(BigDecimal("100.00"))
}

@Test
fun `should observe payment updates`() = runTest {
val payment = Payment(...)

repository.observePayments().test {
// Initial empty list
assertThat(awaitItem()).isEmpty()

// Save payment
repository.save(payment)

// Observe update
val payments = awaitItem()
assertThat(payments).hasSize(1)
assertThat(payments.first().id).isEqualTo(payment.id)
}
}
}

Network Integration Tests with MockWebServer

// GOOD: Test real HTTP calls with MockWebServer
class PaymentApiIntegrationTest {

private lateinit var mockWebServer: MockWebServer
private lateinit var paymentApi: PaymentApi

@Before
fun setup() {
mockWebServer = MockWebServer()
mockWebServer.start()

val retrofit = Retrofit.Builder()
.baseUrl(mockWebServer.url("/"))
.addConverterFactory(GsonConverterFactory.create())
.build()

paymentApi = retrofit.create(PaymentApi::class.java)
}

@After
fun tearDown() {
mockWebServer.shutdown()
}

@Test
fun `should fetch payments from API`() = runTest {
// Given
val responseBody = """
[
{
"id": "123",
"amount": "100.00",
"currency": "USD",
"status": "COMPLETED"
}
]
""".trimIndent()

mockWebServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(responseBody)
.setHeader("Content-Type", "application/json")
)

// When
val payments = paymentApi.getPayments()

// Then
assertThat(payments).hasSize(1)
assertThat(payments.first().id).isEqualTo("123")

// Verify request
val request = mockWebServer.takeRequest()
assertThat(request.path).isEqualTo("/payments")
assertThat(request.method).isEqualTo("GET")
}
}

Testing Best Practices

Effective testing requires more than just writing test code - it requires following practices that make tests maintainable, readable, and reliable. Well-structured tests serve as living documentation, help prevent regressions, and enable confident refactoring.

Test Naming Conventions

Test names should clearly communicate what is being tested, under what conditions, and what the expected outcome is. A good test name tells you everything you need to know without reading the test body. This is especially important when tests fail - a descriptive name helps you immediately understand what broke.

Kotlin supports backtick function names, enabling natural language test names. Use the pattern: "should [expected behavior] when [condition]" or "should [expected behavior] given [context]". Be specific about the scenario and outcome. Avoid vague names like testProcessPayment - they don't convey enough information.

For JUnit 5, you can alternatively use @DisplayName with camelCase method names. This approach is useful if you need to generate reports or if your team prefers traditional method naming. The display name appears in test results and IDE test runners. For our overall testing approach, see the testing strategy guide.

// GOOD: Descriptive test names with backticks
class PaymentServiceTest {

@Test
fun `should process payment successfully when valid`() { }

@Test
fun `should throw exception when payment amount is negative`() { }

@Test
fun `should call gateway exactly once for successful payment`() { }

// GOOD: Alternative with @DisplayName for detailed descriptions
@Test
@DisplayName("Should process payment successfully when all validations pass and gateway is available")
fun processPaymentSuccess() { }

// BAD: Vague, doesn't explain scenario or expected outcome
@Test
fun testPayment() { }

// BAD: Doesn't explain the condition or expectation
@Test
fun processPayment() { }
}

Given-When-Then Structure

The Given-When-Then (GWT) pattern structures tests into three clear phases: setup (Given), execution (When), and verification (Then). This pattern, borrowed from Behavior-Driven Development (BDD), makes tests easier to read and understand by following a natural narrative flow.

Given (Arrange): Set up the initial state, create test objects, configure mocks. This phase prepares everything needed for the test. Be explicit about what state you're establishing - the Given section should make it clear what scenario you're testing.

When (Act): Execute the code under test. This is typically a single method call or a short sequence of operations. Keep this section focused - if you're calling many methods, your test might be too broad or testing multiple scenarios at once.

Then (Assert): Verify the outcome. Check return values, verify mock interactions, and assert on state changes. Be specific about what you're verifying and why. Each assertion should relate directly to the test name's expected behavior.

Use comments to mark each section, making the structure explicit. This is especially valuable in longer tests where the phases might not be obvious. For more testing patterns, see our testing strategy guide.

// GOOD: Clear three-phase test structure
@Test
fun `should update payment status to completed when processing succeeds`() {
// Given (Arrange) - Set up the scenario
val payment = Payment(
id = "123",
status = PaymentStatus.PENDING,
amount = BigDecimal("100.00")
)
val repository = mockk&lt;PaymentRepository>()
every { repository.save(any()) } returns payment
val service = PaymentService(repository)

// When (Act) - Execute the behavior being tested
val updated = service.completePayment(payment)

// Then (Assert) - Verify the expected outcome
assertThat(updated.status).isEqualTo(PaymentStatus.COMPLETED)
verify { repository.save(match { it.status == PaymentStatus.COMPLETED }) }
}

// BAD: No clear structure, mixed phases
@Test
fun `should update payment`() {
val payment = Payment(status = PaymentStatus.PENDING)
val updated = service.completePayment(payment) // Act mixed with Given
assertThat(updated.status).isEqualTo(PaymentStatus.COMPLETED)
val repository = mockk&lt;PaymentRepository>() // Setup after execution!
}

Test Data Builders

Test data builders are functions that create test objects with sensible defaults, allowing tests to override only the properties relevant to each scenario. This pattern reduces boilerplate, improves test readability, and makes tests more maintainable when domain models change.

The Problem: Creating test objects often requires setting many properties, most of which are irrelevant to the specific test. Without builders, tests become verbose with repetitive object construction. When you add a new required property to a domain model, every test that constructs that object must be updated.

The Solution: Test data builders provide default values for all properties. Each test specifies only the properties relevant to its scenario. If a new required property is added, you update the builder once (adding a sensible default), and existing tests continue working without modification.

Builders are particularly valuable for complex domain models with many properties or deeply nested structures. Use default parameters for simple builders, or create builder classes with fluent APIs for complex scenarios. Provide specialized builder methods for common test scenarios (e.g., buildPendingPayment, buildExpiredPayment). For test data management strategies, see our test data guide.

// GOOD: Test data builders with sensible defaults
object PaymentTestBuilder {
// Main builder with defaults for all properties
// Tests override only what matters to them
fun buildPayment(
id: String = "test-${UUID.randomUUID()}",
amount: BigDecimal = BigDecimal("100.00"),
currency: String = "USD",
status: PaymentStatus = PaymentStatus.PENDING,
customerId: String = "customer-1",
recipientName: String = "John Doe",
createdAt: Long = System.currentTimeMillis()
): Payment {
return Payment(
id = id,
amount = amount,
currency = currency,
status = status,
customerId = customerId,
recipientName = recipientName,
createdAt = createdAt
)
}

// Specialized builders for common test scenarios
fun buildPendingPayment(amount: BigDecimal = BigDecimal("100.00")): Payment {
return buildPayment(amount = amount, status = PaymentStatus.PENDING)
}

fun buildCompletedPayment(
amount: BigDecimal = BigDecimal("100.00"),
transactionId: String = "txn-${UUID.randomUUID()}"
): Payment {
return buildPayment(amount = amount, status = PaymentStatus.COMPLETED)
}

fun buildLargePayment(): Payment {
return buildPayment(amount = BigDecimal("50000.00"))
}
}

// Usage - test specifies only relevant properties
@Test
fun `should process pending payment`() {
val payment = PaymentTestBuilder.buildPendingPayment(amount = BigDecimal("250.00"))
// Only the amount matters for this test; other properties use defaults
val result = service.process(payment)
assertThat(result).isSuccess()
}

// BAD: Verbose object construction in every test
@Test
fun `should process pending payment`() {
val payment = Payment(
id = "test-123",
amount = BigDecimal("250.00"), // The only property this test cares about
currency = "USD",
status = PaymentStatus.PENDING,
customerId = "customer-1",
recipientName = "John Doe",
createdAt = System.currentTimeMillis()
)
// Same test, much more noise
}

Further Reading

Internal Documentation

External Resources


Summary

Key Takeaways

  1. JUnit 5 - Modern testing framework with parameterized and nested tests
  2. MockK - Idiomatic Kotlin mocking with relaxed mocks and argument matchers
  3. Coroutine testing - Use test dispatchers and runTest for suspend functions
  4. Flow testing - Turbine for collecting and asserting Flow emissions
  5. Kotest - Expressive BDD-style tests with StringSpec and DescribeSpec
  6. Integration tests - In-memory database and MockWebServer for realistic testing
  7. Test structure - Given-When-Then pattern for clarity
  8. Test data builders - Reusable test object creation
  9. Descriptive names - Tests as living documentation
  10. High coverage - Target >85% for banking applications

Next Steps: Review Android Testing for Android-specific testing strategies and Testing Strategy for overall testing approach.