Slide 1

Slide 1 text

Android Testing Strategy Marcelo Benites

Slide 2

Slide 2 text

Why is it so hard to test an Android application?

Slide 3

Slide 3 text

No access to Android components constructor (Activity, Service, Application, Content Provider and Broadcast Receiver).

Slide 4

Slide 4 text

It is hard to fake Android OS events during testing (Battery Level, Location, Sensors etc...).

Slide 5

Slide 5 text

Android emulator is slow.

Slide 6

Slide 6 text

How do we solve those problems?

Slide 7

Slide 7 text

Clean Architecture

Slide 8

Slide 8 text

Domain ● Business Logic ● No implementation details (HTTP, SQLite, Bluetooth, etc...) ● No external dependencies (including Android SDK)

Slide 9

Slide 9 text

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)

Slide 10

Slide 10 text

Presentation ● Presenter, Controller, ViewModel ● No external dependencies (including Android SDK)

Slide 11

Slide 11 text

External Dependencies ● Android SDK ● RxJava, Fragment, Activity, Room, Retrofit, OKHttp, Dagger, etc...

Slide 12

Slide 12 text

Controller Presenter Use Case Implementation Callback Use Case Flow of control Domain Gateway Gateway Implementation Data Presentation

Slide 13

Slide 13 text

Testing Strategy

Slide 14

Slide 14 text

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.

Slide 15

Slide 15 text

No content

Slide 16

Slide 16 text

Show me the code!

Slide 17

Slide 17 text

Conference Application

Slide 18

Slide 18 text

BDD

Slide 19

Slide 19 text

As a user I want to see conference details

Slide 20

Slide 20 text

Given a conference is registered with name Droidcon When I open conference application Then I see Droidcon on the screen

Slide 21

Slide 21 text

TDD

Slide 22

Slide 22 text

Unit Tests Domain Data Presentation

Slide 23

Slide 23 text

Domain First!

Slide 24

Slide 24 text

State

Slide 25

Slide 25 text

data class State( val name: Name, val value: T? = null ) { enum class Name { IDLE, LOADING, LOADED, ERROR } } Idle Loading Loaded Error

Slide 26

Slide 26 text

Threading

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

Data Contract

Slide 29

Slide 29 text

interface ConferenceGateway { fun getConference(): Conference } data class Conference(val id: String, val name: String)

Slide 30

Slide 30 text

Arrange, Act, Assert

Slide 31

Slide 31 text

Arrange

Slide 32

Slide 32 text

@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) -> Unit>() stateMachine.addStateChangedListener(listenerMock) stateMachine.start() verifyOrder { listenerMock.invoke(State(State.Name.LOADING)) listenerMock.invoke(State(State.Name.LOADED, conference)) } }

Slide 33

Slide 33 text

Act

Slide 34

Slide 34 text

@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) -> Unit>() stateMachine.addStateChangedListener(listenerMock) stateMachine.start() verifyOrder { listenerMock.invoke(State(State.Name.LOADING)) listenerMock.invoke(State(State.Name.LOADED, conference)) } }

Slide 35

Slide 35 text

Assert

Slide 36

Slide 36 text

@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) -> Unit>() stateMachine.addStateChangedListener(listenerMock) stateMachine.start() verifyOrder { listenerMock.invoke(State(State.Name.LOADING)) listenerMock.invoke(State(State.Name.LOADED, conference)) } }

Slide 37

Slide 37 text

Test fails!

Slide 38

Slide 38 text

Make it pass.

Slide 39

Slide 39 text

class ConferenceStateMachine( private val conferenceGateway: ConferenceGateway, private val dispatcher: Dispatcher ) : StateMachine() { override fun start() { loadConference() } private fun loadConference() { dispatcher.dispatch({ moveToLoading() moveToLoaded(conferenceGateway.getConference()) }, { moveToError(it) }) } }

Slide 40

Slide 40 text

Presentation

Slide 41

Slide 41 text

MVP (for the sake of simplicity)

Slide 42

Slide 42 text

View Contract

Slide 43

