單元測試在測試過程中是比較重要的一環,但是也是很多團隊缺失的一環,單元測試的意義是什么?單元測試的實施過程中會有怎樣的坑?為什么一些團隊沒有單元測試呢?是由測試來做單元測試還是開發來做單元測試呢?
單元測試的定義及意義
首先是最經典的測試金字塔,其實針對測試金字塔有很多種搭建方式,例如:
- 從常用的測試技術類型來看: 單元測試->接口測試->UI測試,這可能是比較常見的測試金字塔( unit->api->ui )
- 從系統分層測試(或測試階段)來看: 單元測試->組件測試->集成測試->系統測試
這只是從測試金字塔角度去談測試的方法,也可以說是測試的分類,當然如果是嚴格意義上的測試分類又有很多(例如以是否測試代碼:黑盒,白盒,灰盒;是否運行:靜態測試,動態測試等等)
那單元測試的定義是什么?
單元測試是對軟件中的最小單元進行測試和驗證,通俗來講就是代碼中的一個函數或一個類,單元測試一定是白盒測試。
為什么提到測試金字塔,因為單元測試不僅是測試階段的第一環,也是測試金字塔的基礎,那代表着什么?
- 從重要程度來說,單元測試作為地基,承擔着保證穩定性的作用,最終決定整個軟件質量的不是功能完整,功能實現沒有問題,而是實現功能的代碼邏輯是否正確,程序是否健壯
- 從開發測試成本來說,我們知道在開發測試整個環節,越晚發現問題,解決問題的成本越高;越晚發現問題,代表着測試開發流程要不斷重復,且重復的成本越高,也就是說如果能將大部分的問題或者明顯的代碼邏輯問題解決在單元測試階段,將極大的減少開發測試成本,提高開發測試效率
- 從測試覆蓋來說,測試金字塔越往上執行的測試,可覆蓋case會逐漸變小,例如UI測試只能保證頁面正常,接口異常不會測試覆蓋完整;而接口覆蓋完整了,又不能保證代碼中所有邏輯都覆蓋,某些函數某些類的功能無法覆蓋,而通常發現一些復雜的bug,不太好復現的bug基本都是用功能測試用例覆蓋不全的
- 從自動化測試角度來說,都知道UI自動化測試的性價比是最低的,目前接口自動化測試慢慢成為主流,而一些公司也開始注重單元測試或者關注白盒測試,招聘的測試都需要懂開發,需要可以review代碼,可以看到測試正在慢慢的下沉滲透
單元測試通常由開發工程師完成,一般會伴隨開發代碼一起遞交至代碼庫。單元測試屬於最嚴格的軟件測試手段,是最接近代碼底層實現的驗證手段,可以在軟件開發的早期以最小的成本保證局部代碼的質量。另外,單元測試都是以自動化的方式執行,所以在大量回歸測試的場景下更能帶來高收益。同時,你還會發現,單元測試的實施過程還可以幫助開發工程師改善代碼的設計與實現,並能在單元測試代碼里提供函數的使用示例,因為單元測試的具體表現形式就是對函數以各種不同輸入參數組合進行調用,這些調用方法構成了函數的使用說明。
如何做單元測試
要做好單元測試,首先要知道測試的對象是代碼,代碼的基本特征和邏輯,這樣才能應用的相關的單元測試技術來進行單元測試case設計和進行測試。
要測試什么
單元測試是代碼級別的測試,那么到底怎么測試代碼。開發語言是多種多樣的,客戶端的Java,OC,swift,js等,服務端的Java,PHP,Python,Go等。先不提單元測試,代碼級別的測試還有代碼掃描,代碼覆蓋率的測試,可以找到一些定義好規則的代碼漏洞或者代碼規范方面的問題,那單元測試肯定做的就是除了這些之外的一些工作了。單元測試是對代碼中的一個函數一個類的測試,那測試的是什么?
一個函數或者一個類包含什么,函數名(類名)、參數(屬性/變量)、函數體(類中的各種方法)、返回結果,在函數的實現的中有各種循環、分支判斷、函數調用,我們如果不管代碼處理的是什么樣的業務邏輯,僅看代碼它就是在進行各種數據的處理,這也是為什么有的程序員會厭煩寫業務,因為底層就是各種數據的增刪改查,當然這其中根據業務還會有各種復雜的判斷處理,並且也並不是所有的代碼都是在做增刪改查。
代碼中的循環、每個分支判斷、每個函數的輸入輸出都有可能產生缺陷,而單元測試的話就是測試這些函數(類)的功能輸入輸出、內部條件的判斷。我們來看個例子(以開源項目httprunner為例,作者寫了大量的單元測試)
在httprunner中loader類實現的功能是將yaml格式文件或者json格式文件亦或者存有兩種格式文件的文件夾的接口測試case加載實現為程序中的case model,拿類中其中一個功能函數為例;
def test_load_json_testcases(self): testcase_file_path = os.path.join( os.getcwd(), 'tests/data/demo_testcase_hardcode.json') testcases = loader.load_file(testcase_file_path) self.assertEqual(len(testcases), 3) test = testcases[0]["test"] self.assertIn('name', test) self.assertIn('request', test) self.assertIn('url', test['request']) self.assertIn('method', test['request'])
我們看到loader類實現將json格式的文件轉換為testcase,然后進行斷言是否加載成功,case中的每個字段是否能正確提取出來。
單元測試用例設計
無論是做功能測試,UI自動化測試,接口測試還是單元測試,都有一個很重要的東西那就是用例設計,用例設計體現了對代碼對功能的理解程度,也一定程度上決定了測試功能覆蓋,也會很明顯的體現出軟件質量。我從沒覺得一個好的功能測試能比自動化測試能比接口測試差多少,自動化接口亦或者測試開發更多的是對於測試質量的點綴,無論是提高了效率還是豐富了測試技術來提高測試質量,這些工作都必須基於有很好的功能測試基礎。
功能測試的用例設計是業務功能邏輯的輸入輸出,單元測試中就是函數的輸入輸出,那么單元測試中的輸入輸出有哪些呢?
輸入:
- 被測試函數的輸入參數
- 被測試函數需要的全局變量
- 被測試函數的內部私有變量
- 函數內部調用子函數的數據
- 函數內部調用其他模塊的數據
- 函數內部調用外部服務的數據
- ......
輸出:
- 被測函數的返回值
- 被測試函數的輸出參數
- 被測試函數修改的全局變量
- 被測試函數修改的內部變量
- 被測試函數增刪改的數據庫數據等
- 被測試函數進行的文件更新
- 被測試函數進行的消息隊列的更新
- .......
了解了測試的輸入輸出,進行測試case設計就跟功能測試的用例設計差不多了,首先需要對上述可能產生的情況進行分類,也就是常用的case設計方法:等價類划分,然后針對不同分類的case再進行邊界參數case設計,也就是邊界值法。另外針對代碼實現的邏輯應當根據產品業務邏輯進行預期的輸入輸出設計,而不能根據代碼進行相關的設計,那就沒什么用了。
再以httprunner為例,loader類加載csv文件,在加載時可能是一個參數,也可能是多個,因此需要進行兩個或多個case設計
def test_load_csv_file_one_parameter(self): csv_file_path = os.path.join( os.getcwd(), 'tests/data/user_agent.csv') csv_content = loader.load_file(csv_file_path) self.assertEqual( csv_content, [ {'user_agent': 'iOS/10.1'}, {'user_agent': 'iOS/10.2'}, {'user_agent': 'iOS/10.3'} ] ) def test_load_csv_file_multiple_parameters(self): csv_file_path = os.path.join( os.getcwd(), 'tests/data/account.csv') csv_content = loader.load_file(csv_file_path) self.assertEqual( csv_content, [ {'username': 'test1', 'password': '111111'}, {'username': 'test2', 'password': '222222'}, {'username': 'test3', 'password': '333333'} ] )
以上就是單元測試case設計。
樁代碼(stub)和mock
單元測試是測試軟件的最小單元,它應該是與軟件其他部分相分隔,例如與真實的數據庫、網絡環境等分隔開的,從而只測試我們關心的邏輯部分。那么對於有外部依賴的單元如何進行測試呢?這里提到兩個概念:樁代碼和mock
樁代碼:用來代替真實代碼的臨時代碼,對於依賴的其它部分直接使用固定代碼或固定數據返回,屬於完全模擬外部依賴
mock:這個就很常見了,它的作用也是替代真實的代碼或者數據,與樁代碼不同的是,mock還是可以進行相關的規則制定,還需要關心mock函數的調用和返回數據,例如mock的多次調用是否異常等等。mock用來模擬一些交互進行一些斷言判斷測試是否通過。
但是兩者都是為了對被測試函數進行隔離和補齊。
在項目中如何進行單元測試
以上是單元測試的一些理論基礎知識,那么如何在項目中應用單元測試。我認為單元測試的應用與自動化測試應用於項目應該是相同的考量。
- 項目適合不適合進行單元測試
- 項目中哪些模塊適合單元測試
- 選用什么樣的單元測試框架
- 如何執行單元測試
- 如何將單元測試融入ci進行持續集成
基於上面的考慮,如何在項目中開展單元測試。
- 並不是所有的項目都適合進行單元測試,即使進行單元測試,也應該是一些基礎底層模塊或者核心模塊進行單元測試
- 選擇合適的單元測試框架,Java中的TestNG、JUnit,Python中的Unittest、Pytest,PHP中的PHPUnit
- 將單元測試集成到CI流程當中
通常單元測試的框架選型以及配套的代碼覆蓋率工具的引入由開發架構師和測試架構師共同決定,並針對單元測試的一些細節進行相關的規范規定。
代碼規范
單元測試的運用也需要一些規范支持,例如代碼規范,注釋規范,正是有這些規范的支持才能更好的進行單元測試,或者說沒有這些規范很難進行單元測試。單元測試除了進行代碼測試也為測試人員提供了很好的功能測試用例設計的邏輯參考,也為其它開發者熟悉代碼提供了極大的便利。因此如果想讓單元測試能做到這些功能就必須要能讓別人看懂寫的單元測試,或者寫的代碼,那這就要求需要有代碼規范。而實際的工作中正因為缺少這樣的規范或者開發沒有時間去做到這些規范才導致了單元測試無法推動。那可以想想做哪些方面的規范:
1. 代碼注釋規范
2. 代碼命名規范
3. 單元測試注釋規范
4. 單元測試覆蓋規范
5. 單元測試執行規范
規范這個問題也可以引申出一個問題,是由開發做單元測試,還是測試做單元測試,如果測試來做單元測試的話,需要掌握開發語言及框架,無論是前端的單元測試還是后端的單元測試,都需要熟悉相應的開發語言及相應的框架(開發框架,單元測試框架),只有熟悉這些才能進行合理的單元測試case設計和測試。
以httprunner為例,來看下它的代碼規范
我們看下httprunner中runner類的注釋:除了注釋類的名稱作用,還提供了example告訴如何使用這個類。
class Runner(object): """ Running testcases. Examples: >>> tests_mapping = { "project_mapping": { "functions": {} }, "testcases": [ { "config": { "name": "XXXX", "base_url": "http://127.0.0.1", "verify": False }, "teststeps": [ { "name": "test description", "variables": [], # optional "request": { "url": "http://127.0.0.1:5000/api/users/1000", "method": "GET" } } ] } ] } >>> testcases = parser.parse_tests(tests_mapping) >>> parsed_testcase = testcases[0] >>> test_runner = runner.Runner(parsed_testcase["config"]) >>> test_runner.run_test(parsed_testcase["teststeps"][0]) """ def __init__(self, config, http_client_session=None): """ run testcase or testsuite. Args: config (dict): testcase/testsuite config dict { "name": "ABC", "variables": {}, "setup_hooks", [], "teardown_hooks", [] } http_client_session (instance): requests.Session(), or locust.client.Session() instance. """ self.verify = config.get("verify", True) self.export = config.get("export") or config.get("output", []) self.validation_results = [] config_variables = config.get("variables", {})
再看一個函數的注釋,handle_skip_feature,在執行case中跳過執行某些case
注釋包含函數名解釋,函數參數說明,函數體內分支判斷說明,異常捕獲說明
def _handle_skip_feature(self, test_dict): """ handle skip feature for test - skip: skip current test unconditionally - skipIf: skip current test if condition is true - skipUnless: skip current test unless condition is true Args: test_dict (dict): test info Raises: SkipTest: skip test """ # TODO: move skip to initialize skip_reason = None if "skip" in test_dict: skip_reason = test_dict["skip"] elif "skipIf" in test_dict: skip_if_condition = test_dict["skipIf"] if self.session_context.eval_content(skip_if_condition): skip_reason = "{} evaluate to True".format(skip_if_condition)
如果代碼規范能做到上述那樣,無論是開發者自己梳理單元測試用例case還是其他人來做單元測試,都可以比較容易的着手。相反,試想一下完全沒有代碼規范,或者規范做的不到位,那么在這樣的基礎上做單元測試應該要付出多少的成本。
總結
各個語言都有自己的單元測試框架,各個框架也都根據語言本身的特點或者語言應用的特點提供了許多實用的功能來讓開發者或者測試人員來進行測試,例如iOS的單元測試框架xctest,提供了豐富的API進行單元測試和UI自動化測試。如果我們要提高代碼質量,提高軟件質量,就應該從底層做起,從規范做起,打不好底子在上層搞再多的工作也不會起到應有的效果。真正了解代碼的只有代碼的開發者,真正能把代碼測試好的也是最了解代碼的人。測試要做好,還有很長的路要走。