SlideShare a Scribd company logo
1 of 109
Download to read offline
Android Automated Testing
Deļ¬nition
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
App
adb install app
App
Instrumentation
Tests
Local Tests
adb install app
adb install tests
About Me
https://github.com/roisagiv
https://www.linkedin.com/in/roisagiv/
About You
ā€¢ Experienced in developing Android apps
ā€¢ Familiar with:
ā€¢ LiveData
ā€¢ ViewModel
ā€¢ Coroutines
ā€¢ Room
ā€¢ Heard about / Some experience with testing
About You
Agenda
ā€¢ Android testing tools landscape
ā€¢ Beneļ¬ts and challenges of automated testing
ā€¢ Tips & Tricks
Android Testing Landscape
Android Testing Landscape
Two types of runtimes:
ā€¢ "Unit Tests" - local machine
ā€¢ Instrumentation - Device / Emulator
Powered by JUnit
The build process
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.
Local Tests
app
src
androidTest
main
test
Example
My Weight Tracker
API
https://github.com/roisagiv/WeightTracker-Android
Architecture
Activity / Fragment
Architecture
Activity / Fragment
ViewModel
Architecture
Activity / Fragment
ViewModel
Repository
Architecture
Activity / Fragment
ViewModel
Repository
Network (Retroļ¬t)
Architecture
Activity / Fragment
ViewModel
Repository
Network (Retroļ¬t)Local (Room)
Architecture
Activity / Fragment
ViewModel
Repository
Network (Retroļ¬t)Local (Room)
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>)
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>)
class AirtableAPITest {
@get:Rule
var mockWebServer = MockWebServer()
@Test
fun recordsShouldReturnListOfWeightRecords() = runBlocking {
// Arrange
...
// Act
...
// Assert
...
}
}
class AirtableAPITest {
@get:Rule
var mockWebServer = MockWebServer()
@Test
fun recordsShouldReturnListOfWeightRecords() = runBlocking {
// Arrange
...
// Act
...
// Assert
...
}
}
class AirtableAPITest {
@get:Rule
var mockWebServer = MockWebServer()
@Test
fun recordsShouldReturnListOfWeightRecords() = runBlocking {
// Arrange
...
// Act
...
// Assert
...
}
}
class AirtableAPITest {
@get:Rule
var mockWebServer = MockWebServer()
@Test
fun recordsShouldReturnListOfWeightRecords() = runBlocking {
// Arrange
...
// Act
...
// Assert
...
}
}
@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
...
}
@Test
fun recordsShouldReturnListOfWeightRecords() = runBlocking {
// Arrange
...
// Act
val clientResponse = client.records()
// Assert
...
}
@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
}
Tada!
MockWebServer
class AirtableAPITest {
@get:Rule
var mockWebServer = MockWebServer()
@Test
fun recordsShouldReturnListOfWeightRecords() = runBlocking {
...
}
}
testImplementation "com.squareup.okhttp3:mockwebserver:$rootProject.okhttpVersion"
@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")
...
}
@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")
...
}
{
"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": "Aenean.euismod.mauris@rutrum.net",
"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
@Test
fun recordsShouldReturnFailureResponseInCaseOfServerError() = runBlocking {
// Arrange
mockWebServer.enqueue(MockResponse().setResponseCode(500))
...
// Act
...
// Assert
...
}
FIRST - Principles of Unit Tests
ā€¢ Fast
ā€¢ Isolated
ā€¢ Repeatable
ā€¢ Self-Validating
ā€¢ Thorough / Timely
Recap / Questions?
ā€¢ "Unit Tests" / Local Tests
ā€¢ Test a single class (AirtableAPI)
ā€¢ The structure of a test (AAA)
ā€¢ FIRST principals
ā€¢ MockWebServer
Architecture
Activity / Fragment
ViewModel
Repository
Network (Retroļ¬t)Local (Room)
@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>
}
@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>
}
@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>
}
Instrumentation Tests
Easy(ish) - Access to android SDK / components
ā€¢ Context, SharedPreferences, etc.
ā€¢ UI & Resources
Slow(er) - Android toolchain
ā€¢ DEX
ā€¢ APK install every run
Instrumentation Tests
app
src
androidTest
main
test
@RunWith(AndroidJUnit4::class)
class WeightsDatabaseTest {
@get:Rule
var weightsDatabaseRule = WeightsDatabaseRule()
@Test
fun newSavedItemShouldBeReturnInAllQuery() = runBlocking {
// Arrange
...
// Act
...
// Assert
...
}
}
@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)
}
@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)
}
@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)
}
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 {
...
}
...
}
}
}
}
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()
}
}
}
}
Recap / Questions?
ā€¢ Instrumentation Tests
ā€¢ Test a single component (Room)
ā€¢ InMemory database
ā€¢ JUnit rule
Tip
ā€¢ Skip local tests, focus on instrumentation tests
The Test Pyramid
Architecture
Activity / Fragment
ViewModel
Repository
Network (Retroļ¬t)Local (Room)
interface WeightsRepository {
suspend fun allItems(): LiveData<Resource<List<WeightItem>>>
suspend fun save(item: NewWeightItem): LiveData<Resource<WeightItem?>>
}
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()
}
}
@RunWith(AndroidJUnit4::class)
class LiveWeightsRepositoryTest {
@get:Rule
var mockWebServer = MockWebServer()
@get:Rule
var weightsDatabase = WeightsDatabaseRule()
@get:Rule
var instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun givenCleanStateAllShouldCallNetworkAndSaveInDB() {
}
}
@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 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
...
}
@Test
fun givenCleanStateAllShouldCallNetworkAndSaveInDB() {
// Arrange
...
var observer: TestObserver<Resource<List<WeightItem>>>? = null
val repository = LiveWeightsRepository(client, weightItemsDao)
// Act
runBlocking {
observer = repository.allItems().test(2)
}
// Assert
...
}
@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)
}
}
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()
}
}
}
class TestObserver<T>(expectedCount: Int) : Observer<T> {
...
}
fun <T> LiveData<T>.test(expectedCount: Int = 1) =
TestObserver<T>(expectedCount).also {
observeForever(it)
}
@RunWith(AndroidJUnit4::class)
class LiveWeightsRepositoryTest {
@get:Rule
var mockWebServer = MockWebServer()
@get:Rule
var weightsDatabase = WeightsDatabaseRule()
@get:Rule
var instantTaskExecutorRule = InstantTaskExecutorRule()
@Test
fun givenCleanStateAllShouldCallNetworkAndSaveInDB() {
}
}
@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() {
}
}
@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
...
}
@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)
}
}
@RunWith(AndroidJUnit4::class)
class LiveWeightsRepositoryTest {
...
@Test
fun givenCleanStateAllShouldCallNetworkAndSaveInDB() {
}
@Test
fun givenSavedItemsAllShouldUpdateDB() {
}
@Test
fun saveShouldPerformNetworkAndSaveInDB() {
}
}
@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)
}
}
Architecture
Activity / Fragment
ViewModel
Repository
Network (Retroļ¬t)Local (Room)
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()
}
}
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)
}
}
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() { ... }
}
@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() { ... }
}
@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
...
}
@Test
fun refreshShouldFetchFromServer() {
// Arrange
...
val viewModel = LiveHistoryViewModel(repository)
// Act
val observer = viewModel.state.test(expectedCount = 2)
viewModel.refresh()
observer.await()
// Assert
...
}
@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
)
)
}
}
@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()
}
}
The Test Pyramid
The Test Pyramid
The Test Pyramid
The Modern Test Pyramid (Trophy)
Recap / Questions?
ā€¢ Integration Tests
ā€¢ Test couple of components together
ā€¢ TestObserver & InstantTaskExecutorRule
ā€¢ Modern test pyramid
Tips
ā€¢ Integration tests increase conļ¬dence
ā€¢ Unit tests are disposable
Architecture
Activity / Fragment
ViewModel
Repository
Network (Retroļ¬t)Local (Room)
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()
}
}
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()
}
}
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)
}
}
})
}
}
@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() { ... }
}
@RunWith(AndroidJUnit4::class)
class HistoryActivityTest {
...
private var viewModel: MockHistoryViewModel = MockHistoryViewModel()
@Before
fun before() {
viewModel = MockHistoryViewModel()
loadKoinModules(module {
viewModel<HistoryViewModel> { viewModel }
})
}
@Test
fun successStateShouldDisplayListOfItems() { ... }
...
}
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)
}
}
@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()
}
private fun createSuccessState(): HistoryViewModel.ViewState.Success {
val items: List<WeightItem> = listOf(
WeightItem( ... ),
WeightItem( ... ),
WeightItem( ... ),
WeightItem( ... ),
WeightItem( ... )
)
return HistoryViewModel.ViewState.Success(items)
}
api27-1080x1920x2.625-en/HistoryActivityTest-successStateShouldDisplayListOfItems.png
@Test
fun loadingStateShouldDisplayProgressBar() {
// Arrange
activityRule.launchActivity(Intent())
// Act
viewModel.postViewState(HistoryViewModel.ViewState.Loading)
// Assert
assertDisplayed(R.id.progressBar)
screenshotRule.takeScreenshot()
}
api27-1080x1920x2.625-en/HistoryActivityTest-loadingStateShouldDisplayProgressBar.png
Recap / Questions?
ā€¢ Unit test for the UI
ā€¢ Mock the ViewModel
ā€¢ Screenshots
Tip
ā€¢ Helps verifying different UI states - loading, error,
empty, etc.
End to End tests
Automated the user experience
ā€¢ Multiple screens
ā€¢ Minimal to none mocks / stubs / etc.
ā€¢ Real server*
ā€¢ FIRST principals
My Weight Tracker
My Weight Tracker
Production
E2E
@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()
@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)
}
}
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())
}
}
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())
}
}
@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()
}
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 {
...
}
}
}
Recap / Questions?
ā€¢ End to End tests simulate the user
ā€¢ Separated environment
ā€¢ Server state
Tip
ā€¢ Existing app without tests? start with E2E tests.
The Test Pyramid
The Test Pyramid
Development
Quality
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/roisagivhttps://www.linkedin.com/in/roisagiv/

