Kotlin Test Doubles
Introduction
When writing unit tests in Kotlin, we often need to isolate the component we're testing from its dependencies. This is where test doubles come into play. Test doubles are objects that replace real dependencies during testing to ensure our tests are focused, reliable, and fast.
In this guide, we'll explore the different types of test doubles available in Kotlin testing, how they work, and when to use each one. We'll also look at popular libraries that help create these test doubles efficiently.
Types of Test Doubles
There are five main types of test doubles:
- Dummy objects
- Fake objects
- Stubs
- Spies
- Mocks
Let's explore each of these in detail with Kotlin examples.
Dummy Objects
Dummy objects are the simplest form of test doubles. They are passed around but never actually used. Their only purpose is to fill parameter lists.
Example
// A service that requires an authenticator
class UserService(
private val userRepository: UserRepository,
private val authenticator: Authenticator
) {
fun getUser(id: String): User {
return userRepository.findById(id)
}
}
// In our test, we don't use the authenticator
class UserServiceTest {
@Test
fun `should get user by id`() {
// Create a dummy authenticator
val dummyAuthenticator = object : Authenticator {
override fun authenticate(credentials: Credentials): Boolean {
throw NotImplementedError("This method should not be called")
}
}
// Create a test-specific repository
val repository = TestUserRepository()
// Create the service with our dummy
val service = UserService(repository, dummyAuthenticator)
// Test the method that doesn't use the authenticator
val user = service.getUser("123")
assertEquals("Test User", user.name)
}
}
In this example, dummyAuthenticator
is never used but is required to instantiate the UserService
class.
Fake Objects
Fakes are working implementations of dependencies, but they take shortcuts that make them unsuitable for production. Fakes are useful when the real implementation is complex, slow, or unreliable.
Example
// Interface for a user repository
interface UserRepository {
fun findById(id: String): User
fun save(user: User)
fun findAll(): List<User>
}
// A fake in-memory implementation for testing
class InMemoryUserRepository : UserRepository {
private val users = mutableMapOf<String, User>()
override fun findById(id: String): User {
return users[id] ?: throw NoSuchElementException("User not found")
}
override fun save(user: User) {
users[user.id] = user
}
override fun findAll(): List<User> {
return users.values.toList()
}
}
// Test using the fake repository
@Test
fun `should save and retrieve user`() {
val repository = InMemoryUserRepository()
val userService = UserService(repository, DummyAuthenticator())
val user = User("123", "John Doe")
repository.save(user)
val retrievedUser = userService.getUser("123")
assertEquals("John Doe", retrievedUser.name)
}
Here, InMemoryUserRepository
is a fake that stores users in memory rather than in a database, making it faster and more reliable for testing.
Stubs
Stubs provide predefined responses to method calls, allowing you to control the behavior of dependencies in tests. They're useful when you need to simulate specific scenarios.
Example using MockK library
// Import MockK
import io.mockk.every
import io.mockk.mockk
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
@Test
fun `test getting user with stub`() {
// Create a stub of UserRepository
val stubRepository = mockk<UserRepository>()
// Define behavior for the stub
every { stubRepository.findById("123") } returns User("123", "Stubbed User")
val userService = UserService(stubRepository, DummyAuthenticator())
// Test with the stub
val user = userService.getUser("123")
assertEquals("Stubbed User", user.name)
}
In this example, we created a stub UserRepository
that returns a predefined user when findById("123")
is called.
Spies
Spies are partial mocks that track how methods are used. They allow real methods to execute while also recording method calls for verification.
Example using MockK
import io.mockk.spyk
import io.mockk.verify
import org.junit.jupiter.api.Test
@Test
fun `test updating user with spy`() {
// Create a real repository
val realRepository = InMemoryUserRepository()
// Create a spy wrapping the real repository
val repositorySpy = spyk(realRepository)
// Create user service with the spy
val userService = UserService(repositorySpy, RealAuthenticator())
// Save a user
val user = User("456", "Jane Doe")
repositorySpy.save(user)
// Get the user through service
userService.getUser("456")
// Verify findById was called with the correct parameter
verify { repositorySpy.findById("456") }
}
Here, we're using a spy to verify that findById
is called with the correct parameter when getUser
is invoked.
Mocks
Mocks are pre-programmed objects with expectations about how they should be used. They verify that the code interacts with dependencies correctly.
Example using MockK
import io.mockk.*
import org.junit.jupiter.api.Test
@Test
fun `test deleting user with mock`() {
// Create a mock repository
val mockRepository = mockk<UserRepository>()
val mockAuthenticator = mockk<Authenticator>()
// Define behavior
every { mockAuthenticator.authenticate(any()) } returns true
every { mockRepository.findById("789") } returns User("789", "User to Delete")
every { mockRepository.delete("789") } just Runs
// Create service with mocks
val userService = UserService(mockRepository, mockAuthenticator)
// Call the method we're testing
userService.deleteUser("789", Credentials("admin", "password"))
// Verify interactions occurred in the expected order
verifySequence {
mockAuthenticator.authenticate(any())
mockRepository.findById("789")
mockRepository.delete("789")
}
}
In this example, we're using mocks to verify that deleteUser
authenticates first, then finds the user, and finally deletes it.
Popular Test Double Libraries for Kotlin
Kotlin has several excellent libraries for creating test doubles:
- MockK: A Kotlin-native mocking library with a clean DSL.
- Mockito: A popular Java mocking framework with Kotlin extensions.
- JUnit 5: Provides basic mocking capabilities.
- Kotest: A comprehensive testing framework with mocking support.
Let's see a comprehensive example using MockK:
import io.mockk.*
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
// The service we want to test
class NotificationService(
private val userRepository: UserRepository,
private val emailSender: EmailSender,
private val messageFormatter: MessageFormatter
) {
fun sendNotification(userId: String, message: String): Boolean {
val user = userRepository.findById(userId)
val formattedMessage = messageFormatter.format(message, user.name)
return emailSender.send(user.email, formattedMessage)
}
}
// The test class
class NotificationServiceTest {
private lateinit var mockUserRepository: UserRepository
private lateinit var mockEmailSender: EmailSender
private lateinit var mockMessageFormatter: MessageFormatter
private lateinit var notificationService: NotificationService
@BeforeEach
fun setUp() {
mockUserRepository = mockk()
mockEmailSender = mockk()
mockMessageFormatter = mockk()
notificationService = NotificationService(
mockUserRepository,
mockEmailSender,
mockMessageFormatter
)
}
@Test
fun `should send notification successfully`() {
// Arrange
val userId = "user123"
val message = "Hello!"
val user = User(userId, "Test User", "[email protected]")
val formattedMessage = "Hello, Test User!"
every { mockUserRepository.findById(userId) } returns user
every { mockMessageFormatter.format(message, user.name) } returns formattedMessage
every { mockEmailSender.send(user.email, formattedMessage) } returns true
// Act
val result = notificationService.sendNotification(userId, message)
// Assert
assertTrue(result)
verify {
mockUserRepository.findById(userId)
mockMessageFormatter.format(message, user.name)
mockEmailSender.send(user.email, formattedMessage)
}
}
@Test
fun `should handle email sending failure`() {
// Arrange
val userId = "user456"
val message = "Hello!"
val user = User(userId, "Another User", "[email protected]")
val formattedMessage = "Hello, Another User!"
every { mockUserRepository.findById(userId) } returns user
every { mockMessageFormatter.format(message, user.name) } returns formattedMessage
every { mockEmailSender.send(user.email, formattedMessage) } returns false
// Act
val result = notificationService.sendNotification(userId, message)
// Assert
assertEquals(false, result)
verify {
mockUserRepository.findById(userId)
mockMessageFormatter.format(message, user.name)
mockEmailSender.send(user.email, formattedMessage)
}
}
}
Best Practices for Using Test Doubles
-
Use the simplest test double that meets your needs: Start with fakes or stubs before reaching for mocks.
-
Don't overspecify: Test behavior, not implementation. Verify only what's necessary.
-
Keep tests focused: Each test should verify one behavior.
-
Name test doubles clearly: Use names like
stubUserRepository
ormockEmailSender
to make your tests more readable. -
Reset mocks between tests: Ensure tests don't interfere with each other.
@BeforeEach
fun setUp() {
// Clear any previous mock configurations
clearAllMocks()
// Set up new mocks
mockUserRepository = mockk()
// ... other setup
}
- Don't mock what you don't own: Prefer to mock your own interfaces rather than third-party code.
Real-world Application: Testing a Payment Processor
Let's look at a more complex example of a payment processor that interacts with multiple dependencies:
// The system under test
class PaymentProcessor(
private val paymentGateway: PaymentGateway,
private val orderRepository: OrderRepository,
private val notificationService: NotificationService
) {
fun processPayment(orderId: String, paymentDetails: PaymentDetails): PaymentResult {
val order = orderRepository.findOrder(orderId)
?: return PaymentResult.Failed("Order not found")
if (order.isPaid) {
return PaymentResult.Failed("Order already paid")
}
val paymentResult = paymentGateway.processPayment(
paymentDetails,
order.totalAmount
)
if (paymentResult.isSuccessful) {
orderRepository.markAsPaid(orderId, paymentResult.transactionId)
notificationService.sendPaymentConfirmation(order.customerId, orderId)
return PaymentResult.Success(paymentResult.transactionId)
}
return PaymentResult.Failed(paymentResult.errorMessage)
}
}
// Test class
class PaymentProcessorTest {
private lateinit var mockPaymentGateway: PaymentGateway
private lateinit var mockOrderRepository: OrderRepository
private lateinit var mockNotificationService: NotificationService
private lateinit var paymentProcessor: PaymentProcessor
@BeforeEach
fun setUp() {
mockPaymentGateway = mockk()
mockOrderRepository = mockk()
mockNotificationService = mockk()
paymentProcessor = PaymentProcessor(
mockPaymentGateway,
mockOrderRepository,
mockNotificationService
)
}
@Test
fun `should successfully process payment`() {
// Arrange
val orderId = "order123"
val customerId = "customer456"
val paymentDetails = PaymentDetails("4111111111111111", "12/25", "123")
val order = Order(orderId, customerId, 100.0, false)
val gatewayResult = GatewayPaymentResult(true, "tx123", null)
every { mockOrderRepository.findOrder(orderId) } returns order
every { mockPaymentGateway.processPayment(paymentDetails, 100.0) } returns gatewayResult
every { mockOrderRepository.markAsPaid(orderId, "tx123") } just Runs
every { mockNotificationService.sendPaymentConfirmation(customerId, orderId) } just Runs
// Act
val result = paymentProcessor.processPayment(orderId, paymentDetails)
// Assert
assertEquals("tx123", (result as PaymentResult.Success).transactionId)
// Verify interactions
verifySequence {
mockOrderRepository.findOrder(orderId)
mockPaymentGateway.processPayment(paymentDetails, 100.0)
mockOrderRepository.markAsPaid(orderId, "tx123")
mockNotificationService.sendPaymentConfirmation(customerId, orderId)
}
}
@Test
fun `should return failure when order is already paid`() {
// Arrange
val orderId = "order789"
val paymentDetails = PaymentDetails("4111111111111111", "12/25", "123")
val order = Order(orderId, "customer456", 50.0, true) // Already paid
every { mockOrderRepository.findOrder(orderId) } returns order
// Act
val result = paymentProcessor.processPayment(orderId, paymentDetails)
// Assert
assertTrue(result is PaymentResult.Failed)
assertEquals("Order already paid", (result as PaymentResult.Failed).reason)
// Verify that no further interactions happened
verify(exactly = 1) {
mockOrderRepository.findOrder(orderId)
}
verify(exactly = 0) {
mockPaymentGateway.processPayment(any(), any())
mockOrderRepository.markAsPaid(any(), any())
mockNotificationService.sendPaymentConfirmation(any(), any())
}
}
}
Summary
Test doubles are essential tools for writing effective unit tests in Kotlin. They help isolate the code under test from its dependencies, making tests more focused, reliable, and fast. The main types of test doubles are:
- Dummies: Objects passed around but never used
- Fakes: Working implementations that aren't suitable for production
- Stubs: Objects that provide predefined responses
- Spies: Real objects that record how they're used
- Mocks: Objects with expectations about how they should be used
Using libraries like MockK makes creating these test doubles straightforward in Kotlin. Remember to choose the simplest test double that meets your needs and focus on testing behavior rather than implementation details.
Additional Resources
- MockK Documentation
- Mockito-Kotlin
- Martin Fowler's article on Test Doubles
- Kotlin Testing Documentation
Exercises
- Create a simple
UserRepository
interface and write tests for aUserService
using different types of test doubles. - Refactor the
NotificationService
example to use a spy instead of a mock for one of the dependencies. - Write tests for a shopping cart system that interacts with a product catalog and discount service using appropriate test doubles.
- Experiment with MockK's relaxed mocks and verify how they differ from regular mocks.
- Create a test that verifies the order of interactions between your system under test and its mocked dependencies.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)