Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Framework-Free Dependency Injection (360|AnDev 2019)

Framework-Free Dependency Injection (360|AnDev 2019)

Dependency injection is an important technique that helps us write testable Android apps. For large apps with complex requirements, powerful frameworks like Dagger centralize and organize our dependency relationships and lifecycles and automate injection. However, many applications are simple enough that manual dependency injection provides all the benefits of Dagger with few of the costs (e.g. a steep learning curve; slower build times). In this beginner-friendly talk, we’ll learn how to identify, extract, and inject dependencies by hand in several common Android architectures. Attendees will leave with a solid grasp of dependency injection fundamentals and the ability (and desire!) to introduce manual dependency injection into their own codebases.

3a6060bc7ace07fa75791cd5dac2d46a?s=128

Stuart Kent

July 18, 2019
Tweet

Transcript

  1. None
  2. Stuart Kent @skentphd

  3. Detroit Labs @detroitlabs

  4. Unit Testing Trends

  5. 2014 Fat Activities Robolectric unit tests ☹

  6. 2015 MVP/MVVM JUnit unit tests closer !

  7. 2016 "Dagger by default" JUnit unit tests for all! !

    ... &
  8. 2019 ! Dagger vs Koin Renewed focus on actual DI

    needs More interest in simple DI solutions
  9. This Talk Unit test driven dependency injection Simple method; not

    scary ⚖ Advantages, limitations, options
  10. Takeaways Manual dependency injection is viable Practical recipe Courage to

    critically evaluate tools & opinions
  11. Kotlin

  12. This code works great in production. class HumanTimeHelper { //

    Consumer fun getTimeOfDay(): String { return when (LocalTime.now().hour) { // Dependency in 6..12 -> "Morning" in 13..17 -> "Afternoon" in 18..21 -> "Evening" else -> "Night" } } }
  13. This code works great in production. class HumanTimeHelper { //

    Consumer fun getTimeOfDay(): String { return when (LocalTime.now().hour) { // Dependency in 6..12 -> "Morning" in 13..17 -> "Afternoon" in 18..21 -> "Evening" else -> "Night" } } }
  14. This code works great in production. class HumanTimeHelper { //

    Consumer fun getTimeOfDay(): String { return when (LocalTime.now().hour) { // Dependency in 6..12 -> "Morning" in 13..17 -> "Afternoon" in 18..21 -> "Evening" else -> "Night" } } }
  15. This code works great in production. class HumanTimeHelper { //

    Consumer fun getTimeOfDay(): String { return when (LocalTime.now().hour) { // Dependency in 6..12 -> "Morning" in 13..17 -> "Afternoon" in 18..21 -> "Evening" else -> "Night" } } }
  16. Unit testing HumanTimeHelper is... impossible? @Test fun testGetTimeOfDayMorning() { val

    humanTimeHelper = HumanTimeHelper() val expected = "Morning" val actual = humanTimeHelper.getTimeOfDay() // Will fail ~70% of the time! assertEquals(expected, actual) }
  17. Our consumer (HumanTimeHelper) is tightly coupled to its dependency (LocalTime.now()).

    class HumanTimeHelper { fun getTimeOfDay(): String { return when (LocalTime.now().hour) { in 6..12 -> "Morning" in 13..17 -> "Afternoon" in 18..21 -> "Evening" else -> "Night" } } }
  18. Goals Specify a fake time in unit tests Continue to

    use the real time in production
  19. Goals Specify a fake X in unit tests Continue to

    use the real X in production
  20. Step 1: define an interface that describes the ideal behavior

    of the dependency from the point of view of the consumer. interface IClock { fun hour(): Int }
  21. Step 2: inject an instance of the new interface into

    the consumer via its constructor(s), and save it as a property. class HumanTimeHelper(private val clock: IClock) { fun getTimeOfDay(): String { return when (LocalTime.now().hour) { in 6..12 -> "Morning" in 13..17 -> "Afternoon" in 18..21 -> "Evening" else -> "Night" } } }
  22. Step 3: replace the tightly-coupled dependency with the newly- injected

    instance throughout the consumer's code. class HumanTimeHelper(private val clock: IClock) { fun getTimeOfDay(): String { return when (clock.hour()) { in 6..12 -> "Morning" in 13..17 -> "Afternoon" in 18..21 -> "Evening" else -> "Night" } } }
  23. Step 4: create a production implementation of the new interface

    that mimics original behavior. class SystemClock : IClock { override fun hour() = LocalTime.now().hour }
  24. Step 4: create a production implementation of the new interface

    that mimics original behavior. class SystemClock : IClock { override fun hour() = LocalTime.now().hour }
  25. Step 5: update all consumer constructor calls in production code,

    passing in the production implementation. val humanTimeHelper = HumanTimeHelper(SystemClock())
  26. Step 6: update all consumer constructor calls in test code,

    passing in a mock implementation with deterministic behavior. @Test fun testGetTimeOfDayMorning() { val humanTimeHelper = HumanTimeHelper(object : IClock { override fun hour() = 6 }) val expected = "Morning" val actual = humanTimeHelper.getTimeOfDay() // Will pass 100% of the time! assertEquals(expected, actual) }
  27. Step 6: update all consumer constructor calls in test code,

    passing in a mock implementation with deterministic behavior. @Test fun testGetTimeOfDayMorning() { val humanTimeHelper = HumanTimeHelper(object : IClock { override fun hour() = 6 }) val expected = "Morning" val actual = humanTimeHelper.getTimeOfDay() // Will pass 100% of the time! assertEquals(expected, actual) }
  28. Step 6: update all consumer constructor calls in test code,

    passing in a mock implementation with deterministic behavior. @Test fun testGetTimeOfDayEvening() { val humanTimeHelper = HumanTimeHelper(object : IClock { override fun hour() = 19 }) val expected = "Evening" val actual = humanTimeHelper.getTimeOfDay() // Will pass 100% of the time! assertEquals(expected, actual) }
  29. Recipe Recap

  30. Recipe Recap 1. Create ideal interface.

  31. Recipe Recap 1. Create ideal interface. 2. Inject interface into

    constructor.
  32. Recipe Recap 1. Create ideal interface. 2. Inject interface into

    constructor. 3. Use injected interface.
  33. Recipe Recap 1. Create ideal interface. 2. Inject interface into

    constructor. 3. Use injected interface. 4. Create real implementation.
  34. Recipe Recap 1. Create ideal interface. 2. Inject interface into

    constructor. 3. Use injected interface. 4. Create real implementation. 5. Pass real implementation in production.
  35. Recipe Recap 1. Create ideal interface. 2. Inject interface into

    constructor. 3. Use injected interface. 4. Create real implementation. 5. Pass real implementation in production. 6. Pass mock implementation in tests.
  36. Android

  37. Android MVP We've dealt with a dependency affected by time.

    Next we'll practice our recipe with dependencies affected by: • network conditions • local storage state We'll start with MVP as it's slightly simpler.
  38. None
  39. class CreditCardPresenter(private val view: ICreditCardsView, context: Context) { private val

    prefs = context.getSharedPreferences("creditCard", MODE_PRIVATE) fun onCardSelected(card: CreditCard) { prefs.edit().putInt("lastCardId", card.id).apply() view.advance() } fun refreshCards() { val lastCardId = prefs.getInt("lastCardId", -1) RestApi() .fetchUserCards() .flatMapIterable { it } .map { it.copy(favorite = (it.id == lastCardId)) } .toList() .subscribe(Consumer { view.display(it) }) } }
  40. class CreditCardPresenter(private val view: ICreditCardsView, context: Context) { private val

    prefs = context.getSharedPreferences("creditCard", MODE_PRIVATE) fun onCardSelected(card: CreditCard) { prefs.edit().putInt("lastCardId", card.id).apply() view.advance() } fun refreshCards() { val lastCardId = prefs.getInt("lastCardId", -1) RestApi() .fetchUserCards() .flatMapIterable { it } .map { it.copy(favorite = (it.id == lastCardId)) } .toList() .subscribe(Consumer { view.display(it) }) } }
  41. class CreditCardPresenter(private val view: ICreditCardsView, context: Context) { private val

    prefs = context.getSharedPreferences("creditCard", MODE_PRIVATE) fun onCardSelected(card: CreditCard) { prefs.edit().putInt("lastCardId", card.id).apply() view.advance() } fun refreshCards() { val lastCardId = prefs.getInt("lastCardId", -1) RestApi() .fetchUserCards() .flatMapIterable { it } .map { it.copy(favorite = (it.id == lastCardId)) } .toList() .subscribe(Consumer { view.display(it) }) } }
  42. class CreditCardPresenter(private val view: ICreditCardsView, context: Context) { private val

    prefs = context.getSharedPreferences("creditCard", MODE_PRIVATE) fun onCardSelected(card: CreditCard) { prefs.edit().putInt("lastCardId", card.id).apply() view.advance() } fun refreshCards() { val lastCardId = prefs.getInt("lastCardId", -1) RestApi() .fetchUserCards() .flatMapIterable { it } .map { it.copy(favorite = (it.id == lastCardId)) } .toList() .subscribe(Consumer { view.display(it) }) } }
  43. class CreditCardsActivity : AppCompatActivity(), ICreditCardsView { private lateinit var presenter:

    CreditCardPresenter override fun onCreate(savedInstanceState: Bundle?) { // ... presenter = CreditCardPresenter(this, this) } // ... }
  44. class CreditCardPresenter(private val view: ICreditCardsView, context: Context) { private val

    prefs = context.getSharedPreferences("creditCard", MODE_PRIVATE) fun onCardSelected(card: CreditCard) { prefs.edit().putInt("lastCardId", card.id).apply() view.advance() } fun refreshCards() { val lastCardId = prefs.getInt("lastCardId", -1) RestApi() .fetchUserCards() .flatMapIterable { it } .map { it.copy(favorite = (it.id == lastCardId)) } .toList() .subscribe(Consumer { view.display(it) }) } }
  45. Step 1: Create ideal interface. interface IUserCardsFetcher { fun fetchUserCards():

    Observable<List<CreditCard>> }
  46. Step 2: Inject interface into constructor. class CreditCardPresenter( private val

    view: ICreditCardsView, private val userCardsFetcher: IUserCardsFetcher, context: Context ) { // ... }
  47. Step 3: Use injected interface. fun refreshCards() { val lastCardId

    = prefs.getInt("lastCardId", -1) userCardsFetcher .fetchUserCards() .flatMapIterable { it } .map { it.copy(favorite = (it.id == lastCardId)) } .toList() .subscribe(Consumer { view.display(it) }) }
  48. Step 4: Create Update real implementation. class RestApi : IUserCardsFetcher

    { // ... override fun fetchUserCards(): Observable<List<CreditCard>> { // ... } // ... }
  49. Step 5: Pass real implementation in production. class CreditCardsActivity :

    AppCompatActivity(), ICreditCardsView { private lateinit var presenter: CreditCardPresenter override fun onCreate(savedInstanceState: Bundle?) { // ... presenter = CreditCardPresenter(this, RestApi(), this) } // ... }
  50. Step 6: Pass mock implementation in tests. ⚠ We're not

    ready to test yet; both presenter methods still use a hard-coded dependency!
  51. class CreditCardPresenter( // ... context: Context ) { private val

    prefs = context.getSharedPreferences("creditCard", MODE_PRIVATE) fun onCardSelected(card: CreditCard) { prefs.edit().putInt("lastCardId", card.id).apply() // ... } fun refreshCards() { val lastCardId = prefs.getInt("lastCardId", -1) // ... } }
  52. Step 1: Create ideal interface. interface ILastCardStorage { fun getLastCardId():

    Int? fun saveLastCardId(id: Int) }
  53. Step 2: Inject interface into constructor and remove injected Context.

    class CreditCardPresenter( private val view: ICreditCardsView, private val userCardsFetcher: IUserCardsFetcher, private val lastCardStorage: ILastCardStorage ) { // ... }
  54. Step 3: Use injected interface and remove SharedPreferences property. fun

    onCardSelected(card: CreditCard) { lastCardStorage.saveLastCardId(card.id) view.advance() } fun refreshCards() { val lastCardId = lastCardStorage.getLastCardId() // ... }
  55. Step 4: Create real implementation. class PrefsLastCardStorage(context: Context) : ILastCardStorage

    { private val prefs = context.getSharedPreferences("creditCard", MODE_PRIVATE) override fun getLastCardId(): Int? { val lastCardId = prefs.getInt("lastCardId", -1) return if (lastCardId >= 0) lastCardId else null } override fun saveLastCardId(id: Int) { prefs.edit().putInt("lastCardId", id).apply() } }
  56. Step 4: Create real implementation. class PrefsLastCardStorage(context: Context) : ILastCardStorage

    { private val prefs = context.getSharedPreferences("creditCard", MODE_PRIVATE) override fun getLastCardId(): Int? { val lastCardId = prefs.getInt("lastCardId", -1) return if (lastCardId >= 0) lastCardId else null } override fun saveLastCardId(id: Int) { prefs.edit().putInt("lastCardId", id).apply() } }
  57. Step 5: Pass real implementation in production. class CreditCardsActivity :

    AppCompatActivity(), ICreditCardsView { private lateinit var presenter: CreditCardPresenter override fun onCreate(savedInstanceState: Bundle?) { // ... presenter = CreditCardPresenter( this, RestApi(), PrefsLastCardStorage(this) ) } // ... }
  58. Step 6: Pass mock implementations in tests. @Test fun `correct

    card marked favorite`() { whenever(mockFetcher.fetchUserCards()).thenReturn( Observable.just(listOf( CreditCard(id = 1, lastFour = "1234", favorite = false), CreditCard(id = 2, lastFour = "7529", favorite = false) )) ) whenever(mockStorage.getLastCardId()).thenReturn(2) val presenter = CreditCardPresenter(mockView, mockFetcher, mockStorage) presenter.refreshCards() verify(mockView).display(cardsCaptor.capture()) val favoriteIds = cardsCaptor.firstValue.filter(CreditCard::favorite) assertEquals(favoriteIds.size, 1) assertEquals(favoriteIds.first().id, 2) }
  59. Step 6: Pass mock implementations in tests. @Test fun `correct

    card marked favorite`() { whenever(mockFetcher.fetchUserCards()).thenReturn( Observable.just(listOf( CreditCard(id = 1, lastFour = "1234", favorite = false), CreditCard(id = 2, lastFour = "7529", favorite = false) )) ) whenever(mockStorage.getLastCardId()).thenReturn(2) val presenter = CreditCardPresenter(mockView, mockFetcher, mockStorage) presenter.refreshCards() verify(mockView).display(cardsCaptor.capture()) val favoriteIds = cardsCaptor.firstValue.filter(CreditCard::favorite) assertEquals(favoriteIds.size, 1) assertEquals(favoriteIds.first().id, 2) }
  60. Step 6: Pass mock implementations in tests. @Test fun `correct

    card marked favorite`() { whenever(mockFetcher.fetchUserCards()).thenReturn( Observable.just(listOf( CreditCard(id = 1, lastFour = "1234", favorite = false), CreditCard(id = 2, lastFour = "7529", favorite = false) )) ) whenever(mockStorage.getLastCardId()).thenReturn(2) val presenter = CreditCardPresenter(mockView, mockFetcher, mockStorage) presenter.refreshCards() verify(mockView).display(cardsCaptor.capture()) val favoriteIds = cardsCaptor.firstValue.filter(CreditCard::favorite) assertEquals(favoriteIds.size, 1) assertEquals(favoriteIds.first().id, 2) }
  61. Step 6: Pass mock implementations in tests. @Test fun `correct

    card marked favorite`() { whenever(mockFetcher.fetchUserCards()).thenReturn( Observable.just(listOf( CreditCard(id = 1, lastFour = "1234", favorite = false), CreditCard(id = 2, lastFour = "7529", favorite = false) )) ) whenever(mockStorage.getLastCardId()).thenReturn(2) val presenter = CreditCardPresenter(mockView, mockFetcher, mockStorage) presenter.refreshCards() verify(mockView).display(cardsCaptor.capture()) val favoriteIds = cardsCaptor.firstValue.filter(CreditCard::favorite) assertEquals(favoriteIds.size, 1) assertEquals(favoriteIds.first().id, 2) }
  62. Android MVVM This recipe also works for MVVM... with tweaks!

    • Construction of Android view models is controlled by a ViewModelFactory. • We'll need a custom factory to inject our dependencies.
  63. class CreditCardsViewModel : ViewModel() { // ... }

  64. class CreditCardsActivity : AppCompatActivity() { // ... override fun onCreate(savedInstanceState:

    Bundle?) { // ... viewModel = ViewModelProviders.of(this) .get(CreditCardsViewModel::class.java) } }
  65. class CreditCardsViewModel( private val userCardsFetcher: IUserCardsFetcher, private val lastCardStorage: ILastCardStorage

    ) : ViewModel() { // ... }
  66. class CreditCardsVMF( private val context: Context ) : ViewModelProvider.Factory {

    override fun <T : ViewModel?> create(vm: Class<T>): T { return CreditCardsViewModel( RestApi(), PrefsLastCardStorage(context) ) as T } }
  67. class CreditCardsVMF( private val context: Context ) : ViewModelProvider.Factory {

    override fun <T : ViewModel?> create(vm: Class<T>): T { return CreditCardsViewModel( RestApi(), PrefsLastCardStorage(context) ) as T } }
  68. class CreditCardsActivity : AppCompatActivity() { // ... override fun onCreate(savedInstanceState:

    Bundle?) { // ... viewModel = ViewModelProviders.of(this, CreditCardsVMF(this)) .get(CreditCardsViewModel::class.java) } }
  69. Recipe Strengths

  70. Recipe Strengths • Very explicit.

  71. Recipe Strengths • Very explicit. • Short learning curve.

  72. Recipe Strengths • Very explicit. • Short learning curve. •

    No build or run time performance impact.
  73. Recipe Strengths • Very explicit. • Short learning curve. •

    No build or run time performance impact. • Consumers decoupled from dependency implementations.
  74. Recipe Strengths • Very explicit. • Short learning curve. •

    No build or run time performance impact. • Consumers decoupled from dependency implementations. • Consumers ignorant of dependency lifecycles.
  75. Recipe Strengths • Very explicit. • Short learning curve. •

    No build or run time performance impact. • Consumers decoupled from dependency implementations. • Consumers ignorant of dependency lifecycles. • Possible to adopt incrementally.
  76. Potential Recipe Weaknesses • Your needs dictate whether these are

    important. • Frameworks (Dagger, Koin) solve all of them, but not for free! • Let's identify and evaluate.
  77. Boilerplate Explosion • Constructor injection adds code at declaration and

    call sites. • Evaluation: • Explicit is good. • Proximity is good. • Checked by compiler (unlike e.g. Parcelable). • Easy if each interface has 1 production implementation.
  78. Interface Explosion • Consumer-centric interfaces add code & cognitive load.

    • Evaluation: • Solve by balancing cohesion against interface segregation principle when designing interfaces. • e.g. prefer IRestApi to IUserCardsFetcher.
  79. Not DRY • No centralized control of dependency relationships. •

    Evaluation: • Problematic for UI tests. • Hard to swap production implementations at runtime. • Framework-free solutions exist; see "DIY Dependency Injection with Kotlin" talk by Sam Edwards.
  80. Takeaways Manual dependency injection is viable Practical recipe Courage to

    critically evaluate tools & opinions
  81. Thanks! Stuart Kent @skentphd