More Related Content

What's hot

Using Reflections and Automatic Code Generation
Using Reflections and Automatic Code GenerationUsing Reflections and Automatic Code Generation
Using Reflections and Automatic Code GenerationIvan Dolgushin
Ā 
Second Level Cache in JPA Explained
Second Level Cache in JPA ExplainedSecond Level Cache in JPA Explained
Second Level Cache in JPA ExplainedPatrycja Wegrzynowicz
Ā 
Advanced Object-Oriented JavaScript
Advanced Object-Oriented JavaScriptAdvanced Object-Oriented JavaScript
Advanced Object-Oriented JavaScriptecker
Ā 
SilverStripe CMS JavaScript Refactoring
SilverStripe CMS JavaScript RefactoringSilverStripe CMS JavaScript Refactoring
SilverStripe CMS JavaScript RefactoringIngo Schommer
Ā 
Google Guava - Core libraries for Java & Android
Google Guava - Core libraries for Java & AndroidGoogle Guava - Core libraries for Java & Android
Google Guava - Core libraries for Java & AndroidJordi Gerona
Ā 
Scala @ TechMeetup Edinburgh
Scala @ TechMeetup EdinburghScala @ TechMeetup Edinburgh
Scala @ TechMeetup EdinburghStuart Roebuck
Ā 
Mastering java bytecode with ASM - GeeCON 2012
Mastering java bytecode with ASM - GeeCON 2012Mastering java bytecode with ASM - GeeCON 2012
Mastering java bytecode with ASM - GeeCON 2012Anton Arhipov
Ā 
GeeCON 2017 - TestContainers. Integration testing without the hassle
GeeCON 2017 - TestContainers. Integration testing without the hassleGeeCON 2017 - TestContainers. Integration testing without the hassle
GeeCON 2017 - TestContainers. Integration testing without the hassleAnton Arhipov
Ā 
Intro to Reactive Thinking and RxJava 2
Intro to Reactive Thinking and RxJava 2Intro to Reactive Thinking and RxJava 2
Intro to Reactive Thinking and RxJava 2JollyRogers5
Ā 
Scala coated JVM
Scala coated JVMScala coated JVM
Scala coated JVMStuart Roebuck
Ā 
Automatic Reference Counting @ Pragma Night
Automatic Reference Counting @ Pragma NightAutomatic Reference Counting @ Pragma Night
Automatic Reference Counting @ Pragma NightGiuseppe Arici
Ā 
Scala ActiveRecord
Scala ActiveRecordScala ActiveRecord
Scala ActiveRecordscalaconfjp
Ā 
[QE 2018] Łukasz Gawron ā€“ Testing Batch and Streaming Spark Applications
[QE 2018] Łukasz Gawron ā€“ Testing Batch and Streaming Spark Applications[QE 2018] Łukasz Gawron ā€“ Testing Batch and Streaming Spark Applications
[QE 2018] Łukasz Gawron ā€“ Testing Batch and Streaming Spark ApplicationsFuture Processing
Ā 
Javascript Everywhere
Javascript EverywhereJavascript Everywhere
Javascript EverywherePascal Rettig
Ā 
Java7 New Features and Code Examples
Java7 New Features and Code ExamplesJava7 New Features and Code Examples
Java7 New Features and Code ExamplesNaresh Chintalcheru
Ā 
Djangoā€™s nasal passage
Djangoā€™s nasal passageDjangoā€™s nasal passage
Djangoā€™s nasal passageErik Rose
Ā 
Backbone.js: Run your Application Inside The Browser
Backbone.js: Run your Application Inside The BrowserBackbone.js: Run your Application Inside The Browser
Backbone.js: Run your Application Inside The BrowserHoward Lewis Ship
Ā 

