Skip to content

Instantly share code, notes, and snippets.

@ashdavies
Created July 4, 2023 13:56
Show Gist options
  • Save ashdavies/bdceea52c95d2cd50f21245b6114acef to your computer and use it in GitHub Desktop.
Save ashdavies/bdceea52c95d2cd50f21245b6114acef to your computer and use it in GitHub Desktop.
package io.ashdavies.playground
import junit.framework.TestCase.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Before
import org.mockito.Mockito
import org.mockito.kotlin.any
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.mock
import org.mockito.kotlin.spy
import org.mockito.kotlin.stub
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
internal class CoffeeMakerTest {
/**
* It is often recommended to create mocks in the test class, and not in the test method.
* This is because creation of mocks is expensive, and shouldn't be repeated unnecessarily.
*/
private val mockHeater = mock<Heater>()
/**
* Many real-world uses configure the mock in the setup, as it may seem prudent to do so.
* This however, shares a mutable runtime declaration with each test.
* How can a new developer ascertain the correct state of the mock?
*/
@Before
fun setUp() {
mockHeater.stub {
onGeneric { heat(any()) } doAnswer {
it.getArgument<() -> Any>(0).invoke()
}
}
}
/**
* Basic test creates instances of concrete classes and asserts the result. Since this example
* is simple, it is easy to avoid creating unnecessary test doubles.
*/
@Test
fun `should make coffee with electric heater`() {
val heater = ElectricHeater()
val maker = CoffeeMaker(
pump = Thermosiphon(heater),
heater = heater
)
assertTrue(maker.brew())
}
/**
* Fake heater is a wrapper around the concrete heater which allows us to capture the result
* of the heat method. This is useful when we want to assert the result of the heat method.
*/
@Test
fun `should make coffee with fake heater`() {
val heater = FakeHeater(ElectricHeater())
val maker = CoffeeMaker(
pump = Thermosiphon(heater),
heater = heater
)
maker.brew()
val drinks = heater.drinks
assertEquals(drinks.size, 1)
assertEquals(drinks[0], true)
}
/**
* In this test a mock heater is created before the test starts with an `Answer<*>` to execute
* the lambda passed to the heat method. This is necessary since the mock has no referenced
* implementation, and must be configured at runtime. This test fails because `isHeating` is
* not implemented, and thus always returns false.
*
* Note that this test uses the existing mockHeater instance, thus taking an already mutable
* runtime class definition, that can be difficult to correctly predict.
*/
@Test
fun `should make coffee with mock heater`() {
val maker = CoffeeMaker(
pump = Thermosiphon(mockHeater),
heater = mockHeater,
)
assertThrows(AssertionError::class.java) {
assertTrue(maker.brew())
}
}
/**
* In this test a mock heater is created before the test starts with an `Answer<*>` to execute
* the lambda passed to the heat method. The test then continues to modify the mock as required
* further mutating the runtime declaration.
*/
@Test
fun `should make coffee with modified mock heater`() {
val maker = CoffeeMaker(
pump = Thermosiphon(mockHeater),
heater = mockHeater,
)
/**
* The mock instance is configured after it is passed to another class!
* This is a common anti-pattern, and can lead to unexpected behaviour.
*/
mockHeater.stub {
on { isHeating } doAnswer { true }
}
assertTrue(maker.brew())
}
/**
* In this test both a mock heater, and a mock pump are created to execute the test, the mocks
* are configured with the appropriate stubbing to make the test pass.
*/
@Test
fun `should make coffee with mock heater and pump`() {
val mockPump = mock<Pump> {
on { pump() } doAnswer { true }
}
val maker = CoffeeMaker(
heater = mockHeater,
pump = mockPump,
)
mockHeater.stub {
on { isHeating } doAnswer { null }
}
assertTrue(maker.brew())
}
/**
* This test demonstrates the use of default answers to mock methods. This is useful when you
* may have a nested class that violates the law of demeter, and you want to mock the nested
* class without having to create a new mock instance.
*/
@Test
fun `should mock method with default answers`() {
val mockHeater = mock<Heater>(defaultAnswer = Mockito.RETURNS_SMART_NULLS)
assertFalse(mockHeater.isHeating)
}
/**
* This test demonstrates how spies can have their actual methods called, which when setting up
* mocking behaviour can lead to runtime exceptions.
*/
@Test
fun `should throw spying real object`() {
assertThrows(IndexOutOfBoundsException::class.java) {
spy(emptyList<String>()) {
on { get(0) } doAnswer { "foo" }
}
}
}
@Test
fun `should throw stubbing method with vararg`() {
val mockDistributor = mock<CoffeeDistributor> {
on { announce(any(), any()) } doReturn true
}
val present = mockDistributor.announce(
"Steve",
"Roger",
"Stan",
)
assertThrows(AssertionError::class.java) {
assertTrue(present)
}
}
}
internal class CoffeeService(private val provider: CoffeeStore) {
fun get(type: CoffeeType): CoffeeType = when (provider.has(type)) {
false -> throw OutOfCoffeeException()
true -> type
}
}
internal interface CoffeeDistributor {
fun announce(vararg name: String): Boolean
}
internal interface CoffeeStore {
fun has(type: CoffeeType): Boolean
}
internal enum class CoffeeType {
CAPPUCCINO,
ESPRESSO,
LATTE,
}
internal interface ComplexContext {
fun getBoolean(id: Int): Boolean
fun getString(id: Int): String
fun getInt(id: Int): Int
}
internal class OutOfCoffeeException : Exception()
/**
* The CoffeeMaker class is the class under test.
* It is responsible for creating coffee by heating the heater and pumping the pump.
* The heater and pump are injected into the CoffeeMaker class, and are thus dependencies.
* The heater and pump are interfaces, and thus can be replaced.
*/
internal class CoffeeMaker(private val heater: Heater, private val pump: Pump) {
fun brew(): Boolean = heater.heat { pump.pump() }
}
/**
* Heater represents a device which can transfer heat while performing an action.
* The heat method takes a lambda which performs an action and returns a result.
*/
internal interface Heater {
fun <T : Any> heat(body: () -> T): T
val isHeating: Boolean
}
/**
* Pump represents a device which can pump liquid. The pump method returns a boolean which
* represents whether the pump has pumped.
*/
internal interface Pump {
fun pump(full: Boolean = false): Boolean
}
/**
* Concrete production ready implementation of our Heater interface.
*/
internal class ElectricHeater : Heater {
override var isHeating: Boolean = false
private set
override fun <T : Any> heat(body: () -> T): T {
isHeating = true
val result = body()
isHeating = false
return result
}
}
/**
* Fake implementation of our Heater interface which delegates to the concrete implementation.
*/
private class FakeHeater(private val delegate: Heater) : Heater by delegate {
private val _drinks = mutableListOf<Any>()
val drinks: List<Any> by ::_drinks
override fun <T : Any> heat(body: () -> T): T {
return delegate.heat(body).also { _drinks += it }
}
}
/**
* Concrete implementation of our Pump interface.
*/
internal class Thermosiphon(private val heater: Heater) : Pump {
override fun pump(full: Boolean) = heater.isHeating
}
public class FakePump(private val onPump: (Boolean) -> Boolean) : Pump {
public val pumped = mutableListOf<Pair<Boolean, Boolean>>()
override fun pump(full: Boolean): Boolean = onPump(full).also {
pumped += full to it
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment