Share
Embed
Droidcon Berlin: Testing in Practice
Testing isn't always everybody's favourite task, but that doesn't need to be the case! Writing tests can be an enjoyable way to practice your coding techniques!
But with conflicting opinions on writing test code that is declarative, explicit, terse, concise, and isolated, it can be tough to know how to satisfy all of these whilst still retaining your will to live.
I'll be covering a few techniques, and mechanisms, for writing idiomatic Kotlin code that leaves you with a beautiful test case that not only fulfils all this but gives you accurate code documentation for your project.
Ash Davies
July 03, 2024
Transcript
84 slides
-
Testing in Practice Keeping Your Tests Concise and Declarative Droidcon
Berlin - July '24 ! Ash Davies - SumUp Android / Kotlin GDE Berlin Cat Person ashdavies.dev 1 -
"Hey, I hope you had a relaxing weekend..." — QA
Enginner (8:47 Monday morning) ashdavies.dev 4 -
Testing — Monkey — End-to-End — Acceptance — Instrumentation —
Integration — Regression — Unit ashdavies.dev 6 -
Writing Unit Tests Pros Cons They'll improve the quality of
my code I don't want to It'll take like 10 mins max Literally everyone says that I should ashdavies.dev 9 -
Writing Unit Tests Conclusion I will not write unit tests
ashdavies.dev 10 -
"Can we skip the unit tests, just for this feature?"
— PM ashdavies.dev 12 -
Developer: "I'll get back to it later..." Narrator: "They never
got back to it." ashdavies.dev 13 -
Tests test design as well as logic — Michael Feathers
ashdavies.dev 17 -
class ThermosiphonTest { @Mock var heater: Heater @Mock var logger:
Logger @InjectMocks lateinit var sut: Thermosiphon @SetUp fun setup() { MockAnnotations.init(this) } @Test fun `should heat water`() { thermosiphon.pump() assertTrue(heater.isHot()) } } ashdavies.dev 25 -
Test Doubles Mocks — Behaviour Verification ! — API Insensitivity
" — Scale Poorly # ashdavies.dev 27 -
class ThermosiphonTest { @Mock var heater: Heater @Mock var logger:
Logger @InjectMocks lateinit var sut: Thermosiphon @SetUp fun setup() { MockAnnotations.init(this) whenever(heater.heat(any())).thenReturn(/* ... */ whenever(logger.log(any())).thenAnswer { /* ... */ } @Test fun `should heat water`() { thermosiphon.pump() val argumentCaptor = argumentCaptor() verify(heater).heat(argumentCapture.capture()) assertTrue(argumentCaptor.values[0]) } } ashdavies.dev 28 -
Factory Functions interface Pump { fun pump() } class Thermosiphon(
private val heater: Heater, ) : Pump ashdavies.dev 30 -
Factory Functions interface Pump { fun pump() } fun Pump(heater:
Heater): Pump { return Thermosiphon(heater) } private class Thermosiphon( private val heater: Heater, ) ashdavies.dev 31 -
Factory Functions public actual fun <T> lazy(mode: LazyThreadSafetyMode, initializer: ()
-> T): Lazy<T> = when (mode) { LazyThreadSafetyMode.SYNCHRONIZED -> SynchronizedLazyImpl(initializer) LazyThreadSafetyMode.PUBLICATION -> SafePublicationLazyImpl(initializer) LazyThreadSafetyMode.NONE -> UnsafeLazyImpl(initializer) } ashdavies.dev 33 -
Functional Interfaces fun interface Pump { fun pump() } fun
Pump(heater: Heater): Pump { return Thermosiphon(heater) } private class Thermosiphon( private val heater: Heater, ) ashdavies.dev 35 -
Functional Interfaces fun interface Pump { fun pump() } fun
Pump(heater: Heater) = Pump { /* ... */ } ashdavies.dev 36 -
Functional Interfaces fun interface Pump { fun pump(): Boolean }
- val pump = mock<Pump> { - whenever(heat()).thenReturn(true) - } + val pump = Pump { true } ashdavies.dev 37 -
class ThermosiphonTest { @Test fun `should heat water`() { var
isHot = false val heater = Heater { isHot = true } val thermosiphon = Thermosiphon(heater) thermosiphon.pump() assertTrue(isHot) } } ashdavies.dev 38 -
class ThermosiphonTest { @Test fun `should heat water`() { var
isHot = false val heater = Heater { isHot = true } val thermosiphon = Thermosiphon(heater) thermosiphon.pump() assertTrue(isHot) } @Test fun `should cool down after heating`() { /** ... */ } } ashdavies.dev 39 -
class ThermosiphonTest { @Test fun `should heat water`() { var
isHot = false val heater = Heater { isHot = true } val thermosiphon = Thermosiphon(heater) thermosiphon.pump() assertTrue(isHot) } @Test fun `should cool down after heating`() { /** ... */ } @Test fun `should not catch fire when flying`() { /** ... */ } } ashdavies.dev 40 -
class ThermosiphonTest { @Test fun `should heat water`() { var
isHot = false val heater = Heater { isHot = true } val thermosiphon = Thermosiphon(heater) thermosiphon.pump() assertTrue(isHot) } @Test fun `should cool down after heating`() { /** ... */ } @Test fun `should not catch fire when flying`() { /** ... */ } @Test fun `should not become sentient`() { /** ... */ } } ashdavies.dev 41 -
class ThermosiphonTest { val heater = Heater { isHot =
true } val thermosiphon = Thermosiphon(heater) var isSentient = false var hasExploded = false var isHot = false @Test fun `should heat water`() { /* ... */ } @Test fun `should cool down after heating`() { /** ... */ } @Test fun `should not catch fire when flying`() { /** ... */ } @Test fun `should not become sentient`() { /** ... */ } } ashdavies.dev 42 -
class ThermosiphonTest { val heater = Heater { isHot =
true } val thermosiphon = Thermosiphon( overflow = SmallOverflowTank(), heater = heater, ) var isSentient = false var hasExploded = false var isHot = false @Test fun `should heat water`() { /* ... */ } @Test fun `should cool down after heating`() { /** ... */ } @Test fun `should not catch fire when flying`() { /** ... */ } @Test fun `should not become sentient`() { /** ... */ } } ashdavies.dev 43 -
class ThermosiphonTest { val heater = Heater { isHot =
true } val thermosiphon = Thermosiphon( overflow = SmallOverflowTank(), aiEngine = GeminiEngine(), heater = heater, ) var isSentient = false var hasExploded = false var isHot = false @Test fun `should heat water`() { /* ... */ } @Test fun `should cool down after heating`() { /** ... */ } @Test fun `should not catch fire when flying`() { /** ... */ } @Test fun `should not become sentient`() { /** ... */ } } ashdavies.dev 44 -
class ThermosiphonTest { val heater = Heater { isHot =
true } val thermosiphon = Thermosiphon( overflow = SmallOverflowTank(), aiEngine = GeminiEngine(), heater = heater, launchCodes = emptyList(), ) var isSentient = false var hasExploded = false var isHot = false @Test fun `should heat water`() { /* ... */ } @Test fun `should cool down after heating`() { /** ... */ } @Test fun `should not catch fire when flying`() { /** ... */ } @Test fun `should not become sentient`() { /** ... */ } } ashdavies.dev 45 -
class ThermosiphonTest { @Test fun `should heat water`() { var
isHot = false val thermosiphon = thermosiphon( onHeat = { isHot = true }, ) thermosiphon.pump() assertTrue(isHot) } } fun thermosiphon( onHeat: () -> Unit ) = Thermosiphon( overflow = SmallOverflowTank(), aiEngine = GeminiEngine(), heater = Heater(onHeat), launchCodes = emptyList(), ) ashdavies.dev 46 -
DRY Don't Repeat Yourself — Remove duplication — High code
reusability — Isolating change ashdavies.dev 47 -
DAMP Descriptive AND Meaningful Phrases — Some duplication permitted —
Declarative syntax — Meaningful naming ashdavies.dev 48 -
Documentation /** * Make sure not to change this thing
back to the previous implementation, * because it breaks on that one specific device in production, * when opening the user profile in France using a German locale. **/ fun doMyThing() { /* ... */ } ashdavies.dev 51 -
Documentation /** * Make sure not to change this thing
back to the previous implementation, * because it breaks on that one specific device in production, * when opening the user profile in France using a German locale. * * Update: We changed this to be a remote resolution, this shouldn't happen? * We haven't seen it happen in production anymore, but just leave this * comment here, incase it occurs again... **/ fun doMyThingRefactoredV63() { /* ... */ } ashdavies.dev 52 -
Documentation /** * Make sure not to change this thing
back to the previous implementation, * because it breaks on that one specific device in production, * when opening the user profile in France using a German locale. * * Update: We changed this to be a remote resolution, this shouldn't happen? * We haven't seen it happen in production anymore, but just leave this * comment here, incase it occurs again... * * Update: It's happening again, but for Italian uses in Australia, * I really hope our Italian QA enginner doesn't go on vacation to Melbourne again... **/ fun doMyThingRefactoredToBeMoreSafeIHopeV91() { if (user.locale == Locale.ITALY) { /* Hacky hack McHackFace */ } } ashdavies.dev 53 -
Documentation: Tests fun `should store in user specific locale when
device is in another country() ashdavies.dev 55 -
Documentation: Tests fun `should store in user specific locale when
device is in another country() { val intendedTarget = ... val expectedLocale = ... val actualLocale = doMyThing() assertEquals(expectedLocale, actualLocal) } ashdavies.dev 56 -
commit d4c2d156e78cd579662ac7a658b00ca5aa17fd5d (HEAD -> main, origin/main, origin/HEAD) Author: Ash Davies
<[email protected]> Date: Sun Jun 23 19:19:44 2024 +0200 ashdavies.dev 57 -
class ThermosiphonTest { @Test fun `should heat water`() { var
isHot = false val thermosiphon = thermosiphon( onHeat = { isHot = true }, ) thermosiphon.pump() assertTrue(isHot) } } fun thermosiphon( onHeat: () -> Unit ) = Thermosiphon( overflow = SmallOverflowTank(), aiEngine = GeminiEngine(), heater = Heater(onHeat), launchCodes = emptyList(), ) ashdavies.dev 59 -
Test Doubles Mocks fun Context(checkSelfPermission: (String) -> Int): Context =
mock { whenever(it.checkSelfPermission(any())).thenAnswer { invocation -> checkSelfPermission(invocation.arguments[0] as String) } } val context = Context { PackageManager.PERMISSION_GRANTED } ashdavies.dev 61 -
Refactoring Interface Segregation class MenuProvider( private val navStateStore: NavStateStore =
NavStateStore(), ) { fun get() = combine(navStateStore.isEnabled, /* ... */) { /* ... */ } } class NavStateStore { val isEnabled: Flow<Boolean> = /* ... */ fun setIsEnabled(value: Boolean) { /* ... */ } fun getLastSet(): Long { /* ... */ } } ashdavies.dev 63 -
Refactoring Interface Segregation class MenuProvider( private val navStateStore: NavStateStore =
NavStateStore(), ) { fun get() = combine(navStateStore.isEnabled, /* ... */) { /* ... */ } } class NavStateStore { val isEnabled: Flow<Boolean> = /* ... */ fun setIsEnabled(value: Boolean) { /* ... */ } fun getLastSet(): Long { /* ... */ } } ashdavies.dev 64 -
Refactoring Interface Segregation interface NavStateStore { val isEnabled: Flow<Boolean> }
class InMemoryNavStateStore : NavStateStore { override val isEnabled: Flow<Boolean> = /* ... */ fun setIsEnabled(value: Boolean) { /* ... */ } fun getLastSet(): Long { /* ... */ } } ashdavies.dev 65 -
Refactoring Interface Segregation interface NavStateStore { val isEnabled: Flow<Boolean> }
class PreferencesNavStateStore : NavStateStore { override val isEnabled: Flow<Boolean> = /* ... */ fun setIsEnabled(value: Boolean) { /* ... */ } fun getLastSet(): Long { /* ... */ } } ashdavies.dev 66 -
Refactoring Interface Segregation ✨ interface NavStateStore { val isEnabled: Flow<Boolean>
} class SentientNavStateStore : NavStateStore { override val isEnabled: Flow<Boolean> = /* ... */ fun setIsEnabled(value: Boolean) { /* ... */ } fun getLastSet(): Long { /* ... */ } } ashdavies.dev 67 -
Refactoring Interface Segregation interface NavStateStore { val isEnabled: Flow<Boolean> }
class NavStateStoreImpl : NavStateStore { override val isEnabled: Flow<Boolean> = /* ... */ fun setIsEnabled(value: Boolean) { /* ... */ } fun getLastSet(): Long { /* ... */ } } ashdavies.dev 68 -
Refactoring Objects class MenuProvider( private val menuDefaults: MenuStateDefaults = MenuStateDefaults,
) { /* ... */ } object MenuStateDefaults { val iconWidth = 24 } ashdavies.dev 69 -
Refactoring Objects class MenuProvider( private val menuProperties: MenuStateProperties = MenuStateProperties.Default,
) { /* ... */ } interface MenuStateProperties { val iconWidth: Int companion object Default : MenuStateProperties { override val iconWidth = 24 } } ashdavies.dev 70 -
Refactoring Objects ! class MenuProvider( private val menuProperties: MenuStateProperties =
MenuStateProperties(), ) { /* ... */ } interface MenuStateProperties { val iconWidth: Int companion object { operator fun invoke(): MenuStateProperties { /* ... */ } } } ashdavies.dev 71 -
Refactoring Factory Function private object MenuStateDefaults { val iconWidth =
24 } class MenuProvider( private val menuProperties: MenuStateProperties = MenuStateProperties(), ) { /* ... */ } fun interface MenuStateProperties { val iconWidth: Int } fun MenuStateProperties( iconWidth: Int = MenuStateDefaults.iconWidth, ) = object : MenuStateProperties { override val iconWidth = iconWidth } ashdavies.dev 72 -
Testing Assertions data class WaterState( val temperature: Int, ) fun
interface Heater { fun heat(water: WaterState): WaterState } @Test fun `should produce water for English Breakfast Tea`() { val heater = Heater { it.copy(temperature = 95) } val thermosiphon = Thermosiphon(heater) val initial = WaterState(21) val state = thermosiphon.pump(initial) assertTrue(state.temperature >= 95) } ashdavies.dev 73 -
Testing Assertions data class WaterState( val temperature: Int, + val
filtered: Boolean, ) fun interface Heater { fun heat(water: WaterState): WaterState } @Test fun `should produce water for English Breakfast Tea`() { val heater = Heater { it.copy(temperature = 95) } val thermosiphon = Thermosiphon(heater) val initial = WaterState(21, false) val state = thermosiphon.pump(initial) assertTrue(state.temperature >= 95) } ashdavies.dev 74 -
Testing Assertions data class WaterState( val filtered: Boolean, val temperature:
Int, ) fun interface Heater { fun heat(water: WaterState): WaterState } @Test fun `should produce water for English Breakfast Tea`() { val heater = Heater { it.copy(temperature = 95) } val thermosiphon = Thermosiphon(heater) val initial = WaterState(21, true) val expected = WaterState( filtered = true, temperature = 95, ) assertEquals(expected, thermosiphon.pump(initial)) } ashdavies.dev 75 -
Testing Assertions data class WaterState( val filtered: Boolean, val temperature:
Int, ) fun interface Heater { fun heat(water: WaterState): WaterState } @Test fun `should produce water for English Breakfast Tea`() { val heater = Heater { it.copy(temperature = 95) } val thermosiphon = Thermosiphon(heater) val initial = WaterState(21, true) val expected = WaterState( filtered = true, temperature = 95, ) assertEquals(expected, thermosiphon.pump(initial)) } ashdavies.dev 76 -
Conclusion General — Prefer functional interfaces wherever possible — Utilise
factory functions to isolate behaviour — Segregate behaviour into smaller interfaces ashdavies.dev 77 -
Conclusion Testing — Avoid using mocks unless absolutely necessary —
Restrict test behaviour to function body — Keep individual tests idempotent — It's OK to repeat yourself ashdavies.dev 78 -
Out-Takes: TDD private fun isEven(number: Int): Boolean { // Added
to pass unit test if (number == 11) { return false } // Added to pass unit test if (number == 11) { return false } // Fix for Ticket 12846 if (number == 11407) { return false } // Fix for Ticket 14336 if (number == 9) { return false } return true } ashdavies.dev 81 -
Thank You! Ash Davies - SumUp Android / Kotlin GDE
Berlin ashdavies.dev 83 -
Additional — blog.kotlin-academy.com/item-30-consider-factory- functions-instead-of-constructors-e1c747fc475 — medium.com/@june.pravin/mocking-is-not-practical- use-fakes-e30cc6eaaf4e — testing.googleblog.com/2024/02/increase-test- fidelity-by-avoiding-mocks.html
— handstandsam.com/2020/06/08/wrapping-mockito- mocks-for-reusability/ ashdavies.dev 84