What's hot (20)

Using Reflections and Automatic Code Generation
Using Reflections and Automatic Code GenerationUsing Reflections and Automatic Code Generation
Using Reflections and Automatic Code Generation
Ā 
Java 7 New Features
Java 7 New FeaturesJava 7 New Features
Java 7 New Features
Ā 
Second Level Cache in JPA Explained
Second Level Cache in JPA ExplainedSecond Level Cache in JPA Explained
Second Level Cache in JPA Explained
Ā 
Advanced Object-Oriented JavaScript
Advanced Object-Oriented JavaScriptAdvanced Object-Oriented JavaScript
Advanced Object-Oriented JavaScript
Ā 
SilverStripe CMS JavaScript Refactoring
SilverStripe CMS JavaScript RefactoringSilverStripe CMS JavaScript Refactoring
SilverStripe CMS JavaScript Refactoring
Ā 
Google Guava - Core libraries for Java & Android
Google Guava - Core libraries for Java & AndroidGoogle Guava - Core libraries for Java & Android
Google Guava - Core libraries for Java & Android
Ā 
Scala @ TechMeetup Edinburgh
Scala @ TechMeetup EdinburghScala @ TechMeetup Edinburgh
Scala @ TechMeetup Edinburgh
Ā 
Mastering java bytecode with ASM - GeeCON 2012
Mastering java bytecode with ASM - GeeCON 2012Mastering java bytecode with ASM - GeeCON 2012
Mastering java bytecode with ASM - GeeCON 2012
Ā 
Thinking Beyond ORM in JPA
Thinking Beyond ORM in JPAThinking Beyond ORM in JPA
Thinking Beyond ORM in JPA
Ā 
Thinking Beyond ORM in JPA
Thinking Beyond ORM in JPAThinking Beyond ORM in JPA
Thinking Beyond ORM in JPA
Ā 
GeeCON 2017 - TestContainers. Integration testing without the hassle
GeeCON 2017 - TestContainers. Integration testing without the hassleGeeCON 2017 - TestContainers. Integration testing without the hassle
GeeCON 2017 - TestContainers. Integration testing without the hassle
Ā 
Intro to Reactive Thinking and RxJava 2
Intro to Reactive Thinking and RxJava 2Intro to Reactive Thinking and RxJava 2
Intro to Reactive Thinking and RxJava 2
Ā 
Scala coated JVM
Scala coated JVMScala coated JVM
Scala coated JVM
Ā 
Automatic Reference Counting @ Pragma Night
Automatic Reference Counting @ Pragma NightAutomatic Reference Counting @ Pragma Night
Automatic Reference Counting @ Pragma Night
Ā 
Scala ActiveRecord
Scala ActiveRecordScala ActiveRecord
Scala ActiveRecord
Ā 
[QE 2018] Łukasz Gawron ā€“ Testing Batch and Streaming Spark Applications
[QE 2018] Łukasz Gawron ā€“ Testing Batch and Streaming Spark Applications[QE 2018] Łukasz Gawron ā€“ Testing Batch and Streaming Spark Applications
[QE 2018] Łukasz Gawron ā€“ Testing Batch and Streaming Spark Applications
Ā 
Javascript Everywhere
Javascript EverywhereJavascript Everywhere
Javascript Everywhere
Ā 
Java7 New Features and Code Examples
Java7 New Features and Code ExamplesJava7 New Features and Code Examples
Java7 New Features and Code Examples
Ā 
Djangoā€™s nasal passage
Djangoā€™s nasal passageDjangoā€™s nasal passage
Djangoā€™s nasal passage
Ā 
Backbone.js: Run your Application Inside The Browser
Backbone.js: Run your Application Inside The BrowserBackbone.js: Run your Application Inside The Browser
Backbone.js: Run your Application Inside The Browser
Ā 

Similar to Android Automated Testing

