最近想對我們的單元測試做一下總結,樓主在日常工作中寫了不少單元測試,但有些概念和用法並沒有刨根問題的去追尋,研究。於是把一些不清晰的概念輸入到google中來尋找答案,發現了幾個不錯的帖子,從中學到了東西,也發現了問題,和大家分享,如有錯誤,敬請指正。
我們所做的產品測試包括了下文所說的軟件測試詞匯表中的大部分,也就是“單元測試”,組件測試,系統測試,集成測試,壓力測試和驗收測試。開發團隊成員做的或者參與的是“單元測試”,集成測試。這里的單元測試我加了引號是因為看完下面的文章,我發現我們所做的單元測試並不是嚴格意義上的單元測試,叫功能測試比較恰當。下文所說的功能測試遇到的問題在我們的實際項目中也遇到了。希望日后有機會改進。
另外這篇帖子的標題叫做evil unit testing,這里的evil是有害的意思,但是通讀這篇博客,並沒有講單元測試是有害的,可能作者的意思是把功能測試當作單元測試的想法是有毒的吧。
好了,原文鏈接:
http://www.javaranch.com/unit-testing.jsp
1. 你做的是單元測試么?
我看到過至少6個公司因為他們有“單元測試(unit test)”而滿臉自豪。而我們看到的是這種“單元測試”結果會是一個麻煩。其他人討論單元測試有多么偉大,但是它確實變得讓人痛苦不堪。這種測試需要45分鍾才能跑完,還有你對代碼只做了一點改動,但卻破壞了7個測試用例”。
這些家伙用的是一堆功能測試(functional test)。他們掉入了一個流行的思維陷阱,認為只要是使用Junit來運行的測試用例,就必須是單元測試。你只需要一點點詞匯量,90%的問題就都能解決。
2. 軟件測試詞匯表
- 單元測試(unit test):
可測試代碼的最小的一部分。通常是一個單一的方法,不會使用其它方法或者類。非常快!上千個單元測試能夠在10秒以內跑完!單元測試永遠不會使用:
- 數據庫
- 一個app服務器(或者任何類型的服務器)
- 文件/網絡 I/O或者文件系統
- 另外的應用
- 控制台(System.out,system.err等等)
- 日志
- 大多數其他類(但不包括DTO‘s,String,Integer,mock和一些其他的類)
單元測試幾乎總是回歸測試套件(regression suite)的一部分。
- 回歸測試套件(Regression Suite):
能夠立刻被運行的測試用例的集合。一個例子就是放在一個特定文件夾中的能夠被Junit運行的所有測試用例。一個開發人員能夠在一天中把一個單元測試回歸套件運行20次或者他們可能一個月跑兩次功能測試回歸套件。
- 功能測試(Functional Test):
比一個單元要大,比一個完整的組件測試要小。通常為工作在一起的的幾個方法/函數/類。上百的測試用例允許運行幾個小時。大部分功能測試是功能測試回歸套件的一部分。通常由Junit來運行。
- 集成測試(Integration Test):
測試兩個或者更多的組件一起工作的情況。有時候是回歸套件的一部分。
- 組件測試(Component Test):
運行一個組件。經常由QA,經理,XP客戶等等來執行。這種類別的測試不是回歸套件的一部分,它不由Junit來執行。
- 組件驗收測試(Component Acceptance Test C.A.T.):
作為正常流程的一部分,它是在眾多人面前運行的一個組件測試。由大家共同決定這個組件是不是滿足需求標准。
- 系統測試(system Test):
所有的組件在一起運行。
- 系統驗收測試(System Acceptance Test S.A.T.):
作為正常流程的一部分,它是在眾多人面前運行的一個系統測試,由大家來共同決定這個系統是不是滿足需求標准。
- 壓力測試(Stress Tests):
另外一個程序加載一個組件,一些組件或者整個系統。我曾經看到過把一些小的壓力測試放到回歸功能測試中來進行——這是測試並發代碼的一個很聰明的做法。
- Mock:
在單元測試或者功能測試中使用的一些代碼,通過使用這些代碼來確保你要測試的代碼不會去使用其它的產品代碼(production code)。一個mock類覆蓋了一個產品類中的所有public方法,它們用來插入到嘗試使用產品類的地方。有時候一個mock類用來實現一個接口,它替換了用來實現同樣接口的產品代碼。
- Shunt:
有點像繼承(extends)產品代碼的mock類,只是它的意圖不是覆蓋所有的方法,而只是覆蓋足夠的代碼,所以你能夠測試一些產品方法,同時mock剩余的產品方法。如果你想測試一個可能會使用I/O的類它會變得尤為有用,你的shunt能夠重寫I/O方法同時來測試非I/O方法。
3. 使用太多功能測試(functional test)會有麻煩
不要誤解我的意思。功能測試有很大的價值。我認為一個測試良好的app將會有一個功能測試的回歸套件和一個非回歸功能測試的集合。通常情況下對於一磅產品代碼,我都想看到兩磅單元測試代碼和兩盎司(注:1磅=16盎司)功能測試代碼。但是在太多的項目中我看到的現象是沒有一丁點單元測試,卻有一磅功能測試。
下面的兩幅圖表明了一些類的使用情況。用一些功能測試來測試這些類一塊工作的情況。修復一個類的bug會破壞許多功能測試。。。
上面的情況我看到過多次。其中的一個例子是一個很小的改動破壞了47個測試用例。我們通過開會來決定這個bug是不是要被留在代碼中。最后決定我們要留足夠的時間來fix所有的case。幾個月過去了,事情依然糟糕。。
解決方法是使用單元測試來代替功能測試:
結果是這個工程變的更加靈活。
4. 功能測試認知糾錯
“通過只編寫功能測試用例,我可以寫更少的測試代碼,同時測試更多的功能代碼!”這是真的!但是這會以你的工程變得更加脆弱為代價。另外,如果不使用單元測試,你的應用有些地方很難被測試。同時達到最好的覆蓋率和靈活性是使用功能測試和單元測試的組合,其中單元測試的比重要大,功能測試的比重要小。
“我的業務邏輯是讓所有的類一塊工作,所以只測試一個方法是沒有意義的。”我建議你單獨測試所有的方法。同時我也並不建議你不使用功能測試,它們也是有價值的。
“我不介意我的單元測試組件會花費幾分鍾來運行”但是你的團隊中的其他人介意么?你的team lead介意么?你的manager呢?如果它花費幾分鍾而不是幾秒鍾,你還會在一天的時間把整個測試套件運行多次么?在什么情況下人們根本不會運行測試?
5. 單元測試mock基礎
下面是單元測試的一個簡單例子,測試各種情況卻不依賴其他方法。
1 public void testLongitude() 2 3 { 4 5 assertEquals( "-111.44" , Normalize.longitude( "111.44w" ) ); 6 7 assertEquals( "-111.44" , Normalize.longitude( "111.44W" ) ); 8 9 assertEquals( "-111.44" , Normalize.longitude( "111.44 w" ) ); 10 11 assertEquals( "-111.44" , Normalize.longitude( "111.44 W" ) ); 12 13 assertEquals( "-111.44" , Normalize.longitude( "111.44 w" ) ); 14 15 assertEquals( "-111.44" , Normalize.longitude( "-111.44w" ) ); 16 17 assertEquals( "-111.44" , Normalize.longitude( "-111.44W" ) ); 18 19 assertEquals( "-111.44" , Normalize.longitude( "-111.44 w" ) ); 20 21 assertEquals( "-111.44" , Normalize.longitude( "-111.44 W" ) ); 22 23 assertEquals( "-111.44" , Normalize.longitude( "-111.44" ) ); 24 25 assertEquals( "-111.44" , Normalize.longitude( "111.44-" ) ); 26 27 assertEquals( "-111.44" , Normalize.longitude( "111.44 -" ) ); 28 29 assertEquals( "-111.44" , Normalize.longitude( "111.44west" ) ); 30 31 // ... 32 33 }
當然,任何人都能為上面這種情況做單元測試。但是大部分業務邏輯都使用了其它業務邏輯:
1 public class FarmServlet extends ActionServlet 2 { 3 public void doAction( ServletData servletData ) throws Exception 4 { 5 6 String species = servletData.getParameter("species"); 7 8 String buildingID = servletData.getParameter("buildingID"); 9 10 if ( Str.usable( species ) && Str.usable( buildingID ) ) 11 12 { 13 14 FarmEJBRemote remote = FarmEJBUtil.getHome().create(); 15 16 remote.addAnimal( species , buildingID ); 17 18 } 19 20 } 21 22 }
這里不僅僅調用了其他業務邏輯,還調用了應用服務器!可能還會訪問網絡!上千次的調用可能會花費不少於10秒的時間。另外對EJB的修改可能會破壞我對這個方法的測試!所以我們需要引入一個mock對象。
首先是創建mock。如果FarmEJBRemote是一個類,我將會繼承(extend)它並且重寫(override)它所有的方法。但是既然它是一個接口,我會編寫一個新類並實現(implement)所有方法:
1 public class MockRemote implements FarmEJBRemote 2 3 { 4 5 String addAnimal_species = null; 6 7 String addAnimal_buildingID = null; 8 9 int addAnimal_calls = 0; 10 11 public void addAnimal( String species , String buildingID ) 12 13 { 14 15 addAnimal_species = species ; 16 17 addAnimal_buildingID = buildingID ; 18 19 addAnimal_calls++; 20 21 } 22 23 }
這個類什么都沒做,只是攜帶了單元測試和需要被測試代碼之間要交互的數據。
這個類會讓你感覺不舒服么?應該是這樣。在我剛接觸它的時候有兩件事情把我弄糊塗了:類的屬性不是private的,並且命名上有下划線。如果你需要mock java.sql.connection。總共有40個方法! 為每個方法的各個參數,返回值和計數都實現Getters和setters?嗯…稍微想一下…我們把屬性聲明為private是為了封裝,把事情是如何做的封裝在內部,於是日后我們就可以修改我們的業務邏輯代碼而不用破壞決定要進入我們的內臟的其他代碼(也就是要調用我們的業務邏輯的代碼)。但這對於mock來說並不適用,不是么?根據定義,mock沒有任何業務邏輯。進一步來說,它沒有任何東西不是從其他地方拷貝過來的。所有的mock對象都能100%在build階段生成!..所以雖然有時候我仍然覺的這么實現Mock有一點惡心,但是最后我會重拾自信,這是最好的方法了。只是聞起來會讓你有些不舒服,但是效果比使用其它方法好多了。
現在我需要使用mock代碼來替代調用應用服務器的部分。我對需要使用mock的地方做了高亮:
1 public class FarmServlet extends ActionServlet 2 3 { 4 5 public void doAction( ServletData servletData ) throws Exception 6 7 { 8 9 String species = servletData.getParameter("species"); 10 11 String buildingID = servletData.getParameter("buildingID"); 12 13 if ( Str.usable( species ) && Str.usable( buildingID ) ) 14 15 { 16 17 FarmEJBRemote remote = FarmEJBUtil.getHome().create(); 18 19 remote.addAnimal( species , buildingID ); 20 21 } 22 23 } 24 25 }
首先,讓我們把這句代碼從其他猛獸中分離出來:
1 public class FarmServlet extends ActionServlet 2 3 { 4 5 private FarmEJBRemote getRemote() 7 { 9 return FarmEJBUtil.getHome().create(); 11 } 12 13 public void doAction( ServletData servletData ) throws Exception 14 15 { 16 17 String species = servletData.getParameter("species"); 18 19 String buildingID = servletData.getParameter("buildingID"); 20 21 if ( Str.usable( species ) && Str.usable( buildingID ) ) 22 23 { 24 25 FarmEJBRemote remote = getRemote(); 26 27 remote.addAnimal( species , buildingID ); 28 29 } 30 31 } 32 33 }
這有一點痛..我將會繼承我的產品類然后重寫getRemote(),於是我可以把mock代碼混入到這個操作中了。我需要做一點點改動:
1 public class FarmServlet extends ActionServlet 2 3 { 4 5 FarmEJBRemote getRemote() 6 7 { 8 9 return FarmEJBUtil.getHome().create(); 10 11 } 12 13 14 15 public void doAction( ServletData servletData ) throws Exception 16 17 { 18 19 String species = servletData.getParameter("species"); 20 21 String buildingID = servletData.getParameter("buildingID"); 22 23 if ( Str.usable( species ) && Str.usable( buildingID ) ) 24 25 { 26 27 FarmEJBRemote remote = getRemote(); 28 29 remote.addAnimal( species , buildingID ); 30 31 } 32 33 } 34 35 }
如果你是一個好的面向對象工程師,你現在應該瘋了!破壞單元測試代碼中的封裝性是很不舒服的,但是破壞產品代碼封裝性的事情就不要做了!長篇大論的解釋有可能幫助事態平息,我的觀點是:在你的產品代碼中,對類的第一次封裝要永遠保持警惕…但是,有時候,你可能考慮用價值20美元的可測試性來和價值1美元的封裝性來做交易。為了讓你減輕一點痛苦,你可以加一個注釋:
1 public class FarmServlet extends ActionServlet 2 3 { 4 5 //exposed for unit testing purposes only! 6 7 FarmEJBRemote getRemote() 8 9 { 10 11 return FarmEJBUtil.getHome().create(); 12 13 } 14 15 16 17 public void doAction( ServletData servletData ) throws Exception 18 19 { 20 21 String species = servletData.getParameter("species"); 22 23 String buildingID = servletData.getParameter("buildingID"); 24 25 if ( Str.usable( species ) && Str.usable( buildingID ) ) 26 27 { 28 29 FarmEJBRemote remote = getRemote(); 30 31 remote.addAnimal( species , buildingID ); 32 33 } 34 35 } 36 37 }
現在我可以實現一個類來返回mock值了:
1 class FarmServletShunt extends FarmServlet 2 3 { 4 5 FarmEJBRemote getRemote_return = null; 6 7 FarmEJBRemote getRemote() 8 9 { 10 11 return getRemote_return; 12 13 } 14 15 }
注意一下怪異的名字:“shunt”。我不確定它是什么意思,但我認為這個詞語來自電子工程/工藝,它指用一段電線來臨時組裝一個完整的電路。一開始聽起來這個想法很愚蠢,但是過后我就慢慢習慣了。
一個shunt有點像mock,一個沒有重寫所有方法的mock。用這種方法,你可以mock一些方法,然后測試其他的方法。一個單元測試可以由幾個shunts來完成,它們重寫了相同的類,每個shunt測試了類的不同部分。Shunt通常情況下為嵌套類。
終場表演的時候到了!看一下單元測試代碼!
1 public class TestFarmServlet extends TestCase 2 3 { 4 5 static class FarmServletShunt extends FarmServlet 6 7 { 8 9 FarmEJBRemote getRemote_return = null; 10 11 FarmEJBRemote getRemote() 12 13 { 14 15 return getRemote_return; 16 17 } 18 19 } 20 21 public void testAddAnimal() throws Exception 22 23 { 24 25 MockRemote mockRemote = new MockRemote(); 26 27 FarmServletShunt shunt = new FarmServletShunt(); 28 29 shunt.getRemote_return = mockRemote(); 30 31 32 33 // just another mock to make 34 35 MockServletData mockServletData = new MockServletData(); 36 37 mockServletData.getParameter_returns.put("species","dog"); 38 39 mockServletData.getParameter_returns.put("buildingID","27"); 40 41 42 43 shunt.doAction( mockServletData ); 44 45 assertEquals( 1 , mockRemote.addAnimal_calls ); 46 47 assertEquals( "dog" , mockRemote.addAnimal_species ); 48 49 assertEquals( 27 , mockRemote.addAnimal_buildingID ); 50 51 } 52 53 }
基本的測試框架我們就展示完了。下面我要和大家分享一個和單元測試有關的概念——依賴注入,也是我們的單元測試中要到的,敬請期待。