iT邦幫忙

第 11 屆 iT 邦幫忙鐵人賽

DAY 4
2
Mobile Development

Android TDD 測試驅動開發系列 第 4

Day04 - 晴天9折,雨天沒折

「晴天9折,雨天沒折。」

經常在路上會看到賣雨傘的店家會做這樣的促銷。如果我們要寫一個可以計算在晴天跟雨天有不同折扣的雨傘價錢功能。可以怎麼做呢?

新增Umbrella 類別,新增方法totalPrice傳入購買數量、價錢,回傳計算售價結果。既然售價跟天氣有關,我們需要一個Weather().isSunny()取得天氣現在是否是晴天。在計算價錢時,當天氣為晴天,雨傘打9折。

fun main() {
    val umbrella = Umbrella()
    val totalPrice = umbrella.totalPrice(1, 600)
    println("totalPrice:$totalPrice")
}

class Umbrella {
    //購買雨傘計價
    fun totalPrice(quantity: Int, price: Int): Int {
        //取得是否是晴天
        val isSunny = Weather().isSunny()
        //購買數量 * 價錢
        var price = quantity * price

        if (isSunny) {
            //晴天 -> 打9折
            price = (price * 0.9).toInt()
        }

        return price
    }
}

我們假設這個Weather.isSunny() 真的取天氣API取得目前是否晴天。而對totalPrice而言與weather是一個外部相依。totalPrice裡的行為會因weather有所不同,但卻無法控制或得知weather.isSunny()的回傳值。既然無法知道預期結果,當然也就無法測試。

外部相依將導至難以測試

依賴注入Dependency injection (DI)

依賴注入就是為了解決這種相依的問題。讓開發者能夠寫出低耦合的程式碼。

建立一個Interface IWeather,裡面有一個方法isSunny。原本的類別Weather則實作介面IWeather。

interface IWeather {
    fun isSunny(): Boolean
}

class Weather : IWeather {
    override fun isSunny(): Boolean {
        return true
    }
}

將Weather 改成IWeather
weather

接著把weather提出為function的參數,totalPrice就不再相依weather的實體,而是相依於一個介面。

fun totalPrice(weather:IWeather, quantity: Int, price: Int): Int {
    //取得是否晴天
    val isSunny = weather.isSunny()
    ...
}

weather2

在main,計算雨傘價錢時,則需傳入weather。

fun main() {
    val weather:IWeather = Weather()
    val umbrella = Umbrella()
    umbrella.totalPrice(weather, 1, 600)
}

這樣就把totoalPrice與Weather相依,改為相依為一個IWeather的Interface
weather.isSunny() 這個方法我們只需要知道會有一個實作這個interface的Instance來取得是否晴天。
而哪一個Instance去實作isSunny(),對這個totalPrace來說已經不重要了。

這樣的作法我們就叫依賴注入

依賴抽象(Interface),而不依賴實體

寫測試

之前因為天氣是不確定的所以無法測試,現在我們可以解決這個問題了

步驟:

  1. 建立一個假的天氣類別StubWeather,繼承IWeather,
  2. 新增屬性 fakeIsSunny,用來讓外部設定isSunny()回傳預期的天氣
  3. 實作 isSunny 回傳fakesSunny。
class StubWeather :IWeather{
    //建立屬性,讓外部可設定假的天氣要回傳晴天或雨天
    var fakeIsSunny = false
    
    override fun isSunny(): Boolean {
        //回傳設定的假天氣
        return fakeIsSunny
    }
}

步驟:

  1. 利用剛剛建立的StubWeather()
  2. 設定fakeIsSunnny = true 。讓isSunny永遠會回傳true
  3. 呼叫被測試程式,進行晴天的測試
  4. 驗證是否符號晴天的打9折計算結果
@Test
fun totalPrice_sunnyDay(){
    val umbrella = Umbrella()
    //1.建一個假的Weather
    val weather = StubWeather()
    //2.設定這個假的Weather永遠回傳目前天氣為晴天
    weather.fakeIsSunny = true

    //3.呼叫被測試程式,進行晴天的測試
    val actual = umbrella.totalPrice(weather, 3,100)
    val expected = 270
    //4.驗證是否符號晴天的打9折計算結果
    Assert.assertEquals(expected, actual)
}

同樣雨天測試,現在也能透過StubWeather,讓umbrella.totalPrice的weather.isSunny總是回傳雨天

@Test
fun totalPrice_rainingDay(){
    val umbrella = Umbrella()
    //建一個假的Weather
    val weather = StubWeather()
    //設定這個假的Weather永遠回傳目前天氣為晴天
    weather.fakeIsSunny = false

    //晴天的測試
    val actual = umbrella.totalPrice(weather, 3,100)
    val expected = 300
    Assert.assertEquals(expected, actual)
}

執行測試,綠燈。這樣就完成透過依賴注入的方式,讓我們可以寫測試了。

Injection 的種類

  • Method injection
  • Constructor injection
  • Property injection
  • Ambient context

Method injection
透過公開方法注入參數,在這個例子,我們將totalPrice裡的weather,提出到function當做傳入的參數。這種注入方式叫Method injection

Constructor injection
透過Constructor 建構子來注入參數,使用 Constructor injection 可以確保要注入的物件在被使用之前一定會初始化。並且透過Constructor注入的參數將不會再被修改。一般來說,Constructor injection是較推薦的方式。

Property Dependency
透過直接修改Property來注入。實際上較不常用。

Ambient context
透過修改環境物件,例Singleton。使用Ambient context是較不建議的作法。

在寫單元測試時,DI可說是非常重要的技巧,把對於某個物件的控制權移轉給第三方,解開了相依物件的耦合。之後開始Android的測試時,我們將再介紹DI的框架,讓DI的使用更方便。

範例下載:
https://github.com/evanchen76/TDD_DISample

練習:
請試著修改這個範例的Method injection為Constructor injection。

小技巧

使用重構 -> Extract Interface,將類別直接重構為實作Interface
extract interface


上一篇
Day03 - JUnit 測試框架
下一篇
Day05 - 假物件 Stub、Mock
系列文
Android TDD 測試驅動開發30

尚未有邦友留言

立即登入留言