Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

Why Test? ashdavies.dev 2

Slide 3

Slide 3 text

Photos by Daniel Romero, Taylor Vick on Unsplash 3

Slide 4

Slide 4 text

"Hey, I hope you had a relaxing weekend..." — QA Enginner (8:47 Monday morning) ashdavies.dev 4

Slide 5

Slide 5 text

ashdavies.dev 5

Slide 6

Slide 6 text

Testing — Monkey — End-to-End — Acceptance — Instrumentation — Integration — Regression — Unit ashdavies.dev 6

Slide 7

Slide 7 text

ashdavies.dev 7

Slide 8

Slide 8 text

Testing Photo by DiEGO MüLLER on Unsplash 8

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

Writing Unit Tests Conclusion I will not write unit tests ashdavies.dev 10

Slide 11

Slide 11 text

⏱ ashdavies.dev 11

Slide 12

Slide 12 text

"Can we skip the unit tests, just for this feature?" — PM ashdavies.dev 12

Slide 13

Slide 13 text

Developer: "I'll get back to it later..." Narrator: "They never got back to it." ashdavies.dev 13

Slide 14

Slide 14 text

"Tests Saved My Ass" — Ben Kadel youtu.be/8QvXErxv9qw 14

Slide 15

Slide 15 text

"Our OKR is 100% Code Coverage" — Former CTO ashdavies.dev 15

Slide 16

Slide 16 text

ashdavies.dev 16

Slide 17

Slide 17 text

Tests test design as well as logic — Michael Feathers ashdavies.dev 17

Slide 18

Slide 18 text

Architecture Anti-Patterns and Code Smell ashdavies.dev 18

Slide 19

Slide 19 text

Architecture — Coupling — Inheritance — Mutability — Polymorphism ashdavies.dev 19

Slide 20

Slide 20 text

Problem Solving ashdavies.dev 20

Slide 21

Slide 21 text

Solutions Looking for a Problem ashdavies.dev 21

Slide 22

Slide 22 text

Problem Solving ashdavies.dev 22

Slide 23

Slide 23 text

ashdavies.dev 23

Slide 24

Slide 24 text

// Let's Code ashdavies.dev 24

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

Test Doubles Mocks (╯°□°)╯︵ ┻━┻ ashdavies.dev 26

Slide 27

Slide 27 text

Test Doubles Mocks — Behaviour Verification ! — API Insensitivity " — Scale Poorly # ashdavies.dev 27

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

Factory Functions ashdavies.dev 29

Slide 30

Slide 30 text

Factory Functions interface Pump { fun pump() } class Thermosiphon( private val heater: Heater, ) : Pump ashdavies.dev 30

Slide 31

Slide 31 text

Factory Functions interface Pump { fun pump() } fun Pump(heater: Heater): Pump { return Thermosiphon(heater) } private class Thermosiphon( private val heater: Heater, ) ashdavies.dev 31

Slide 32

Slide 32 text

Factory Functions CoroutineScope() CompletableDeferred() Channel() MutableStateFlow() List() ashdavies.dev 32

Slide 33

Slide 33 text

Factory Functions public actual fun lazy(mode: LazyThreadSafetyMode, initializer: () -> T): Lazy = when (mode) { LazyThreadSafetyMode.SYNCHRONIZED -> SynchronizedLazyImpl(initializer) LazyThreadSafetyMode.PUBLICATION -> SafePublicationLazyImpl(initializer) LazyThreadSafetyMode.NONE -> UnsafeLazyImpl(initializer) } ashdavies.dev 33

Slide 34

Slide 34 text

Functional Interfaces ashdavies.dev 34

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

Functional Interfaces fun interface Pump { fun pump() } fun Pump(heater: Heater) = Pump { /* ... */ } ashdavies.dev 36

Slide 37

Slide 37 text

Functional Interfaces fun interface Pump { fun pump(): Boolean } - val pump = mock { - whenever(heat()).thenReturn(true) - } + val pump = Pump { true } ashdavies.dev 37

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

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

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

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

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

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

Slide 47

Slide 47 text

DRY Don't Repeat Yourself — Remove duplication — High code reusability — Isolating change ashdavies.dev 47

Slide 48

Slide 48 text

DAMP Descriptive AND Meaningful Phrases — Some duplication permitted — Declarative syntax — Meaningful naming ashdavies.dev 48

Slide 49

Slide 49 text

DRY vs DAMP ashdavies.dev 49

Slide 50

Slide 50 text