Getting to Grips with SilverStripe Testing
Getting to Grips with SilverStripe TestingGetting to Grips with SilverStripe Testing
Getting to Grips with SilverStripe TestingMark Rickerby
Ā 
ęƔXMLę›“å„½ē”Øēš„Java Annotation
ęƔXMLę›“å„½ē”Øēš„Java AnnotationęƔXMLę›“å„½ē”Øēš„Java Annotation
ęƔXMLę›“å„½ē”Øēš„Java Annotationjavatwo2011
Ā 
Spring into rails
Spring into railsSpring into rails
Spring into railsHiro Asari
Ā 
Spring data requery
Spring data requerySpring data requery
Spring data requerySunghyouk Bae
Ā 
More on Fitnesse and Continuous Integration (Silicon Valley code camp 2012)
More on Fitnesse and Continuous Integration (Silicon Valley code camp 2012)More on Fitnesse and Continuous Integration (Silicon Valley code camp 2012)
More on Fitnesse and Continuous Integration (Silicon Valley code camp 2012)Jen Wong
Ā 
Revolution or Evolution in Page Object
Revolution or Evolution in Page ObjectRevolution or Evolution in Page Object
Revolution or Evolution in Page ObjectArtem Sokovets
Ā 
Getting the most out of Java [Nordic Coding-2010]
Getting the most out of Java [Nordic Coding-2010]Getting the most out of Java [Nordic Coding-2010]
Getting the most out of Java [Nordic Coding-2010]Sven Efftinge
Ā 
Event-driven IO server-side JavaScript environment based on V8 Engine
Event-driven IO server-side JavaScript environment based on V8 EngineEvent-driven IO server-side JavaScript environment based on V8 Engine
Event-driven IO server-side JavaScript environment based on V8 EngineRicardo Silva
Ā 
Lambda Chops - Recipes for Simpler, More Expressive Code
Lambda Chops - Recipes for Simpler, More Expressive CodeLambda Chops - Recipes for Simpler, More Expressive Code
Lambda Chops - Recipes for Simpler, More Expressive CodeIan Robertson
Ā 
Belfast JUG 23-10-2013
Belfast JUG 23-10-2013Belfast JUG 23-10-2013
Belfast JUG 23-10-2013eamonnlong
Ā 
Š¢Š°Ń€Š°Ń ŠžŠ»ŠµŠŗсŠøŠ½ - Sculpt! Your! Tests!
Š¢Š°Ń€Š°Ń ŠžŠ»ŠµŠŗсŠøŠ½  - Sculpt! Your! Tests!Š¢Š°Ń€Š°Ń ŠžŠ»ŠµŠŗсŠøŠ½  - Sculpt! Your! Tests!
Š¢Š°Ń€Š°Ń ŠžŠ»ŠµŠŗсŠøŠ½ - Sculpt! Your! Tests!DataArt
Ā 
Testing basics for developers
Testing basics for developersTesting basics for developers
Testing basics for developersAnton Udovychenko
Ā 
Pragmatic unittestingwithj unit
Pragmatic unittestingwithj unitPragmatic unittestingwithj unit
Pragmatic unittestingwithj unitliminescence
Ā 
Intro to Testing in Zope, Plone
Intro to Testing in Zope, PloneIntro to Testing in Zope, Plone
Intro to Testing in Zope, PloneQuintagroup
Ā 
Refactoring In Tdd The Missing Part
Refactoring In Tdd The Missing PartRefactoring In Tdd The Missing Part
Refactoring In Tdd The Missing PartGabriele Lana
Ā 

Similar to Android Automated Testing (20)

JUnit 5
JUnit 5JUnit 5
JUnit 5
Ā 
Getting to Grips with SilverStripe Testing
Getting to Grips with SilverStripe TestingGetting to Grips with SilverStripe Testing
Getting to Grips with SilverStripe Testing
Ā 
ęƔXMLę›“å„½ē”Øēš„Java Annotation
ęƔXMLę›“å„½ē”Øēš„Java AnnotationęƔXMLę›“å„½ē”Øēš„Java Annotation
ęƔXMLę›“å„½ē”Øēš„Java Annotation
Ā 
Spring into rails
Spring into railsSpring into rails
Spring into rails
Ā 
Json generation
Json generationJson generation
Json generation
Ā 
Play vs Rails
Play vs RailsPlay vs Rails
Play vs Rails
Ā 
Spring data requery
Spring data requerySpring data requery
Spring data requery
Ā 
More on Fitnesse and Continuous Integration (Silicon Valley code camp 2012)
More on Fitnesse and Continuous Integration (Silicon Valley code camp 2012)More on Fitnesse and Continuous Integration (Silicon Valley code camp 2012)
More on Fitnesse and Continuous Integration (Silicon Valley code camp 2012)
Ā 
Revolution or Evolution in Page Object
Revolution or Evolution in Page ObjectRevolution or Evolution in Page Object
Revolution or Evolution in Page Object
Ā 
Getting the most out of Java [Nordic Coding-2010]
Getting the most out of Java [Nordic Coding-2010]Getting the most out of Java [Nordic Coding-2010]
Getting the most out of Java [Nordic Coding-2010]
Ā 
Event-driven IO server-side JavaScript environment based on V8 Engine
Event-driven IO server-side JavaScript environment based on V8 EngineEvent-driven IO server-side JavaScript environment based on V8 Engine
Event-driven IO server-side JavaScript environment based on V8 Engine
Ā 
Lambda Chops - Recipes for Simpler, More Expressive Code
Lambda Chops - Recipes for Simpler, More Expressive CodeLambda Chops - Recipes for Simpler, More Expressive Code
Lambda Chops - Recipes for Simpler, More Expressive Code
Ā 
Belfast JUG 23-10-2013
Belfast JUG 23-10-2013Belfast JUG 23-10-2013
Belfast JUG 23-10-2013
Ā 
JDK Power Tools
JDK Power ToolsJDK Power Tools
JDK Power Tools
Ā 
Š¢Š°Ń€Š°Ń ŠžŠ»ŠµŠŗсŠøŠ½ - Sculpt! Your! Tests!
Š¢Š°Ń€Š°Ń ŠžŠ»ŠµŠŗсŠøŠ½  - Sculpt! Your! Tests!Š¢Š°Ń€Š°Ń ŠžŠ»ŠµŠŗсŠøŠ½  - Sculpt! Your! Tests!
Š¢Š°Ń€Š°Ń ŠžŠ»ŠµŠŗсŠøŠ½ - Sculpt! Your! Tests!
Ā 
Testing basics for developers
Testing basics for developersTesting basics for developers
Testing basics for developers
Ā 
Pragmatic unittestingwithj unit
Pragmatic unittestingwithj unitPragmatic unittestingwithj unit
Pragmatic unittestingwithj unit
Ā 
Intro to Testing in Zope, Plone
Intro to Testing in Zope, PloneIntro to Testing in Zope, Plone
Intro to Testing in Zope, Plone
Ā 
Refactoring In Tdd The Missing Part
Refactoring In Tdd The Missing PartRefactoring In Tdd The Missing Part
Refactoring In Tdd The Missing Part
Ā 
My java file
My java fileMy java file
My java file
Ā 

