
我叫Vadim,我是Mail.Ru Search的首席开发人员。 我将分享我们在单元测试中的经验。 本文包括三个部分:第一部分,我将告诉您在单元测试的帮助下,我们通常会实现什么; 第二部分介绍了我们遵循的原则; 从第三部分开始,您将学习如何在Python中实现上述原理。
目标
了解为什么要应用单元测试非常重要。 具体行动将取决于此。 如果您不正确地使用单元测试,或者在单元测试的帮助下您没有想要的东西,那么将不会有什么好处。 因此,事先了解您追求的目标非常重要。
在我们的项目中,我们追求一些目标。
首先是平庸的
回归 :修复代码中的某些内容,运行测试,然后发现没有任何问题。 尽管实际上,它并不像听起来那样简单。
第二个目标是
评估体系结构的影响 。 如果您在项目中引入强制性单元测试,或者只是与开发人员就单元测试的使用达成一致,这将立即影响编写代码的风格。 如果对这些函数进行了单元测试,则无法在300行上编写带有50个局部变量和15个参数的函数。 此外,通过这些测试,界面将变得更加易于理解,并且会出现一些问题区域。 毕竟,如果代码不是那么热,那么测试将是一条曲线,它将立即引起您的注意。
第三个目标是
使代码更清晰 。 假设您来到一个新项目,并获得了50 MB的源代码。 您可能无法弄清楚它们。 如果没有单元测试,那么除了阅读源代码之外,唯一了解代码工作的方法就是“戳方法”。 但是,如果系统非常复杂,则可能需要花费大量时间才能通过接口获取必要的代码段。 借助单元测试,您可以在任何地方查看代码的执行方式。
第四个目标是
简化调试 。 例如,您找到了某个类并想要对其进行调试。 如果只有单元测试而不是单元测试,或者根本没有测试,那么它只能通过接口到达正确的位置。 我碰巧参加了一个项目,在其中测试了一些功能,花了一个半小时来创建用户,向他收费,更改他的状态,启动某种cron,以便将该状态转移到其他地方,然后在界面中单击某些内容,然后启动某项内容半小时后,终于出现了该用户的红利计划。 如果我进行了单元测试,那么我可以立即到达正确的地方。
最后,将所有先前的目标结合在一起的最重要且非常抽象的目标是
舒适感 。 当进行单元测试时,使用代码可以减少压力,因为我了解发生了什么。 我可以采用一个陌生的资源,更正三行,运行测试,并确保代码能够按预期工作。 测试甚至不是绿色的:它们可以是红色的,但恰好是我所期望的。 也就是说,我了解代码的工作原理。
原则
如果您了解自己的目标,则可以理解要实现这些目标需要做什么。 问题就从这里开始。 事实是,已经有很多关于单元测试的书籍和文章,但是该理论还很不成熟。
如果您曾经阅读过有关单元测试的文章,尝试应用所描述的内容,但没有成功,那么原因很可能是理论的不完善。 这总是发生。 我和所有开发人员一样,曾经以为问题出在我身上。 然后他意识到:不可能是我错了很多次了。 他认为在单元测试中,有必要从自己的考虑出发,采取更加明智的行动。
在所有书籍和文章中都可以找到标准建议:“您不应该测试实现,而应该测试接口”。 毕竟,实现可以更改,但是接口不能更改。 让我们对其进行测试,以使测试不会每次都落在所有情况下。 该建议似乎还不错,而且一切似乎合乎逻辑。 但是我们非常了解:为了测试某些东西,您需要选择一些测试值。 通常,在测试函数时,会区分所谓的等价类:函数在其上统一行为的一组值。 粗略地讲,每个测试是否。 但是为了知道我们有什么等效类,需要一个实现。 您不必测试它,但需要它,应该调查一下它,以便知道选择哪个测试值。
与任何测试人员交谈:他会告诉您,通过手动测试,他总是会想到实现。 根据他的经验,他完全理解程序员通常在哪里犯错。 测试人员不会检查所有内容,首先输入5,然后输入6,然后输入7。他检查5,abc,–7,并且数字为100个字符,因为他知道这些值的实现可能有所不同,但是对于6和7,则不太可能。
因此,目前尚不清楚如何遵循“测试界面而非实现”的原则。 你不能只是闭上眼睛,写一个测试。 TDD正在尝试部分解决此问题。 该理论建议一次引入一个等效类,并为它们编写测试。 我已经读过很多关于这个主题的书和文章,但是不知何故。 但是,我同意应首先编写测试的论点。 我们首先将此原理测试称为“测试”。 我们没有TDD,因此,与上述相关的是,测试不是在创建代码之前编写的,而是与之并行编写的。
我绝对不建议追溯编写测试。 毕竟,它们会影响体系结构,如果已经解决了架构问题,那么现在就来不及影响它-必须重写所有内容。 换句话说,代码可测试性是代码必须
赋予的一个单独的属性,它不会变成这样。 因此,我们尝试将测试与代码一起编写。 不要相信“让我们在三个月内编写一个项目,然后在一周内通过测试覆盖所有内容”之类的故事,这将永远不会发生。
要理解的最重要的事情:单元测试不是验证代码的方法,也不是验证其正确性的方法。 这是您的体系结构和应用程序设计的一部分。 使用单元测试时,您会改变习惯。 仅验证正确性的测试是验收测试。 认为您可以使用单元测试覆盖某些内容,或者不需要检查代码,这将是一个错误。
Python实现
我们使用来自xUnit系列的标准unittest库。 故事是这样的:里面有SmallTalk语言,还有SUnit库。 每个人都喜欢它,他们开始复制它。 该库以Junit的名称导入Java,然后以C ++的CppUnit的名称导入Java,然后以RUnit的名称导入Ruby(然后将其重命名为RSpec)。 最终,该库从Java移到了Python,名称为unittest。 他们从字面上将其导入,以至于甚至保留了CamelCase,尽管这与PEP 8不符。
关于xUnit,有一本很棒的书,“ xUnit测试模式”。 它描述了如何使用该家族的框架。 这本书的唯一缺点是它的大小:它很大,但是其中约2/3的内容是样式目录。 本书的前三分之一真是太好了,这是我遇到的最好的IT书籍之一。
单元测试是具有一定标准体系结构的常规代码。 所有单元测试都包括三个阶段:设置,练习和验证。 您准备好数据,运行测试,看看是否一切都进入正确的状态。

