什么是 TDD
測(cè)試驅(qū)動(dòng)開(kāi)發(fā)(Test-driven development, 簡(jiǎn)稱 TDD),是一種通過(guò)迭代進(jìn)行許多由測(cè)試支持的小更改的迭代開(kāi)發(fā)軟件的方法闻丑。
它有四個(gè)步驟:
- 寫(xiě)一個(gè)失敗的測(cè)試
- 使測(cè)試通過(guò)
- 重構(gòu)
- 重復(fù)
這個(gè)步驟也被稱為 TDD 循環(huán)漩怎,能徹底和準(zhǔn)確地測(cè)試代碼。
為什么應(yīng)該使用 TDD嗦嗡?
TDD 是確保軟件能夠正常工作并在未來(lái)繼續(xù)良好工作的唯一最佳方法勋锤。為什么?
你可以不按照 TDD 的方式來(lái)寫(xiě)測(cè)試代碼侥祭。例如叁执,先編寫(xiě)所有代碼,然后在寫(xiě)測(cè)試矮冬;或者完全跳過(guò)編寫(xiě)測(cè)試代碼谈宛,直接用手動(dòng)測(cè)試。為什么 TDD 比這些方法更好胎署?
因?yàn)?TDD 提供了確保測(cè)試良好的方法:
第一步是編寫(xiě)一個(gè)失敗的測(cè)試吆录。根據(jù)定義,這證明了測(cè)試是可能失敗的琼牧。
在編寫(xiě)新的測(cè)試之前恢筝,所有其他以前的測(cè)試都必須通過(guò)哀卫。這確保了測(cè)試的可重復(fù)性:不只是運(yùn)行正在進(jìn)行的單個(gè)測(cè)試,而是不斷地運(yùn)行所有測(cè)試撬槽。
通過(guò)頻繁地運(yùn)行每個(gè)測(cè)試此改,您會(huì)受到激勵(lì),以確保測(cè)試能夠快速運(yùn)行侄柔。所有的測(cè)試僅需要幾秒鐘才能運(yùn)行共啃,最好是一秒鐘或更短。
重構(gòu)時(shí)暂题,同時(shí)更新代碼和測(cè)試代碼移剪。這可以確保測(cè)試得到維護(hù)。
通過(guò)并行迭代編寫(xiě)代碼和測(cè)試敢靡,可以確保代碼是可測(cè)試的挂滓。如果在完成代碼后編寫(xiě)測(cè)試,那么代碼很可能需要相當(dāng)多的重構(gòu)才能完成單元測(cè)試啸胧。
哪些是需要測(cè)試的入篮?
更好的測(cè)試覆蓋并不總是意味著你的應(yīng)用程序得到了更好的測(cè)試兜畸。有些事情你應(yīng)該測(cè)試析珊,有些事情你不應(yīng)該測(cè)試敢会。以下是注意事項(xiàng):
為無(wú)法以自動(dòng)化方式捕獲的代碼編寫(xiě)測(cè)試。這包括類的方法中的代碼陷谱、自定義的 getter 和 setter 以及您自己編寫(xiě)的大多數(shù)其他內(nèi)容烙博。
不要為自動(dòng)生成的代碼編寫(xiě)測(cè)試。例如烟逊,不值得為生成的 getter 和 setter 編寫(xiě)測(cè)試渣窜。
不要為編譯器可能捕捉到的問(wèn)題編寫(xiě)測(cè)試。如果測(cè)試的問(wèn)題將生成錯(cuò)誤或警告宪躯,Xcode 將為您捕獲它乔宿。
不要為依賴代碼編寫(xiě)測(cè)試,例如應(yīng)用程序使用的系統(tǒng)框架或第三方框架访雪∠耆穑框架作者負(fù)責(zé)編寫(xiě)這些測(cè)試。例如臣缀,不應(yīng)該為 UIKit 類編寫(xiě)測(cè)試坝橡,因?yàn)?UIKit 開(kāi)發(fā)人員負(fù)責(zé)編寫(xiě)這些測(cè)試。但是精置,應(yīng)該為自定義子類編寫(xiě)測(cè)試:這是自定義代碼计寇,因此你要負(fù)責(zé)編寫(xiě)測(cè)試。
上面的一個(gè)例外是編寫(xiě)測(cè)試以確定框架如何工作。這是非常有用的番宁。但是蹲堂,不需要長(zhǎng)期保存這些測(cè)試。相反贝淤,后續(xù)應(yīng)該刪除它們。
另一個(gè)例外是“健全性測(cè)試”政供,它可以確保第三方代碼如您所期望的那樣工作播聪。如果庫(kù)不是完全穩(wěn)定的,或者您不信任它布隔,那么這類測(cè)試非常有用离陶。
TDD 需要花太多時(shí)間
關(guān)于 TDD 最常見(jiàn)的抱怨是它花費(fèi)的時(shí)間太長(zhǎng)了。
但是衅檀,一旦你習(xí)慣了招刨,TDD 會(huì)變得更快。然而哀军,事實(shí)是沉眶,與根本不編寫(xiě)任何測(cè)試相比,您最終編寫(xiě)的代碼更多杉适。剛剛開(kāi)始用 TDD 可能需要更多的時(shí)間谎倔。
但是,你要知道:開(kāi)發(fā)的成本不僅僅是最開(kāi)始編寫(xiě)的第一個(gè)版本的代碼猿推。它還包括隨著時(shí)間的推移添加新功能片习、修改現(xiàn)有代碼、修復(fù)錯(cuò)誤等等蹬叭。從長(zhǎng)遠(yuǎn)來(lái)看藕咏,遵循 TDD 比不遵循 TDD 花費(fèi)的時(shí)間要少得多,因?yàn)樗拇a更易于維護(hù)秽五,錯(cuò)誤更少孽查。
還有另一個(gè)要考慮的成本:生產(chǎn)中缺陷對(duì)客戶的影響。一個(gè)問(wèn)題被發(fā)現(xiàn)的時(shí)間越長(zhǎng)筝蚕,成本就越高卦碾。它可能導(dǎo)致負(fù)面評(píng)論、失去信任和收入損失起宽。
如果在開(kāi)發(fā)過(guò)程中發(fā)現(xiàn)了問(wèn)題洲胖,那么調(diào)試起來(lái)更容易,修復(fù)也更快坯沪。如果你在幾周后發(fā)現(xiàn)它绿映,你將花費(fèi)更多的時(shí)間來(lái)加速代碼的運(yùn)行并找出根本原因。通過(guò)遵循 TDD,你的測(cè)試最終有助于保護(hù)你的應(yīng)用程序免受 bug 的影響叉弦。
什么時(shí)候應(yīng)該使用 TDD丐一?
TDD 可以在產(chǎn)品生命周期的任何時(shí)候使用:新開(kāi)發(fā)的、已存在的應(yīng)用程序以及介于兩者之間的一切淹冰。然而库车,如何以及從哪里開(kāi)始 TDD 確實(shí)取決于項(xiàng)目的狀態(tài)。
然而樱拴,有一個(gè)重要的問(wèn)題需要問(wèn):您的項(xiàng)目是否應(yīng)該使用 TDD柠衍?
一般來(lái)說(shuō),如果你的應(yīng)用要持續(xù)幾個(gè)月以上晶乔,會(huì)有多個(gè)版本和/或需要復(fù)雜的邏輯珍坊,那么你最好還是使用 TDD。
如果你為一些臨時(shí)性的東西創(chuàng)建一個(gè)應(yīng)用程序正罢,你應(yīng)該評(píng)估 TDD 是否有意義阵漏。如果真的只有一個(gè)版本的應(yīng)用程序,你可能不會(huì)遵循 TDD翻具,或者只對(duì)關(guān)鍵或困難的部分進(jìn)行 TDD履怯。
歸根結(jié)底,TDD是一種工具呛占,您可以決定何時(shí)最好地使用它虑乖!
TDD 簡(jiǎn)單案例
在上面的內(nèi)容已經(jīng)提到,TDD 的流程有四個(gè)步驟:
- 寫(xiě)一個(gè)失敗的測(cè)試
- 使測(cè)試通過(guò)
- 重構(gòu)
- 重復(fù)
下面通過(guò)例子來(lái)演示每個(gè)步驟晾虑。
寫(xiě)一個(gè)失敗的測(cè)試
在 playground 中編寫(xiě)以下測(cè)試用例:
class CashRegisterTests: XCTestCase {
func testInit_createsCashRegister() {
XCTAssertNotNil(CashRegister())
}
}
// 調(diào)用這個(gè)可以在 playground 運(yùn)行測(cè)試
CashRegisterTests.defaultTestSuite.run()
在 iOS 中疹味,測(cè)試用例的方法都是以 test
開(kāi)頭。因?yàn)闆](méi)有定義 CashRegister
帜篇,所以編譯器報(bào)錯(cuò)糙捺。而對(duì)于 TDD 循環(huán)來(lái)說(shuō),編譯報(bào)錯(cuò)可以看做是測(cè)試失敗笙隙,所以到此我們完成第一步:寫(xiě)一個(gè)失敗的測(cè)試洪灯。
使測(cè)試通過(guò)
如果編寫(xiě)最少的代碼使得上面的測(cè)試通過(guò)?當(dāng)然是定義 CashRegister
竟痰。
class CashRegister {
}
執(zhí)行 playground 后签钩,得到以下輸出:
Test Suite 'CashRegisterTests' started at 2020-08-06 02:17:19.397
Test Case '-[__lldb_expr_1.CashRegisterTests testInit_createsCashRegister]' started.
Test Case '-[__lldb_expr_1.CashRegisterTests testInit_createsCashRegister]' passed (0.210 seconds).
Test Suite 'CashRegisterTests' passed at 2020-08-06 02:17:19.608.
Executed 1 test, with 0 failures (0 unexpected) in 0.210 (0.212) seconds
可以看到測(cè)試通過(guò),所以到此我們完成第二步:使測(cè)試通過(guò)坏快。
重構(gòu)
在這一步中铅檩,我們將清理應(yīng)用程序代碼和測(cè)試代碼。通過(guò)這樣做莽鸿,可以不斷地維護(hù)和改進(jìn)代碼昧旨。以下是一些可以重構(gòu)的內(nèi)容:
重復(fù)的邏輯:反問(wèn)自己拾给,能抽出任何屬性、方法或類來(lái)消除重復(fù)嗎兔沃?
注釋:注釋?xiě)?yīng)該解釋為什么要做某件事蒋得,而不是怎么做的。盡量消除解釋代碼工作原理的注釋乒疏。應(yīng)該通過(guò)將復(fù)雜方法分解為幾個(gè)命名良好的方法额衙,重命名屬性和方法以使其更清晰,或者有時(shí)只是簡(jiǎn)單地將代碼結(jié)構(gòu)更好地表達(dá)出來(lái)怕吴。
錯(cuò)誤代碼:有時(shí)某個(gè)特定的代碼塊似乎是錯(cuò)誤的入偷。相信你的直覺(jué),試著消除這些錯(cuò)誤代碼械哟。例如,你可能有做過(guò)多假設(shè)的邏輯殿雪,使用硬編碼字符串或有其他問(wèn)題暇咆。
現(xiàn)在, CashRegister
和 CashRegisterTests
沒(méi)有太多的邏輯丙曙,也沒(méi)有什么可以重構(gòu)的爸业。所以到此我們完成第三步:重構(gòu)。
重復(fù)
前面已經(jīng)完成了第一個(gè) TDD 周期亏镰,現(xiàn)在我們重復(fù)這個(gè)周期扯旷。在這內(nèi)容,將為 CashRegister
添加以下方法:
- 接受可用資金參數(shù)的初始化器
- 用于添加 item 的方法
接受可用資金參數(shù)的初始化函數(shù)
首先寫(xiě)測(cè)試用例:
func testInitAvailableFunds_setsAvailableFunds() {
// given
let availableFunds: Decimal = 100
// when
let sut = CashRegister(availableFunds: availableFunds)
// then
XCTAssertEqual(sut.availableFunds, availableFunds)
}
編譯錯(cuò)誤索抓,因?yàn)?CashRegister
還沒(méi)有那個(gè)初始化函數(shù)钧忽。下面編寫(xiě)這個(gè)函數(shù):
class CashRegister {
var availableFunds: Decimal
init(availableFunds: Decimal = 0) {
self.availableFunds = availableFunds
}
}
編譯錯(cuò)誤消失,執(zhí)行 playground 后逼肯,得到以下輸出:
Test Suite 'CashRegisterTests' started at 2020-08-06 09:42:40.794
Test Case '-[__lldb_expr_3.CashRegisterTests testInit_createsCashRegister]' started.
Test Case '-[__lldb_expr_3.CashRegisterTests testInit_createsCashRegister]' passed (0.174 seconds).
Test Case '-[__lldb_expr_3.CashRegisterTests testInitAvailableFunds_setsAvailableFunds]' started.
Test Case '-[__lldb_expr_3.CashRegisterTests testInitAvailableFunds_setsAvailableFunds]' passed (0.008 seconds).
Test Suite 'CashRegisterTests' passed at 2020-08-06 09:42:40.978.
Executed 2 tests, with 0 failures (0 unexpected) in 0.182 (0.184) seconds
可以看到測(cè)試通過(guò)耸黑。到此我們完成了兩個(gè)步驟:1. 寫(xiě)一個(gè)失敗的測(cè)試;2. 使測(cè)試通過(guò)篮幢。
第三步是重構(gòu)大刊,這一步中我們將清理應(yīng)用程序代碼和測(cè)試代碼。
對(duì)于測(cè)試代碼三椿,可以發(fā)現(xiàn) testInit_createsCashRegister
是沒(méi)必要的缺菌,所以可以把它刪掉。
對(duì)于應(yīng)用代碼搜锰,初始化器 init(availableFunds: Decimal = 0)
的參數(shù)有一個(gè)默認(rèn)值伴郁,我們得思考這個(gè)默認(rèn)值是否有必要?這將會(huì)產(chǎn)生以下兩種情況:
- 如果保留默認(rèn)值纽乱,那么就得考慮為這個(gè)默認(rèn)值添加測(cè)試
- 如果不保留默認(rèn)值蛾绎,那么就把它刪掉
在這里,我們把它刪掉,變成:
init(availableFunds: Decimal) {
self.availableFunds = availableFunds
}
重構(gòu)完成后租冠,繼續(xù)執(zhí)行 playground鹏倘,發(fā)現(xiàn)還能測(cè)試通過(guò)。通過(guò)這個(gè)例子顽爹,可以感覺(jué)到遵循 TDD 能讓我們?cè)谥貥?gòu)時(shí)更有信心纤泵。
用于添加 item 的方法
首先寫(xiě)測(cè)試用例:
func testAddItem_oneItem_addsCostToTransactionTotal() {
// given
let availableFunds: Decimal = 100
let sut = CashRegister(availableFunds: availableFunds)
let itemCost: Decimal = 42
// when
sut.addItem(itemCost)
// then
XCTAssertEqual(sut.transactionTotal, itemCost)
}
編譯錯(cuò)誤,因?yàn)?CashRegister
還沒(méi)有 addItem
方法和 transactionTotal
屬性镜粤。下面編寫(xiě) addItem
方法和 transactionTotal
屬性:
class CashRegister {
var transactionTotal: Decimal = 0
var availableFunds: Decimal
init(availableFunds: Decimal = 0) {
self.availableFunds = availableFunds
}
func addItem(_ cost: Decimal) {
transactionTotal = cost
}
}
在 addItem
方法的實(shí)現(xiàn)中捏题,直接把 cost
賦值給 transactionTotal
很明顯是不對(duì)的。但對(duì)于這個(gè)測(cè)試用例來(lái)說(shuō)肉渴,這么做也能讓編譯錯(cuò)誤消失公荧。所以我們暫時(shí)先這么做。執(zhí)行 playground 后同规,可以看到測(cè)試通過(guò)循狰。
接下來(lái)進(jìn)行重構(gòu)。
對(duì)于測(cè)試代碼券勺,可以發(fā)現(xiàn)一下的代碼在測(cè)試用例中重復(fù)了:
let availableFunds: Decimal = 100
let sut = CashRegister(availableFunds: availableFunds)
所以我們可以把這兩個(gè)變量抽出來(lái)绪钥,作為 CashRegisterTests
的屬性,并且在 setUp()
和 tearDown()
方法中初始化和重置他們的值关炼。最終重構(gòu)后的代碼如下:
class CashRegisterTests: XCTestCase {
var availableFunds: Decimal!
var sut: CashRegister!
override func setUp() {
super.setUp()
availableFunds = 100
sut = CashRegister(availableFunds: availableFunds)
}
override func tearDown() {
availableFunds = nil
sut = nil
super.tearDown()
}
func testInitAvailableFunds_setsAvailableFunds() {
XCTAssertEqual(sut.availableFunds, availableFunds)
}
func testAddItem_oneItem_addsCostToTransactionTotal() {
// given
let itemCost: Decimal = 42
// when
sut.addItem(itemCost)
// then
XCTAssertEqual(sut.transactionTotal, itemCost)
}
}
在 setUp()
中初始哈變量的值程腹;在 tearDown()
方法中重置變量的值。另外儒拂,我們應(yīng)該總是在 tearDown()
中把變量設(shè)置為 nil
寸潦,因?yàn)?XCTestCase
類只在所有測(cè)試完成之后才會(huì)釋放變量占用的內(nèi)存,所以如果我們有很多測(cè)試用例并且不在 tearDown()
中把變量設(shè)置為 nil
的話社痛,那么測(cè)試的性能可能會(huì)受到影響甸祭。
添加兩個(gè) items
testAddItem_oneItem
測(cè)試用例證明了 addItem()
在添加一個(gè) item 時(shí)是正確的。如果添加兩個(gè)或更多 items 時(shí)會(huì)怎樣呢褥影?我們來(lái)測(cè)試一下池户。
添加測(cè)試用例如下:
func testAddItem_twoItems_addsCostsToTransactionTotal() {
// given
let itemCost: Decimal = 42
let itemCost2: Decimal = 20
let expectedTotal = itemCost + itemCost2
// when
sut.addItem(itemCost)
sut.addItem(itemCost2)
// then
XCTAssertEqual(sut.transactionTotal, expectedTotal)
}
執(zhí)行后,發(fā)現(xiàn)剛剛添加的測(cè)試用例失敗了凡怎。這證明 addItem()
方法的實(shí)現(xiàn)有問(wèn)題校焦,我們很快找到問(wèn)題所在,把實(shí)現(xiàn)改為:
transactionTotal += cost
再次執(zhí)行后统倒,所有測(cè)試通過(guò)寨典。
接下來(lái)是重構(gòu):仔細(xì)查看測(cè)試代碼,發(fā)現(xiàn) itemCost
可以抽取出來(lái)房匆,重構(gòu)后代碼為:
class CashRegisterTests: XCTestCase {
var availableFunds: Decimal!
var sut: CashRegister!
var itemCost: Decimal!
override func setUp() {
super.setUp()
availableFunds = 100
sut = CashRegister(availableFunds: availableFunds)
itemCost = 42
}
override func tearDown() {
availableFunds = nil
sut = nil
itemCost = nil
super.tearDown()
}
func testInitAvailableFunds_setsAvailableFunds() {
XCTAssertEqual(sut.availableFunds, availableFunds)
}
func testAddItem_oneItem_addsCostToTransactionTotal() {
// when
sut.addItem(itemCost)
// then
XCTAssertEqual(sut.transactionTotal, itemCost)
}
func testAddItem_twoItems_addsCostsToTransactionTotal() {
// given
let itemCost2: Decimal = 20
let expectedTotal = itemCost + itemCost2
// when
sut.addItem(itemCost)
sut.addItem(itemCost2)
// then
XCTAssertEqual(sut.transactionTotal, expectedTotal)
}
}
參考資料
iOS Test-Driven Development by Tutorials
完
有問(wèn)題可以直接留言耸成。