昨天,我們說過要結束系統設計單元了。今天讓我們正式進入程式設計的奇淫巧技。
身為架構師,我注意到許多軟體工程師無法分辨單元測試和整合測試。即使這兩種測試就連名字都不相同,但我們始終無法在正確的地方正確運用。
因此,這篇文章是為了幫助大家了解他們並且正確實作。
讓我們先從單元測試開始,並且解釋如何運用「依賴注入」來提升單元測試的品質。
這邊有一個函式,又稱為單元,saveData
。他的目標是將資料塞入資料庫中,但資料庫連線不是這個單元在意的工作,因此他從外部取得資料庫連線,並且將資料透過SQL寫入。
function saveData(data, {q = query, con = connect} = {}) {
/**
Call 'q' to execute the db query
Call 'con' to connect to the database
*/
con()
const strQuery = "insert into mydatabase.mytable (data) value ('" + data +"')";
if(q(strQuery)) {
return true;
} else {
return {
error: true,
msg: "There was a problem saving your data"
}
}
}
正如我們看到的,我們需要驗證成功和失敗的情況來達成完整的測試覆蓋率。但為了驗證這個單元,我們需要準備一個實體的資料庫來做測試,才有辦法提供連線並且實際寫入資料。這樣對於單元測試來說,實作負擔很大,要從哪裡生資料庫出來?又要怎麼提供資料庫連線?像這樣難以掌控的外部依賴是應該要在單元測試避免的。
因此,我們可以藉由依賴注入來減少外部依賴。依賴注入的原理是,我們假造一個可以控制的虛擬依賴,用這個虛擬依賴覆蓋掉原本很難控制的外部依賴,例如資料庫。
為了要測試單元的完整,正常來說我們必須要在資料庫中準備假資料並且實際操作資料以取得正確結果,一但透過虛擬依賴,這些過程都可以省略,因此可以更專注在行為的測試。
describe("Unit Test", () => {
it ("should return true if the data is saved into the database", async () => {
const result = await saveData('hi there!', {q: () => true, con: () => true})
result.should.be.true;
})
it ("should return an error object if the data is not saved into the database", async () => {
const result = await saveData('hi there!', {q: () => false, con: () => true})
result.should.equal({
error: true,
msg: "There was a problem saving your data"
})
})
}
上述的範例中,我們建立了一個假的資料庫並且確認我們的商業邏輯是正確的。關鍵字是「商業邏輯」,我們要在單元測試中驗證完整的商業邏輯,但不需要管資料庫是什麼。
透過依賴注入,我們可以輕鬆的驗證商業邏輯並且達到很高的測試覆蓋率。
當然,也可以用替身(mock)來取代依賴注入。但是,我認為,替身會大幅引入撰寫測試案例的複雜度。因為必須要仔細瞭解哪些元件能夠替換,並且為了替換而撰寫額外的替身類別和預期結果。
就結果來說,替身需要寫一堆僅僅只有測試才需要用到的程式碼。但透過依賴注入,我們可以簡單地運用獨立的模組/物件/介面進行整合。
好,我們已經確保單元能夠在沒有資料庫的情況下正確運行了。但當實際的資料庫加入後,事情不一定會進展得這麼順利,因此,我們還必須驗證資料庫的行為和我們預期的一致。
但我們剛已經驗證過單元了,所以現在我們只需要驗證資料庫的部分,亦即是,"insert into mydatabase.mytable (data) value ('" + data +"')"
。這就免不了需要解決單元測試我們沒有解決的問題:準備一個資料庫,並且在某處初始化資料庫連線。
而這也就是整合測試的目標,要測試外部依賴正常工作。讓我們跳過準備的過程,單純來看整合測試的測試案例。
describe("Integration Test", () => {
it ("should save data in database", async () => {
const strQuery = "insert into mydatabase.mytable (data) value ('hello world')"
const result = await query(strQuery)
result.should.be.equal(1);
})
}
這範例其實結構不夠好,因為我們其實可以套用分層架構來建立一個SQL的抽象層,稱為DAL(data access layer)。如此一來就會有一個乾淨的介面可以測試資料庫行為,而不是直接使用原始的SQL指令。
更有甚者,在領域驅動開發中,有一個相似的設計模式稱為Repository
,他提供一個資料庫存取的包裝(encapsulation)。無論是DAL或是Repository
,都是為了更方便開發,同時也會簡化撰寫整合測試的難度。
但我們的目的很簡單,就是為了測試那句SQL能夠正常工作,並且結果如我們所預期,因此這個例子就以最簡單的方式呈現。
為什麼我們需要分辨單元測試和整合測試呢?
原因是進行整合測試會消耗非常多時間,大部分的時間都會花在等待資料庫存取上。假設一個整合測試案例會佔用100ms(這對資料庫存取來說已經很快了),那麼我們就很難寫幾千個整合測試,光是測試時間就浪費不少。
為了完整測試系統,我們總是試圖覆蓋每個行為,因此,控制全部測試的時間是至關重要的。
這也就是為什麼測試金字塔的最底層是單元測試,而整合測試在其之上。
每個區塊的大小表示著測試案例的數量。單元測試的數量應該遠大於整合測試才對。
讓我們總結一下單元測試和整合測試最主要的差別。
單元測試是為了測試商業邏輯,而整合測試是為了驗證外部依賴
如果當我們混淆兩者的使用情境,那麼我們會事倍功半。