Write Effective Tests

Problem

Effective testing requires unit tests, mocking dependencies, and testing coroutines. JUnit provides the testing framework, but Java mocking libraries don’t work well with Kotlin features. MockK is designed specifically for Kotlin.

This guide shows how to write tests with JUnit 5 and MockK.

Basic Setup

Project Configuration

Add testing dependencies.

// ✅ build.gradle.kts
dependencies {
  testImplementation(kotlin("test"))
  testImplementation("io.mockk:mockk:1.13.9")
  testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
}

tasks.test {
  useJUnitPlatform()
}

Simple Test

Write basic JUnit tests.

import kotlin.test.*

class CalculatorTest {
  // ✅ Basic test
  @Test
  fun `add two numbers`() {
    val calculator = Calculator()
    val result = calculator.add(2, 3)
    assertEquals(5, result)
  }

  // ✅ Test with assertions
  @Test
  fun `divide by zero throws exception`() {
    val calculator = Calculator()
    assertFailsWith<ArithmeticException> {
      calculator.divide(10, 0)
    }
  }

  // ✅ Multiple assertions
  @Test
  fun `calculator operations`() {
    val calc = Calculator()

    assertEquals(5, calc.add(2, 3))
    assertEquals(-1, calc.subtract(2, 3))
    assertEquals(6, calc.multiply(2, 3))
    assertEquals(2, calc.divide(6, 3))
  }
}

Pattern: Use backticks for readable test names, kotlin.test assertions.

MockK Basics

Creating Mocks

Mock dependencies with MockK.

import io.mockk.*

// ✅ Interface mock
interface UserRepository {
  fun findById(id: String): User?
  fun save(user: User): User
}

class UserServiceTest {
  // ✅ Create mock
  private val repository = mockk<UserRepository>()
  private val service = UserService(repository)

  @Test
  fun `get user by id`() {
    val user = User("1", "Alice", "alice@example.com")

    // ✅ Define mock behavior
    every { repository.findById("1") } returns user

    // ✅ Call service
    val result = service.getUser("1")

    // ✅ Verify result
    assertEquals(user, result)

    // ✅ Verify mock called
    verify { repository.findById("1") }
  }
}

How it works: mockk<T>() creates mock, every defines behavior, verify checks calls.

Mock Behavior

Define various mock responses.

class UserServiceTest {
  private val repository = mockk<UserRepository>()

  @Test
  fun `different mock behaviors`() {
    // ✅ Return value
    every { repository.findById("1") } returns user1

    // ✅ Return null
    every { repository.findById("999") } returns null

    // ✅ Throw exception
    every { repository.save(any()) } throws IllegalStateException("DB error")

    // ✅ Multiple calls return different values
    every { repository.findById("2") } returnsMany listOf(user2, null)

    // ✅ Answer with lambda
    every { repository.save(any()) } answers {
      val user = firstArg<User>()
      user.copy(id = "generated-id")
    }
  }
}

Argument Matching

Matchers

Match method arguments flexibly.

@Test
fun `argument matchers`() {
  val repository = mockk<UserRepository>()

  // ✅ any() - matches any argument
  every { repository.findById(any()) } returns null

  // ✅ eq() - matches exact value
  every { repository.findById(eq("1")) } returns user1

  // ✅ match - custom matcher
  every {
    repository.save(match { it.email.contains("@") })
  } returns user

  // ✅ Slot for capturing arguments
  val slot = slot<String>()
  every { repository.findById(capture(slot)) } returns user

  repository.findById("123")
  assertEquals("123", slot.captured)  // Captured value
}

Argument Verification

Verify method calls with argument checks.

@Test
fun `verify with arguments`() {
  val repository = mockk<UserRepository>(relaxed = true)
  val service = UserService(repository)

  service.createUser("Alice", "alice@example.com")

  // ✅ Verify exact arguments
  verify { repository.save(match { it.name == "Alice" }) }

  // ✅ Verify call count
  verify(exactly = 1) { repository.save(any()) }

  // ✅ Verify not called
  verify(exactly = 0) { repository.findById(any()) }

  // ✅ Verify at least/at most
  verify(atLeast = 1) { repository.save(any()) }
  verify(atMost = 2) { repository.save(any()) }
}

Setup and Teardown

Test Lifecycle

Use JUnit lifecycle methods.

class UserServiceTest {
  private lateinit var repository: UserRepository
  private lateinit var service: UserService

  // ✅ Run before each test
  @BeforeEach
  fun setup() {
    repository = mockk()
    service = UserService(repository)
  }

  // ✅ Run after each test
  @AfterEach
  fun teardown() {
    clearAllMocks()
  }

  @Test
  fun `test 1`() {
    // repository and service initialized
  }

  @Test
  fun `test 2`() {
    // Fresh mocks for each test
  }
}

Test Fixtures

Share test data.

class UserServiceTest {
  // ✅ Companion object for shared data
  companion object {
    val testUser = User("1", "Alice", "alice@example.com")
    val testUsers = listOf(
      User("1", "Alice", "alice@example.com"),
      User("2", "Bob", "bob@example.com")
    )

    // ✅ @BeforeAll - runs once for class
    @BeforeAll
    @JvmStatic
    fun setupClass() {
      // One-time setup
    }
  }

