11. 單元測試
本章節我們來講講django工程中如何實現單元測試,單元測試如何編寫以及在可持續項目中單元測試的重要性。
下面是單元測試的定義:
單元測試是開發者編寫的一小段代碼,用於檢驗被測代碼的一個很小的、很明確的功能是否正確。
1. 它是一種驗證行為
程序中的每一項功能都是測試來驗證它的正確性。它為以后的開發提供支援。就算是開發后期,我們也可以輕松的增加功能或更改程序結構,而不用擔心這個過程中會破壞重要的東西,它為代碼的重構提供了保障。這樣,我們就可以更自由的對程序進行改進。
2. 它是一種設計行為
編寫單元測試將使我們從調用者觀察、思考。特別是先寫測試(test-first),迫使我們把程序設計成易於調用和可測試的,即迫使我們解除軟件中的耦合。什么時候測試?單元測試越早越好,早到什么程度?極限編程(Extreme Programming,或簡稱XP)講究TDD,即測試驅動開發,先編寫測試代碼,再進行開發。
不過在實際的編碼過程中,我們不必過分強調先干什么后寫什么,重要的是高效和個人感覺舒適。從筆者的經驗來看,根據設計或需求先編寫某個功能函數的框架,然后就着手編寫測試函數,針對產品的功能編寫測試用例,最后編寫函數的實現代碼,每完成一個功能點都運行單元測試,隨時補充完善測試用例。這種測試同行代碼編寫模式,會對函數的構思有很大的幫助,如何去編寫可單元測試的函數慢慢的就會變成書寫和思考習慣。
所謂先編寫產品功能的函數框架,是指先編寫函數空的實現,考慮參數的有哪些參數和可驗證的返回值,同時默認直接返回一個合適值(假定值),編譯通過后即可編寫測試代碼,這時,函數名、參數表、返回類型都應該確定下來了,所編寫的測試代碼以后需修改的可能性比較小,當然由於個人編碼成熟度的不同,實際開發過程中調整也在所難免,好在單元測試可以迅速跟蹤調准導致的影響。
3. 它是一種編寫文檔的行為
單元測試是一種無價的文檔,它是展示函數或類如何使用的最佳文檔。這份文檔是可編譯、可運行的,並且它保持最新,永遠與代碼同步。
4. 它具有回歸性。
自動化的單元測試避免了代碼出現回歸,編寫完成之后,可以隨時隨地的快速運行測試。筆者的經驗表明一個盡責的單元測試方法將會在軟件開發的早期階段就可以發現很多的Bug,並且修改它們的成本也很低。在軟件開發的后期階段,Bug的發現和修改將會變得更加困難,尤其修改BUG可能導致引入新的BUG,並要消耗大量的時間和開發費用。
筆者的經歷的項目就遇到這樣的問題,項目上線的前一天的某個BUG修改,導致當晚一直加班深夜解決新引入的BUG問題,所以后來單元測試的回歸性在筆者的項目經驗里最喜歡的特性。無論什么時候作出修改都要進行完整的回歸測試,可以避免修改可能引入的BUG。在生命周期中盡早地對軟件產品進行測試將使效率和質量得到最好的保證。在提供了經過測試的單元的情況下,系統集成過程將會極大地簡化。開發人員可以將精力集中在單元之間的交互作用和全局的功能實現上,而不是陷入充滿很多Bug的單元之中不能自拔。
如果考慮做一個可以持續改進和維護的項目,尤其有大量的業務規則和邏輯的系統,單元測試就顯得非常重要,單元測試主要這對業務邏輯編寫測試代碼,確定編碼是否滿足測試要求。下面我們就進入Django的單元測試實踐吧。
11.1. 運行單元測試
我們創建好Django app 每個app 都會創建一個單元測試tests.py的單元測試文件,代碼如下:
""" This file demonstrates writing tests using the unittest module. These will pass when you run "manage.py test". Replace this with more appropriate tests for your application. """ from django.test import TestCase class SimpleTest(TestCase): def test_basic_addition(self): """ Tests that 1 + 1 always equals 2. """ self.assertEqual(1 + 1, 2)
我們現在可以在IDE環境中,TEST->All Tests運行這個測試例子看看單元測試運行的效果。
11.2. 開始我們的第一個單元測試
我們來看看就提交入庫單這個業務來說,目前的views.py函數AddInStockBill是沒辦法進行單元測試的,應為期參數涉及到web請求內容參數request,如何編寫可具備單元測試的功能代碼也是早期編寫單元測試,可以讓我們逐步掌握的代碼解耦的思維模式。
入庫單業務的關鍵點,就是入庫單提交后我們需要更新該入庫單對應物料的庫存數據,偽代碼如下:
1. 根據當前入庫單的物料,在庫存表中查找當前物料的庫存記錄;
2. 如果有當前庫存記錄返回當前庫存對象,如果沒有就創建一個新的對象;
3. 更新入庫單對應物料的當前庫存數據;
我們來看看如何嘗試測試先行的開發模式去考慮一個入庫單model提交將導致庫存的更新場景,用代碼說話吧:
from django.test import TestCase from inventory.models import * from inventory import views class InventoryTest(TestCase): def test_updating_inventory_in(self): #1.創建一個Item實例; item = Item() item.ItemId = 1 item.ItemCode = '1001' item.ItemName = '普通螺母' #2.床建一個新入庫單對象 inStockBill = InStockBill() inStockBill.InStockBillCode='201501010001' inStockBill.InStockDate = '2015-01-01' inStockBill.Operator = '張三' inStockBill.Amount = 10 inStockBill.Item = item #3.創建當前該物料的庫存對象 inventory = Inventory() inventory.InventoryId = 1
inventory.Item = item inventory.Amount = 10 #當前庫存數量 #如何構建更新庫存的函數,讓其可具備測試調用 views.UpdatingInventoryIn(inStockBill,inventory) #校驗測試是否滿足當前場景 self.assertEqual(inventory.Amount ,20)
當前我們當前運行單元測試肯定會出錯,因為我們還沒有編寫views.UpdatingInventoryIn函數:
def UpdatingInventoryIn(inStockBill,inventory): inventory.Amount = inventory.Amount + inStockBill.Amount
11.3. 執行單元測試
在IDE環境中執行改成我們寫好的單元測試,我們看到結果如下圖,測試通過。
這里我們的單元測試主要針對核心業務來構建,不考慮相關對象的獲取方式,就是說測試用例是我們根據測試場景來構建的,不考慮對象是否在數據庫中,也就是與持久層沒有關系。早年筆者在這里也是大費周折,測試數據與持久層數據緊密耦合,結果更換數據庫或者認為刪除數據后,單元測試的回歸測試就無法執行,單元測試的優勢大打折扣。單元測試的回歸性在后續代碼重構,業務變更中有着巨大的優勢,不能回歸的單元測試價值就少了很多,所以我們在考慮單元測試時,一定要盡量與持久層數據解耦,測試用例數據在測試代碼中構建。
11.4. 代碼的持續改進
前面的代碼中,我們的測試用例場景是假定該物料是已經有庫存數據的,那如果該物料以前沒有庫存數據,我們的代碼怎么來寫呢,我們還是從測試用例開始吧,增加測試用例代碼。
…
#校驗測試是否滿足當前場景 self.assertEqual(inventory.Amount ,20) inventory = Inventory() #當前沒有庫存數據,我們創建對象屬性都沒有賦值 views.UpdatingInventoryIn(inStockBill,inventory) self.assertEqual(inventory.Amount ,10) self.assertEqual(inventory.Item.ItemId ,inStockBill.Item.ItemId)
執行測試錯誤,因為views.UpdatingInventoryIn(inStockBill,inventory)沒有對inventory為空的情況處理,我改進代碼來滿足這樣的需求。於是函數代碼就變成了下面這樣:
def UpdatingInventoryIn(inStockBill,inventory):
if (inventory.InventoryId == None): inventory.Item = inStockBill.Item inventory.Amount = 0 inventory.Amount = inventory.Amount + inStockBill.Amount
執行測試通過,剛才的測試用例是寫在一個測試函數里還是分開寫,主要是看我們的測試用例復雜度了,復雜度高的就分開來寫,簡單就寫在一個測試函數里。函數粒度的選擇由程序員來考慮了,核心就是關注可讀性,函數太長我們就把函數拆小,提高可讀性。(這里筆者強烈推薦《代碼重構》這本很多年以前的經典書)。
最后們views里的增加入庫單函數重構成如下這樣代碼:
@transaction.commit_on_success def AddInStockBill(request): if request.method == 'POST': form = InStockBillForm(request.POST) if form.is_valid(): cd = form.cleaned_data inStockBill = InStockBill() inStockBill.InStockBillCode = cd['InStockBillCode'] inStockBill.InStockDate = cd['InStockDate'] inStockBill.Amount = cd['Amount'] inStockBill.Operator = cd['Operator'] inStockBill.Item = cd['Item'] inventorys = inStockBill.Item.inventory_set.all() if (inventorys.count()==0): currentInventory = Inventory() else: currentInventory = inventorys[0] #注意的函數調用,你會發現更新庫存與如何獲取庫存對象完全解耦 UpdatingInventoryIn(inStockBill,currentInventory) currentInventory.save() #更新庫存 inStockBill.save() #保存入庫單數據 return HttpResponseRedirect('/success/') else: form = InStockBillForm() return render_to_response('InStockAdd.html',{'form': form} ,context_instance = RequestContext(request))
11.5. 小結
如何編寫單元測試代碼,測試先行的模式會讓編碼人員去思考如何把業務邏輯抽象出來變成一個可以用單元測試來跟蹤的函數單元很有幫助,如果我們在編寫一個與數據庫打交道的應用系統,把業務邏輯與如何獲取數據解耦合對系統的可擴展性和可維護性相當的重要,尤其當我們打算構建一個可以持續改進的系統時尤為如此。