什麼是依賴? 什麼是依賴反轉原則? (四)

Kash Yang
8 min readJun 28, 2023

--

在學習程式設計的過程中,一定會常聽到

不要設計高耦合的程式,要解耦,才好維護

看完前面三篇聊到的依賴抽象化依賴反轉原則這幾個觀念。應該對什麼是高耦合是什麼,解耦可以怎麼做有些基本的認識。

不過好維護又是什麼意思呢?除了前面提到的增加彈性,或是要改東西比較不會改太多地方,還有沒有別的呢?

當然有囉!好維護其實表示了不容易出錯,或是不容易改錯,那要如何做到呢?靠的就是測試

這一篇就是要來從軟體測試的角度切入,討論一下這些觀念在實務面上能帶來的好處

不過正如學習路上的各種觀念一樣,每一個都可以很深入的討論,一個可能又會帶出另外一個觀念。

測試也是一樣,有各種不同的測試,這篇不會每一個都討論,畢竟我們要的是討論依賴反轉原則的好處,所以我們把範圍限縮,只討論單元測試 (Unit Test) 的測試主體,這篇會全部都用Kotlin來寫,概念是不會被語言給限制的!

什麼是單元測試

Wiki:

是針對程式模組(軟體設計的最小單位)來進行正確性檢驗的測試工作。程式單元是應用的最小可測試部件。在程序化編程中,一個單元就是單個程式、函式、過程等;對於物件導向程式設計,最小單元就是方法,包括基礎類別(超類)、抽象類、或者衍生類別(子類)中的方法。

簡而言之呢就是盡可能想辦法把你的程式切分成數個小單元(小模組),分別對這些小單元進行測試,確保他都有正確運作。

爾後透過這些小模組組合而成的大模組,出錯的機會就會低很多的一種測試概念,通常這些最小單位就會是一個class、一個method等等。

測試的過程是:透過操作這個模組,來比對預期結果和實際結果有沒有一致,一致表示通過 (Pass) 測試,反之則是沒通過 (Fail)。我們用一個簡單的例子來說明一下:

假設你寫了一個calculator的class,提供大家做加法運算

class Calculator {
fun add(a: Int, b: Int) = a + b
}

你今天要測試這個模組有沒有正確的實作加法,你的單元測試可能會這樣寫

@Test
fun test_calculator_add {
let calculator = Calculator()

// 用到Assert相關的操作,這個很多測試框架都有可以自行google,這邊也不多討論

// 預期使用calculator計算5 + 5應該要等於 10
assertEquals(10, calculator.add(5, 5))

// 預期使用calculator計算10 + 15應該要等於 25
assertEquals(25, calculator.add(10, 15))

...
...
}

如果哪一天有人不小心改錯Calculator,把add()的實作從加法改為乘法,那就能透過這個測試案例 (Test Case) 找出問題囉。

fun add(a: Int, b: Int) = a * b //test case會跑失敗

這就是一個最基本的測試案例!

不過你可能發現了一件事,就是單元測試的輸入都是自己設定的,所以

單元測試只能確保在這些輸入下你的程式沒有問題,不表示其他輸入沒有問題

比如説你測試案例只寫了下面這樣,遇上加法改為乘法的狀況會被認為程式沒有問題,因為2 + 2 = 2 * 2 = 4

assertEquals(4, calculator.add(2, 2)) // 實作是乘法的話,test case 依然會pass

不過這就是單元測試的特性,在已知這些特性下,要怎麼確保自己程式碼的正確性呢?

還是老話一句,需要動手下去做多多練習囉!

了解了簡單的單元測試案例之後,我們來進入我們的正題,這跟依賴反轉原則有什麼關係?

先回到先前的範例

class IOSProgrammer(private val mac: XcodeRunnable) {
fun buildCode() {
mac.build()
}
}

那麼這個IOSProgrammer的模組該怎麼測試呢?

首先我們希望公司的程式碼理應在公司給你的機器上面build,那預期上是當iOS Programmer的buildCode()這個method被呼叫的時候,mac的相關build()的功能應該也要被呼叫到,表示使用這台機器來build