Recently uploaded

Crypto Cloud Review - How To Earn Up To $500 Per DAY Of Bitcoin 100% On AutoP...
Crypto Cloud Review - How To Earn Up To $500 Per DAY Of Bitcoin 100% On AutoP...Crypto Cloud Review - How To Earn Up To $500 Per DAY Of Bitcoin 100% On AutoP...
Crypto Cloud Review - How To Earn Up To $500 Per DAY Of Bitcoin 100% On AutoP...SelfMade bd
Ā 
%in Soweto+277-882-255-28 abortion pills for sale in soweto
%in Soweto+277-882-255-28 abortion pills for sale in soweto%in Soweto+277-882-255-28 abortion pills for sale in soweto
%in Soweto+277-882-255-28 abortion pills for sale in sowetomasabamasaba
Ā 
WSO2CON 2024 - IoT Needs CIAM: The Importance of Centralized IAM in a Growing...
WSO2CON 2024 - IoT Needs CIAM: The Importance of Centralized IAM in a Growing...WSO2CON 2024 - IoT Needs CIAM: The Importance of Centralized IAM in a Growing...
WSO2CON 2024 - IoT Needs CIAM: The Importance of Centralized IAM in a Growing...WSO2
Ā 
OpenChain - The Ramifications of ISO/IEC 5230 and ISO/IEC 18974 for Legal Pro...
OpenChain - The Ramifications of ISO/IEC 5230 and ISO/IEC 18974 for Legal Pro...OpenChain - The Ramifications of ISO/IEC 5230 and ISO/IEC 18974 for Legal Pro...
OpenChain - The Ramifications of ISO/IEC 5230 and ISO/IEC 18974 for Legal Pro...Shane Coughlan
Ā 
AzureNativeQumulo_HPC_Cloud_Native_Benchmarks.pdf
AzureNativeQumulo_HPC_Cloud_Native_Benchmarks.pdfAzureNativeQumulo_HPC_Cloud_Native_Benchmarks.pdf
AzureNativeQumulo_HPC_Cloud_Native_Benchmarks.pdfryanfarris8
Ā 
WSO2CON 2024 - How CSI Piemonte Is Apifying the Public Administration
WSO2CON 2024 - How CSI Piemonte Is Apifying the Public AdministrationWSO2CON 2024 - How CSI Piemonte Is Apifying the Public Administration
WSO2CON 2024 - How CSI Piemonte Is Apifying the Public AdministrationWSO2
Ā 
WSO2Con2024 - From Code To Cloud: Fast Track Your Cloud Native Journey with C...
WSO2Con2024 - From Code To Cloud: Fast Track Your Cloud Native Journey with C...WSO2Con2024 - From Code To Cloud: Fast Track Your Cloud Native Journey with C...
WSO2Con2024 - From Code To Cloud: Fast Track Your Cloud Native Journey with C...WSO2
Ā 
Large-scale Logging Made Easy: Meetup at Deutsche Bank 2024
Large-scale Logging Made Easy: Meetup at Deutsche Bank 2024Large-scale Logging Made Easy: Meetup at Deutsche Bank 2024
Large-scale Logging Made Easy: Meetup at Deutsche Bank 2024VictoriaMetrics
Ā 
WSO2CON 2024 - Building a Digital Government in Uganda
WSO2CON 2024 - Building a Digital Government in UgandaWSO2CON 2024 - Building a Digital Government in Uganda
WSO2CON 2024 - Building a Digital Government in UgandaWSO2
Ā 
Artyushina_Guest lecture_YorkU CS May 2024.pptx
Artyushina_Guest lecture_YorkU CS May 2024.pptxArtyushina_Guest lecture_YorkU CS May 2024.pptx
Artyushina_Guest lecture_YorkU CS May 2024.pptxAnnaArtyushina1
Ā 
WSO2Con2024 - Software Delivery in Hybrid Environments
WSO2Con2024 - Software Delivery in Hybrid EnvironmentsWSO2Con2024 - Software Delivery in Hybrid Environments
WSO2Con2024 - Software Delivery in Hybrid EnvironmentsWSO2
Ā 
WSO2CON 2024 - Cloud Native Middleware: Domain-Driven Design, Cell-Based Arch...
WSO2CON 2024 - Cloud Native Middleware: Domain-Driven Design, Cell-Based Arch...WSO2CON 2024 - Cloud Native Middleware: Domain-Driven Design, Cell-Based Arch...
WSO2CON 2024 - Cloud Native Middleware: Domain-Driven Design, Cell-Based Arch...WSO2
Ā 
WSO2CON 2024 - Building the API First Enterprise ā€“ Running an API Program, fr...
WSO2CON 2024 - Building the API First Enterprise ā€“ Running an API Program, fr...WSO2CON 2024 - Building the API First Enterprise ā€“ Running an API Program, fr...
WSO2CON 2024 - Building the API First Enterprise ā€“ Running an API Program, fr...WSO2
Ā 
%in tembisa+277-882-255-28 abortion pills for sale in tembisa
%in tembisa+277-882-255-28 abortion pills for sale in tembisa%in tembisa+277-882-255-28 abortion pills for sale in tembisa
%in tembisa+277-882-255-28 abortion pills for sale in tembisamasabamasaba
Ā 
WSO2Con2024 - Low-Code Integration Tooling
WSO2Con2024 - Low-Code Integration ToolingWSO2Con2024 - Low-Code Integration Tooling
WSO2Con2024 - Low-Code Integration ToolingWSO2
Ā 
WSO2Con2024 - From Blueprint to Brilliance: WSO2's Guide to API-First Enginee...
WSO2Con2024 - From Blueprint to Brilliance: WSO2's Guide to API-First Enginee...WSO2Con2024 - From Blueprint to Brilliance: WSO2's Guide to API-First Enginee...
WSO2Con2024 - From Blueprint to Brilliance: WSO2's Guide to API-First Enginee...WSO2
Ā 
WSO2CON 2024 - Designing Event-Driven Enterprises: Stories of Transformation
WSO2CON 2024 - Designing Event-Driven Enterprises: Stories of TransformationWSO2CON 2024 - Designing Event-Driven Enterprises: Stories of Transformation
WSO2CON 2024 - Designing Event-Driven Enterprises: Stories of TransformationWSO2
Ā 
WSO2Con2024 - Simplified Integration: Unveiling the Latest Features in WSO2 L...
WSO2Con2024 - Simplified Integration: Unveiling the Latest Features in WSO2 L...WSO2Con2024 - Simplified Integration: Unveiling the Latest Features in WSO2 L...
WSO2Con2024 - Simplified Integration: Unveiling the Latest Features in WSO2 L...WSO2
Ā 
WSO2CON 2024 - Not Just Microservices: Rightsize Your Services!
WSO2CON 2024 - Not Just Microservices: Rightsize Your Services!WSO2CON 2024 - Not Just Microservices: Rightsize Your Services!
WSO2CON 2024 - Not Just Microservices: Rightsize Your Services!WSO2
Ā 

