Hand-roll test doubles
“I’m long past on record that I think the use of auto-mockers […] is bad policy.”
— GeePaw Hill, July 13th, 2021
Mocking libraries1 are everywhere these days, promising easy test setup and readable code. But honestly, it’s almost always way better to just roll your own test doubles. Especially if you care about clean, maintainable tests. The only real exception is during legacy migrations, and even then, mocking libraries should be considered a temporary crutch, not a long-term solution. Let’s talk about why explicit code virtually always beats the magic of mocking frameworks.
What are Test Doubles Anyway?¶
Test doubles are objects that stand in for real dependencies during testing. They allow you to isolate the unit under test, controlling its interactions with other parts of the system and ensuring predictable test outcomes.
Before using test doubles, consider whether you can use the real dependency; if it’s cheap, fast, and safe, using the real thing is preferable. This approach aligns with the concept of sociable unit tests, which verify the behavior of multiple components in collaboration. Test doubles should only be used when using the real dependency is impractical or problematic.
Here are the common types (roughly ordered by my personal preference, from most to least preferred in most situations):
-
Fakes
Fakes are working implementations of a dependency, but they are simplified for testing purposes. Examples include in-memory databases or simplified versions of external services. They provide a functional but lightweight alternative to the real dependency.
-
Stubs
Stubs provide canned answers to method calls (indirect input). They simplify test setup by returning predefined values, avoiding complex setup of real dependencies.
-
Spies
Spies are a bit more nuanced than stubs. They “spy” on the interactions with the real object (or a partial mock) for indirect output. There are two main types of spies:
-
Interaction-based
These are the type of spies commonly implemented by mocking libraries. They focus on verifying how a dependency was used. They track method calls, the arguments passed to those methods, and the order of calls.
-
State-based
These spies focus on observing the state changes of the object being spied on. Instead of verifying specific method calls, they verify that the object’s internal state has changed in the expected way as a result of the interaction.
-
-
Dummies
These are objects passed as arguments but are never actually used within the method being tested. They are simply placeholders to satisfy method signatures.
-
Mocks
Mocks are pre-programmed with expectations about how they will be called. If these expectations are not met during the test, the test fails. Mocks are used to verify interactions and enforce specific behavior.
The Problem with Mocking Libraries¶
While mocking libraries offer convenient test double creation and reduced boilerplate, they also present noteworthy drawbacks:
-
Mocks are not aware of the interface contract
Mocks only know about the specific methods and behaviors you explicitly define in each test. This means there’s no compile-time or runtime check to ensure these mock setups adhere to the actual interface contract. This lack of enforcement creates a significant risk of introducing subtle differences between the mock and the real class that can go undetected.
The term “contract”, in this context, refers to both the syntax (method signatures) and semantics (intended behavior) of an interface.
-
Tight Coupling to Implementation
Overusing mocks ties tests to internal implementation details. Refactoring internal code, even without changing the public interface, can break numerous tests, hindering refactoring efforts.
-
Testing Interactions, Not Behavior
Mocking often focuses on verifying interactions (how dependencies are used) rather than behavior (what the code achieves). This leads to brittle tests that don’t effectively validate the intended functionality.
-
“Magic” and Increased Complexity
Mocking libraries rely on complex mechanisms and introduce their own APIs, creating a “magic” layer that obscures test behavior and increases the learning curve. This makes tests harder to understand, debug, and maintain, especially for newcomers.
Why Hand-Rolling is Often Better¶
Hand-rolled test doubles offer several compelling advantages over mocking libraries:
-
Direct Interface Design Feedback
Creating hand-rolled test doubles provides immediate feedback on your interface design. If a test double becomes complex, it suggests the interface it’s doubling is too complex, prompting you to apply principles like the Interface Segregation Principle for cleaner code.
-
Testable Test Doubles
Hand-rolled test doubles, especially fakes, are regular classes that can be independently tested. This ensures their correctness and enhances the reliability of your system’s tests, especially when fakes contain complex logic. Check out Test contract for a good way to test fakes.
-
Explicit and Understandable Code
Hand-rolled doubles are written directly in your test code, making them easy to understand, debug, and maintain. Their behavior is entirely explicit, avoiding the “magic” of mocking frameworks.
-
Reduced Coupling to Implementation
By focusing on essential behavior, hand-rolled doubles minimize coupling to implementation details. This makes your tests more resilient to refactoring, as internal changes are less likely to break them.
-
Tests Focused on Behavior
Hand-rolling encourages you to focus on the inputs and outputs relevant to the test, promoting behavior-driven testing. This results in more expressive and maintainable tests that focus on what the code does, not how.
-
Simplified Debugging
When a test with hand-rolled doubles fails, debugging is easier because the test double’s behavior is directly visible in the code. You avoid the need to delve into complex mocking framework internals.
Examples¶
This section demonstrates hand-rolled test doubles in Kotlin, showcasing each of the five main types:
Tip
Fakes are particularly well-suited for implementing repository interfaces because they provide simplified, in-memory implementations that mimic the behavior of a real database or external service, making tests fast and predictable.
interface UserRepository {
fun getUser(id: Int): User?
fun saveUser(user: User)
fun deleteUser(id: Int)
}
data class User(val id: Int, val name: String)
class UserService(private val userRepository: UserRepository) {
fun findUser(id: Int): String? {
return userRepository.getUser(id)?.name
}
fun createUser(name: String): Int {
// In real app, you would use a proper ID generation strategy
val newId = (0..Int.MAX_VALUE).random()
userRepository.saveUser(User(newId, name))
return newId
}
fun removeUser(id: Int) {
userRepository.deleteUser(id)
}
}
class InMemoryUserRepository : UserRepository {
private val users = mutableMapOf<Int, User>()
override fun getUser(id: Int): User? {
return users[id]
}
override fun saveUser(user: User) {
users[user.id] = user
}
override fun deleteUser(id: Int) {
users.remove(id)
}
}
class UserServiceTest {
@Test
fun `finds user if user exists`() {
val repository = InMemoryUserRepository()
val userService = UserService(repository)
val testUser = User(1, "Test User")
repository.saveUser(testUser)
val userName = userService.findUser(1)
assertEquals("Test User", userName)
}
@Test
fun `doesn't find user if user does not exist`() {
val repository = InMemoryUserRepository()
val userService = UserService(repository)
val userName = userService.findUser(999)
assertNull(userName)
}
@Test
fun `creates the user`() {
val repository = InMemoryUserRepository()
val userService = UserService(repository)
val newUserId = userService.createUser("New User")
val retrievedUser = repository.getUser(newUserId)
assertEquals("New User", retrievedUser?.name)
}
@Test
fun `deletes the user`() {
val repository = InMemoryUserRepository()
val userService = UserService(repository)
val testUser = User(1, "Test User")
repository.saveUser(testUser)
userService.removeUser(1)
val retrievedUser = repository.getUser(1)
assertNull(retrievedUser)
}
}
Tip
This example uses the Configurable Responses pattern as implemented by whiskeysierra/test-doubles.
interface DataProvider {
fun getData(id: Int): String?
}
class DataProcessor(private val dataProvider: DataProvider) {
fun process(id: Int, fallback: String = "Default Data"): String {
return "Processed: ${dataProvider.getData(id) ?: fallback}"
}
}
class StubDataProvider(private val responses: Iterator<String?>) : DataProvider {
override fun getData(id: Int) = responses.next()
}
class DataProcessorTest {
@Test
fun `retrieves and processes data`() {
val provider = StubDataProvider(responses = once("Test Data", null, "Other Data"))
val processor = DataProcessor(provider)
assertEquals("Processed: Test Data", processor.process(1))
assertEquals("Processed: Default Data", processor.process(2))
assertEquals("Processed: Other Data", processor.process(3))
}
@Test
fun `uses fallback when no data is available`() {
val provider = StubDataProvider(responses = once(null, "Real Data"))
val processor = DataProcessor(provider)
assertEquals("Processed: Fallback Data", processor.process(4, "Fallback Data"))
assertEquals("Processed: Real Data", processor.process(5, "Fallback Data"))
}
}
Tip
This example uses the Output Tracking pattern as implemented by whiskeysierra/test-doubles.
interface EmailService {
fun sendEmail(recipient: String, message: String)
}
class UserNotifier(private val emailService: EmailService) {
fun notifyUser(userEmail: String, message: String) {
emailService.sendEmail(userEmail, message)
emailService.sendEmail("admin@example.com", "User notified") // Send notification to admin as well
}
}
import io.github.whiskeysierra.testdoubles.spy.OutputTracking
import io.github.whiskeysierra.testdoubles.spy.Tracker
import org.assertj.core.util.VisibleForTesting
class SpyEmailService : EmailService {
private val tracking = OutputTracking<Pair<String, String>>()
@VisibleForTesting
fun track(): Tracker<Pair<String, String>> = tracking.track()
override fun sendEmail(recipient: String, message: String) {
tracking.add(Pair(recipient, message))
}
}
import io.github.whiskeysierra.testdoubles.spy.Tracker
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
class UserNotifierTest {
@Test
fun `sends emails to user and admin`() {
val spy = SpyEmailService()
val notifier = UserNotifier(spy)
val sentEmails: Tracker<Pair<String, String>> = spy.track()
notifier.notifyUser("test@example.com", "Hello!")
assertThat(sentEmails).containsExactly(
Pair("test@example.com", "Hello!"),
Pair("admin@example.com", "User notified")
)
}
}
Warning
Only hand-roll a dummy if a suitable default/noop implementation (like a Null Object) doesn’t already exist for the required interface.
interface Currency {
val code: String
val symbol: String
val displayName: String
val defaultFractionDigits: Int
}
data class Money(val amount: BigDecimal, val currency: Currency)
data object EUR : Currency {
override val code: String = "EUR"
override val symbol: String = "€"
override val displayName: String = "Euro"
override val defaultFractionDigits: Int = 2
}
data object USD : Currency {
override val code = "USD";
override val symbol = "$";
override val displayName = "US Dollar";
override val defaultFractionDigits = 2
}
interface CurrencyConverter {
fun convert(amount: Money, preferredCurrency: Currency): Money
}
/**
* Converts amounts to EUR. Ignores the [preferredCurrency] parameter.
*/
class EuroConverter : CurrencyConverter {
override fun convert(amount: Money, preferredCurrency: Currency): Money {
val euroAmount = when (amount.currency.code) {
"USD" -> amount.amount.multiply(BigDecimal("0.92"))
"GBP" -> amount.amount.multiply(BigDecimal("1.15"))
else -> amount.amount
}
return Money(euroAmount, EUR)
}
}
data object DummyCurrency : Currency {
override val code: String
get() = throw UnsupportedOperationException("Dummy Currency should not be used.")
override val symbol: String
get() = throw UnsupportedOperationException("Dummy Currency should not be used.")
override val displayName: String
get() = throw UnsupportedOperationException("Dummy Currency should not be used.")
override val defaultFractionDigits: Int
get() = throw UnsupportedOperationException("Dummy Currency should not be used.")
}
class EuroConverterTest {
@Test
fun `converts to eur ignoring preferred currency`() {
val converter = EuroConverter()
val amount = Money(BigDecimal("100"), USD)
val convertedAmount = converter.convert(amount, DummyCurrency)
assertEquals(BigDecimal("92.00"), convertedAmount.amount)
assertEquals("EUR", convertedAmount.currency.code)
}
}
A Word of Caution
While hand-rolling mocks is technically possible (as shown below), it’s often less ideal than using simpler types of test doubles.
Hand-rolled mocks present two key challenges. First, the need to pre-program and verify expectations increases the complexity of the mock itself, making it harder to write, understand, and maintain. Second, and this applies to mocks in general, hiding expectations and verifications within the mock makes the tests themselves less expressive and readable, as they contain less visible evidence (assertions). Consider using simpler test doubles like stubs, spies, or fakes before resorting to hand-rolled mocks.
interface PaymentGateway {
fun processPayment(amount: Double): Boolean
}
class OrderProcessor(private val paymentGateway: PaymentGateway) {
fun processOrder(amount: Double) {
if (!paymentGateway.processPayment(amount)) {
throw PaymentFailedException("Payment failed for amount: $amount")
}
// Order processing logic here
}
}
class PaymentFailedException(message: String) : RuntimeException(message)
class MockPaymentGateway : PaymentGateway {
private var paymentProcessed = false
private var expectedAmount: Double? = null
var wasCalled = false
fun willReturn(value: Boolean) {
paymentProcessed = value
}
fun expectPaymentOf(amount: Double) {
expectedAmount = amount
}
override fun processPayment(amount: Double): Boolean {
wasCalled = true
if (expectedAmount != null && expectedAmount != amount) {
throw AssertionError("Expected payment of $expectedAmount but received $amount")
}
return paymentProcessed
}
}
class OrderProcessorTest {
@Test
fun `processOrder successfully processes payment`() {
val mock = MockPaymentGateway()
mock.willReturn(true)
val processor = OrderProcessor(mock)
processor.processOrder(100.0)
assert(mock.wasCalled)
}
@Test
fun `processOrder throws exception when payment fails`() {
val mock = MockPaymentGateway()
mock.willReturn(false)
val processor = OrderProcessor(mock)
assertThrows<PaymentFailedException> {
processor.processOrder(50.0)
}
assert(mock.wasCalled)
}
@Test
fun `processOrder checks the amount passed to the payment gateway`() {
val mock = MockPaymentGateway()
mock.expectPaymentOf(200.0)
mock.willReturn(true)
val processor = OrderProcessor(mock)
processor.processOrder(200.0)
assert(mock.wasCalled)
}
@Test
fun `processOrder throws assertion if unexpected amount passed`() {
val mock = MockPaymentGateway()
mock.expectPaymentOf(200.0)
val processor = OrderProcessor(mock)
assertThrows<AssertionError> {
processor.processOrder(100.0)
}
assert(mock.wasCalled)
}
}
Conclusion¶
Mocking libraries have their place, especially for complex, existing interfaces or external dependencies you can’t easily replicate But for many cases, hand-rolled test doubles are a much better choice. They lead to simpler, more maintainable tests and give you valuable feedback on your design. So, before reaching for a mocking library, take a moment to consider if a simple hand-rolled double might do the trick. You might be surprised how often it’s the right answer.
Testing Without Mocks¶
As astute readers may have noticed, this discussion draws on several patterns from James Shore’s Testing Without Mocks article. It is packed with useful testing tricks and techniques, from test design to dependency management, offering practical ways to improve test clarity, maintainability, and effectiveness. Even if you’re not buying into the whole premise, checking out these patterns can seriously improve your test design.