這時候我們可以使用Mockito 中的verify這個method幫我們確認,這個verify的細節我們這邊不深入討論,他可以確認條件(VerificationMode)有沒有達成

我們這次會使用到的是times()的這個mode,來確認是不是有正確呼叫,使用上會像下面這樣

// 確認mac這個物件的build()是不是被呼叫了1次
verify(mac, times(1)).build()

你可能會想問,verify是怎麼知道這個mac的build有沒有被呼叫呢?

這時候我們要介紹一個觀念,叫做 Mock Object

什麼是Mock Object

wiki:

是以可控的方式模擬真實對象行為的假的對象。程式設計師通常創造模擬對象來測試其他對象的行為

可以理解成你把一個間諜安插在某一個位置上,當有任何人事物跟這個間諜互動的時候,他都會回報給你知道,你就能掌握跟這個間諜有關所有活動

間諜可以對比到Mock Object,某一個位置則對比到class的中的成員,互動對比到函式的呼叫,所以回到範例中:

我們要把一個mock的mac 安插到iOSProgrammer裏面,然後看才能確認buildCode()的行為,是不是有跟mock的mac有正確的互動

@RunWith(JUnit4::class)
class IOSProgrammerTest {

@Mock //表示這個變數是一個Mock Object
private lateinit var mockMac: XcodeRunnable

private lateinit var iOSProgrammer: IOSProgrammer

@Before //表示開始測試之前會跑的code
fun setUp() {
//生成 Mock Object: mockMac
MockitoAnnotations.openMocks(this)

// 生成要測試的對象: iOSPRogrammer
iOSProgrammer = IOSProgrammer(mockMac)
}

@Test
fun test_mac_build_called_once() {
//執行build code的行為
iOSProgrammer.buildCode()

//確認是不是有呼叫到一次
verify(mockMac, times(1)).build()
}
}

這時候應該已經發現到了一件事,就是如果這個mac變數藏在IOSPRogrammer的class裡面的話 (就是沒有使用注入的方式),那麼安插mock object的這件事情會變得困難,就會比較難測試

另外如果沒有透過抽象化的方式符合依賴反轉原則的話,會有很多case要測,且加一個裝置就要改一次test case,也造成測試維護的麻煩

@Mock
private lateinit var mockMacbookPro: MacbookPro
@Mock
private lateinit var mockMacbookAir: MacbookAir

...

iOSProgrammerMacBookPro = IOSProgrammerMacBookPro(mockMacbookPro)
iOSProgrammerMacBookAir = IOSProgrammerMacBookAir(mockMacbookAir)
...

iOSProgrammerMacBookPro.buildCode()
iOSProgrammerMacBookAir.buildCode()
verify(mockMacbookPro, times(1)).build()
verify(mockMacbookAir, times(1)).build()
...

透過這個系列文章的範例,大家應該能感受到這幾個觀念的重要性,還有實務上到底為什麼希望大家要這麼做,期待大家都能有所收穫,多多練習!

這個系列就寫到這邊啦

祝各位Happy Coding!

一開始寫unit test的人心中應該會有個疑惑,這測試看起來也太廢了吧,這寫出來不是廢話嗎?輸入都是我的,那肯定跑得過啊!?

裁判、球證、旁證都是我的人,你要怎麼跟我鬥

其實一開始還真的是這樣,但隨著大家開始改code,總會有人不小心改壞東西而不自覺,這時候這種基本的檢查反而能幫忙找到一些低級失誤,又或是因為app畫面切換或是多執行緒的應用上,讓你的函式被多呼叫了幾次。

所以才會有所謂TDD (Test-Driven Development) 的出現(這篇沒有要講啦)

大家可能覺得函式的return值不正確或許很嚴重,但函式被多呼叫幾次不是就是浪費效能而已嗎?會有什麼影響呢?

試想今天如果你開發的是銀行的程式,多扣一次錢、或是錢扣錯地方呢?應該就會造成很大的困擾囉!

所以不要輕忽單元測試的重要性,再怎麼沒空也應該寫一些基本的,才是負責的工程師喔!

--

--