  @Test
  fun `use test fixtures`() {
    assertEquals("Alice", testUser.name)
  }
}

Coroutine Testing

Testing Suspend Functions

Test coroutines with kotlinx-coroutines-test.

import kotlinx.coroutines.test.*
import kotlin.test.Test

class AsyncUserServiceTest {
  @Test
  fun `fetch user asynchronously`() = runTest {
    val repository = mockk<UserRepository>()

    // ✅ Mock suspend function
    coEvery { repository.fetchUserAsync("1") } returns user

    val service = UserService(repository)
    val result = service.fetchUser("1")

    assertEquals(user, result)

    // ✅ Verify suspend function called
    coVerify { repository.fetchUserAsync("1") }
  }

  @Test
  fun `handle async error`() = runTest {
    val repository = mockk<UserRepository>()

    // ✅ Mock suspend function throwing exception
    coEvery { repository.fetchUserAsync(any()) } throws NetworkException()

    val service = UserService(repository)

    assertFailsWith<NetworkException> {
      service.fetchUser("1")
    }
  }
}

Pattern: Use coEvery and coVerify for suspend functions, runTest for test scope.

Testing Flow

Test Flow emissions.

import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.toList

@Test
fun `test flow emissions`() = runTest {
  val repository = mockk<UserRepository>()

  // ✅ Mock Flow
  val users = listOf(user1, user2, user3)
  every { repository.getAllUsersFlow() } returns flowOf(*users.toTypedArray())

  val service = UserService(repository)
  val result = service.getAllUsers().toList()

  assertEquals(users, result)
}

@Test
fun `test StateFlow updates`() = runTest {
  val viewModel = UserViewModel()

  viewModel.loadUser("1")

  // ✅ Verify StateFlow value
  assertEquals(UiState.Success(user), viewModel.uiState.value)
}

Relaxed Mocks

Simplifying Mock Setup

Use relaxed mocks for simpler tests.

// ❌ Normal mock - must define every method
val repository = mockk<UserRepository>()
every { repository.findById(any()) } returns null
every { repository.save(any()) } returns user
every { repository.delete(any()) } returns true

// ✅ Relaxed mock - returns defaults
val repository = mockk<UserRepository>(relaxed = true)
// All methods return default values automatically

@Test
fun `relaxed mock example`() {
  val repository = mockk<UserRepository>(relaxed = true)
  val service = UserService(repository)

  // ✅ Only mock what matters
  every { repository.findById("1") } returns user

  service.processUser("1")

  // ✅ Verify only important calls
  verify { repository.findById("1") }
  // Other methods can be called without explicit setup
}

Use case: When you don’t care about most interactions.

Common Pitfalls

Forgetting to Mock Dependencies

// ❌ NullPointerException - method not mocked
@Test
fun `unmocked method`() {
  val repository = mockk<UserRepository>()
  // Forgot: every { repository.findById(any()) } returns ...

  val service = UserService(repository)
  val result = service.getUser("1")  // ❌ Crashes!
}

// ✅ Mock all used methods
@Test
fun `properly mocked`() {
  val repository = mockk<UserRepository>()
  every { repository.findById(any()) } returns null

  val service = UserService(repository)
  val result = service.getUser("1")  // ✅ Works
}

Not Using coEvery for Suspend Functions

// ❌ Wrong - every doesn't work with suspend
@Test
fun `wrong mock for suspend function`() = runTest {
  val repository = mockk<UserRepository>()
  every { repository.fetchUserAsync("1") } returns user  // ❌ Won't work!
}

// ✅ Use coEvery
@Test
fun `correct mock for suspend function`() = runTest {
  val repository = mockk<UserRepository>()
  coEvery { repository.fetchUserAsync("1") } returns user  // ✅ Correct
}

Over-Verifying

// ❌ Testing implementation details
@Test
fun `over verification`() {
  service.updateUser("1", update)

  verify { repository.findById("1") }
  verify { repository.save(any()) }
  verify { cache.invalidate("1") }
  verify { logger.info(any()) }
  // Too many implementation details!
}

// ✅ Verify behavior, not implementation
@Test
fun `behavior verification`() {
  service.updateUser("1", update)

  verify { repository.save(match { it.id == "1" }) }
  // Only verify the important outcome
}

Variations

Spy Objects

Spy on real objects.

// ✅ Spy wraps real object
val realService = UserService(realRepository)
val spy = spyk(realService)

// ✅ Partially mock
every { spy.validateUser(any()) } returns true

// Real methods called unless mocked
spy.createUser("Alice", "alice@example.com")

verify { spy.createUser(any(), any()) }

Object Mocking

Mock Kotlin objects.

object Logger {
  fun log(message: String) {
    println(message)
  }
}

@Test
fun `mock object`() {
  mockkObject(Logger)

  every { Logger.log(any()) } just Runs

  Logger.log("test")

  verify { Logger.log("test") }

  unmockkObject(Logger)
}

Related Patterns

Learn more:

Cookbook recipes:

Last updated