Slide 43 text

interface ConferenceView { fun hideLoading() fun showLoading() fun hideError() fun showError() fun showConferenceName(name: String) fun hideConferenceName() }

Slide 44

Slide 44 text

Arrange

Slide 45

Slide 45 text

@Test fun `Given a loaded conference When state is updated Then show conference name`() { val view = mockk() val presenter = ConferencePresenter(FakeDispatcher(), view) presenter.invoke(State(State.Name.LOADED, Conference("1", "Droidcon"))) verify { view.hideError() view.hideLoading() view.showConferenceName("Droidcon") } }

Slide 46

Slide 46 text

Act

Slide 47

Slide 47 text

@Test fun `Given a loaded conference When state is updated Then show conference name`() { val view = mockk() val presenter = ConferencePresenter(FakeDispatcher(), view) presenter.invoke(State(State.Name.LOADED, Conference("1", "Droidcon"))) verify { view.hideError() view.hideLoading() view.showConferenceName("Droidcon") } }

Slide 48

Slide 48 text

Assert

Slide 49

Slide 49 text

@Test fun `Given a loaded conference When state is updated Then show conference name`() { val view = mockk(relaxed = true) val presenter = ConferencePresenter(FakeDispatcher(), view) presenter.invoke(State(State.Name.LOADED, Conference("1", "Droidcon"))) verify { view.hideError() view.hideLoading() view.showConferenceName("Droidcon") } }

Slide 50

Slide 50 text

Data

Slide 51

Slide 51 text

Integration Tests Data Fake DB Fake External Service

Slide 52

Slide 52 text

Arrange

Slide 53

Slide 53 text

OkHttp and MockWebServer

Slide 54

Slide 54 text

@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() }

Slide 55

Slide 55 text

Act

Slide 56

Slide 56 text

@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() }

Slide 57

Slide 57 text

Assert

Slide 58

Slide 58 text

@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() }

Slide 59

Slide 59 text

Component Tests Domain Data Fake DB Fake External Service Presentation Fake GUI

Slide 60

Slide 60 text

No content

Slide 61

Slide 61 text

Dependency Injection

Slide 62

Slide 62 text

interface DependencyManager { val conferenceStateMachine: StateMachine val mainDispatcher: Dispatcher } interface ViewContainer { val dependencyManager: DependencyManager }

Slide 63

Slide 63 text

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() } }

Slide 64

Slide 64 text

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() } }

Slide 65

Slide 65 text

@RunWith(AndroidJUnit4::class) class ConferenceComponentTest { @get:Rule val rule: ActivityTestRule = 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() } }

Slide 66

Slide 66 text

Arrange

Slide 67

Slide 67 text

@RunWith(AndroidJUnit4::class) class ConferenceComponentTest { @get:Rule val rule: ActivityTestRule = 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() } }

Slide 68

Slide 68 text

Act

Slide 69

Slide 69 text

@RunWith(AndroidJUnit4::class) class ConferenceComponentTest { @get:Rule val rule: ActivityTestRule = 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() } }

Slide 70

Slide 70 text

Assert

Slide 71

Slide 71 text

@RunWith(AndroidJUnit4::class) class ConferenceComponentTest { @get:Rule val rule: ActivityTestRule = 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() } }

Slide 72

Slide 72 text

End-to-End Tests Domain Data Presentation DB External Service Android SDK

Slide 73

Slide 73 text

Arrange

Slide 74

Slide 74 text

Act

Slide 75

Slide 75 text

@RunWith(AndroidJUnit4::class) class ConferenceEndToEndTest { @get:Rule val rule: ActivityTestRule = 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"))) } }

Slide 76

Slide 76 text

Assert

Slide 77

Slide 77 text

@RunWith(AndroidJUnit4::class) class ConferenceEndToEndTest { @get:Rule val rule: ActivityTestRule = 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"))) } }

Slide 78

Slide 78 text

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

Slide 79

Slide 79 text

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)

Slide 80

Slide 80 text

https://github.com/marcelorbenites/android-testing-strategy