原文链接:
最近我创建了一个playground项目来了解更多关于Kotlin和RxJava的信息。 这是一个非常简单的项目,但有一部分,我进行了一些尝试:测试。
在kotlin的测试上可能会有一些陷阱,而且由于它是新出的,所以没有太多的例子。 我认为分享我的经验帮助你来避免踩坑是一个好主意。
关于架构
该应用程序遵循基本MVP架构。 它使用Dagger2进行依赖注入,RxJava2用于数据流。
这些库根据不同的条件提供来自网络或本地存储的数据。 我们使用Retrofit进行网络请求,以及Room作为本地数据库。
我不会详细讲解架构和这些工具。 我想大多数人已经熟悉了他们。 您可以在此提交中查看:
我们将从测试数据库开始,然后向上层测试。
测试数据库
对于数据库,我们使用Android架构组件中的Room Persistence Library。 它是SQLite上的抽象层,可以减少样板代码。
这是最简单的部分。 我们不需要对Kotlin或RxJava做任何具体的事情。 我们先来看看UserDao界面的代码,以决定我们应该测试什么。
@Daointerface UserDao { @Query("SELECT * FROM user ORDER BY reputation DESC LIMIT (:arg0 - 1) * 30, 30") fun getUsers(page: Int) : List@Insert(onConflict = OnConflictStrategy.REPLACE) fun insertAll(users: List )}复制代码
getUsers函数根据页码从数据库中请求下一个30个用户。
insertAll
插入列表中的所有用户。
我们可以从这里发现几件事情,需要测试什么:
- 检查插入的用户是否与检索到的用户相同。
- 检查检索用户正确排序。
- 检查我们是否插入具有相同ID的用户,它将替换旧的记录。
- 检查是否查询页面,最多可以有30个用户。
- 检查我们是否查询第二页,我们将获得正确数量的元素。
下面的代码片段显示了5例这样的实现。
@RunWith(AndroidJUnit4::class)class UserDaoTest { lateinit var userDao: UserDao lateinit var database: AppDatabase @Before fun setup() { val context = InstrumentationRegistry.getTargetContext() database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() userDao = database.userDao() } @After fun tearDown() { database.close() } @Test fun testInsertedAndRetrievedUsersMatch() { val users = listOf(User(1, "Name", 100, "url"), User()) userDao.insertAll(users) val allUsers = userDao.getUsers(1) assertEquals(users, allUsers) } @Test fun testUsersOrderedByCorrectly() { val users = listOf( User(1, "Name", 100, "url"), User(2, "Name2", 500, "url"), User(3, "Name3", 300, "url")) userDao.insertAll(users) val allUsers = userDao.getUsers(1) val expectedUsers = users.sortedByDescending { it.reputation } assertEquals(expectedUsers, allUsers) } @Test fun testConflictingInsertsReplaceUsers() { val users = listOf( User(1, "Name", 100, "url"), User(2, "Name2", 500, "url"), User(3, "Name3", 300, "url")) val users2 = listOf( User(1, "Name", 1000, "url"), User(2, "Name2", 700, "url"), User(4, "Name3", 5500, "url")) userDao.insertAll(users) userDao.insertAll(users2) val allUsers = userDao.getUsers(1) val expectedUsers = listOf( User(4, "Name3", 5500, "url"), User(1, "Name", 1000, "url"), User(2, "Name2", 700, "url"), User(3, "Name3", 300, "url")) assertEquals(expectedUsers, allUsers) } @Test fun testLimitUsersPerPage_FirstPageOnly30Items() { val users = (1..40L).map { User(it, "Name $it", it *100, "url") } userDao.insertAll(users) val retrievedUsers = userDao.getUsers(1) assertEquals(30, retrievedUsers.size) } @Test fun testRequestSecondPage_LimitUsersPerPage_showOnlyRemainingItems() { val users = (1..40L).map { User(it, "Name $it", it *100, "url") } userDao.insertAll(users) val retrievedUsers = userDao.getUsers(2) assertEquals(10, retrievedUsers.size) }}复制代码
在setup方法中,我们需要配置我们的数据库。 在每次测试之前,我们使用Room的内存数据库创建一个干净的数据库。
测试在这里非常简单,不需要进一步解释。 我们在每个测试中遵循的基本模式如
下所示:- 将数据插入数据库
- 从数据库查询数据
- 对所检索的数据作出断言
我们可以使用Kotlin Collections API中的函数来简化测试数据的创建,就像这部分代码一样:
val users = (1..40L).map { User(it, "Name $it", it *100, "url") }复制代码
我们创建了一个范围,然后将其映射到用户列表。 这里有多个Kotlin概念:范围,高阶函数,字符串模板。
Commit:
测试UserRepository
对于repository和interactor,我们将使用相同的工具。
- 使用Mockit模拟类的依赖。
- TestObserver用于测试Observables(在我们的例子中是Singles)
但首先我们需要启用该选项来mock最终的类。 在kotlin里,默认情况下每个class都是final的。 幸运的是,Mockito 2已经支持模拟 final class,但是我们需要启用它。
我们需要在以下位置创建一个文本文件:test / resources / mockito-extensions /
,名称为org.mockito.plugins.MockMaker
,并附带以下文本:mock-maker-inline
现在我们可以开始使用Mockito来编写我们的测试。 首先,我们将添加最新版本的Mockito和JUnit。
testImplementation 'org.mockito:mockito-core:2.8.47'testImplementation 'junit:junit:4.12'复制代码
UserRepository
的代码如下:
class UserRepository( private val userService: UserService, private val userDao: UserDao, private val connectionHelper: ConnectionHelper, private val preferencesHelper: PreferencesHelper, private val calendarWrapper: CalendarWrapper) { private val LAST_UPDATE_KEY = "last_update_page_" fun getUsers(page: Int, forced: Boolean): Single{ return Single.create { emitter: SingleEmitter -> if (shouldUpdate(page, forced)) { loadUsersFromNetwork(page, emitter) } else { loadOfflineUsers(page, emitter) } } } private fun shouldUpdate(page: Int, forced: Boolean) = when { forced -> true !connectionHelper.isOnline() -> false else -> { val lastUpdate = preferencesHelper.loadLong(LAST_UPDATE_KEY + page) val currentTime = calendarWrapper.getCurrentTimeInMillis() lastUpdate + Constants.REFRESH_LIMIT < currentTime } } private fun loadUsersFromNetwork(page: Int, emitter: SingleEmitter ) { try { val users = userService.getUsers(page).execute().body() if (users != null) { userDao.insertAll(users.items) val currentTime = calendarWrapper.getCurrentTimeInMillis() preferencesHelper.saveLong(LAST_UPDATE_KEY + page, currentTime) emitter.onSuccess(users) } else { emitter.onError(Exception("No data received")) } } catch (exception: Exception) { emitter.onError(exception) } } private fun loadOfflineUsers(page: Int, emitter: SingleEmitter ) { val users = userDao.getUsers(page) if (!users.isEmpty()) { emitter.onSuccess(UserListModel(users)) } else { emitter.onError(Exception("Device is offline")) } }}复制代码
在getUsers
方法中,我们创建一个Single
,它会发送users或一个error。 根据不同的条件,shouldUpdate
方法决定用户是否应该从网络加载或从本地数据库加载。
还有一点需要注意的是CalendarWrapper
字段。 这是一个简单的包装器,有一个返回当前时间的方法。 在它帮助下,我们可以模拟我们测试的时间。
那么我们应该在这里测试什么? 在这里最重要的测试是在shouldUpdate
方法背后的逻辑。 让我们为它做一些测试。
测试这个的方法是先调用getUsers
方法,并在返回的Single
去调用test
方法。 test
方法会创建一个TestObserver
并将其订阅到Single
。
TestObserver是一种特殊类型的Observer,它记录事件并允许对它们进行断言。
我们还必须模拟UserRepository
的依赖关系,并且存储一些他们的方法来返回指定的数据。 我们可以像在Java中一样使用Mockito,或者使用Niek Haarman的库。 我们将在这个例子中使用Mockito,但如果您好奇,可以检查Github资料库。
如果我们要使用Mockito的when
方法,我们需要把它放在反引号之间,因为它是Kotlin中的保留字。 为了使这看起来更好,我们可以使用as
关键字引入具有不同名称的when
方法。
import org.mockito.Mockito.`when` as whenever复制代码
现在我们可以使用whenever
方法进行stubbing。
class UserRepositoryTest { @Mock lateinit var mockUserService: UserService @Mock lateinit var mockUserDao: UserDao @Mock lateinit var mockConnectionHelper: ConnectionHelper @Mock lateinit var mockPreferencesHelper: PreferencesHelper @Mock lateinit var mockCalendarWrapper: CalendarWrapper @Mock lateinit var mockUserCall: Call@Mock lateinit var mockUserResponse: Response lateinit var userRepository: UserRepository @Before fun setup() { MockitoAnnotations.initMocks(this) userRepository = UserRepository(mockUserService, mockUserDao, mockConnectionHelper, mockPreferencesHelper, mockCalendarWrapper) } @Test fun testGetUsers_isOnlineReceivedOneItem_emitListWithOneItem() { val userListModel = UserListModel(listOf(User())) setUpStubbing(true, 1000 * 60 * 60 * 12 + 1, 0, modelFromUserService = userListModel) val testObserver = userRepository.getUsers(1, false).test() testObserver.assertNoErrors() testObserver.assertValue { userListModelResult: UserListModel -> userListModelResult.items.size == 1 } verify(mockUserDao).insertAll(userListModel.items) } @Test fun testGetUsers_isOfflineOneItemInDatabase_emitListWithOneItem() { val modelFromDatabase = listOf(User()) setUpStubbing(false, 1000 * 60 * 60 * 12 + 1, 0, modelFromDatabase = modelFromDatabase) val testObserver = userRepository.getUsers(1, false).test() testObserver.assertNoErrors() testObserver.assertValue { userListModelResult: UserListModel -> userListModelResult.items.size == 1 } } private fun setUpStubbing(isOnline: Boolean, currentTime: Long, lastUpdateTime: Long, modelFromUserService: UserListModel = UserListModel(emptyList()), modelFromDatabase: List = emptyList()) { whenever(mockConnectionHelper.isOnline()).thenReturn(isOnline) whenever(mockCalendarWrapper.getCurrentTimeInMillis()).thenReturn(currentTime) whenever(mockPreferencesHelper.loadLong("last_update_page_1")).thenReturn(lastUpdateTime) whenever(mockUserService.getUsers(1)).thenReturn(mockUserCall) whenever(mockUserCall.execute()).thenReturn(mockUserResponse) whenever(mockUserResponse.body()).thenReturn(modelFromUserService) whenever(mockUserDao.getUsers(1)).thenReturn(modelFromDatabase) }}复制代码
以上我们可以看到UserRepositoryTest
的代码。 我们在这个例子中使用Mockito注解来初始化mocks,但是可以用不同的方法来完成。 每个测试包括3个步骤:
- 指定stubbed方法返回什么值。 我们使用
setUpStubbing
私有方法来避免我们的测试中的样板代码。 我们可以在每个具有不同参数的测试用例中调用此方法,这取决于正在测试的状态。 Kotlin的默认参数在这里非常有用,因为有时我们不需要指定每个参数。 - 调用
getUsers
方法,并通过在返回的Single
上调用test
方法来获取一个TestObserver。 - 在
TestObserver
或模拟对象上进行一些断言以验证预期的行为。 在这个例子中,我们使用assertNoErrors
方法来验证Single
不会发出错误。 我们使用的另一种方法是assertValue
。 有了它的帮助,我们可以断言Single
发出的值是不是正确。 执行此操作的方式是将lambda传递给assertValue
方法,该方法返回一个布尔值。 如果它返回true,则断言将通过。 在这种情况下,我们验证发出的列表包含1个元素。 有很多其他方法可以在TestObserver
上做出断言,这些可以在TestObserver的超类中找到。
在此提交中可以找到这些更改:
测试 GetUsers interactor
测试GetUsers
interactor的方法类似于我们用来测试UserRepository
的方法。
GetUsers
是一个非常简单的类,它的目的是将data层中的数据转换为presentation层中的数据。
class GetUsers(private val userRepository: UserRepository) { fun execute(page: Int, forced: Boolean) : Single
> { val usersList = userRepository.getUsers(page, forced) return usersList.map { userListModel: UserListModel? -> val items = userListModel?.items ?: emptyList() items.map { UserViewModel(it.userId, it.displayName, it.reputation, it.profileImage) } } }}复制代码
我们使用RxJava和Kotlin Collection API中的一些转换来实现想要的结果。
来看看我们的测试长什么样:
class GetUsersTest { @Mock lateinit var mockUserRepository: UserRepository lateinit var getUsers: GetUsers @Before fun setup() { MockitoAnnotations.initMocks(this) getUsers = GetUsers(mockUserRepository) } @Test fun testExecute_userListModelWithOneItem_emitListWithOneViewModel() { val userListModel = UserListModel(listOf(User(1, "Name", 100, "Image url"))) setUpStubbing(userListModel) val testObserver = getUsers.execute(1, false).test() testObserver.assertNoErrors() testObserver.assertValue { userViewModels: List-> userViewModels.size == 1 } testObserver.assertValue { userViewModels: List -> userViewModels.get(0) == UserViewModel(1, "Name", 100, "Image url") } } @Test fun testExecute_userListModelEmpty_emitEmptyList() { val userListModel = UserListModel(emptyList()) setUpStubbing(userListModel) val testObserver = getUsers.execute(1, false).test() testObserver.assertNoErrors() testObserver.assertValue { userViewModels: List -> userViewModels.isEmpty() } } private fun setUpStubbing(userListModel: UserListModel) { val fakeSingle = Single.create { e: SingleEmitter ? -> e?.onSuccess(userListModel) } whenever(mockUserRepository.getUsers(1, false)) .thenReturn(fakeSingle) }}复制代码
唯一的区别在于,我们创建一个假的从getUsers
方法返回的Single对象。 我们使用Single将UserListModel
发送给setUpStubbing
方法,在这里我们创建了假的Single,并将其设置为getUsers
方法的返回值。
剩下的代码使用与UserRepositoryTest中相同的概念。
Commit在这:
这是第一部分。 我们学习了如何在Kotlin测试中使用RxJava来处理一些常见问题,如何利用一些Kotlin功能来编写更简单的测试,并且还可以看看如何测试Room数据库。
在第二部分中,我将向您展示如何在TestScheduler的帮助下测试Presenter,以及如何使用Espresso和假数据来进行UI测试。 敬请关注。
Thanks for reading my article.