文章
问答
冒泡
单元测试与设计模式的艺术

前言

在我工作的这几年,设计模式这个词就不断萦绕于耳,为了学好设计模式,我曾不断的生搬硬套4人帮的著作(我是一名python程序员),在不断的工程实践当中,越发发现,设计模式的本质并不是前人总结好的套路,他遵循一定的原则。也就是我们耳熟能详的“SOLID”,一旦你的软件遵循原则,那么他其实也是你自己的模式。可回过头来你会发现,遵循这些原则并不容易,在日常coding当中,我们经常会为了快点实现功能,而把原则抛向脑后。美其名曰:先实现!再优化!随后便陷入了怪圈,实现好了不想动->动了又怕出错->新的需求来了->没时间重构->继续用糟糕的实现的死循环怪圈!

https://i.loli.net/2019/01/15/5c3cec001e577.png

久而久之,代码变不可维护,开始可能是只有自己和上帝知道,最后就只剩下上帝知道你到底做了什么。

那么我们该怎么办呢?有什么办法才能让我们摆脱这个怪圈!?测试先行->单元测试->TDD

设计模式的本质

什么是设计模式?

软件工程中,设计模式是指软件设计问题的推荐方案,设计模式一般涉及如何组织代码和使用最佳实践来解决常见问题,设计模式作为一个高层次的方案,并不关注具体实现细节(面向对象特征)。经常会有人错误的把设计模式或者设计原则当成刻板的教条,却从来不关注软件工程当中从来没有”银弹“。于是就出现了如下几个误解

  1. 我们应该一开始就想好应该使用什么设计模式。

很多人经常喜欢纠结在代码中应该使用哪种设计模式,他们甚至都还没有尝试一下使用自己的方式解决问题,形成了设计模式教条主义!这种想法不仅仅是错误的,而且违背了设计模式的本质,设计模式本质是在已有的方案上发现更好的方案(不是全新发明啊,别自做聪明)。如果你一个方案都没有,又何谈一个更好的呢?

  1. 我们无论在什么时候都应该使用设计模式

软件设计当中是没有银弹的,很多时候,我们有些代码根本不需要设计模式!随便一个简单的功能便教条主义的使用设计模式,使得方案夹杂了多余的接口与分层,使其方案变得极其复杂,更违背了使用设计模式的初衷。有些人以为只要我使用了设计模式,我的代码就会变得更加优雅,容易维护。事实上却适得其反,本末倒置。

软件设计的基本原则

老生常谈,SOLID,当我们剥离了设计模式的高层,会发现。设计模式的初衷是对常见问题给出符合SOLID设计原则的推荐方案,同理,既然设计模式由设计原则演化,本质上一样仅仅只是指导和建议。盲目实施设计原则一样会导致我们不能集中主要目标,即产生可工作可维护的代码。具体的SOLID的原则就不在这里细讲了。

我们该怎么办?

再说了上面的东西后,我们不免会陷入焦虑当中。既然设计模式不是那么可靠,设计原则也并非想象当中那么好用。我们该如何生产出优质的代码?经过3年的实践,我终于发现,其实不用想那么多,总结就是一句话:行动起来,实现目标。

行动起来

在我过往的工作当中,起初也不是TDD的忠实粉丝,我更喜欢撸完功能再总结逻辑进行重构,并且我也是一个不折不扣的设计模式教条主义者。在最开始的工作当中,简单的业务逻辑,简单的系统模块非常容易这么做。但随着业务不断扩大,逻辑逐渐复杂。我已经无法控制代码复杂度的上升。之后才意识到TDD的重要性。下面我将介绍一下我实践TDD的一些经验。

第一步:关注目标

当我们开始动手去实现的时候,我们经常回想我们该怎么做?我们该用什么样的数据结构?什么样的算法?什么样的设计方案?怎么做才能更加优雅?其实大可不必,我们只需要关注目标产出,并提出三个假设进行思考

  1. WHAT,做什么?

我们做的是个什么东西,这个东西抽象出来到底是个什么?从此发散出大概的一个逻辑。

  1. WHO,给谁做?

我们这个东西给谁做的?是给其他合作项目组做的?是对模块的?还是给用户做的?从而发散出大概边界条件和输入输出。

  1. WHY,为什么要做?