Recently uploaded (20)

Crypto Cloud Review - How To Earn Up To $500 Per DAY Of Bitcoin 100% On AutoP...
Crypto Cloud Review - How To Earn Up To $500 Per DAY Of Bitcoin 100% On AutoP...Crypto Cloud Review - How To Earn Up To $500 Per DAY Of Bitcoin 100% On AutoP...
Crypto Cloud Review - How To Earn Up To $500 Per DAY Of Bitcoin 100% On AutoP...
Ā 
%in Soweto+277-882-255-28 abortion pills for sale in soweto
%in Soweto+277-882-255-28 abortion pills for sale in soweto%in Soweto+277-882-255-28 abortion pills for sale in soweto
%in Soweto+277-882-255-28 abortion pills for sale in soweto
Ā 
WSO2CON 2024 - IoT Needs CIAM: The Importance of Centralized IAM in a Growing...
WSO2CON 2024 - IoT Needs CIAM: The Importance of Centralized IAM in a Growing...WSO2CON 2024 - IoT Needs CIAM: The Importance of Centralized IAM in a Growing...
WSO2CON 2024 - IoT Needs CIAM: The Importance of Centralized IAM in a Growing...
Ā 
OpenChain - The Ramifications of ISO/IEC 5230 and ISO/IEC 18974 for Legal Pro...
OpenChain - The Ramifications of ISO/IEC 5230 and ISO/IEC 18974 for Legal Pro...OpenChain - The Ramifications of ISO/IEC 5230 and ISO/IEC 18974 for Legal Pro...
OpenChain - The Ramifications of ISO/IEC 5230 and ISO/IEC 18974 for Legal Pro...
Ā 
AzureNativeQumulo_HPC_Cloud_Native_Benchmarks.pdf
AzureNativeQumulo_HPC_Cloud_Native_Benchmarks.pdfAzureNativeQumulo_HPC_Cloud_Native_Benchmarks.pdf
AzureNativeQumulo_HPC_Cloud_Native_Benchmarks.pdf
Ā 
WSO2CON 2024 - How CSI Piemonte Is Apifying the Public Administration
WSO2CON 2024 - How CSI Piemonte Is Apifying the Public AdministrationWSO2CON 2024 - How CSI Piemonte Is Apifying the Public Administration
WSO2CON 2024 - How CSI Piemonte Is Apifying the Public Administration
Ā 
Abortion Pill Prices Tembisa [(+27832195400*)] šŸ„ Women's Abortion Clinic in T...
Abortion Pill Prices Tembisa [(+27832195400*)] šŸ„ Women's Abortion Clinic in T...Abortion Pill Prices Tembisa [(+27832195400*)] šŸ„ Women's Abortion Clinic in T...
Abortion Pill Prices Tembisa [(+27832195400*)] šŸ„ Women's Abortion Clinic in T...
Ā 
WSO2Con2024 - From Code To Cloud: Fast Track Your Cloud Native Journey with C...
WSO2Con2024 - From Code To Cloud: Fast Track Your Cloud Native Journey with C...WSO2Con2024 - From Code To Cloud: Fast Track Your Cloud Native Journey with C...
WSO2Con2024 - From Code To Cloud: Fast Track Your Cloud Native Journey with C...
Ā 
Large-scale Logging Made Easy: Meetup at Deutsche Bank 2024
Large-scale Logging Made Easy: Meetup at Deutsche Bank 2024Large-scale Logging Made Easy: Meetup at Deutsche Bank 2024
Large-scale Logging Made Easy: Meetup at Deutsche Bank 2024
Ā 
WSO2CON 2024 - Building a Digital Government in Uganda
WSO2CON 2024 - Building a Digital Government in UgandaWSO2CON 2024 - Building a Digital Government in Uganda
WSO2CON 2024 - Building a Digital Government in Uganda
Ā 
Artyushina_Guest lecture_YorkU CS May 2024.pptx
Artyushina_Guest lecture_YorkU CS May 2024.pptxArtyushina_Guest lecture_YorkU CS May 2024.pptx
Artyushina_Guest lecture_YorkU CS May 2024.pptx
Ā 
WSO2Con2024 - Software Delivery in Hybrid Environments
WSO2Con2024 - Software Delivery in Hybrid EnvironmentsWSO2Con2024 - Software Delivery in Hybrid Environments
WSO2Con2024 - Software Delivery in Hybrid Environments
Ā 
WSO2CON 2024 - Cloud Native Middleware: Domain-Driven Design, Cell-Based Arch...
WSO2CON 2024 - Cloud Native Middleware: Domain-Driven Design, Cell-Based Arch...WSO2CON 2024 - Cloud Native Middleware: Domain-Driven Design, Cell-Based Arch...
WSO2CON 2024 - Cloud Native Middleware: Domain-Driven Design, Cell-Based Arch...
Ā 
WSO2CON 2024 - Building the API First Enterprise ā€“ Running an API Program, fr...
WSO2CON 2024 - Building the API First Enterprise ā€“ Running an API Program, fr...WSO2CON 2024 - Building the API First Enterprise ā€“ Running an API Program, fr...
WSO2CON 2024 - Building the API First Enterprise ā€“ Running an API Program, fr...
Ā 
%in tembisa+277-882-255-28 abortion pills for sale in tembisa
%in tembisa+277-882-255-28 abortion pills for sale in tembisa%in tembisa+277-882-255-28 abortion pills for sale in tembisa
%in tembisa+277-882-255-28 abortion pills for sale in tembisa
Ā 
WSO2Con2024 - Low-Code Integration Tooling
WSO2Con2024 - Low-Code Integration ToolingWSO2Con2024 - Low-Code Integration Tooling
WSO2Con2024 - Low-Code Integration Tooling
Ā 
WSO2Con2024 - From Blueprint to Brilliance: WSO2's Guide to API-First Enginee...
WSO2Con2024 - From Blueprint to Brilliance: WSO2's Guide to API-First Enginee...WSO2Con2024 - From Blueprint to Brilliance: WSO2's Guide to API-First Enginee...
WSO2Con2024 - From Blueprint to Brilliance: WSO2's Guide to API-First Enginee...
Ā 
WSO2CON 2024 - Designing Event-Driven Enterprises: Stories of Transformation
WSO2CON 2024 - Designing Event-Driven Enterprises: Stories of TransformationWSO2CON 2024 - Designing Event-Driven Enterprises: Stories of Transformation
WSO2CON 2024 - Designing Event-Driven Enterprises: Stories of Transformation
Ā 
WSO2Con2024 - Simplified Integration: Unveiling the Latest Features in WSO2 L...
WSO2Con2024 - Simplified Integration: Unveiling the Latest Features in WSO2 L...WSO2Con2024 - Simplified Integration: Unveiling the Latest Features in WSO2 L...
WSO2Con2024 - Simplified Integration: Unveiling the Latest Features in WSO2 L...
Ā 
WSO2CON 2024 - Not Just Microservices: Rightsize Your Services!
WSO2CON 2024 - Not Just Microservices: Rightsize Your Services!WSO2CON 2024 - Not Just Microservices: Rightsize Your Services!
WSO2CON 2024 - Not Just Microservices: Rightsize Your Services!
Ā 