设定
最困难和最有趣的阶段。 将系统恢复到要测试的原始状态可能非常困难。 而且系统的状态可以任意复杂。
到调用函数时,可能已经发生了很多事件,可能已经在内存中创建了100万个对象。 在与您的软件相关的所有组件中-在文件系统,数据库,缓存中-已经存在某些内容,并且该功能只能在此环境中工作。 如果没有准备好环境,则该功能的动作将毫无意义。
通常每个人都声称您绝对不能使用文件系统,数据库或任何其他单独的组件,因为这使您的测试不是模块化的,而是集成的。 我认为这是不正确的,因为集成测试是由集成测试完成的。 如果您不使用某些组件进行验证,而只是为了使系统正常工作,那没有什么错。 您的代码与计算机和操作系统的许多组件进行交互。 使用文件系统或数据库的唯一问题是速度。
直接在代码中,我们使用
依赖注入 。 您可以将参数而不是默认参数放入函数中。 您甚至可以将链接转发到库。 或者,您可以拖延存根而不是请求,以免测试中的代码访问网络。 您可以将自定义记录器存储在类属性中,以免写入磁盘并节省时间。
对于存根,我们使用unittest中的常规模拟。 还有一个补丁函数,而不是诚实地实现依赖关系,只是说:“在此软件包中,导入替代了另一个。” 这很方便,因为您不必在任何地方扔东西。 的确如此,然后不清楚是谁替换了什么,因此请谨慎使用。
至于文件系统,那么伪造它非常简单。 有一个带有
io.StringIO
和
io.BytesIO
的io模块。 您可以创建实际上不访问磁盘的类似文件的对象。 但是,如果突然这对您来说还不够,那么将有一个很棒的tempfile模块,带有上下文管理器,用于临时文件,目录,命名文件或其他任何内容。 如果IO由于某种原因不适合您,则Tempfile是超级模块。
使用数据库,一切都变得更加复杂。 有一个标准建议:“使用的不是真实的,而是假的。” 我不认识你,但是我一生中没有见过任何假货和足够的功能。 每当我问到有关在Python或Perl下具体需要采取什么措施的建议时,他们都会回答说没有人准备任何东西,而是愿意编写自己的东西。 我无法想象如何编写一个仿真器,例如PostgreSQL。 另一个提示:“然后获取SQLite。” 但这将破坏隔离,因为SQLite可用于文件系统。 此外,如果使用MySQL或PostgreSQL之类的工具,则SQLite可能无法正常工作。 如果在您看来您没有使用特定产品的特定功能,那么您很可能会误会。 当然,即使对于诸如日期之类的平凡事物,您也使用仅DBMS支持的特定功能。
结果,他们通常使用真实的基础。 解决方案还不错,只需要显示一定的精度即可。 不要使用集中式数据库,因为测试可能会相互中断。 理想情况下,基座本身应在测试过程中上升,并在测试后停止。
稍差一点的情况是当您需要运行将要使用的本地数据库时。 但是问题是,数据将如何到达那里? 我们已经说过,系统必须有一些初始状态,数据库中必须有一些数据。 它们来自何处不是一个容易的问题。
我遇到的最幼稚的方法是使用真实数据库的副本。 定期从中获取副本,从中删除敏感数据。 作者认为真实数据最适合测试。 另外,为真实数据库的副本编写测试是一种折磨。 您不知道那里有什么数据。 您需要首先找到要测试的内容。 如果此信息不存在,则不清楚该怎么做。 最终,他们决定在该项目中为运营部门的帐户编写测试,该测试“将永远不会改变”。 当然,一段时间后,她改变了。
通常,通常要做出以下决定:“让我们对真实库进行转换,将其复制并不再同步。 然后将有可能绑定到特定对象,观察在那里发生的事情并编写测试。” 问题立即出现:将新表添加到数据库时会发生什么? 显然,您将必须手动输入假数据。
但是,由于我们还是会这样做,因此,我们立即手动准备基础演员表。 这个选项与Django通常所说的Fixture非常相似:它们生成巨大的JSON,在所有情况下都上传测试用例,在测试开始时将它们发送到数据库,一切对我们来说都很好。 这种方法也有很多缺点。 数据堆积在一个堆中,尚不清楚它与什么测试有关。 没有人能理解数据是被删除还是未被删除。 数据库存在不兼容的状态:例如,一项测试需要在数据库中没有用户,而另一项则需要拥有这些用户。 这两个条件不能同时存储在同一模具中。 在这种情况下,其中一项测试将必须修改数据库。 而且,由于仍然要处理此问题,因此最简单的方法是从空数据库开始,以便每个测试都在其中放置必要的数据,并在测试结束时清除数据库。 这种方法的唯一缺点是很难在每个测试中创建数据。 在我工作的项目之一中,要创建服务,有必要在不同的表中生成8个实体:个人帐户上的服务,客户上的个人帐户,法人实体上的客户,城市中的法人实体,城市中的客户等等。 除非您在链中创建所有这些内容,否则您将无法满足外键,没有任何效果。
对于这种情况,有一些特殊的图书馆极大地方便了人们的生活。 您可以编写辅助工具,通常称为工厂(不要与设计模式混淆)。 例如,我们使用了适用于Django的factory_boy库。 这是factory_girl库的克隆,由于政治上的正确性,该库去年已重命名为factory_bot。 为您自己的框架编写这样的库不会花费任何费用。 它基于一个非常重要的想法:您曾经为要生成的对象创建了一个工厂,为其建立了连接,然后告诉用户:“创建时,使用您的姓氏,并使用组工厂来自己生成组”。 在工厂中,一切都完全一样:以这种方式生成名称,以及诸如此类的相关实体。
结果,代码中仅剩下最后一行:
user = UserFactory()
。 已经创建了用户,您可以与他一起工作,因为在幕后,他生成了所需的一切。 如果需要,您可以手动配置一些内容。
为了在测试后清理数据,我们使用了一些琐碎的事务。 在每个测试的开始,BEGIN完成,测试对基数进行操作,然后在测试之后,完成ROLLBACK。 如果测试本身需要事务(例如,因为它向数据库提交了一些额外的东西),它将调用我们称为
break_db
的方法,告诉框架它破坏了数据库,然后框架重新滚动它。 事实证明,这很慢,但是由于通常很少有需要事务的测试,因此一切都井井有条。
锻炼身体
关于这个阶段,没有什么特别的要说的。 唯一可能出问题的地方就是转向例如Internet。 一段时间以来,我们在管理上为此感到挣扎:我们告诉程序员,我们必须浸入某个地方的函数,或者抛出特殊标志,以使这些函数不起作用。 如果测试访问公司etcd,则不好。 结果,我们得出的结论是,一切都被浪费了:我们自己总是忘记了某个函数调用了一个函数,该函数调用了转到etcd的函数。 因此,在基类的setUp中,我们添加了所有调用的moki,即在存根未放置的所有调用的情况下通过存根阻止了该调用。
可以使用修补程序轻松制作存根,将修补程序放在单独的词典中,并可以访问所有测试。 默认情况下,测试无法进行任何操作,并且如果您仍然需要打开某些测试,则可以将其重定向。 很舒服 詹金斯将不再在晚上向您的客户发送短信:)
验证一下
在这一阶段,我们积极使用自写的断言,甚至单行断言。 如果您在测试中测试文件的存在,那么
self.assertTrue(file_exists(f))
建议写断言
not file exists
self.assertTrue(file_exists(f))
来代替assert
self.assertTrue(file_exists(f))
。 Holivar与此相关:我应该继续在名称中使用CamelCase,例如在unittest中使用,还是应该遵循PEP 8? 我没有答案 如果您遵循PEP 8,则在测试代码中,CamelCase和snake_case会变得一团糟。 并且,如果您使用CamelCase,则它不对应于PEP 8。
还有最后一个。 假设您有一个正在测试某些东西的代码,并且有许多数据选项需要在其上运行。 如果使用py.test,则可以使用不同的输入数据运行相同的测试。 如果您没有py.test,则可以使用
这样的装饰器 。 将一个表传递给装饰器,并且一个测试转换为其他多个测试,每个测试都测试一种情况。
结论
不要无条件地信任文章和书籍。 如果您认为它们是错误的,则可能确实如此。
随意使用依赖性测试。 这没有错。 如果您引发了memcached,因为没有它,您的代码将无法正常运行,那就可以了。 但是,如果可能的话,最好不要使用它。
注意工厂。 这是一个非常有趣的模式。
PS:我邀请您进入我的作者的电报频道,以Python进行编程-@pythonetc。