Android Testing Framework 介紹 - Kotest

黃德銘
Dcard Tech Blog
Published in
9 min readAug 3, 2021

身為軟體工程師的我,在遇到問題時總會思考是否能將解答自動化以減少未來的使用成本,以軟體來說在開發新功能解決所遇到的問題後,可以額外撰寫自動化測試來減少人工測試及未來維護的成本。

在 Android 中有許多測試框架可以使用,像是 JUnit, Cucumber, Calabash, Spek …等,而近幾年有一款新的測試框架吸引了我的目光,也就是這篇介紹的 Kotest,在介紹之前我想先跟最常見的 JUnit 來做些比較,JUnit 有哪些不足而 Kotest 可以補足的。

JUnit 優點

  • 使用簡單
  • 很多常用的基本功能都有
  • 撰寫風格很固定
  • 適合撰寫 unit test
  • Android Studio 內建就支援,省去許多環境設定的麻煩

缺點

  • 執行順序不會照 test 撰寫順序(筆者本身偏好)
  • test 命名較為不便
  • 不適合撰寫 BDD(Behavior-Driven Development) 的測試
  • 階層只有一層,想針對該物件的不同功能寫測試可能較不便
  • JUnit 的 BeforeClass 須使用 static function,若有繼承需求使用上會較麻煩

列出了筆者認為的優缺點後,接著介紹 Kotest 的特色。

提供多達 10 種不同的撰寫 style

每款不同的測試框架會有不同的撰寫方式,在一款新的東西出現時,開發者的轉換成本是很重要的,除非是開發一個新專案,不然舊轉新的陣痛期一定是考量的重點之一,而 10 種不同的撰寫 style 能大大降低轉換成本,這邊就介紹幾種撰寫 style:

  • FunSpec

這個撰寫方式跟 JUnit 十分相似,能夠很快速的上手。

class FunTest: FunSpec({
test("String length should return the length of the string") {
Assert.assertEquals("sammy".length, 5)
}
}
)
  • DescribeSpec

DescribeSpec 提供多階層的撰寫 style,在不同情況下可以用多個 context 設定測試所需要的環境。

class DescribeTest: DescribeSpec({
var value = 0
context("DescribeSpec Example") {
describe("set value to 1") {
value = 1

it("value should be 1") {
Assert.assertEquals(1, value)
}
}
describe("set value to 2") {
value = 2

it("value should be 2") {
Assert.assertEquals(2, value)
}
}
}
}
)
  • AnnotationSpec

寫法跟 JUnit 幾乎一樣,只要繼承 AnnotationSpec() 就可以了,對於 JUnit 移植到 Kotest 的成本相當低。

class AnnotationTest: AnnotationSpec() {
@Before
fun beforeTest() {
println("Before each test")
}

@Test
fun test1() {
Assert.assertEquals(1, 1)
}
}

提供口語化的 assertion

在 Kotest 中可以用 JUnit 提供的 assertion,也可使用 Kotest 提供的 assertion。

class FunTest: FunSpec({
test("String length should return the length of the string") {
Assert.assertEquals("sammy".length, 5)
"sammy".length shouldBe 5
}
test("String length's type should be Int") {
Assert.assertTrue("sammy".length is Int)
"sammy".length.shouldBeInstanceOf<Int>()
}
test(list is empty") {
val list = emptyList<Int>()
Assert.assertTrue(list.isEmpty())
list.shouldBeEmpty()
}
}
)

Kotest 的 assertion 滿口語化的,看起來也優雅許多,官方文件說提供的 assertion 多達 320 種。

Kotest 也提供自訂 Matcher 的功能,有些會重複利用的 assertion 就可以包裝起來。

class FunTest : FunSpec({
test("should contain foo") {
"hello foo" should containFoo()
}
}
)

fun containFoo() = object : Matcher<String> {
override fun test(value: String) = MatcherResult(
value.contains("foo"),
"String $value should include foo",
"String $value should not include foo"
)
}

可選擇獨立或共用的測試環境

一般在測試物件時,我們會希望各個測試互相獨立不要互相影響,但是在測試 Singleton 物件時可能就會遇上一些麻煩,所以 Kotest 提供設定不同的 Isolation Mode 讓我們依需求選擇。

  • SingleInstance(預設)
class SingleInstanceExample : WordSpec({
val id = UUID.randomUUID()
"a" should {
println(id)
"b" {
println(id)
}
"c" {
println(id)
}
}
})

三次印出來的東西都一樣。

  • InstancePerTest
class InstancePerTestExample : WordSpec() {

override fun isolationMode(): IsolationMode = IsolationMode.InstancePerTest

val counter = AtomicInteger(0)

init {
"a" should {
println("a=" + counter.getAndIncrement())
"b" {
println("b=" + counter.getAndIncrement())
}
"c" {
println("c=" + counter.getAndIncrement())
}
}
}
}

這個 Spec 的 should 是個可以裝其他 Test 的 Container,在 Kotest 中 Container 也是一個 Test,所以在這邊的 test case 會是 a, a b, a c,印出的結果會是:

a=0
a=0
b=1
a=0
c=1
  • InstancePerLeaf
class InstancePerLeafExample : WordSpec() {

override fun isolationMode(): IsolationMode = IsolationMode.InstancePerLeaf

val counter = AtomicInteger(0)

init {
"a" should {
println("a=" + counter.getAndIncrement())
"b" {
println("b=" + counter.getAndIncrement())
}
"c" {
println("c=" + counter.getAndIncrement())
}
}
}
}

這個 Mode 不會獨立執行 Container,所以在這邊的 test case 會是 a b, a c,這個也是筆者最常用的 mode。

印出的結果會是:

a=0
b=1
a=0
c=1

Module 層級的設定

在用 JUnit 寫測試時,每個測試會需要一些初始化設定,像是在針對 ViewModel 寫測試會使用到 LiveData,以及一些物件也會需要 mock,所以會需要像這樣的初始化設定。

class MainViewModelTest {
@get:Rule
val instantTaskExecutorRule = InstantTaskExecutorRule()
@Before
fun beforeTest() {
// mock object
}
}

在每個測試都寫一遍老實說會有點心累,而且複製貼上對於未來擴充很不方便,也許可以透過繼承的方式來解決,但 JUnit 的 BeforeClass 在繼承上使用更加麻煩;而 Kotest 提供了針對整個 module 設定的功能,使用上也相當簡單。

object ProjectConfig : AbstractProjectConfig() {
override val isolationMode: IsolationMode = IsolationMode.InstancePerLeaf

override fun beforeAll() {
super.beforeAll()
}

override fun afterAll() {
super.afterAll()
}
}

檔案擺放位置:

這樣在同 Module 下測試的 IsolationMode 都是 InstancePerLeaf了,也可以在 ProjectConfig 中的 beforeAll() 來 mock Coroutine, LiveData …等東西,在 mock 常用物件的狀態時就不用各個地方都要寫一次,相當方便呢!

Kotest 在使用上容易上手,更新速度也很快,筆者以前回報過 3 個 bug 也很迅速就修復了,所以滿推薦大家來玩玩看,更詳細的內容可以到官方文件參閱。

連結:Quick start, Github

--

--

黃德銘
Dcard Tech Blog

愛東玩西玩的 Android 工程師,玩過 Ktor, Jetpack Compose for Desktop 和 iOS