Android Automated Testing

  • 2. Deļ¬nition 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
  • 7. ā€¢ Experienced in developing Android apps ā€¢ Familiar with: ā€¢ LiveData ā€¢ ViewModel ā€¢ Coroutines ā€¢ Room ā€¢ Heard about / Some experience with testing About You
  • 8. Agenda ā€¢ Android testing tools landscape ā€¢ Beneļ¬ts and challenges of automated testing ā€¢ Tips & Tricks
  • 10. Android Testing Landscape Two types of runtimes: ā€¢ "Unit Tests" - local machine ā€¢ Instrumentation - Device / Emulator Powered by JUnit
  • 12. 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.
  • 22. 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>)
  • 23. 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>)
  • 24. class AirtableAPITest { @get:Rule var mockWebServer = MockWebServer() @Test fun recordsShouldReturnListOfWeightRecords() = runBlocking { // Arrange ... // Act ... // Assert ... } }
  • 25. class AirtableAPITest { @get:Rule var mockWebServer = MockWebServer() @Test fun recordsShouldReturnListOfWeightRecords() = runBlocking { // Arrange ... // Act ... // Assert ... } }
  • 26. class AirtableAPITest { @get:Rule var mockWebServer = MockWebServer() @Test fun recordsShouldReturnListOfWeightRecords() = runBlocking { // Arrange ... // Act ... // Assert ... } }
  • 27. class AirtableAPITest { @get:Rule var mockWebServer = MockWebServer() @Test fun recordsShouldReturnListOfWeightRecords() = runBlocking { // Arrange ... // Act ... // Assert ... } }
  • 28. @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 ... }
  • 29. @Test fun recordsShouldReturnListOfWeightRecords() = runBlocking { // Arrange ... // Act val clientResponse = client.records() // Assert ... }
  • 30. @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 }
  • 31. Tada!
  • 32. MockWebServer class AirtableAPITest { @get:Rule var mockWebServer = MockWebServer() @Test fun recordsShouldReturnListOfWeightRecords() = runBlocking { ... } } testImplementation "com.squareup.okhttp3:mockwebserver:$rootProject.okhttpVersion"
  • 33. @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") ... }
  • 34. @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") ... }
  • 35. { "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": "Aenean.euismod.mauris@rutrum.net", "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
  • 36. @Test fun recordsShouldReturnFailureResponseInCaseOfServerError() = runBlocking { // Arrange mockWebServer.enqueue(MockResponse().setResponseCode(500)) ... // Act ... // Assert ... }
  • 37. FIRST - Principles of Unit Tests ā€¢ Fast ā€¢ Isolated ā€¢ Repeatable ā€¢ Self-Validating ā€¢ Thorough / Timely
  • 38. Recap / Questions? ā€¢ "Unit Tests" / Local Tests ā€¢ Test a single class (AirtableAPI) ā€¢ The structure of a test (AAA) ā€¢ FIRST principals ā€¢ MockWebServer
  • 40. @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> }
  • 41. @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> }
  • 42. @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> }
  • 43. Instrumentation Tests Easy(ish) - Access to android SDK / components ā€¢ Context, SharedPreferences, etc. ā€¢ UI & Resources Slow(er) - Android toolchain ā€¢ DEX ā€¢ APK install every run
  • 45. @RunWith(AndroidJUnit4::class) class WeightsDatabaseTest { @get:Rule var weightsDatabaseRule = WeightsDatabaseRule() @Test fun newSavedItemShouldBeReturnInAllQuery() = runBlocking { // Arrange ... // Act ... // Assert ... } }
  • 46. @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) }
  • 47. @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) }
  • 48. @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) }
  • 49. 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 { ... } ... } } } }
  • 50. 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() } } } }
  • 51. Recap / Questions? ā€¢ Instrumentation Tests ā€¢ Test a single component (Room) ā€¢ InMemory database ā€¢ JUnit rule Tip ā€¢ Skip local tests, focus on instrumentation tests
  • 54. interface WeightsRepository { suspend fun allItems(): LiveData<Resource<List<WeightItem>>> suspend fun save(item: NewWeightItem): LiveData<Resource<WeightItem?>> }
  • 55. 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() } }
  • 56. @RunWith(AndroidJUnit4::class) class LiveWeightsRepositoryTest { @get:Rule var mockWebServer = MockWebServer() @get:Rule var weightsDatabase = WeightsDatabaseRule() @get:Rule var instantTaskExecutorRule = InstantTaskExecutorRule() @Test fun givenCleanStateAllShouldCallNetworkAndSaveInDB() { } }
  • 57. @RunWith(AndroidJUnit4::class) class LiveWeightsRepositoryTest { @get:Rule var mockWebServer = MockWebServer() @get:Rule var weightsDatabase = WeightsDatabaseRule() @get:Rule var instantTaskExecutorRule = InstantTaskExecutorRule() @Test fun givenCleanStateAllShouldCallNetworkAndSaveInDB() { } }
  • 58. @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 ... }
  • 59. @Test fun givenCleanStateAllShouldCallNetworkAndSaveInDB() { // Arrange ... var observer: TestObserver<Resource<List<WeightItem>>>? = null val repository = LiveWeightsRepository(client, weightItemsDao) // Act runBlocking { observer = repository.allItems().test(2) } // Assert ... }
  • 60. @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) } }
  • 61. 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() } } }
  • 62. class TestObserver<T>(expectedCount: Int) : Observer<T> { ... } fun <T> LiveData<T>.test(expectedCount: Int = 1) = TestObserver<T>(expectedCount).also { observeForever(it) }
  • 63. @RunWith(AndroidJUnit4::class) class LiveWeightsRepositoryTest { @get:Rule var mockWebServer = MockWebServer() @get:Rule var weightsDatabase = WeightsDatabaseRule() @get:Rule var instantTaskExecutorRule = InstantTaskExecutorRule() @Test fun givenCleanStateAllShouldCallNetworkAndSaveInDB() { } }
  • 64. @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() { } }
  • 65. @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 ... }
  • 66. @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) } }
  • 67. @RunWith(AndroidJUnit4::class) class LiveWeightsRepositoryTest { ... @Test fun givenCleanStateAllShouldCallNetworkAndSaveInDB() { } @Test fun givenSavedItemsAllShouldUpdateDB() { } @Test fun saveShouldPerformNetworkAndSaveInDB() { } }
  • 68. @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) } }
  • 70. 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() } }
  • 71. 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) } }
  • 72. 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() { ... } }
  • 73. @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() { ... } }
  • 74. @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 ... }
  • 75. @Test fun refreshShouldFetchFromServer() { // Arrange ... val viewModel = LiveHistoryViewModel(repository) // Act val observer = viewModel.state.test(expectedCount = 2) viewModel.refresh() observer.await() // Assert ... }
  • 76. @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 ) ) } }
  • 77. @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() } }
  • 81.
  • 82. The Modern Test Pyramid (Trophy)
  • 83. Recap / Questions? ā€¢ Integration Tests ā€¢ Test couple of components together ā€¢ TestObserver & InstantTaskExecutorRule ā€¢ Modern test pyramid Tips ā€¢ Integration tests increase conļ¬dence ā€¢ Unit tests are disposable
  • 85. 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() } }
  • 86. 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() } }
  • 87. 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) } } }) } }
  • 88. @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() { ... } }
  • 89. @RunWith(AndroidJUnit4::class) class HistoryActivityTest { ... private var viewModel: MockHistoryViewModel = MockHistoryViewModel() @Before fun before() { viewModel = MockHistoryViewModel() loadKoinModules(module { viewModel<HistoryViewModel> { viewModel } }) } @Test fun successStateShouldDisplayListOfItems() { ... } ... }
  • 90. 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) } }
  • 91. @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() }
  • 92. private fun createSuccessState(): HistoryViewModel.ViewState.Success { val items: List<WeightItem> = listOf( WeightItem( ... ), WeightItem( ... ), WeightItem( ... ), WeightItem( ... ), WeightItem( ... ) ) return HistoryViewModel.ViewState.Success(items) }
  • 94. @Test fun loadingStateShouldDisplayProgressBar() { // Arrange activityRule.launchActivity(Intent()) // Act viewModel.postViewState(HistoryViewModel.ViewState.Loading) // Assert assertDisplayed(R.id.progressBar) screenshotRule.takeScreenshot() }
  • 96. Recap / Questions? ā€¢ Unit test for the UI ā€¢ Mock the ViewModel ā€¢ Screenshots Tip ā€¢ Helps verifying different UI states - loading, error, empty, etc.
  • 97. End to End tests Automated the user experience ā€¢ Multiple screens ā€¢ Minimal to none mocks / stubs / etc. ā€¢ Real server* ā€¢ FIRST principals
  • 100. @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()
  • 101. @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) } }
  • 102. 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()) } }
  • 103. 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()) } }
  • 104. @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() }
  • 105. 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 { ... } } }
  • 106. Recap / Questions? ā€¢ End to End tests simulate the user ā€¢ Separated environment ā€¢ Server state Tip ā€¢ Existing app without tests? start with E2E tests.
  • 109. 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/roisagivhttps://www.linkedin.com/in/roisagiv/