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

Android Automated Testing

Roi Sagiv
September 18, 2019

Android Automated Testing

A talk a gave on the Israeli Android Academy. Sept 2019.

Roi Sagiv

September 18, 2019
Tweet

More Decks by Roi Sagiv

Other Decks in Technology

Transcript

  1. Definition The use of software separate from the software being

    tested to control the execution of tests and the comparison of actual outcomes with predicted outcomes https://en.wikipedia.org/wiki/Test_automation
  2. • Experienced in developing Android apps • Familiar with: •

    LiveData • ViewModel • Coroutines • Room • Heard about / Some experience with testing About You
  3. Android Testing Landscape Two types of runtimes: • "Unit Tests"

    - local machine • Instrumentation - Device / Emulator Powered by JUnit
  4. Local Tests Fast - Runs on the JVM (Local machine)

    • No DEX • No APK Installation Limited - No access to Android SDK / components • Views, Context, SharedPreferences, res... • *Can be simulated (Robolectrics & others) or mocked.
  5. interface AirtableAPI { @GET("./") suspend fun records(): Response<RecordsApiResponse> @POST("./") suspend

    fun create(@Body fields: CreateRecordBody): Response<AirtableRecord> companion object { fun build(baseUrl: String, apiKey: String): AirtableAPI { ... } } } data class RecordsApiResponse(val records: List<AirtableRecord>) data class AirtableRecord(val id: String, val fields: Map<String, String>) data class CreateRecordBody(val fields: Map<String, Any>)
  6. interface AirtableAPI { @GET("./") suspend fun records(): Response<RecordsApiResponse> @POST("./") suspend

    fun create(@Body fields: CreateRecordBody): Response<AirtableRecord> companion object { fun build(baseUrl: String, apiKey: String): AirtableAPI { ... } } } data class RecordsApiResponse(val records: List<AirtableRecord>) data class AirtableRecord(val id: String, val fields: Map<String, String>) data class CreateRecordBody(val fields: Map<String, Any>)
  7. class AirtableAPITest { @get:Rule var mockWebServer = MockWebServer() @Test fun

    recordsShouldReturnListOfWeightRecords() = runBlocking { // Arrange ... // Act ... // Assert ... } }
  8. class AirtableAPITest { @get:Rule var mockWebServer = MockWebServer() @Test fun

    recordsShouldReturnListOfWeightRecords() = runBlocking { // Arrange ... // Act ... // Assert ... } }
  9. class AirtableAPITest { @get:Rule var mockWebServer = MockWebServer() @Test fun

    recordsShouldReturnListOfWeightRecords() = runBlocking { // Arrange ... // Act ... // Assert ... } }
  10. class AirtableAPITest { @get:Rule var mockWebServer = MockWebServer() @Test fun

    recordsShouldReturnListOfWeightRecords() = runBlocking { // Arrange ... // Act ... // Assert ... } }
  11. @Test fun recordsShouldReturnListOfWeightRecords() = runBlocking { // Arrange val client

    = AirtableAPI.build( mockWebServer.url("/").toString(), API_KEY ) mockWebServer.enqueue( MockResponse() .setResponseCode(200) .setBody(Resources.read("records_success.json")) ) // Act ... // Assert ... }
  12. @Test fun recordsShouldReturnListOfWeightRecords() = runBlocking { // Arrange ... //

    Act val clientResponse = client.records() // Assert ... }
  13. @Test fun recordsShouldReturnListOfWeightRecords() = runBlocking { ... // Assert assertThat(mockWebServer.requestCount).isEqualTo(1)

    val request = mockWebServer.takeRequest() assertThat(request.path).isEqualTo("/") assertThat(request.headers["Authorization"]) .isEqualTo("Bearer $API_KEY") assertThat(clientResponse.isSuccessful).isTrue() val records = clientResponse.body()?.records ?: listOf() assertThat(records).hasSize(3) val record = records[0] assertThat(record.fields).hasSize(6) assertThat(record.fields["Weight"]).isEqualTo("80") assertThat(record.fields["Date"]).isEqualTo("2019-06-03T21:03:00.000Z }
  14. MockWebServer class AirtableAPITest { @get:Rule var mockWebServer = MockWebServer() @Test

    fun recordsShouldReturnListOfWeightRecords() = runBlocking { ... } } testImplementation "com.squareup.okhttp3:mockwebserver:$rootProject.okhttpVersion"
  15. @Test fun recordsShouldReturnListOfWeightRecords() = runBlocking { // Arrange ... mockWebServer.enqueue(

    MockResponse() .setResponseCode(200) .setBody(Resources.read("records_success.json")) ) // Act ... // Assert assertThat(mockWebServer.requestCount).isEqualTo(1) val request = mockWebServer.takeRequest() assertThat(request.path).isEqualTo("/") assertThat(request.headers["Authorization"]).isEqualTo("Bearer $API_KEY") ... }
  16. @Test fun recordsShouldReturnListOfWeightRecords() = runBlocking { // Arrange ... mockWebServer.enqueue(

    MockResponse() .setResponseCode(200) .setBody(Resources.read("records_success.json")) ) // Act ... // Assert assertThat(mockWebServer.requestCount).isEqualTo(1) val request = mockWebServer.takeRequest() assertThat(request.path).isEqualTo("/") assertThat(request.headers["Authorization"]).isEqualTo("Bearer $API_KEY") ... }
  17. { "records": [ { "id": "recx6FjYsKNY5R5sF", "fields": { "UserName": "fsdfsdf",

    "Date": "2019-06-03T21:03:00.000Z", "Weight": 80, "Week Avg": 81.5, "Created time": "2019-06-29T20:58:53.000Z", "Last modified time": "2019-07-06T11:02:34.000Z" }, "createdTime": "2019-06-29T20:58:53.000Z" }, { "id": "recVzvGP6aXci0Lly", "fields": { "UserName": "[email protected]", "Date": "2020-01-10T00:48:24.000Z", "Weight": 101, "Week Avg": 82.5, "Notes": "hymenaeos. Mauris ut quam vel", "Created time": "2019-07-06T11:05:54.000Z", "Last modified time": "2019-07-06T11:05:54.000Z" }, "createdTime": "2019-07-06T11:05:54.000Z" }, { "id": "rec8hgWP8HFByqnSz", "fields": { app src test resources records_success.json
  18. FIRST - Principles of Unit Tests • Fast • Isolated

    • Repeatable • Self-Validating • Thorough / Timely
  19. Recap / Questions? • "Unit Tests" / Local Tests •

    Test a single class (AirtableAPI) • The structure of a test (AAA) • FIRST principals • MockWebServer
  20. @Database(entities = [WeightItem::class], version = 1) @TypeConverters(DateConverters::class) abstract class WeightsDatabase

    : RoomDatabase() { abstract fun weightItemsDao(): WeightItemsDao } @Entity(tableName = "WeightItems") data class WeightItem( @PrimaryKey val id: String, val date: OffsetDateTime, val weight: Double, val notes: String? ) @Dao interface WeightItemsDao { @Insert(onConflict = REPLACE) suspend fun save(item: WeightItem) @Query("SELECT * from WeightItems") suspend fun all(): List<WeightItem> }
  21. @Database(entities = [WeightItem::class], version = 1) @TypeConverters(DateConverters::class) abstract class WeightsDatabase

    : RoomDatabase() { abstract fun weightItemsDao(): WeightItemsDao } @Entity(tableName = "WeightItems") data class WeightItem( @PrimaryKey val id: String, val date: OffsetDateTime, val weight: Double, val notes: String? ) @Dao interface WeightItemsDao { @Insert(onConflict = REPLACE) suspend fun save(item: WeightItem) @Query("SELECT * from WeightItems") suspend fun all(): List<WeightItem> }
  22. @Database(entities = [WeightItem::class], version = 1) @TypeConverters(DateConverters::class) abstract class WeightsDatabase

    : RoomDatabase() { abstract fun weightItemsDao(): WeightItemsDao } @Entity(tableName = "WeightItems") data class WeightItem( @PrimaryKey val id: String, val date: OffsetDateTime, val weight: Double, val notes: String? ) @Dao interface WeightItemsDao { @Insert(onConflict = REPLACE) suspend fun save(item: WeightItem) @Query("SELECT * from WeightItems") suspend fun all(): List<WeightItem> }
  23. Instrumentation Tests Easy(ish) - Access to android SDK / components

    • Context, SharedPreferences, etc. • UI & Resources Slow(er) - Android toolchain • DEX • APK install every run
  24. @RunWith(AndroidJUnit4::class) class WeightsDatabaseTest { @get:Rule var weightsDatabaseRule = WeightsDatabaseRule() @Test

    fun newSavedItemShouldBeReturnInAllQuery() = runBlocking { // Arrange ... // Act ... // Assert ... } }
  25. @Test fun newSavedItemShouldBeReturnInAllQuery() = runBlocking { // Arrange val dao

    = weightsDatabaseRule.weightItemsDao val newEntry = WeightItem( id = Random.nextInt().toString(), date = OffsetDateTime.now(), weight = Random.nextDouble(), notes = "" ) // Act dao.save(newEntry) // Assert val items = dao.all() assertThat(items).hasSize(1) assertThat(items[0].weight).isEqualTo(newEntry.weight) }
  26. @Test fun newSavedItemShouldBeReturnInAllQuery() = runBlocking { // Arrange val dao

    = weightsDatabaseRule.weightItemsDao val newEntry = WeightItem( id = Random.nextInt().toString(), date = OffsetDateTime.now(), weight = Random.nextDouble(), notes = "" ) // Act dao.save(newEntry) // Assert val items = dao.all() assertThat(items).hasSize(1) assertThat(items[0].weight).isEqualTo(newEntry.weight) }
  27. @Test fun newSavedItemShouldBeReturnInAllQuery() = runBlocking { // Arrange val dao

    = weightsDatabaseRule.weightItemsDao val newEntry = WeightItem( id = Random.nextInt().toString(), date = OffsetDateTime.now(), weight = Random.nextDouble(), notes = "" ) // Act dao.save(newEntry) // Assert val items = dao.all() assertThat(items).hasSize(1) assertThat(items[0].weight).isEqualTo(newEntry.weight) }
  28. class WeightsDatabaseRule : TestRule { internal lateinit var weightItemsDao: WeightItemsDao

    private lateinit var db: WeightsDatabase override fun apply(base: Statement?, description: Description?): Statement { return object : Statement() { override fun evaluate() { ... try { base?.evaluate() } finally { ... } ... } } } }
  29. class WeightsDatabaseRule : TestRule { internal lateinit var weightItemsDao: WeightItemsDao

    private lateinit var db: WeightsDatabase override fun apply(base: Statement?, description: Description?): Statement { return object : Statement() { override fun evaluate() { db = Room.inMemoryDatabaseBuilder( InstrumentationRegistry.getInstrumentation().targetContext, WeightsDatabase::class.java ).build() weightItemsDao = db.weightItemsDao() try { base?.evaluate() } finally { db.close() } } } }
  30. Recap / Questions? • Instrumentation Tests • Test a single

    component (Room) • InMemory database • JUnit rule Tip • Skip local tests, focus on instrumentation tests
  31. class LiveWeightsRepository( private val airtableAPI: AirtableAPI, private val weightItemsDao: WeightItemsDao

    ) : WeightsRepository { override suspend fun allItems(): LiveData<Resource<List<WeightItem>>> { return object : NetworkBoundResource<List<WeightItem>, RecordsApiResponse>() { ... }.build().asLiveData() } override suspend fun save(item: NewWeightItem): LiveData<Resource<WeightItem?>> { return object : NetworkBoundResource<WeightItem?, AirtableRecord>() { ... }.build().asLiveData() } }
  32. @RunWith(AndroidJUnit4::class) class LiveWeightsRepositoryTest { @get:Rule var mockWebServer = MockWebServer() @get:Rule

    var weightsDatabase = WeightsDatabaseRule() @get:Rule var instantTaskExecutorRule = InstantTaskExecutorRule() @Test fun givenCleanStateAllShouldCallNetworkAndSaveInDB() { } }
  33. @RunWith(AndroidJUnit4::class) class LiveWeightsRepositoryTest { @get:Rule var mockWebServer = MockWebServer() @get:Rule

    var weightsDatabase = WeightsDatabaseRule() @get:Rule var instantTaskExecutorRule = InstantTaskExecutorRule() @Test fun givenCleanStateAllShouldCallNetworkAndSaveInDB() { } }
  34. @Test fun givenCleanStateAllShouldCallNetworkAndSaveInDB() { // Arrange val body = Assets.read(

    "records_success.json", InstrumentationRegistry.getInstrumentation().context ) mockWebServer.enqueue( MockResponse().setResponseCode(200).setBody(body) ) val client = AirtableAPI.build( mockWebServer.url("").toString(), API_KEY ) val weightItemsDao = weightsDatabase.weightItemsDao var observer: TestObserver<Resource<List<WeightItem>>>? = null val repository = LiveWeightsRepository(client, weightItemsDao) // Act ... // Assert ... }
  35. @Test fun givenCleanStateAllShouldCallNetworkAndSaveInDB() { // Arrange ... var observer: TestObserver<Resource<List<WeightItem>>>?

    = null val repository = LiveWeightsRepository(client, weightItemsDao) // Act runBlocking { observer = repository.allItems().test(2) } // Assert ... }
  36. @Test fun givenCleanStateAllShouldCallNetworkAndSaveInDB() { // Arrange ... // Act runBlocking

    { observer = repository.allItems().test(2) } // Assert assertThat(mockWebServer.takeRequest()).isNotNull() assertThat(mockWebServer.requestCount).isEqualTo(1) observer?.await() observer?.assertValues { assertThat(it[0].status).isEqualTo(Status.LOADING) assertThat(it[0].data).hasSize(0) assertThat(it[1].status).isEqualTo(Status.SUCCESS) assertThat(it[1].data).hasSize(3) } }
  37. class TestObserver<T>(expectedCount: Int) : Observer<T> { private val values =

    mutableListOf<T>() private val latch: CountDownLatch = CountDownLatch(expectedCount) override fun onChanged(value: T) { values.add(value) latch.countDown() } fun assertValues(function: (List<T>) -> Unit) { function.invoke(values) } fun await(timeout: Long = 5, unit: TimeUnit = TimeUnit.SECONDS) { if (!latch.await(timeout, unit)) { throw TimeoutException() } } }
  38. class TestObserver<T>(expectedCount: Int) : Observer<T> { ... } fun <T>

    LiveData<T>.test(expectedCount: Int = 1) = TestObserver<T>(expectedCount).also { observeForever(it) }
  39. @RunWith(AndroidJUnit4::class) class LiveWeightsRepositoryTest { @get:Rule var mockWebServer = MockWebServer() @get:Rule

    var weightsDatabase = WeightsDatabaseRule() @get:Rule var instantTaskExecutorRule = InstantTaskExecutorRule() @Test fun givenCleanStateAllShouldCallNetworkAndSaveInDB() { } }
  40. @RunWith(AndroidJUnit4::class) class LiveWeightsRepositoryTest { @get:Rule var mockWebServer = MockWebServer() @get:Rule

    var weightsDatabase = WeightsDatabaseRule() @get:Rule var instantTaskExecutorRule = InstantTaskExecutorRule() @Test fun givenCleanStateAllShouldCallNetworkAndSaveInDB() { } @Test fun givenSavedItemsAllShouldUpdateDB() { } }
  41. @Test fun givenSavedItemsAllShouldUpdateDB() { // Arrange ... val weightItemsDao =

    weightsDatabase.weightItemsDao runBlocking { weightItemsDao.save( WeightItem( id = "recVzvGP6aXci0Lly", date = OffsetDateTime.parse("2019-06-12T21:00:00.000Z"), weight = 172.42, notes = "" ) ) } // Act ... // Assert ... }
  42. @Test fun givenSavedItemsAllShouldUpdateDB() { // Arrange ... // Act ...

    // Assert assertThat(mockWebServer.takeRequest()).isNotNull() assertThat(mockWebServer.requestCount).isEqualTo(1) observer?.await() observer?.assertValues { assertThat(it[0].status).isEqualTo(Status.LOADING) assertThat(it[0].data).hasSize(1) assertThat(it[1].status).isEqualTo(Status.SUCCESS) assertThat(it[1].data).hasSize(3) } }
  43. @RunWith(AndroidJUnit4::class) class LiveWeightsRepositoryTest { ... @Test fun givenCleanStateAllShouldCallNetworkAndSaveInDB() { }

    @Test fun givenSavedItemsAllShouldUpdateDB() { } @Test fun saveShouldPerformNetworkAndSaveInDB() { } }
  44. @Test fun saveShouldPerformNetworkAndSaveInDB() { // Arrange ... val repository =

    LiveWeightsRepository(client, weightItemsDao) // Act runBlocking { observer = repository .save(NewWeightItem(OffsetDateTime.now(), 10.0, null)) .test(2) } // Assert ... runBlocking { assertThat(historyDataItemDao.all()).hasSize(1) } }
  45. abstract class HistoryViewModel : ViewModel() { abstract val state: LiveData<ViewState>

    abstract fun refresh() sealed class ViewState { object Loading : ViewState() data class Error(val error: Throwable? = null) : ViewState() data class Success(val list: List<WeightItem>) : ViewState() } }
  46. class LiveHistoryViewModel(private val repository: WeightsRepository) : HistoryViewModel() { private val

    refreshAction: MutableLiveData<Unit> = MutableLiveData() override val state: LiveData<ViewState> = refreshAction.switchMap { ... } override fun refresh() { refreshAction.postValue(Unit) } }
  47. class LiveHistoryViewModel(private val repository: WeightsRepository) : HistoryViewModel() { private val

    refreshAction: MutableLiveData<Unit> = MutableLiveData() override val state: LiveData<ViewState> = refreshAction.switchMap { liveData<ViewState>( context = viewModelScope.coroutineContext + Dispatchers.IO ) { emitSource(repository.allItems().map { when (it.status) { Status.LOADING -> ViewState.Loading Status.SUCCESS -> ViewState.Success(it.data!!) Status.ERROR -> ViewState.Error() } }) } } override fun refresh() { ... } }
  48. @RunWith(AndroidJUnit4::class) class HistoryViewModelTest { @get:Rule var instantTaskExecutorRule = InstantTaskExecutorRule() @get:Rule

    var mockWebServer = MockWebServer() @get:Rule var weightsDatabase = WeightsDatabaseRule() @Test fun refreshShouldFetchFromServer() { ... } @Test fun givenServerErrorShouldReturnError() { ... } }
  49. @Test fun refreshShouldFetchFromServer() { // Arrange val body = Assets.read(

    "records_success.json", InstrumentationRegistry.getInstrumentation().context ) mockWebServer.enqueue(MockResponse().setResponseCode(200).setBody(body)) val client = AirtableAPI.build(mockWebServer.url("").toString(), API_KEY) val repository = LiveWeightsRepository(client, weightsDatabase.weightItemsDao) val viewModel = LiveHistoryViewModel(repository) // Act ... // Assert ... }
  50. @Test fun refreshShouldFetchFromServer() { // Arrange ... val viewModel =

    LiveHistoryViewModel(repository) // Act val observer = viewModel.state.test(expectedCount = 2) viewModel.refresh() observer.await() // Assert ... }
  51. @Test fun refreshShouldFetchFromServer() { ... // Assert assertThat(mockWebServer.requestCount).isEqualTo(1) observer.assertValues {

    assertThat(it).hasSize(2) assertThat(it[0]).isSameInstanceAs(HistoryViewModel.ViewState.Loading) val items = it[1] as? HistoryViewModel.ViewState.Success assertThat(items?.list).hasSize(3) assertThat(items?.list?.get(0)?.id).isEqualTo("recx6FjYsKNY5R5sF") assertThat(items?.list?.get(0)?.date).isEqualTo( OffsetDateTime.parse( "2019-06-03T21:03:00.000Z", DateTimeFormatter.ISO_OFFSET_DATE_TIME ) ) } }
  52. @Test fun givenServerErrorShouldReturnError() { // Arrange mockWebServer.enqueue(MockResponse().setResponseCode(400)) val client =

    AirtableAPI.build(mockWebServer.url("").toString(), API_KEY) val repository = LiveWeightsRepository(client, weightsDatabase.weightItemsDao) val viewModel = LiveHistoryViewModel(repository) // Act val observer = viewModel.state.test(expectedCount = 2) viewModel.refresh() // Assert observer.await() assertThat(mockWebServer.requestCount).isEqualTo(1) observer.assertValues { assertThat(it).hasSize(2) assertThat(it[0]).isSameInstanceAs(HistoryViewModel.ViewState.Loading) val error = it[1] as? HistoryViewModel.ViewState.Error assertThat(error).isNotNull() } }
  53. Recap / Questions? • Integration Tests • Test couple of

    components together • TestObserver & InstantTaskExecutorRule • Modern test pyramid Tips • Integration tests increase confidence • Unit tests are disposable
  54. class HistoryActivity : AppCompatActivity() { private var linearLayoutManager: LinearLayoutManager? =

    null private var adapter: HistoryRecyclerAdapter? = null private val viewModel by viewModel<HistoryViewModel>() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ... } override fun onStart() { super.onStart() viewModel.refresh() } }
  55. class HistoryActivity : AppCompatActivity() { private var linearLayoutManager: LinearLayoutManager? =

    null private var adapter: HistoryRecyclerAdapter? = null private val viewModel by viewModel<HistoryViewModel>() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ... } override fun onStart() { super.onStart() viewModel.refresh() } }
  56. class HistoryActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) {

    super.onCreate(savedInstanceState) ... viewModel.state.observe(this, Observer { state -> when (state) { is HistoryViewModel.ViewState.Error -> { } is HistoryViewModel.ViewState.Loading -> { progressBar.visibility = View.VISIBLE } is HistoryViewModel.ViewState.Success -> { progressBar.visibility = View.GONE adapter?.submitList(state.list) } } }) } }
  57. @RunWith(AndroidJUnit4::class) class HistoryActivityTest { @get:Rule val activityRule = ActivityTestRule( HistoryActivity::class.java,

    false, false ) @get:Rule val animationsRule = DisableAnimationsRule() @get:Rule val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(WRITE_EXTERNAL_STORAGE) @get:Rule val screenshotRule = ScreenshotRule() private var viewModel: MockHistoryViewModel = MockHistoryViewModel() @Before fun before() { ... } }
  58. @RunWith(AndroidJUnit4::class) class HistoryActivityTest { ... private var viewModel: MockHistoryViewModel =

    MockHistoryViewModel() @Before fun before() { viewModel = MockHistoryViewModel() loadKoinModules(module { viewModel<HistoryViewModel> { viewModel } }) } @Test fun successStateShouldDisplayListOfItems() { ... } ... }
  59. class MockHistoryViewModel : HistoryViewModel() { private val internalState: MutableLiveData<ViewState> =

    MutableLiveData() override val state: LiveData<ViewState> = internalState override fun refresh() = Unit fun postViewState(viewState: ViewState) { internalState.postValue(viewState) } }
  60. @Test fun successStateShouldDisplayListOfItems() { // Arrange activityRule.launchActivity(Intent()) // Act viewModel.postViewState(createSuccessState())

    // Assert InstrumentationRegistry.getInstrumentation().waitForIdleSync() onView(withId(R.id.recycler_history_items)).perform( RecyclerHelpers.waitUntil( RecyclerHelpers.hasItemCount(greaterThan(0)) ) ) assertRecyclerViewItemCount(R.id.recycler_history_items, 5) screenshotRule.takeScreenshot() }
  61. private fun createSuccessState(): HistoryViewModel.ViewState.Success { val items: List<WeightItem> = listOf(

    WeightItem( ... ), WeightItem( ... ), WeightItem( ... ), WeightItem( ... ), WeightItem( ... ) ) return HistoryViewModel.ViewState.Success(items) }
  62. Recap / Questions? • Unit test for the UI •

    Mock the ViewModel • Screenshots Tip • Helps verifying different UI states - loading, error, empty, etc.
  63. End to End tests Automated the user experience • Multiple

    screens • Minimal to none mocks / stubs / etc. • Real server* • FIRST principals
  64. @RunWith(AndroidJUnit4::class) class AddWeightE2ETest { @Before fun before() = runBlocking {

    ... } @Test fun addNewWeight() { val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) ApplicationRobot.launchApp() val historyRobot = HistoryRobot(uiDevice) historyRobot.assertPageDisplayed() historyRobot.assertNumberOfItemsInList(5) historyRobot.navigateToAddWeight() val addWeightRobot = AddWeightRobot(uiDevice) addWeightRobot.assertPageDisplayed()
  65. @Test fun addNewWeight() { val uiDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) ApplicationRobot.launchApp() val

    historyRobot = HistoryRobot(uiDevice) historyRobot.assertPageDisplayed() historyRobot.assertNumberOfItemsInList(5) historyRobot.navigateToAddWeight() val addWeightRobot = AddWeightRobot(uiDevice) addWeightRobot.assertPageDisplayed() addWeightRobot.typeWeight("12.34") addWeightRobot.save() historyRobot.assertPageDisplayed() historyRobot.assertNumberOfItemsInList(6) } }
  66. class HistoryRobot(private val uiDevice: UiDevice) { fun assertPageDisplayed() { assertDisplayed(R.string.app_name)

    } fun assertNumberOfItemsInList(expected: Int) { uiDevice.findObject(UiSelector().textContains("Test Node 1")) .waitForExists(500) onView(withContentDescription(R.string.content_description_history_list)) .check(RecyclerViewItemCountAssertion(expected)) } fun navigateToAddWeight() { onView(withContentDescription(R.string.content_description_add_weight)) .perform(click()) } }
  67. class AddWeightRobot(private val uiDevice: UiDevice) { fun assertPageDisplayed() { uiDevice.findObject(UiSelector().textContains("SAVE"))

    .waitForExists(5000) assertDisplayed("SAVE") } fun typeWeight(weight: String) { onView(withContentDescription(R.string.content_description_weight)) .perform(typeText(weight)) closeSoftKeyboard() } fun save() { onView(withContentDescription(R.string.content_description_save_button)) .perform(click()) } }
  68. @Before fun before() = runBlocking { val airtable = AirtableBatchAPI.build(BuildConfig.AIRTABLE_E2E_URL,

    BuildConfig.AIRTABLE_E2E_KEY) val records = airtable.records() assertThat(records.isSuccessful).isTrue() records.body()?.records?.map { it.id }?.let { if (it.isNotEmpty()) { assertThat(airtable.delete(it).isSuccessful).isTrue() } } val body = Assets.read( "create_records_e2e_body.json", InstrumentationRegistry.getInstrumentation().context ) val response = airtable.create( Gson().fromJson(body, JsonObject::class.java) ) assertThat(response.isSuccessful).isTrue() }
  69. interface AirtableBatchAPI { @GET("./") suspend fun records(): Response<RecordsApiResponse> @POST("./") suspend

    fun create(@Body body: JsonObject): Response<Unit> @DELETE("./") suspend fun delete(@Query("records[]") records: List<String>): Response<Unit> companion object { fun build(baseUrl: String, apiKey: String): AirtableBatchAPI { ... } } }
  70. Recap / Questions? • End to End tests simulate the

    user • Separated environment • Server state Tip • Existing app without tests? start with E2E tests.
  71. Thank You! • https://martinfowler.com/articles/practical-test-pyramid.html • https://kentcdodds.com/blog/write-tests • https://dhh.dk/2014/tdd-is-dead-long-live-testing.html • https://medium.com/@copyconstruct/testing-in-production-the-safe-

    way-18ca102d0ef1 • https://twitter.com/thepracticaldev/status/733908215021199360 • https://github.com/goldbergyoni/javascript-testing-best-practices https://github.com/roisagiv https://www.linkedin.com/in/roisagiv/