這系列的文章跟Android有關的部份我會用Kotlin來展示,因應google已經宣佈未來會加強對Kotlin的支援有些sample不再發行Java版本,所以還沒有開始Kotlin的朋友可以開始慢慢學習Kotlin了,如果還沒接觸Kotlin的人也沒關係,因為這邊不是Kotlin教學所以不會介紹基本語法,但使用到的語法都算基本應該不難理解。
講到unit test我們從最簡單的範例開始講大概是長這樣,我們實作一個Utility add function然後這add有input value a和b,最後會相對應輸出一個output value,一開始我們先不要急著進入程式語言testing framework的部份,我們先用很直覺的println把我們寫好的結果列印出來比對一下是不是跟我們想要驗證的結果一樣,這也是一種unit test。如下面範例我們把utility實體化後去做true and false的比對,此時我們就完成了一個很簡單的unit test來驗證function add有沒有合乎預期結果。
class Utility {
fun add(a: Int, b: Int): Int {
return a + b
}
}
fun main() {
val utility = Utility()
println(utility.add(1, 1) == 2) //true
println(utility.add(2, 3) == 5) //true
println(utility.add(3, 5) == 8) //true
}
看起來寫unit test沒那麼困難,就是給一個input然後再給一個output去比對就解決了,可惜現實世界沒那麼單純。不是所有function內只會呼叫程式語言原生類別的物件及運算,如果今天function內必須呼叫非原生類別的物件及運算那該怎麼做?
例如我們有一個Price class負責計算每個商品的最後售價,然後我們有一個DiscountRule class來負責定義每個discount type的折扣比率,程式想做到的目的很簡單就只是計算一個相乘後的價格。
class Price {
private val rule = DiscountRule(1)
fun getSalePrice(price: Int): Int {
val result = price * rule.getDiscountRate()
return result.toInt()
}
fun getSaleTitle(): String {
return "We have ${rule.getDiscountTitle()} price now!"
}
}
class DiscountRule(private val type: Int) {
fun getDiscountRate(): Float {
return when (type) {
1 -> 0.7F
2 -> 0.8F
else -> 1F
}
}
fun getDiscountTitle(): String {
return when (type) {
1 -> "Annual"
2 -> "Seasonal"
else -> "Normal"
}
}
}
我們在main的測試也很簡單把它print出來比較看看。這個程式要示範的目的比上面Utility class的範例多了自定class。
fun main() {
val price = Price()
println(price.getSalePrice(1000) == 700) //true
println(price.getSalePrice(500) == 350) //true
println(price.getSaleTitle() == "We have Annual price now!") //true
}
目前程式看起來都沒問題,但是今天要把測試Price class裡面用不同DiscountRule的情況那好像就出現了問題。因為我們在Price class裡面把DicountRule直接實體化instance出來,所以程式就硬生生的變成了只能執行一種流程。
這裡發現了兩個問題,其一、測試程式無法測試其他狀況,只能測試你在主程式中定義好的流程DiscountRule(1)。其二、把DiscountRule寫死在Price中會導至每次要換DiscountRule的時候都要去修改Price的code。這樣寫法看似沒問題但是當你的Price class的邏輯越來越複雜,很多地方都呼叫Price的時候修改Price本身就是一個很大風險,你的task需求改了DiscountRule,結果有一個地方卻依據之前已經定義好的Rule來寫它的邏輯就容易造成side effect。
這也是在OOP SOLID原則裡談到的開放擴充原則open-closed principle,對修改封閉、對擴充開放,盡量把程式依照此原則設計就比較不容發生後續修改有不預期的狀況。所以我們要怎麼改會比較好呢?
依照這個範例我們只要把原本實體化DiscountRule的地方改成用setter加入的方式來注入到Price類別中。在Java裡面我們會習慣用一個setter function,這也是所謂的依賴注入(Dependency Injection),把兩者的關係用一個介面的方式連結起來,由外部呼叫的物件(Main function)來決定實體是誰,而不是直接在內部(Price)物件寫死。
fun setRule(rule: DiscountRule) {
this.rule = rule
}
但是在Kotlin裡面我們只要把用private val rule來宣告的地方換成用lateinit var rule來宣告就可以了,在Kotlin裡用var宣告本身就有隱含setter非必要不必另外override。
把instance實體化的方式改成setter後我們在main function裡面執行時、就可以針對不同情況對Price class裡的rule setter給予不同的DiscountRule。此時可以輕鬆改變Price裡getSalePrice跟getSaleTitle的行為,這樣不論在測試或是後續修改程式都方便多了。
class Price {
lateinit var rule: DiscountRule
fun getSalePrice(price: Int): Int {
val result = price * rule.getDiscountRate()
return result.toInt()
}
fun getSaleTitle(): String {
return "We have ${rule.getDiscountTitle()} price now!"
}
}
fun main() {
val price = Price()
price.rule = DiscountRule(0)
println(price.getSalePrice(1000) == 1000)
println(price.getSalePrice(500) == 500)
println(price.getSaleTitle() == "We have Normal price now!")
price.rule = DiscountRule(1)
println(price.getSalePrice(1000) == 700)
println(price.getSalePrice(500) == 350)
println(price.getSaleTitle() == "We have Annual price now!")
price.rule = DiscountRule(2)
println(price.getSalePrice(1000) == 800)
println(price.getSalePrice(500) == 400)
println(price.getSaleTitle() == "We have Seasonal price now!")
}
所以從這邊看出來不管是OCP還是DI這些心法都是要讓我們的程式修改更容易更不容易出錯,也是要開始寫做unit test前必須要知道的基本功。