自动化测试在 Mobile Android App 开发中的实践总结。
测试金字塔沿着金字塔逐级向上,从小型测试到大型测试,各类测试的保真度逐级提高,但维护和调试工作所需的执行时间和工作量也逐级增加。因此,您编写的单元测试应多于集成测试,集成测试应多于端到端测试。虽然各类测试的比例可能会因应用的用例不同而异,但我们通常建议各类测试所占比例如下:小型测试占 70%,中型测试占 20%,大型测试占 10%。
用于验证应用的行为,一次验证一个类。
原则(F.I.R.S.T)
Fast(快),单元测试要运行的足够快,单个测试方法一般要立即(一秒之内)给出结果。Idependent(独立),测试方法之间不要有依赖(先执行某个测试方法,再执行另一个测试方法才能通过)。Repeatable(重复),可以在本地或 CI 不同环境(机器上)上反复执行,不会出现不稳定的情况。Self-Validating(自验证),单元测试必须包含足够多的断言进行自我验证。Timely(及时),理想情况下应测试先行,至少保证单元测试应该和实现代码一起及时完成并提交。
除此之外,测试代码应该具备最好的可读性和最少的维护代价,绝大多数情况下写测试应该就像用领域特定语言描述一个事实,甚至不用经过仔细地思考。
构建本地单元测试当需要更快地运行测试而不需要与在真实设备上运行测试关联的保真度和置信度时,可以使用本地单元测试来验证应用的逻辑。
如果测试对
Android框架有依赖性(特别是与框架建立复杂交互的测试),则最好使用Robolectric添加框架依赖项。例:待测试的类同时依赖
Context、Intent、Bundle、Application等Android Framework中的类时,此时我们可以引入Robolectric框架进行本地单元测试的编写。如果测试对
Android框架的依赖性极小,或者如果测试仅取决于我们自己应用的对象,则可以使用诸如Mockito之类的模拟框架添加模拟依赖项。(BasicUnitAndroidTest)例:待测试的类只依赖
java api(最理想的情况),此时对于待测试类所依赖的其他类我们就可以利用Mockito框架 mock 其依赖类,再进行当前类的单元测试编写。(EmailValidatorTest)例:待测试的类除了依赖
java api外仅依赖Android Framework中Context这个类,此时我们就可以利用Mockito框架mockContext类,再进行当前类的单元测试编写。(SharedPreferencesHelperTest)
在Android Studio项目中,本地单元测试的源文件存储在module-name/src/test/java/中。
在模块的顶级build.gradle文件中,将以下库指定为依赖项:
dependencies { // Required -- JUnit 4 framework testImplementation "junit:junit:$junitVersion" // Optional -- Mockito framework testImplementation "org.mockito:mockito-core:$mockitoCoreVersion" // Optional -- Robolectric environment testImplementation "androidx.test:core:$xcoreVersion" testImplementation "androidx.test.ext:junit:$extJunitVersion" testImplementation "org.robolectric:robolectric:$robolectricVersion" }
如果单元测试依赖于资源,需要在 module 的 build.gradle 文件中启用includeAndroidResources选项。然后,单元测试可以访问编译版本的资源,从而使测试更快速且更准确地运行。
android { // ... testOptions { unitTests { includeAndroidResources = true } } }
@RunWith(AndroidJUnit4::class)@Config(manifest = Config.NONE)class PeopleDaoTest { private lateinit var database: PeopleDatabase private lateinit var peopleDao: PeopleDao @Before fun `create db`() { database = Room.inMemoryDatabaseBuilder( ApplicationProvider.getApplicationContext(), PeopleDatabase::class.java ).allowMainThreadQueries().build() peopleDao = database.peopleDao() } @Test fun `should return empty list when getPeople without inserted data`() { val result = peopleDao.getPeople(pageId = 1) assertThat(result).isNotNull() assertThat(result).isEmpty() }
如果单元测试包含异步操作时,可以使用awaitility 库进行测试;当使用RxJava响应式编程库时,可以自定义 rule:
class RxJavaRule : TestWatcher() { override fun starting(description: Description?) { super.starting(description) RxJavaPlugins.setIoSchedulerHandler { Schedulers.trampoline() } RxJavaPlugins.setNewThreadSchedulerHandler { Schedulers.trampoline() } RxJavaPlugins.setComputationSchedulerHandler { Schedulers.trampoline() } RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() } RxAndroidPlugins.setInitMainThreadSchedulerHandler { Schedulers.trampoline() } } override fun finished(description: Description?) { super.finished(description) RxJavaPlugins.reset() RxAndroidPlugins.reset() }}
TestScheduler中triggerActions的使用。
@RunWith(JUnit4::class)class FilmViewModelTest { @get:Rule val instantTaskExecutorRule = InstantTaskExecutorRule() @get:Rule val rxJavaRule = RxJavaRule() private val repository = mock(Repository::class.java) private val testScheduler = TestScheduler() private lateinit var viewModel: FilmViewModel @Before fun init() { viewModel = FilmViewModel(repository) } @Test fun `should return true when loadFilms is loading`() { `when`(repository.getPopularFilms(1)).thenReturn( Single.just(emptyList()) .subscribeOn(testScheduler) ) viewModel.loadFilms(0) assertThat(getValue(viewModel.isLoading)).isTrue() testScheduler.triggerActions() assertThat(getValue(viewModel.isLoading)).isFalse() } @Test fun `should return films list when loadFilms successful`() { `when`(repository.getPopularFilms(1)).thenReturn( Single.just( listOf( Film(123, "", "", "", "", "", "", 1) ) ).subscribeOn(testScheduler) ) viewModel.loadFilms(0) assertThat(getValue(viewModel.films)).isNull() testScheduler.triggerActions() assertThat(getValue(viewModel.films)).isNotNull() assertThat(getValue(viewModel.films).size).isEqualTo(1) }}
TestSubscriber的使用。
@RunWith(JUnit4::class)class WebServiceTest { private lateinit var webService: WebService private lateinit var mockWebServer: MockWebServer @get:Rule val instantExecutorRule = InstantTaskExecutorRule() @Before fun `start service`() { mockWebServer = MockWebServer() webService = Retrofit.Builder() .baseUrl(mockWebServer.url("/")) .addConverterFactory(GsonConverterFactory.create()) .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) .build() .create(WebService::class.java) } @Test fun `should return fim list when getFilms successful`() { assertThat(webService).isNotNull() enqueueResponse("popular_films.json") val testObserver = webService.getPopularFilms(page = 1) .map { it.data }.test() testObserver.assertNoErrors() testObserver.assertValueCount(1) testObserver.assertValue { assertThat(it).isNotEmpty() assertThat(it[0].id).isEqualTo(297761) assertThat(it[1].id).isEqualTo(324668) it.size == 2 } testObserver.assertComplete() testObserver.dispose() } @After fun `stop service`() { mockWebServer.shutdown() } private fun enqueueResponse(fileName: String) { val inputStream = javaClass.classLoader?.getResourceAsStream("api-response/$fileName") ?: return val source = inputStream.source().buffer() val mockResponse = MockResponse() mockWebServer.enqueue( mockResponse .setBody(source.readString(Charsets.UTF_8)) ) }}
构建插桩单元测试
插桩单元测试是在物理设备和模拟器上运行的测试,此类测试可以利用Android框架API。插桩测试提供的保真度比本地单元测试要高,但运行速度要慢得多。因此,我们建议只有在必须针对真实设备的行为进行测试时才使用插桩单元测试。
在Android Studio项目中,插桩测试的源文件存储在module-name/src/androidTest/java/。
在模块的顶级build.gradle文件中,将以下库指定为依赖项:
android { defaultConfig { testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } }
dependencies { androidTestImplementation "androidx.test.ext:junit:$extJunitVersion" androidTestImplementation "androidx.test:core:$xcoreVersion" androidTestImplementation "androidx.test:rules:$rulesVersion" // Optional -- Truth library androidTestImplementation "androidx.test.ext:truth:$androidxtruthVersion" androidTestImplementation "org.mockito:mockito-core:$mockitoCoreVersion" androidTestImplementation "org.mockito:mockito-android:$mockitoAndroidVersion" }
@RunWith(AndroidJUnit4::class)@SmallTestclass FilmDaoTest { @get:Rule var instantTaskExecutorRule = InstantTaskExecutorRule() private lateinit var database: FilmDatabase private lateinit var filmDao: FilmDao @Before fun initDb() { database = Room.inMemoryDatabaseBuilder( ApplicationProvider.getApplicationContext(), FilmDatabase::class.java ).build() filmDao = database.filmData() } @Test fun should_return_film_list_when_getFilms_with_inserted_film_list() { filmDao.insert( Film(100, "", "", "", "", "", "", 1) ) filmDao.insert( Film(101, "", "", "", "", "", "", 1) ) val result = filmDao.getFilms(1) assertThat(result).isNotNull() assertThat(result).isNotEmpty() assertThat(result.size).isEqualTo(2) assertThat(result[0].id).isEqualTo(100) assertThat(result[0].page).isEqualTo(1) assertThat(result[1].id).isEqualTo(101) } @Test fun should_return_film_list_with_size_1_when_getFilms_with_inserted_2_same_film() { filmDao.insert( Film(100, "", "", "", "", "", "", 1) ) filmDao.insert( Film(100, "1223", "111", "", "", "", "", 1) ) val result = filmDao.getFilms(1) assertThat(result).isNotNull() assertThat(result).isNotEmpty() assertThat(result.size).isEqualTo(1) assertThat(result[0].id).isEqualTo(100) assertThat(result[0].page).isEqualTo(1) } @Test fun should_return_empty_list_when_getFilms_with_deleteAll_called() { filmDao.insert( Film(100, "", "", "", "", "", "", 1) ) filmDao.deleteAll() val newResult = filmDao.getFilms(1) assertThat(newResult).isNotNull() assertThat(newResult).isEmpty() } @After fun closeDb() = database.close()}
总结:
基于目前流行的
MVP、MVVM架构设计模式,MVP中Model层和Presenter层尽量不依赖Android Framework,MVVM中Model层和ViewModel层尽量不依赖Android Framework。类的设计做到单一职责原则,依赖其他类时提供方便 mock 的方式(例如作为构造方法参数传递),某一个方法依赖其他对象时,小重构该对象作为方法参数传入。
方法尽量短小(方法太长时可以利用重构手法在方法中再提取方法)。
只覆盖 public 方法单元测试,privite 方法可以间接测试。
当依赖
Android Framework API非常少时,可以采用Mock Android api的方式。当严重依赖
Android Framework API时,引入Robolectric库模拟Android环境或者放入 AndroidTest 目录作为插桩单元测试在物理设备上跑。使用
Robolectric库写本地单元测试时,依赖的某些类的方法调用出问题导致测试failed时,可以使用shadow类提供默认实现。每条测试采用
Given、When、Then的方式进行区分.```@Testpublic void shoulddosomethingifsomeconditionfulfills() {// Given 设置前置条件
// When 执行被测方法
// Then 验证方法结果}
## 集成测试(中型测试)**用于验证模块内堆栈级别之间的交互或相关模块之间的交互**- 如果应用使用了用户不直接与之交互的组件(如`Service`或`ContentProvider`),应验证这些组件在应用中的行为是否正确。### 设置测试环境**参考插桩单元测试环境设置**#### Service 测试- **利用`ServiceTestRule`,可在单元测试方法运行之前启动服务,并在测试完成后关闭服务。**- **`ServiceTestRule`类不支持测试`IntentService`对象。如果需要测试`IntentService`对象,可以应将逻辑封装在一个单独的类中,并创建相应的单元测试。**
@MediumTest@RunWith(AndroidJUnit4.class)public class LocalServiceTest { @Rule public final ServiceTestRule mServiceRule = new ServiceTestRule();
@Testpublic void testWithBoundService() throws TimeoutException { // Create the service Intent. Intent serviceIntent = new Intent(getApplicationContext(), LocalService.class); // Data can be passed to the service via the Intent. serviceIntent.putExtra(LocalService.SEED_KEY, 42L); // Bind the service and grab a reference to the binder. IBinder binder = mServiceRule.bindService(serviceIntent); // Get the reference to the service, or you can call public methods on the binder directly. LocalService service = ((LocalService.LocalBinder) binder).getService(); // Verify that the service is working correctly. assertThat(service.getRandomInt(), is(any(Integer.class)));}
}
#### ContentProvider 的测试**使用`ProviderTestRule`**
@Rule public ProviderTestRule mProviderRule = new ProviderTestRule.Builder(MyContentProvider.class, MyContentProvider.AUTHORITY).build();
@Test public void verifyContentProviderContractWorks() { ContentResolver resolver = mProviderRule.getResolver(); // perform some database (or other) operations Uri uri = resolver.insert(testUrl, testContentValues); // perform some assertions on the resulting URI assertNotNull(uri); }
@Rule public ProviderTestRule mProviderRule = new ProviderTestRule.Builder(MyContentProvider.class, MyContentProvider.AUTHORITY) .setDatabaseCommands(DATABASENAME, INSERTONEENTRYCMD, INSERTANOTHERENTRY_CMD) .build();
@Test public void verifyTwoEntriesInserted() { ContentResolver mResolver = mProviderRule.getResolver(); // two entries are already inserted by rule, we can directly perform assertions to verify Cursor c = null; try { c = mResolver.query(URITOQUERY_ALL, null, null, null, null); assertNotNull(c); assertEquals(2, c.getCount()); } finally { if (c != null && !c.isClosed()) { c.close(); } } }
- `Android`没有为`BroadcastReceiver`提供单独的测试用例类。要验证 `BroadcastReceiver`是否正确响应,可以测试向其发送`Intent`对象的组件。或者,可以通过调用`ApplicationProvider.getApplicationContext()`来创建`BroadcastReceiver`的实例,然后调用要测试的`BroadcastReceiver`方法(通常,这是`onReceive()`方法)## 端到端测试(大型测试)**用于验证跨越了应用的多个模块的用户操作流程****界面测试的一种方法是直接让测试人员对目标应用执行一系列用户操作,并验证其行为是否正常。不过,这种人工方法会非常耗时、繁琐且容易出错。一种更高效的方法是编写界面测试,以便以自动化方式执行用户操作。自动化方法可以以可重复的方式快速可靠地运行测试。**### 设置测试环境
dependencies { androidTestImplementation "androidx.test.ext:junit:$extJunitVersion" androidTestImplementation "androidx.test:core:$xcoreVersion" androidTestImplementation "androidx.test:rules:$rulesVersion" // Optional -- Truth library androidTestImplementation "androidx.test.ext:truth:$androidxtruthVersion" androidTestImplementation "org.mockito:mockito-core:$mockitoCoreVersion" androidTestImplementation "org.mockito:mockito-android:$mockitoAndroidVersion" // Optional -- UI testing with Espresso androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion" androidTestImplementation "androidx.test.espresso:espresso-intents:$espressoVersion" // Optional -- UI testing with UI Automator androidTestImplementation "androidx.test.uiautomator:uiautomator:$uiautomatorVersion"}
```
涵盖单个应用的界面测试:这种类型的测试可验证目标应用在用户执行特定操作或在其
Activity中输入特定内容时的行为是否符合预期。它可让您检查目标应用是否返回正确的界面输出来响应应用Activity中的用户交互。诸如Espresso之类的界面测试框架可让您以编程方式模拟用户操作,并测试复杂的应用内用户交互。(espresso 测试单个应用的界面例子)涵盖多个应用的界面测试:这种类型的测试可验证不同用户应用之间交互或用户应用与系统应用之间交互的正确行为。例如,您可能想要测试相机应用是否能够与第三方社交媒体应用或默认的
Android相册应用正确分享图片。支持跨应用交互的界面测试框架(如UI Automator)可让您针对此类场景创建测试。(uiautomator 测试多个应用的界面)
参考例子 testing-samples
阅读全文: http://gitbook.cn/gitchat/activity/5e3bccf27b7cfc50b92ef1be
您还可以下载 CSDN 旗下精品原创内容社区 GitChat App ,阅读更多 GitChat 专享技术内容哦。
