1. 单元测试
本章节我们来讲讲django工程中如何实现单元测试,单元测试如何编写以及在可持续项目中单元测试的重要性。
下面是单元测试的定义:
单元测试是开发者编写的一小段代码,用于检验被测代码的一个很小的、很明确的功能是否正确。
1. 它是一种验证行为
程序中的每一项功能都是测试来验证它的正确性。它为以后的开发提供支援。就算是开发后期,我们也可以轻松的增加功能或更改程序结构,而不用担心这个过程中会破坏重要的东西,它为代码的重构提供了保障。这样,我们就可以更自由的对程序进行改进。
2. 它是一种设计行为
编写单元测试将使我们从调用者观察、思考。特别是先写测试(test-first),迫使我们把程序设计成易于调用和可测试的,即迫使我们解除软件中的耦合。什么时候测试?单元测试越早越好,早到什么程度?极限编程(Extreme Programming,或简称XP)讲究TDD,即测试驱动开发,先编写测试代码,再进行开发。
不过在实际的编码过程中,我们不必过分强调先干什么后写什么,重要的是高效和个人感觉舒适。从笔者的经验来看,根据设计或需求先编写某个功能函数的框架,然后就着手编写测试函数,针对产品的功能编写测试用例,最后编写函数的实现代码,每完成一个功能点都运行单元测试,随时补充完善测试用例。这种测试同行代码编写模式,会对函数的构思有很大的帮助,如何去编写可单元测试的函数慢慢的就会变成书写和思考习惯。
所谓先编写产品功能的函数框架,是指先编写函数空的实现,考虑参数的有哪些参数和可验证的返回值,同时默认直接返回一个合适值(假定值),编译通过后即可编写测试代码,这时,函数名、参数表、返回类型都应该确定下来了,所编写的测试代码以后需修改的可能性比较小,当然由于个人编码成熟度的不同,实际开发过程中调整也在所难免,好在单元测试可以迅速跟踪调准导致的影响。
3. 它是一种编写文档的行为
单元测试是一种无价的文档,它是展示函数或类如何使用的最佳文档。这份文档是可编译、可运行的,并且它保持最新,永远与代码同步。
4. 它具有回归性。
自动化的单元测试避免了代码出现回归,编写完成之后,可以随时随地的快速运行测试。笔者的经验表明一个尽责的单元测试方法将会在软件开发的早期阶段就可以发现很多的Bug,并且修改它们的成本也很低。在软件开发的后期阶段,Bug的发现和修改将会变得更加困难,尤其修改BUG可能导致引入新的BUG,并要消耗大量的时间和开发费用。
笔者的经历的项目就遇到这样的问题,项目上线的前一天的某个BUG修改,导致当晚一直加班深夜解决新引入的BUG问题,所以后来单元测试的回归性在笔者的项目经验里最喜欢的特性。无论什么时候作出修改都要进行完整的回归测试,可以避免修改可能引入的BUG。在生命周期中尽早地对软件产品进行测试将使效率和质量得到最好的保证。在提供了经过测试的单元的情况下,系统集成过程将会极大地简化。开发人员可以将精力集中在单元之间的交互作用和全局的功能实现上,而不是陷入充满很多Bug的单元之中不能自拔。
如果考虑做一个可以持续改进和维护的项目,尤其有大量的业务规则和逻辑的系统,单元测试就显得非常重要,单元测试主要这对业务逻辑编写测试代码,确定编码是否满足测试要求。下面我们就进入Django的单元测试实践吧。
1.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运行这个测试例子看看单元测试运行的效果。
1.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
1.3. 执行单元测试
在IDE环境中执行改成我们写好的单元测试,我们看到结果如下图,测试通过。
这里我们的单元测试主要针对核心业务来构建,不考虑相关对象的获取方式,就是说测试用例是我们根据测试场景来构建的,不考虑对象是否在数据库中,也就是与持久层没有关系。早年笔者在这里也是大费周折,测试数据与持久层数据紧密耦合,结果更换数据库或者认为删除数据后,单元测试的回归测试就无法执行,单元测试的优势大打折扣。单元测试的回归性在后续代码重构,业务变更中有着巨大的优势,不能回归的单元测试价值就少了很多,所以我们在考虑单元测试时,一定要尽量与持久层数据解耦,测试用例数据在测试代码中构建。
1.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))
1.5. 小结
如何编写单元测试代码,测试先行的模式会让编码人员去思考如何把业务逻辑抽象出来变成一个可以用单元测试来跟踪的函数单元很有帮助,如果我们在编写一个与数据库打交道的应用系统,把业务逻辑与如何获取数据解耦合对系统的可扩展性和可维护性相当的重要,尤其当我们打算构建一个可以持续改进的系统时尤为如此。