Those of us who are writing unit tests, integration tests and end to end tests often think that if the code is testable then it means it is well designed. But is it a true statement?
Badly designed code can be tested but often at a large cost. After years of work I learned that striving for testability can sometimes lead to damaged designs.
Classes that expose methods just so the tests could check assertions about their state, broken encapsulation just to test properly!
Tests often say more about our code then people would like to admit, large mocking sections, unreadable test cases and scenarios, the need to know the internal implementation of the class, etc.
During this talk we will look at different examples of such issues that good designs should avoid. We will also look where does a bad design lead.
9. “If you have mocks returning mocks
returning mocks, then your test is
completely coupled with the
implementation not the interface.”
Kent Beck
Slido: #DD22A
10. Difficulty in mocking properly
Need to know what methods are called?
Need to know what arguments are passed?
Train wreck design?
Final methods or classes?
Static functions?
Insufficient access?
Slido: #DD22A
11. // a train wreck design?
appContext.getSystem().getSettings().getInt(...
// the longer the train, the longer the mocking
whenever(context.getSystem()).thenReturn(system)
whenever(system.getSettings()).thenReturn(settings)
// a possible fix?
class NewFeatureController(
private val system: System,
private val settings: Settings,
) {
// can we do better?
Slido: #DD22A
12. // an even better fix?
class NewFeatureController(
private val startScreen: (Screen) -> Unit,
private val getIntSetting: (key: String, default: Int) -> Int,
private val putIntSetting: (key: String, value: Int) -> Unit,
private val getBooleanSetting: (key: String, value: Boolean) -> Boolean,
// ...
) {
// we don’t need this anymore
whenever(context.getSystem()).thenReturn(system)
whenever(system.getSettings()).thenReturn(settings)
// but what if need more functions?
Slido: #DD22A
13. Hard to instantiate the tested code
Too many constructor arguments?
Invoking the constructor is not the only step?
Need to pass specific framework dependencies?
Need to receive specific framework callbacks/events?
14. class NewFeatureController(
private val startScreen: (Screen) -> Unit,
private val getIntSetting: (key: String, default: Int) -> Int,
private val putIntSetting: (key: String, value: Int) -> Unit,
private val getBooleanSetting: (key: String, value: Boolean) -> Boolean,
// ...
@Test
fun `after welcome controller should show the dialog`() {
val getIntSetting = { key, default -> 0 }
val getBooleanSetting = { key, default -> if (key == “
WELCOME_FLOW_COMPLETED”) … }
val putIntSetting = { key, value -> ... }
val startScreen = { screen -> ... }
// ...
val tested = NewFeatureController(
startScreen, getIntSetting,
putIntSetting, getBooleanSetting
)
Slido: #DD22A
15. @Test
fun `some test`() {
val tested = Foo()
// calling constructor is not enough
tested.initialize()
// calling functions indicating some stage of the environment lifecycle
tested.onStart()
tested.onResume()
// ...
// we can finally call the tested method
tested.someTestedMethod()
// ...
}
Slido: #DD22A
16. Leaking state between tests
Tested code is using singletons?
Tested code is using global variables?
Incomplete setup?
Incomplete cleanup?
Slido: #DD22A
17. Let’s rework the example!
Hopefully it will be better this time
Slido: #DD22A
18. class NewFeatureController(private val context: AppContext) {
fun showReminderIfNeed() {
updateCounter()
if (counterIsBelowMaxAttempts()
&& userFinishedWelcomeFlow()
&& !userDoesNotWantToSeeItAgain()
) {
startFeatureReminderDialog()
}
}
Is the class needed?
Can we skip passing AppContext?
What does it mean “If Needed”?
Do we need this method?
Can this be baked into the check?
Are these the only conditions?
Maybe starting a screen and checking conditions can
be generalized?
Slido: #DD22A
19. What do we need?
Don’t use AppContext as an argument, be more
explicit!
Something that will model a condition!
A function that starts a screen after checking
conditions
Slido: #DD22A
22. val userCompletedWelcomeFlow: Condition = { // … }
val userHasNotDismissedDialogBefore: Condition = { // … }
val dialogShownLessThenThreeTimes: Condition = { // … }
// …
val startSuccessful =
System.startScreen(
screen = NewFeatureDialog.class,
condition = userCompletedWelcomeFlow and
userHasNotDismissedDialogBefore and
dialogShownLessThenThreeTimes,
) {
putExtra(“MESSAGE”, context.getResources(R.string.message))
}
Slido: #DD22A
23. But is it really better?
Is it better cognitively?
Previously everything was in one place!
One needs a mental model of the startScreen and
Condition!
We have more tests not less!
24. val userCompletedWelcomeFlow: Condition = { // … }
val userHasNotDismissedDialogBefore: Condition = { // … }
val dialogShownLessThenThreeTimes: Condition = { // … }
// …
val startSuccessful =
System.startScreen(
screen = NewFeatureDialog.class,
condition = userCompletedWelcomeFlow and
userHasNotDismissedDialogBefore and
dialogShownLessThenThreeTimes,
) {
putExtra(“MESSAGE”, context.getResources(R.string.message))
}
Slido: #DD22A
25. Test induced damage?
Making it test better made it read worse?
Making it test better made it harder to understand?
26. data class Person(
val birthday: LocalDate,
val name: String,
val surname: String
) {
// …
fun getAge(): Long =
YEARS.between(birthday, LocalDate.now())
// …
}
Reads wonderfully like a sentence!
Age is the number of YEARS between persons birthday and the current date
27. data class Person(
val birthday: LocalDate,
val name: String,
val surname: String
) {
// …
fun getAge(now: () -> LocalDate = { LocalDate.now() }): Long =
YEARS.between(birthday, now())
// …
}
Reads less wonderfully!
Age is the number of YEARS between persons birthday and the specified date that could be
current date if not specified
28. fun sumAge(
people: List<Person>,
now: () -> LocalDate = { LocalDate.now() }
) {
return people.foldLeft(0) { sum, person ->
sum + person.getAge(now)
}
}
It’s leaking up the call chain!