麻省理工18年春软件构造课程阅读03“测试”
本文內容來自MIT_6.031_sp18: Software Construction課程的Readings部分,采用CC BY-SA 4.0協議。
由于我們學校(哈工大)大二軟件構造課程的大部分素材取自此,也是推薦的閱讀材料之一,于是打算做一些翻譯工作,自己學習的同時也能幫到一些懶得看英文的朋友。另外,該課程的閱讀資料中有許多練習題,但是沒有標準答案,所給出的答案均為譯者所寫,有錯誤的地方還請指出。
譯者:李秋豪 江家偉
審校:李秋豪
V1.0 Tue Mar 6 01:54:34 CST 2018
本次課程的目標
- 理解測試的意義,并了解“測試優先編程”的過程
- 能夠使用“分區”的方法選擇合適的輸入輸出測試用例
- 能夠通過代碼覆蓋率來評價一個測試的好壞
- 理解黑盒/白盒測試、單元/集成測試、自動化回歸測試。
驗證(Validation)
“測試”是“驗證”的一種例子。而驗證的目的就是發現程序中的問題,以此提升你對程序正確性的信心。驗證包括:
- 形式推理,即通過理論推理證明程序的正確性。形式推理目前還缺乏自動化的工具,通常需要漫長的手工計算。即使是這樣,一些關鍵性的小程序也是需要被證明的,例如操作系統的調度程序、虛擬機里的字節碼解釋器,或者是 文件系統.
- 代碼審查. 即讓別人仔細的閱讀、審校、評價你的代碼,這也是發現bug的一個常用方法,我們會在下一個reading里面介紹這種方法。
- 測試.即選擇合適的輸入輸出用例,通過運行程序檢查程序的問題。
即使是最優的驗證,程序也不可能達到十全十美,這里列出了一般情況下程序的剩余缺陷率residual defect rates(軟件發行時存在的bug比率) ,這里的單位是每 kloc (每一千行代碼):
- 1 - 10 defects/kloc: 常見的工業級軟件。
- 0.1 - 1 defects/kloc: 高質量驗證后的軟件。例如Java的官方庫可能就是這個級別。
- 0.01 - 0.1 defects/kloc: 最高級別、軍工/安全關鍵軟件。例如NASA或者像Praxis這樣的公司(譯者注:1.歐洲著名的電力行業信息化解決方案專家,隸屬于世界第三大的電力集團E.ON,總部位于德國。2.美國教師資格證考試 這里不知道說的是第一個還是第二個(體現一下幽默感?))
這看起來讓人沮喪,想一想,如果你寫了100萬行的大型程序,那你很可能沒檢查出1000個bug!
為什么軟件測試很困難
這里有一些在工業界測試產品常用的方法,可是它們在軟件行業無法發揮應有的作用。
盡力測試(嘗試所有的可能):這通常是不可行的,因為大多數情況下輸入空間會非常大,例如僅僅是一個浮點數乘法a*b ,其總共的取值就有2^64種可能性!
隨機測試 (試一下看看行不行): 這通常難以發現bug,除非這個程序到處都是bug以至于隨便一個輸入都能崩潰。即使我們修復了測試出來的bug,隨機的輸入也不能使我們對程序的正確性很確定。
基于統計方法的測試:遺憾的是,這種方法對軟件不那么奏效。在物理系統里,工程師可以通過特定的方法加速實驗的進程,例如在一天的時間里打開關閉一個冰箱門一千次,以此來模擬幾年的正常使用,最終得到產品的”失敗率“。以后的測試結果也將會集中分布在這個比率左右,工程師們就對這個比率進行進一步的研究。但是軟件的行為通常是離散且不可預測的。程序可能在上一秒還完全正常的工作,突然就崩潰了,也可能對于大多數輸入都沒問題,對于一個值就崩潰了(沒有預兆,更談不上失敗率,所以很難提前做好監測的準備),例如 有名的奔騰處理器除法bug ,在90億次的除法中才可能會有一個錯誤。
綜上,我們必須系統而且小心的選擇測試用例,這也是下面要講的。
閱讀小練習
測試基礎
阿麗亞娜5型火箭,為歐洲空間局研發的民用衛星一次性運載火箭,名稱來源于神話人物阿麗雅杜妮(Ariadne)的法語拼寫。1996年6月4日,在風和日麗的法屬圭亞那太空發射場,阿麗亞娜5型運載火箭首航,計劃運送4顆太陽風觀察衛星到預定軌道。但在點火升空之后的40秒后,在4000米高空,這個價值5億美元的運載系統就發生了爆炸,瞬間灰飛煙滅化為烏有。
爆炸原因由于火箭某段控制程序直接移植自阿麗亞娜4型火箭,其中一個需要接收64位數據的變量為了節省存儲空間而使用了16位字節,從而在控制過程中產生了整數溢出,導致導航系統對火箭控制失效,程序進入異常處理模塊,引爆自毀。
這個故事告訴了我們什么?
- [x] 即使是高度關鍵性的程序也可能有bug
- [ ] 測試所有可能輸入是解決這樣的問題的最好辦法
- [x] 與很多物理工程學上的系統不同,軟件的行為是離散的
- [ ] 靜態檢查有助于發現這個bug
靜態類型檢查不會檢測到此錯誤,因為代碼有意(強轉)將64位精度轉換為16位精度。
測試應該具備的心態(Putting on Your Testing Hat)
測試需要一個正確的態度:當你在寫一個程序的時候,你的心態一定是讓這個程序正常運行,但是作為一個測試者,你應該想方設法讓程序崩潰。
這是一個隱晦但重要的區別,一個優秀的測試員會“揮舞的重錘敲打代碼可能有問題的地方”,而不是“小心的呵護它”。
測試優先編程(Test-first Programming)
測試開始的時間應該盡量早,并且要頻繁地測試。當你有一大堆未經驗證的代碼時,不要把測試工作留到最后。把測試工作留到最后只會讓調試的時間更久并且調試過程更加痛苦,因為你的代碼將會充斥著bug。反之,如果你在編碼的過程中就進行測試,情況就會好的多。
在測試優先編程中,測試程序先于代碼完成。編寫一個函數應該按如下步驟進行:
規格說明描述了這個函數的輸入輸出行為。它確定了函數參數的類型和對它們的所有約束(例如sqrt函數的參數必須是非負的)。它還定義了函數的返回值類型以及返回值和輸入之間的關系。你已經在這門課中對許多問題都查看并使用過規格說明。在代碼中,規格說明包括了函數簽名和一些描述函數功能的注釋。我們將會在接下來的幾節課里討論更多關于規格說明的問題。
先完成測試用例的編寫能夠讓你更好地理解規格說明。規格說明也可能存在問題——不正確、不完整、模棱兩可、缺失邊界情況。先嘗試編寫測試用例,可以在你浪費時間實現一個有問題的規格說明之前發現這些問題。
通過分區的方法選擇測試用例
選擇合適的測試用例是一個具有挑戰性但是有缺的問題。我們即希望測試空間足夠小,以便能夠快速完成測試,又希望測試用例能夠驗證盡可能多的情況。
為了達到這個目的,我們可以先將輸入空間劃分為幾個子域(subdomains) ,每一個子域都是一類相似的數據。如上圖所示,我們在每個子域中選取一些數據,它們合并起來就是我們需要的輸入用例。
分區背后的原理在于同一類型的數據在程序中的行為大多類似,所以我們可以用一小部分代表整體的行為。這個方法的優點在于強迫程序相應輸入空間里的不同地方,有效的利用了測試資源。
如果我們要確保測試的輸出能夠覆蓋輸出空間的不同地方,也可以將輸出空間劃分為幾個子域(哪些輸出代表程序發生了相似的行為)。大多數情況下,對輸入分區就足夠了
例子1: BigInteger.multiply()
現在讓我們來看一個例子。 BigInteger 是Java庫中的一個類,它能夠表示任意大小的整數。同時,它有一個multiply 方法,能夠對兩個BigInteger類型的值進行相乘操作:
/*** @param val another BigInteger* @return a BigInteger whose value is (this * val).*/ public BigInteger multiply(BigInteger val)例如,計算ab的值:
BigInteger a = ...; BigInteger b = ...; BigInteger ab = a.multiply(b);這個例子顯示即使只有一個參數,這個操作實際上有兩個操作符:你調用這個方法所在的對象(上面是a ),以及你傳入的參數(上面是b )。(在Python中,接受方法調用的對象會顯式以self被聲明。在Java中你不需要聲明這個對象,它隱式的被稱作this )我們可以把 multiply 看成一個有兩個參數的方法,參數的類型是 BigInteger ,并且輸出的類型也是 BigInteger 即:
multiply : BigInteger × BigInteger → BigInteger
所以我們的輸入空間是二維的,用二維點陣(a,b)表示。現在我們對其進行分區,想一想乘法是怎么工作的,我們可以將點陣初步分為以下四個區:
- a和b都是正整數
- a和b都是負整數
- a是正整數,b是負整數
- b是正整數,a是負整數
這里也有一些特殊的情況要單獨分出來:0 1 -1
- a或b是1\0\-1
最后,作為一個認真的測試員,我們還要想一想BigInteger的乘法可能是怎么運算的:它可能在輸入數據絕對值較小時使用 int 或 long ,這樣運算起來快一些,只有當數據很大時才會使用更費勁的存儲方法(例如列表)。所以我們也應該將對數據的大小進行分區:
- a或b較小
- a或b的絕對值大于Long.MAX_VALUE ,即Java原始整型的最大值,大約是2^63。
現在我們可以將上面劃分的區域整合起來,得到最終劃分的點陣:
- 0
- 1
- -1
- 較小正整數
- 較小負整數
- 大正整數
- 大負整數
所以我們一共可以得到 7 × 7 = 49 個分區,它們完全覆蓋了a和b組成的所有輸入空間。然后從這個”柵欄“里的每個區選取各自的測試用例,例如:
- (a,b) = (-3, 25) 代表 (小負整數, 小正整數)
- (a,b) = (0, 30) 代表 (0, 小正整數)
- (a,b) = (2^100, 1) 代表 (大正整數, 1)
- 等等
例子2: max()
現在我們看看Java庫中的另一個例子:針對整數int的max() 函數,它屬于 Math 類:
/*** @param a an argument* @param b another argument* @return the larger of a and b.*/ public static int max(int a, int b)和上面的例子一樣,我們先分析輸入空間:
max : int × int → int (譯者注:這里的乘號不代表乘法,而是一種封閉的二元運算關系,參見近世代數)
通過描述分析,我們可以將其分區為:
- a < b
- a = b
- a > b
所以可以選擇以下測試用例:
- (a, b) = (1, 2) 代表 a < b
- (a, b) = (9, 9) 代表 a = b
- (a, b) = (-5, -6) 代表 a > b
注意分區之間的“邊界”
bug經常會在各個分區的邊界處發生,例如:
- 在正整數和負整數之間的0
- 數字類型的最大值和最小值,例如 int 和 double
- 空集,例如空的字符串,空的列表,空的數組
- 集合類型中的第一個元素或最后一個元素
為什么這些邊界的地方經常產生bug呢?一個很重要的原因就是程序員經常犯“丟失一個(off-by-one mistakes)”的錯誤。例如將<=寫成< ,或者將計數器用0來初始化而不是1。另外一個原因就是邊界處的值可能需要用特殊的行為來處理,例如當int類型的變量達到最大值以后,再對其加正整數反而會變成負數。
所以,我們在分區后,測試用例不要忘了加上邊界上的值,現在重新做一下上面那個例子:
max : int × int → int.
分區:
- a與b的關系
- a < b
- a = b
- a > b
- a的值
- a = 0
- a < 0
- a > 0
- a = 最小的整數
- a = 最大的整數
- value of b
- b = 0
- b < 0
- b > 0
- b = 最小的整數
- b = 最大的整數
現在我們再次選取測試用例覆蓋上面的分區和邊界值:
- (1, 2) 代表 a < b, a > 0, b > 0
- (-1, -3) 代表 a > b, a < 0, b < 0
- (0, 0) 代表 a = b, a = 0, b = 0
- (Integer.MIN_VALUE, Integer.MAX_VALUE) 代表 a < b, a = minint, b = maxint
- (Integer.MAX_VALUE, Integer.MIN_VALUE) 代表 a > b, a = maxint, b = minint
覆蓋分區的兩個極限情況
在分區后,我們可以選擇“盡力(how exhaustive we want)”的程度來測試我們的分區,這里有兩個極限情況:
- 完全笛卡爾乘積
即對每一個存在組合都進行測試。例如在第一個例子multiply中,我們一共使用了 7 × 7 = 49 個測試用例,每一個組合都用上了。對于第二個例子,就會是 3 × 5 × 5 = 75個測試用例。要注意的是,實際上有一些組合是不存在的,例如 a < b, a=0, b=0。 - 每一個分區被覆蓋即可
即每一個分區至少被覆蓋一次。例如我們在第二個例子max中只使用了5個測試用例,但是這5個用例覆蓋到了我們的三維輸入空間的所有分區。
在實際測試中我們通常在這兩個極限中折中,這種折中是基于人們的經驗,對代碼的獲取度(黑白盒測試)、以及對代碼的覆蓋率,這些我們會在后面講到。
閱讀小練習
分區
思考下面這個規格說明:
/*** Reverses the end of a string.** 012345 012345* For example: reverseEnd("Hello, world", 5) returns "Hellodlrow ,"* <-----> <----->** With start == 0, reverses the entire text.* With start == text.length(), reverses nothing.** @param text non-null String that will have its end reversed* @param start the index at which the remainder of the input is reversed,* requires 0 <= start <= text.length()* @return input text with the substring from start to the end of the string reversed*/ public static String reverseEnd(String text, int start)對于 start 參數進行測試,下面的哪一個分區是合理的 ?
- [ ] start = 0, start = 5, start = 100
- [ ] start < 0, start = 0, start > 0
- [x] start = 0, 0 < start < text.length(), start = text.length()
- [ ] start < text.length(), start = text.length(), start > text.length()
譯者注:要特別注意的是,本文談到的都是對程序正確性進行測試,即輸入都是規格說明里面的合法值。至于那些非法的值則是對魯棒性(robust)或者安全性的測試。
對于 text 參數進行測試,下面的哪一個分區是合理的 ?
- [ ] text 包含一些數字; text不包含字母, 但是包含一些數字; text 既不包含字母,也不包含數字
- [ ] text.length() = 0; text.length() > 0
- [x] text.length() = 0; text.length()-start 是奇數; text.length()-start 是偶數(譯者注,這個選項是第二個的超集,多的地方在于奇數偶數的判斷,原因在于如果一個字符串字符的個數是奇數個,那么中間的那個字符就不需要移動位置了,這可能需要特殊的行為來處理,也可能是bug產生的原因)
- [ ] 測試0到100個字符的所有字符串
用JUnit做自動化單元測試
一個良好的測試程序應該測試軟件的每一個模塊(方法或者類)。如果這種測試每次是對一個孤立的模塊單獨進行的,那么這就稱為“單元測試”。單元測試的好處在于debug,如果你發現一個單元測試失敗了,那么bug很可能就在這個單元內部,而不是軟件的其他地方。
JUnit 是Java中一個被廣泛只用的測試庫,我們在以后的課程中也會大量使用它。一個JUnit測試單元是以一個方法(method)寫出的,其首部有一個 @Test聲明。一個測試單元通常含有對測試的模塊進行的一次或多次調用,同時會用斷言檢查模塊的返回值,比如 assertEquals, assertTrue, 和 assertFalse.i
例如,我們對上面提到的 Math.max() 模塊進行測試,JUnit就可以這樣寫:
@Test public void testALessThanB() {assertEquals(2, Math.max(1, 2)); }@Test public void testBothEqual() {assertEquals(9, Math.max(9, 9)); }@Test public void testAGreaterThanB() {assertEquals(-5, Math.max(-5, -6)); }要注意的是 assertEquals 的參數順序很重要。它的第一個應該是我們期望的值,通常是一個我們算好的常數,第二個參數就是我們要進行的測試。如果你把順序弄反了,JUnit可能會輸出一些奇怪的錯誤報告。記住, 所有JUnit支持的斷言 都要寫成這個順序:第一個是期望值,第二個是代碼測試結果。
如果一個測試斷言失敗了,它會立即返回,JUnit也會記錄下這次測試的失敗。一個測試類可以有很多 @Test 方法,它們可以各自獨立的進行測試,即使有一個失敗了,其它的測試也會繼續進行。
寫下你的測試策略
現在假設我們要測試reverseEnd這個模塊:
/*** Reverses the end of a string.** For example:* reverseEnd("Hello, world", 5)* returns "Hellodlrow ,"** With start == 0, reverses the entire text.* With start == text.length(), reverses nothing.** @param text non-null String that will have* its end reversed* @param start the index at which the* remainder of the input is* reversed, requires 0 <=* start <= text.length()* @return input text with the substring from* start to the end of the string* reversed*/ static String reverseEnd(String text, int start)我們應該在測試時記錄下我們的測試策略,例如我們是如何分區的,有哪些特殊值、邊界值等等:
/** Testing strategy** Partition the inputs as follows:* text.length(): 0, 1, > 1* start: 0, 1, 1 < start < text.length(),* text.length() - 1, text.length()* text.length()-start: 0, 1, even > 1, odd > 1** Include even- and odd-length reversals because* only odd has a middle element that doesn't move.** Exhaustive Cartesian coverage of partitions.*/另外,每一個測試方法都要有一個小的注解,告訴讀者這個測試方法是代表我們測試策略中的哪一部分,例如:
// covers test.length() = 0, // start = 0 = text.length(), // text.length()-start = 0 @Test public void testEmpty() {assertEquals("", reverseEnd("", 0)); }閱讀小測試
假設你在為 max(int a, int b) 寫測試,它是屬于Math.java的. 并且你將JUnit測試放在 MathTest.java文件中.
下面這些文字說明應該分別放在哪里?
關于 a 參數的分區策略
[ ] 寫在 Math.java開頭的注釋里
[x] 寫在 MathTest.java開頭的注釋里
[ ] 寫在 max() 開頭的注釋里
[ ] 寫在JUnit測試的注釋里
屬性 @Test
[ ] 在 Math 之前
[ ] 在 MathTest 之前
[ ] 在max() 之前
[x] 在 JUnit 測試之前
注釋 “代表 a < b”
[ ] 寫在 Math.java開頭的注釋里
[ ] 寫在 MathTest.java開頭的注釋里
[ ] 寫在 max() 開頭的注釋里
[x] 寫在JUnit測試的注釋里
注釋 “@返回a和b的最大值”
[ ] 寫在 Math.java開頭的注釋里
[ ] 寫在 MathTest.java開頭的注釋里
[x] 寫在 max() 開頭的注釋里
[ ] 寫在JUnit測試的注釋里
黑盒測試與白盒測試
回想上面提到的:規格說明是對函數行為的描述——參數類型、返回值類型和對它們的約束條件以及參數和返回值之間的關系。
黑盒測試意味著只依據函數的規格說明來選擇測試用例,而不關心函數是如何實現的。這也是到目前為止我們的例子里一直在做的。我們在沒有看實際代碼的情況下分段并且尋找multiply和max的邊界。
白盒測試 的意思是在考慮函數的實際實現方法的前提下選擇測試用例。比如說,如果函數的實現中,對不同的輸入采用不同的算法,那么你應該根據這些不同的區域來分類(譯者注:將輸入分為不同的類,每類輸入將會觸發代碼實現中的一種處理算法)。如果代碼實現中維護一個內部緩存來記錄之前得到的輸入的答案,那你應該測試重復的輸入。
在做白盒測試時。你必須注意:你的測試用例不需要嘗試規格說明中沒有明確要求的實現行為。例如,如果規格說明中說“如果輸入沒有格式化,那么將拋出異常”,那么你不應該特地的檢查程序是否拋出NullPointerExpection異常,因為當前的代碼實現決定了程序有可能拋出這個異常。在這種情況下,規格說明允許任何異常被拋出,所以你的測試用例同樣應該“寬容”地保留實現者的自由。我們將會在這門課接下來的課時中討論更多關于規格說明的問題。
閱讀小練習
黑盒測試 vs. 白盒測試
思考下面這個方法:
/*** Sort a list of integers in nondecreasing order. Modifies the list so that * values.get(i) <= values.get(i+1) for all 0<=i<values.length()-1*/ public static void sort(List<Integer> values) {// choose a good algorithm for the size of the listif (values.length() < 10) {radixSort(values);} else if (values.length() < 1000*1000*1000) {quickSort(values);} else {mergeSort(values);} }下面哪一個是白盒測試中產生的邊界值?
- [ ] values = [] (the empty list)
- [ ] values = [1, 2, 3]
- [x] values = [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
- [ ] values = [0, 0, 1, 0, 0, 0, 0]
覆蓋率
一種判斷測試的好壞的方法就是看該測試對軟件的測試程度。這種測試程度也稱為“覆蓋率”。以下是常見的三種覆蓋率:
- 聲明覆蓋率: 每一個聲明都被測試到了嗎?
- 分支覆蓋率:對于每一個if 或 while 等等控制操作,它們的分支都被測試過嗎?
- 路徑覆蓋率: 每一種分支的組合路徑都被測試過嗎?
其中,分支覆蓋率要比聲明覆蓋率嚴格(需要更多的測試),路徑覆蓋率要比分支覆蓋率嚴格。在工業界,100%的聲明覆蓋率一個普遍的要求,但是這有時也是不可能實現的,因為會存在一些“不可能到達的代碼”(例如有一些斷言)。100%的分支覆蓋率是一種很高的要求,對于軍工/安全關鍵的軟件可能會有此要求 (e.g., MC/DC, modified condition/decision coverage)。不幸的是,100%的路徑覆蓋率是不可能的,因為這會讓測試用例空間以指數速度增長。
一個標準的方法就是不斷地增加測試用例直到覆蓋率達到了預定的要求。在實踐中,聲明覆蓋通常用覆蓋率工具進行計數。利用這樣的工具,白盒測試會變得很容易,你只需要不斷地調整覆蓋的地方,直到所有重要的聲明都被覆蓋到。
在Eclipse中有一個好用的代碼覆蓋率工具 EclEmma 。如上圖所示,EclEmma會將被執行過的代碼用綠色標出,沒有被執行的代碼用紅色標出。對于一個分支語句,如果它的一個分支一直沒有被執行,那么這個分支判斷語句會被標為黃色。例如上圖中,我們發現for循環中的if語句每一次都是假的,我們下一步要做的就是調整測試用例使得這個判斷可以為真。
閱讀小練習
使用覆蓋率工具
對于現在的Eclipse, EclEmma 已經安裝了,我們直接使用即可。
現在我們創建一個類 Hailstone.java
public class Hailstone {public static void main(String[] args) {int n = 3;while (n != 1) {if (n % 2 == 0) {n = n / 2;} else {n = 3 * n + 1;}}} }利用EclEmma 運行main函數, Run → Coverage As → Java Application.并改變n的初始化值,觀察EclEmma 標出行顏色的變化。
當n = 3時,n = n/2這一行是什么顏色 ?
綠
當n = 16時,n = 3 * n + 1這一行是什么顏色 ?
紅
當n的初始值是多少時,行while (n != 1)會變成黃色 ?
1
單元測試 vs. 集成測試和樁(Stubs)
我們已經討論過“單元測試”——對孤立的模塊進行測試。這使得debugging變得簡單,當一個單元測試報錯是,我們只需要在這個單元找bug,而不是在整個程序去找。
與此相對應的,“集成測試”是對于組合起來的模塊進行測試,甚至是整個程序。如果集成測試報錯,我們就只能在大的范圍去找了。但是這種測試依然是必要的,因為程序經常由于模塊之間的交互而產生bug。例如,一個模塊的輸入是另一個模塊的輸出,但是設計者在設計模塊的時候將輸入輸出類型弄錯了。另外,如果我們已經做好單元測試了,即我們可以確性各個單元獨立的正確性,我們的搜索bug的范圍也會小很多。
下面假設你在設計一個搜索引擎。其中有兩個模塊 getWebPage(), extractWords() ,其中 getWebPage()負責下載網頁,extractWords() 負責將頁面內容拆成一個個詞匯:
/** @return the contents of the web page downloaded from url */ public static String getWebPage(URL url) {...}/** @return the words in string s, in the order they appear, * where a word is a contiguous sequence of * non-whitespace and non-punctuation characters */ public static List<String> extractWords(String s) { ... }而這兩個模塊又是被另一個模塊 makeIndex()作為網絡爬蟲的一部分使用的:
/** @return an index mapping a word to the set of URLs * containing that word, for all webpages in the input set */ public static Map<String, Set<URL>> makeIndex(Set<URL> urls) { ...for (URL url : urls) {String page = getWebPage(url);List<String> words = extractWords(page);...}... }我們的測試可以分為:
- 對getWebPage()進行單元測試,輸入不同的 URLs
- 對extractWords()進行單元測試,輸入不同的字符串
- 對 makeIndex() 進行單元測試,輸入不同的 URLs
測試員有時會犯這樣一個錯誤:extractWords() 的測試用例依賴于getWebPage() 的正確性。正如前面所提到的,單元測試應該盡可能將模塊孤立起來。如果我們在對extractWords() 的測試輸入進行分區后,其值中包含有 getWebPage() 的輸出,那么如果getWebPage() 本身就有bug,程序的測試將變得不可信!正確的做法應該是先組建好獨立的測試用例,例如一些下載好的網頁,將其作為測試用例進行測試。
注意到 makeIndex() 的單元測試并不能完全孤立,因為我們在測試它的時候實際上也測試了它調用的模塊。如果測試失敗,這些bug也可能來自于它調用過的模塊之中——這也是為什么我們要先單元測試 getWebPage() 和 extractWords() ,這樣一來我們就能確定bug出現在鏈接這些模塊的代碼之中。
如果我們要做更高于 makeIndex()這一層的測試,我們將它調用的模塊寫成樁 。例如,一個 getWebPage() 的樁不會真正去訪問網頁,而是返回一個預先設置好的網頁(mock web page),不管參數URL是什么。一個類的樁通常被稱為“模擬對象”( mock object)。在構建大型系統的時候樁是一種重要的手段,但是在本門課程中我們不會使用它。
譯者注:關于mocks、stubs、fakes這些概念,可以參考:
- https://en.wikipedia.org/wiki/Mock_object
- 軟件測試中Mock和Stub
自動化測試和回歸測試
沒有什么能比自動化更能讓測試簡單的東西了。自動化測試(Automated testing)是指自動地運行測試對象,輸入對應的測試用例,并記錄結果的測試。
能夠進行自動化測試的代碼稱作測試驅動(test driver,也被稱作test harness 或者 test runner)。一個測試驅動不應該在測試的時候停下來等待你的輸入,而是自動調用模塊輸入測試用例進行測試,最后的結果應該是“測試完成,一切正常”或者“這些測試發了報錯:.....”。一個好的測試架構,例如JUnit,允許你構建這樣的測試驅動。
注意到自動化測試架構比如JUnit讓測試變得簡單,但是你還是要自己去構建好的測試用例。“自動化生成測試用例”是一個很難的問題,目前還處于活躍的研究之中。
要特別注意的是,當你修改你的代碼后,別忘了重新運行之前的自動化測試。軟件工程師經常遭遇修改大型/復雜程序所帶來的痛苦,不論是修改一個bug、增加一個新的功能、優化一段代碼的性能,都有可能帶來新的問題。無論什么時候,自動化測試都能保證軟件最重要的底線——行為和結果是正確的,即使只是一小段測試。我們稱修改代碼帶來新的bug的現象為“回歸”,而在修改后重新運行所有的測試稱為“回歸測試”。
一個好的測試應該是能發現bug的,你應該不斷的充實你的測試用例。所以無論什么時候修改了一個bug,記得將導致bug的輸入添加到你的測試用例里,并在以后的回歸測試中去使用它——畢竟這個bug已經出現了,說明它可能是一個很容易犯的錯誤。
這些思想也是“測試優先debugging”的核心,當bug出現時,立刻將觸發bug的輸入存放到測試用例中,當你修復bug后,再次運行這些 (譯者注:注意不僅是觸發bug的輸入)測試,如果它們都通過的話,你的debug也就完成了。
在實踐中,自動化測試和回歸測試通常結合起來使用。因為回歸測試只有自動化才可行(不然大量的測試沒法實現)。反過來,如果你已經構建了自動化測試,你通常也會用它來防止回歸的發生。所以自動化回歸測試(automated regression testing)是軟件工程里的一個“最佳實踐”(best-practice)。
閱讀小練習
回歸測試
以下哪一個選項是對回歸測試的最好定義 ?
[x] 當你改變代碼后應該再次進行測試
[ ] 代碼的每一個模塊都應該有能夠完全測試它的測試
[ ] 測試應該在寫代碼之前完成,以此來檢查你寫的規格說明
[ ] 當新的測試報錯時,你應該重新運行之前的所有版本的代碼直到找到開始引入這個bug的版本。
自動化測試
什么情況下應該重新運行所有的 JUnit 測試?
[x] 在使用 git add/commit/push之前
[x] 在優化一個函數的性能后
[ ] 在使用覆蓋率工具時
[x] 在修改一個bug后
測試方法
以下哪一些方法/思想對于“測試優先編程”中未寫代碼之前選擇測試用例是有幫助的?
- [x] 黑盒
- [ ] 回歸
- [ ] 靜態類型
- [x] 分區
- [x] 分區邊界
- [ ] 白盒
- [ ] 覆蓋率
總結
在這個reading中,我們學到了以下知識:
- 測試優先編程——在寫代碼前先寫好測試用例,盡早發現bug。
- 利用分區與分區邊界來選擇測試用例。
- 白盒測試與聲明覆蓋率。
- 單元測試——將測試模塊隔離開來。
- 自動化回歸測試杜絕新的bug產生。
還記得好軟件具備的三個屬性嗎?試著將它們和這篇reading的內容聯系起來:
- 遠離bug 測試的意義在于發現程序中的bug,而“測試優先編程”的價值在于盡可能早的發現這些bug。
- 易讀性 額.......測試并不會使代碼審查變得容易,但是我們也要注意正確書寫測試注釋。
- 可改動性 我們針對改動后的程序進行測試時只需要依賴規格說明中的行為描述。(譯者注:再說一遍,這里的測試針對的是正確性而不是魯棒性)。另外,當我們完成修改后,自動化回歸測試能夠幫助我們杜絕新的bug產生。
轉載于:https://www.cnblogs.com/liqiuhao/p/8512205.html
總結
以上是生活随笔為你收集整理的麻省理工18年春软件构造课程阅读03“测试”的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: etlgr是什么服务器_ETL是指什么
- 下一篇: 关于游戏平衡性——王者荣耀英雄伤害数值参