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