上一篇我们写好了 EtaResponseMapper
的 unit test。但 data layer 还有 EtaResponseMapper
未写 unit test。今天我们就写这一个 class 的 unit test。
我们先前在设定 Ktor client 时帮它加了 logging 功能,这样我们就可以在 Logcat 看到 request 和 response 的资讯,方便 debug。而它所用的 logger 是按照 Simple Logging Facade for Java (SLF4J) 规格。SLF4J 其实是一个 Java 有名的 logging interface,如果一些组件或 library 想用 logging 功能的话,它们可以用 SLF4J 的 interface 发送 log 到 logger,但最终所用的 logger 是由用那些组件的一方控制。这样就不会把 log 乱射和可以把 log 集中处理。在 Android 的话,我们会用 logback-android。
把这段内容放到本篇才说是因为我们会用到 Ktor 的 mock client。如果我们只加了 logback-android 的话,在执行 unit test 时就会出现以下错误(节录):
SLF4J: Class path contains multiple SLF4J bindings.
SLF4J: Found binding in [jar:file:/C:/Users/Eric/.gradle/caches/transforms-3/ff97545d615cededbad0c653ea1a09c7/transformed/jetified-logback-android-2.0.0-runtime.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: Found binding in [jar:file:/C:/Users/Eric/.gradle/caches/transforms-3/afd61d02052ffb4feaec186ea7b45062/transformed/jetified-logback-android-2.0.0/jars/classes.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: See http://www.slf4j.org/codes.html#multiple_bindings for an explanation.
Failed to instantiate [ch.qos.logback.classic.LoggerContext]
Reported exception:
java.lang.RuntimeException: Method getExternalStorageState in android.os.Environment not mocked. See http://g.co/androidstudio/not-mocked for details.
at android.os.Environment.getExternalStorageState(Environment.java)
at ch.qos.logback.core.android.AndroidContextUtil.getMountedExternalStorageDirectoryPath(Unknown Source)
at ch.qos.logback.core.android.AndroidContextUtil.setupProperties(Unknown Source)
at ch.qos.logback.classic.util.ContextInitializer.autoConfig(Unknown Source)
at org.slf4j.impl.StaticLoggerBinder.init(Unknown Source)
at org.slf4j.impl.StaticLoggerBinder.<clinit>(Unknown Source)
at org.slf4j.LoggerFactory.bind(LoggerFactory.java:150)
at org.slf4j.LoggerFactory.performInitialization(LoggerFactory.java:124)
at org.slf4j.LoggerFactory.getILoggerFactory(LoggerFactory.java:417)
at org.slf4j.LoggerFactory.getLogger(LoggerFactory.java:362)
at org.slf4j.LoggerFactory.getLogger(LoggerFactory.java:388)
at io.ktor.client.features.logging.LoggerJvmKt$DEFAULT$1.<init>(LoggerJvm.kt:13)
...
SLF4J: Actual binding is of type [ch.qos.logback.classic.util.ContextSelectorStaticBinder]
这是因为 logback-android 会尝试找 Android app 的 assets 目录内的 logback.xml,这个 XML 档是用来设定 logger。在执行 app 时是完全正常,但在执行 unit test 时就因为当前执行环境没有 Android SDK(就是那个 android.os.Environment.getExternalStorageState
)而弹出错误讯息。要解决它其中一个方法是用 Robolectric,因为 Robolectric 可以帮我们补回 getExternalStorageState
。但为了一个 logger 而要在一个本身不会接触到 Android SDK 的地方的 unit test 加 Robolectric 实在是太大阵仗,执行 unit test 时只是下载 Robolectric 的 JAR 都用了好几分钟。所以我们用另一个方法:就是在 unit test 是把 logback-android 换成 logback-classic,那就不会在 unit test 时 call 了 Android SDK 的 method。按照 GitHub issue 的建议,我们把 build.gradle 改成这样:
dependencies {
implementation "com.github.tony19:logback-android:$logbackAndroidVersion"
}
configurations.all { config ->
if (config.name.toLowerCase().contains('test')) {
config.resolutionStrategy.dependencySubstitution {
substitute module("com.github.tony19:logback-android:$logbackAndroidVersion") with module("ch.qos.logback:logback-classic:$logbackVersion")
}
}
}
这样就不会出现错误讯息。
getStations
这个 test 写法很简单,因为它就是回传一个写死的 Map
。
class EtaRepositoryImplTest {
@MockK
private lateinit var etaResponseMapper: Mapper<HttpResponse, EtaResult>
@Before
fun setUp() {
MockKAnnotations.init(this)
}
@Test
fun getStations() {
val client = mockk<HttpClient>()
val repository = EtaRepositoryImpl(client, etaResponseMapper)
expectThat(repository.getLinesAndStations()).hasSize(4)
}
}
在 setUp
,这次我们多了一个新的写法:MockKAnnotations.init(this)
。首先,因为 setUp
加了 @Before
,所以这个 function 每次执行 test case 前都会行一次。而 MockKAnnotations.init(this)
的意思是叫 MockK 把所有加了 @MockK
的 property 都初始化,即是代你执行了 mockk<XXX>()
,Mockito 都有类似用法。顺带一提,如果想每次行完一个 test case 之後都执行一些东西可以把它们放到 @After
的 function 内。
由於 repository.getLinesAndStations
回传的东西是固定不变,我们就不把传回的 Map
内容逐一检查,因为这和罚抄一次没甚麽分别。所以简单地检查是不是有四个项目就算了。接着就开始准备写 getEta
的 test case。
EtaRepositoryImpl
的 constructor 有两个 dependency:HttpClient
和 Mapper<HttpResponse, EtaResult>
。後者我们之前已经写好了 unit test,前者是我们这次测试的重点:看看 server 的 response 能否顺利地被 deserialize 为 EtaResponse
和把 HttpResponse
交到 EtaResponseMapper
处理。现实上的 production server 会在不同时段返回不同的结果,例如只会在事故时提供事故的结果。所以我们要先准备一个假 server,然後 response 换做我们预先准备好的 JSON 档案,这样就可以试到不同的情景又不用大费周章部署一个测试专用的 server。
我们会把 response body JSON 档案放到 app/src/test/resources/api 内,留意要放在 test 的 resources 目录内,这样就不会在 APK/AAB 找到这些档案。
之後我们就准备 mock server 的部分。和 OkHttp 一样,Ktor client 都有提供测试专用的 artifact ktor-client-mock
。它提供了 MockEngine
可以让我们指定 response 内容而不会把 request 发送出去。
下面是 Ktor 网站提供的 MockEngine
示范:
class ApiClientTest {
@Test
fun sampleClientTest() {
runBlocking {
val mockEngine = MockEngine { request ->
respond(
content = ByteReadChannel("""{"ip":"127.0.0.1"}"""),
status = HttpStatusCode.OK,
headers = headersOf(HttpHeaders.ContentType, "application/json")
)
}
// ApiClient 内会 call httpClient.get
val apiClient = ApiClient(mockEngine)
Assert.assertEquals("127.0.0.1", apiClient.getIp().ip)
}
}
}
用法就是把原先传入去 HttpClient(engine)
的 engine 换成 MockEngine
。而 MockEngine
可以设定它会 response 甚麽的内容。由於我们几乎每一个 test case 都会用到 HttpClient
,所以我们把准备 HttpClient
的部分抽成一个 function 方便 call。
private fun mockHttpClient(
status: HttpStatusCode,
resourceName: String,
exception: Exception? = null,
): Pair<HttpClient, CapturingSlot<HttpRequestData>> {
val requestBlock = mockk<(HttpRequestData) -> Unit>()
val requestSlot = slot<HttpRequestData>()
every { requestBlock(capture(requestSlot)) } just Runs
val engine = MockEngine { request ->
requestBlock(request)
if (exception != null) throw exception
respond(
content = ByteReadChannel(
javaClass.classLoader?.getResourceAsStream(resourceName)
?.readBytes() ?: ByteArray(0)
),
status = status,
headers = headersOf(HttpHeaders.ContentType, "application/json"),
)
}
return DataModule.provideKtorHttpClient(
engine = engine,
logging = Optional.empty()
) to requestSlot
}
和 Ktor 网站的写法相比我们写得比它复杂,因为:
HttpRequestData
放出来做 assertion(因为每次网址都会因应 EtaRepository.getEta
的参数而有所不同)第一部分就是靠回传出来的 Pair 交给 caller(交 requestSlot
给 caller 做 assertion);第二部分就是靠 exception
参数是不是 null 来决定会不会 throw exception;第三部分就是用 javaClass.classLoader.getResourceAsStream
读取指定档案。
getEta
throw exception现在我们测试如果 getEta
throw exception 时会不会回传 EtaResult.Error
:
@Test
fun `getEta throw exception`() {
runBlocking {
val (client, _) = mockHttpClient(
HttpStatusCode.OK,
"api/schedule_incident.json",
RuntimeException("Something went wrong")
)
val repository = EtaRepositoryImpl(client, etaResponseMapper)
val result = repository.getEta(Language.CHINESE, Line.TKL, Station.TKO)
expectThat(result).isA<EtaResult.Error>().and {
get(EtaResult.Error::e).isA<RuntimeException>()
}
}
}
检查 request URL 的部分我们会在其他 test case 做。你或许会留意到我们今次不是用 runBlockingTest
而是用 runBlocking
。因为这次用 runBlocking
会出现以下的错误:
This job has not completed yet
java.lang.IllegalStateException: This job has not completed yet
at kotlinx.coroutines.JobSupport.getCompletionExceptionOrNull(JobSupport.kt:1190)
at kotlinx.coroutines.test.TestBuildersKt.runBlockingTest(TestBuilders.kt:53)
at kotlinx.coroutines.test.TestBuildersKt.runBlockingTest$default(TestBuilders.kt:45)
at net.swiftzer.etademo.data.EtaRepositoryImplTest.getEta normal(EtaRepositoryImplTest.kt:45)
出现「This job has not completed yet」的原因应该是 Ktor 开了新 thread 做 HTTP request/respond。但我找不到地方让我换走 Executor
或者 Dispatcher
(但 HttpClientEngineConfig
可以改变 threadsCount
)。而 Ktor 网站的示范亦都是用 runBlocking
,可能它真的没有办法用 runBlockingTest
。
TestCoroutineDispatcher
>>: [Day 19] Mattermost - Webhooks
一日客语:中文:星 客语:sen24 sam三声 简单了解: 1.生成器:function* nam...
前言 该系列是为了让看过Vue官方文件或学过Vue但是却不知道怎麽下手去重构现在有的网站而去规画的系...
「开心农场? 你认真的?」 『不然呢? 告诉你,不只开心农场,还有开心湖好吗...』 「开心湖? 先...
使用 Learner Lab 建立 WordPress 网站 (EC2) AWS Academy L...
在debian下使用alien转换MegaRAID Storage Manager的RPM到deb过...