我们为什么要做这个?我们有没有相似的东西可以扩展?从而发散出我们做的这个东西能不能通过其他模块扩展得来。

第二步:测试先行

思考上述三个问题,我们应该能大概了解到做这个东西大概有哪些步骤?哪些边界条件?有哪些输入和输出?我们需要哪些外部系统或者外部模块的依赖?并作出以下预估。

  1. 外部系统依赖项

当实现一个需求时,往往我们需要大量的外部模块和外部系统例如:数据库,缓存,其他模块和依赖,我们需要知道大概需要哪些东西?能够帮助我们完成目标

  1. 假定边界条件

我们需求会有输入边界条件?如:不能大于多少的数字?是否需要登陆条件?用户的角色是什么?有哪些权限才能够使用?

  1. 细化处理步骤,保证测试用例简单且快速

这个时候我们就要动手写测试用例了,这个时候不要着急如何编写单元测试,如写好依赖的数据库,缓存。就只需简简单单的写出假设的边界条件和部分测试函数

test_xxx1(test): 
    test.data = "22222" 
test_func(test.data) :
    assert xxx === out 
def test_xxx2(test): 
    test.data = "233333" 
test_func(test.data) 
    assert xxx === out 

第三步:开始编码

《黑客与画家》当中,作者告诉我们,写代码就像画画一样,不是一下子就画出来了。而是,我们用铅笔画出结构,然后拿橡皮擦擦改改,然后上色,画出阴影,然后再擦掉重新修改。那么我们编辑器就是我们的铅笔,而我们的测试用例就是我们的橡皮。此时我们更多做的是持续修改测试用例和我们的代码。不断地拓展边界条件和依赖。直到代码按照我们预估的进行。如此反复带着反馈工作。

解除依赖,完善设计

如果按照TDD工作的话,你会发现,你的代码基本上遵循了单一原则(S),因为一个好的代码首先是易于测试的,单元测试也是一个函数一个函数测试的。但这时你会发现一个问题,就是依赖,因为此时代码肯定会有许多强依赖。如我们查询数据库,其他模块儿的配置项。此时你应该考虑你的代码是不是违反了依赖倒置原则。即所谓高层模块不应该依赖于底层模块,两者都应该依赖抽象。我用伪代码稍作示例

如:我们的代码

def login(request):
    balab = db.sql("select * from User")

在上述代码中,我们依赖了底层模块的数据库,这就导致了我们测试用例当中,无法测试,因为我们必须要保证环境当中有数据库,而单元测试最好是不要依赖外部系统,这就导致了我们的代码不能易于测试,因为你违反了依赖倒置原则,我们稍作修改

class User:
    def __init__(self, db):
        self.db = db
    def get_user(self):
        self.db.query(xxxxx)
    def login(request):
        user = User.get_user()

此时我们就能把依赖清除掉,做成mock,让get_user直接返回我们的假数据进行测试,如果我们还要对db.query做测试,我们可以对self.db依然是抽象,我们把db也mock掉,输出我们假的数据。方便我们做测试

恼人的全局变量

无法更好的将全局变量引入到测试夹具当中,上述方法并不能解决我们的问题,这该怎么办?

解决方法其实很简单,抽象出全局对象,子类化全局对象伪造。以便放入夹具当中

完善工作流程

坚持TDD的好处是明显的,你会发现不知不觉当中你已经完善了代码,并且保证了代码质量。且每一步骤都有迹可循,避免了环境问题难以测试,完善的工作流程会让你更加专注与工作,而不是其奇怪该的问题。此时你的工作流会是这样的

TDD工作流

结语

TDD的好处是显而易见的,但是不代表说单元测试就一定是OK的,我们仍需要大量的集成测试来完善测试用例。测试用例只是橡皮而不是指导,更不能摆脱基准测试的作用。他只是你的好朋友,帮助你快速反馈代码的工具,他只是你的橡皮擦。

参考资料

  • 《修改代码的艺术》

  • 《单元测试的艺术》

  • 《精通Python设计模式》

  • 《测试驱动开发的艺术》


关于作者

雷米Remy
获得点赞
文章被阅读