Documentation ashdavies.dev 50

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

Documentation ashdavies.dev 54

Slide 55

Slide 55 text

Documentation: Tests fun `should store in user specific locale when device is in another country() ashdavies.dev 55

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

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

Slide 58

Slide 58 text

ashdavies.dev 58

Slide 59

Slide 59 text

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

Slide 60

Slide 60 text

What about Context?! ashdavies.dev 60

Slide 61

Slide 61 text

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

Slide 62

Slide 62 text

Refactoring Interface Segregation ashdavies.dev 62

Slide 63

Slide 63 text

Refactoring Interface Segregation class MenuProvider( private val navStateStore: NavStateStore = NavStateStore(), ) { fun get() = combine(navStateStore.isEnabled, /* ... */) { /* ... */ } } class NavStateStore { val isEnabled: Flow = /* ... */ fun setIsEnabled(value: Boolean) { /* ... */ } fun getLastSet(): Long { /* ... */ } } ashdavies.dev 63

Slide 64

Slide 64 text

Refactoring Interface Segregation class MenuProvider( private val navStateStore: NavStateStore = NavStateStore(), ) { fun get() = combine(navStateStore.isEnabled, /* ... */) { /* ... */ } } class NavStateStore { val isEnabled: Flow = /* ... */ fun setIsEnabled(value: Boolean) { /* ... */ } fun getLastSet(): Long { /* ... */ } } ashdavies.dev 64

Slide 65

Slide 65 text

Refactoring Interface Segregation interface NavStateStore { val isEnabled: Flow } class InMemoryNavStateStore : NavStateStore { override val isEnabled: Flow = /* ... */ fun setIsEnabled(value: Boolean) { /* ... */ } fun getLastSet(): Long { /* ... */ } } ashdavies.dev 65

Slide 66

Slide 66 text

Refactoring Interface Segregation interface NavStateStore { val isEnabled: Flow } class PreferencesNavStateStore : NavStateStore { override val isEnabled: Flow = /* ... */ fun setIsEnabled(value: Boolean) { /* ... */ } fun getLastSet(): Long { /* ... */ } } ashdavies.dev 66

Slide 67

Slide 67 text

Refactoring Interface Segregation ✨ interface NavStateStore { val isEnabled: Flow } class SentientNavStateStore : NavStateStore { override val isEnabled: Flow = /* ... */ fun setIsEnabled(value: Boolean) { /* ... */ } fun getLastSet(): Long { /* ... */ } } ashdavies.dev 67

Slide 68

Slide 68 text

Refactoring Interface Segregation interface NavStateStore { val isEnabled: Flow } class NavStateStoreImpl : NavStateStore { override val isEnabled: Flow = /* ... */ fun setIsEnabled(value: Boolean) { /* ... */ } fun getLastSet(): Long { /* ... */ } } ashdavies.dev 68

Slide 69

Slide 69 text

Refactoring Objects class MenuProvider( private val menuDefaults: MenuStateDefaults = MenuStateDefaults, ) { /* ... */ } object MenuStateDefaults { val iconWidth = 24 } ashdavies.dev 69

Slide 70

Slide 70 text

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

Slide 71

Slide 71 text

Refactoring Objects ! class MenuProvider( private val menuProperties: MenuStateProperties = MenuStateProperties(), ) { /* ... */ } interface MenuStateProperties { val iconWidth: Int companion object { operator fun invoke(): MenuStateProperties { /* ... */ } } } ashdavies.dev 71

Slide 72

Slide 72 text

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

Slide 73

Slide 73 text

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

Slide 74

Slide 74 text

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

Slide 75

Slide 75 text

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

Slide 76

Slide 76 text

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

Slide 77

Slide 77 text

Conclusion General — Prefer functional interfaces wherever possible — Utilise factory functions to isolate behaviour — Segregate behaviour into smaller interfaces ashdavies.dev 77

Slide 78

Slide 78 text

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

Slide 79

Slide 79 text

But wait, there's more! Out-Takes ashdavies.dev 79

Slide 80

Slide 80 text

Out-Takes: Flaky the Little Flake android- review.googlesource.com/c/ platform/frameworks/support/ +/2776638 ashdavies.dev 80

Slide 81

Slide 81 text

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

Slide 82

Slide 82 text

Out-Takes: Unreachable State ashdavies.dev 82

Slide 83

Slide 83 text

Thank You! Ash Davies - SumUp Android / Kotlin GDE Berlin ashdavies.dev 83

Slide 84

Slide 84 text

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