Droidcon - Android Testing Strategy

Droidcon - Android Testing Strategy

Testing an Android application was always hard. In the past couple of years we got access to better tools, but we still rely a lot on architecture and design decisions in order to properly test an Android application. This talk will discuss the layers of automated testing an Android application should have, from End-to-end, Component, Integration to Unit tests. Also we will discuss BDD, TDD and Clean Architecture as tools to implement those layers.

238946b7b66d06821299e392128b3a95?s=128

Marcelo Benites

September 10, 2019
Tweet

Transcript

  1. Android Testing Strategy Marcelo Benites

  2. Why is it so hard to test an Android application?

  3. No access to Android components constructor (Activity, Service, Application, Content

    Provider and Broadcast Receiver).
  4. It is hard to fake Android OS events during testing

    (Battery Level, Location, Sensors etc...).
  5. Android emulator is slow.

  6. How do we solve those problems?

  7. Clean Architecture

  8. Domain • Business Logic • No implementation details (HTTP, SQLite,

    Bluetooth, etc...) • No external dependencies (including Android SDK)
  9. Data • Simple adapter to a data source: Http, File

    System, Bluetooth, WebSocket, Push etc... • Should not be complex. Move complexity to the Domain • No external dependencies (including Android SDK)
  10. Presentation • Presenter, Controller, ViewModel • No external dependencies (including

    Android SDK)
  11. External Dependencies • Android SDK • RxJava, Fragment, Activity, Room,

    Retrofit, OKHttp, Dagger, etc...
  12. Controller Presenter Use Case Implementation Callback <i> Use Case <i>

    Flow of control Domain Gateway <i> Gateway Implementation Data Presentation
  13. Testing Strategy

  14. Testing Strategy • Unit Tests - Test Presentation, Domain and

    Data in isolation. • Integration Tests - Test Data and Presentation interaction with External Dependencies. • Component Tests - Test Presentation, Domain and Data interaction using Test Doubles (Mock, Fake, Spy, Stub) for External Dependencies. • End-to-End Tests - Test Presentation, Domain Data and External Dependencies interaction.
  15. None
  16. Show me the code!

  17. Conference Application

  18. BDD

  19. As a user I want to see conference details

  20. Given a conference is registered with name Droidcon When I

    open conference application Then I see Droidcon on the screen
  21. TDD

  22. Unit Tests Domain Data Presentation

  23. Domain First!

  24. State

  25. data class State<T>( val name: Name, val value: T? =

    null ) { enum class Name { IDLE, LOADING, LOADED, ERROR } } Idle Loading Loaded Error
  26. Threading

  27. interface Dispatcher { fun dispatch(execute: () -> Unit, error: (Throwable)

    -> Unit) fun dispatch(execute: () -> Unit) }
  28. Data Contract

  29. interface ConferenceGateway { fun getConference(): Conference } data class Conference(val

    id: String, val name: String)
  30. Arrange, Act, Assert

  31. Arrange

  32. @Test fun `Given a registered conference When start is called

    Then should emit loaded state with conference`() { val conference = Conference("1", "Droidcon") val stateMachine = ConferenceStateMachine( FakeDispatcher(), FakeConferenceGateway(conference) ) val listenerMock = mockk<(State<Conference, GatewayError>) -> Unit>() stateMachine.addStateChangedListener(listenerMock) stateMachine.start() verifyOrder { listenerMock.invoke(State(State.Name.LOADING)) listenerMock.invoke(State(State.Name.LOADED, conference)) } }
  33. Act

  34. @Test fun `Given a conference When start is called Then

    should emit loaded state with conference`() { val conference = Conference("1", "Droidcon") val stateMachine = ConferenceStateMachine( FakeDispatcher(), FakeConferenceGateway(conference) ) val listenerMock = mockk<(State<Conference, GatewayError>) -> Unit>() stateMachine.addStateChangedListener(listenerMock) stateMachine.start() verifyOrder { listenerMock.invoke(State(State.Name.LOADING)) listenerMock.invoke(State(State.Name.LOADED, conference)) } }
  35. Assert

  36. @Test fun `Given a conference When start is called Then

    should emit loaded state with conference`() { val conference = Conference("1", "Droidcon") val stateMachine = ConferenceStateMachine( FakeDispatcher(), FakeConferenceGateway(conference) ) val listenerMock = mockk<(State<Conference, GatewayError>) -> Unit>() stateMachine.addStateChangedListener(listenerMock) stateMachine.start() verifyOrder { listenerMock.invoke(State(State.Name.LOADING)) listenerMock.invoke(State(State.Name.LOADED, conference)) } }
  37. Test fails!

  38. Make it pass.

  39. class ConferenceStateMachine( private val conferenceGateway: ConferenceGateway, private val dispatcher: Dispatcher

    ) : StateMachine<Conference>() { override fun start() { loadConference() } private fun loadConference() { dispatcher.dispatch({ moveToLoading() moveToLoaded(conferenceGateway.getConference()) }, { moveToError(it) }) } }
  40. Presentation

  41. MVP (for the sake of simplicity)

  42. View Contract

  43. interface ConferenceView { fun hideLoading() fun showLoading() fun hideError() fun

    showError() fun showConferenceName(name: String) fun hideConferenceName() }
  44. Arrange

  45. @Test fun `Given a loaded conference When state is updated

    Then show conference name`() { val view = mockk<ConferenceView>() val presenter = ConferencePresenter(FakeDispatcher(), view) presenter.invoke(State(State.Name.LOADED, Conference("1", "Droidcon"))) verify { view.hideError() view.hideLoading() view.showConferenceName("Droidcon") } }
  46. Act

  47. @Test fun `Given a loaded conference When state is updated

    Then show conference name`() { val view = mockk<ConferenceView>() val presenter = ConferencePresenter(FakeDispatcher(), view) presenter.invoke(State(State.Name.LOADED, Conference("1", "Droidcon"))) verify { view.hideError() view.hideLoading() view.showConferenceName("Droidcon") } }
  48. Assert

  49. @Test fun `Given a loaded conference When state is updated

    Then show conference name`() { val view = mockk<ConferenceView>(relaxed = true) val presenter = ConferencePresenter(FakeDispatcher(), view) presenter.invoke(State(State.Name.LOADED, Conference("1", "Droidcon"))) verify { view.hideError() view.hideLoading() view.showConferenceName("Droidcon") } }
  50. Data

  51. Integration Tests Data Fake DB Fake External Service

  52. Arrange

  53. OkHttp and MockWebServer

  54. @Test fun `When conference is requested Then call conference endpoint

    with GET method`() { val server = MockWebServer() server.start() val baseUrl = server.url("/").toString() val gateway = OkHttpConferenceGateway(baseUrl, OkHttpClient()) val json = """ { "id": "1", "name": "Droidcon" } """ server.enqueue(MockResponse().setResponseCode(200).setBody(json)) val conference = gateway.getConference() assertEquals(Conference("1", "Droidcon"), conference) val request = server.takeRequest() assertEquals("GET", request.method) assertEquals("/conference", request.path) server.shutdown() }
  55. Act

  56. @Test fun `When conference is requested Then call conference endpoint

    with GET method`() { val server = MockWebServer() server.start() val baseUrl = server.url("/").toString() val gateway = OkHttpConferenceGateway(baseUrl, OkHttpClient()) val json = """ { "id": "1", "name": "Droidcon" } """.trimIndent() server.enqueue(MockResponse().setResponseCode(200).setBody(json)) val conference = gateway.getConference() assertEquals(Conference("1", "Droidcon"), conference) val request = server.takeRequest() assertEquals("GET", request.method) assertEquals("/conference", request.path) server.shutdown() }
  57. Assert

  58. @Test fun `When conference is requested Then call conference endpoint

    with GET method`() { val server = MockWebServer() server.start() val baseUrl = server.url("/").toString() val gateway = OkHttpConferenceGateway(baseUrl, OkHttpClient()) val json = """ { "id": "1", "name": "Droidcon" } """.trimIndent() server.enqueue(MockResponse().setResponseCode(200).setBody(json)) val conference = gateway.getConference() assertEquals(Conference("1", "Droidcon"), conference) val request = server.takeRequest() assertEquals("GET", request.method) assertEquals("/conference", request.path) server.shutdown() }
  59. Component Tests Domain Data Fake DB Fake External Service Presentation

    Fake GUI
  60. None
  61. Dependency Injection

  62. interface DependencyManager { val conferenceStateMachine: StateMachine<Conference> val mainDispatcher: Dispatcher }

    interface ViewContainer { val dependencyManager: DependencyManager }
  63. class TestActivity : AppCompatActivity(), ViewContainer { var testDependencyManager: DependencyManager? =

    null override val dependencyManager: DependencyManager by lazy { testDependencyManager!! } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val view = LinearLayout(this) view.id = 1 setContentView(view) } fun showFragment(fragment: Fragment) { supportFragmentManager .beginTransaction() .replace(1, fragment) .commit() } }
  64. class TestActivity : AppCompatActivity(), ViewContainer { var testDependencyManager: DependencyManager? =

    null override val dependencyManager: DependencyManager by lazy { testDependencyManager!! } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val view = LinearLayout(this) view.id = 1 setContentView(view) } fun showFragment(fragment: Fragment) { supportFragmentManager .beginTransaction() .replace(1, fragment) .commit() } }
  65. @RunWith(AndroidJUnit4::class) class ConferenceComponentTest { @get:Rule val rule: ActivityTestRule<TestActivity> = ActivityTestRule(TestActivity::class.java)

    @Test fun `Given a registered conference When conference screen appears Then show conference name`() { val server = MockWebServer() server.start() val baseUrl = server.url("/").toString() val json = """ { "id": "1", "name": "Droidcon" } """ server.enqueue(MockResponse().setResponseCode(200).setBody(json)) val stateMachine = ConferenceStateMachine(OkHttpConferenceGateway(baseUrl, OkHttpClient()), FakeDispatcher()) stateMachine.start() rule.activity.testDependencyManager = FakeDependencyManager(stateMachine, FakeDispatcher()) rule.activity.showFragment(ConferenceFragment()) onView(withId(R.id.conferenceName)).check(matches(ViewMatchers.withText("Droidcon"))) server.shutdown() } }
  66. Arrange

  67. @RunWith(AndroidJUnit4::class) class ConferenceComponentTest { @get:Rule val rule: ActivityTestRule<TestActivity> = ActivityTestRule(TestActivity::class.java)

    @Test fun `Given a registered conference When conference screen appears Then show conference name`() { val server = MockWebServer() server.start() val baseUrl = server.url("/").toString() val json = """ { "id": "1", "name": "Droidcon" } """ server.enqueue(MockResponse().setResponseCode(200).setBody(json)) val stateMachine = ConferenceStateMachine(OkHttpConferenceGateway(baseUrl, OkHttpClient()), FakeDispatcher()) stateMachine.start() rule.activity.testDependencyManager = FakeDependencyManager(stateMachine, FakeDispatcher()) rule.activity.showFragment(ConferenceFragment()) onView(withId(R.id.conferenceName)).check(matches(ViewMatchers.withText("Droidcon"))) server.shutdown() } }
  68. Act

  69. @RunWith(AndroidJUnit4::class) class ConferenceComponentTest { @get:Rule val rule: ActivityTestRule<TestActivity> = ActivityTestRule(TestActivity::class.java)

    @Test fun `Given a registered conference When conference screen appears Then show conference name`() { val server = MockWebServer() server.start() val baseUrl = server.url("/").toString() val json = """ { "id": "1", "name": "Droidcon" } """ server.enqueue(MockResponse().setResponseCode(200).setBody(json)) val stateMachine = ConferenceStateMachine(OkHttpConferenceGateway(baseUrl, OkHttpClient()), FakeDispatcher()) stateMachine.start() rule.activity.testDependencyManager = FakeDependencyManager(stateMachine, FakeDispatcher()) rule.activity.showFragment(ConferenceFragment()) onView(withId(R.id.conferenceName)).check(matches(ViewMatchers.withText("Droidcon"))) server.shutdown() } }
  70. Assert

  71. @RunWith(AndroidJUnit4::class) class ConferenceComponentTest { @get:Rule val rule: ActivityTestRule<TestActivity> = ActivityTestRule(TestActivity::class.java)

    @Test fun `Given a registered conference When conference screen appears Then show conference name`() { val server = MockWebServer() server.start() val baseUrl = server.url("/").toString() val json = """ { "id": "1", "name": "Droidcon" } """ server.enqueue(MockResponse().setResponseCode(200).setBody(json)) val stateMachine = ConferenceStateMachine(OkHttpConferenceGateway(baseUrl, OkHttpClient()), FakeDispatcher()) stateMachine.start() rule.activity.testDependencyManager = FakeDependencyManager(stateMachine, FakeDispatcher()) rule.activity.showFragment(ConferenceFragment()) onView(withId(R.id.conferenceName)).check(matches(ViewMatchers.withText("Droidcon"))) server.shutdown() } }
  72. End-to-End Tests Domain Data Presentation DB External Service Android SDK

  73. Arrange

  74. Act

  75. @RunWith(AndroidJUnit4::class) class ConferenceEndToEndTest { @get:Rule val rule: ActivityTestRule<MainActivity> = ActivityTestRule(MainActivity::class.java,

    true, false) @Test fun givenRegisteredConferenceWhenApplicationStartsStartShowConferenceName() { rule.launchActivity(Intent()) Thread.sleep(2000) onView(ViewMatchers.withId(R.id.conferenceName)).check(matches(withText("Droidcon"))) } }
  76. Assert

  77. @RunWith(AndroidJUnit4::class) class ConferenceEndToEndTest { @get:Rule val rule: ActivityTestRule<MainActivity> = ActivityTestRule(MainActivity::class.java,

    true, false) @Test fun givenRegisteredConferenceWhenApplicationStartsStartShowConferenceName() { rule.launchActivity(Intent()) Thread.sleep(2000) onView(ViewMatchers.withId(R.id.conferenceName)).check(matches(withText("Droidcon"))) } }
  78. Unit Tests Integration Tests Component Tests End-to-End Tests Domain Data

    Fake DB Fake External Service Presentation Fake GUI DB External Service Android SDK
  79. Reference • The Clean Architecture - Robert Martin (http://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) •

    Test Double - Martin Fowler (https://martinfowler.com/bliki/TestDouble.html) • Testing Strategies in a Microservices Architecture - Martin Fowler (https://martinfowler.com/articles/microservice-testing/#conclusion-summary) • Managing State with RxJava - Jake Wharton (https://www.youtube.com/watch?v=0IKHxjkgop4) • Android Testing Strategy - Marcelo Benites (https://engineering.talkdesk.com/android-testing-strategy-73269539c13d)
  80. https://github.com/marcelorbenites/android-testing-strategy