Python中使用unittest做单元测试时如何优雅地处理input

一、问题描述

假设我们有下面的一个函数需要接受测试:

def add_3(num):
    return 3 + num

上面这个函数就是把给定的参数加上3,然后返回。用unittest来做测试非常简单,代码如下:

import unittest
from operation import add_3


class TestOperation(unittest.TestCase):
    def test_add_3(self):
        # 断言add_3(5)的返回值是8
        self.assertEqual(add_3(5),8)



if __name__ == '__main__':
    unittest.main()

测试这样的函数很简单,很清晰。关键的代码就是self.assertEuqal(add_3(5),8).这行代码的可读性非常强,注释都显得多余。

下面我要修改一下add_3函数,让它不需要接受参数num,而是等待用户输入,根据用户输入的值来返回结果。修改后的代码如下:

def add_3():
    while True:
        num = input('请输入一个整数>>>')
        if num.isdigit():
            break
    return int(num) + 3

改成这样之后,怎么测试呢?add_3不需要参数了,那我们就不给它参数了呗。把之前的测试稍微修改一下,可以变成下面的样子:

 

import unittest
from operation import add_3


class TestOperation(unittest.TestCase):
    def test_add_3(self):
        # 断言add_3()在用户输入5的时候返回值是8
        self.assertEqual(add_3(),8)



if __name__ == '__main__':
    unittest.main()

 如果我们运行这个新的测试,运行到input语句的时候,控制台会停下,等我们输入。为了按照我们的计划完成测试,我们必须输入5。运行效果如下图所示:

测试也顺利地完成了,但是这种方式好吗?很显然,不好。主要的缺陷如下:

  1. 不够自动化。这里只测试了一个函数,只需要一次输入。如果同时测试多个函数,每个函数都需要多次输入呢?而且别忘了,输入什么值还是有要求的(比如,这里的计划就是输入5)。这样看来,效率太低。
  2. 代码的可读性不高。我们第一个版本的测试语句self.assertEqual(add_3(5),8)做到了完美的自我解释,不需要注释也能看到要测试的数据是什么,期望的结果是什么。但是第二个版本self.assertEuqal(add_3(),8)看起来就像在说这个函数永远返回8一样,必须依赖注释才知道用户输入5的时候这个程序返回8.

幸运的是,unittest已经给我们提供了处理input的好方法。下面我们就来看看如何使用unittest.mock.patch来优雅地实现绕过手动输入的测试。

二、使用unittest.mock.patch来优雅地处理input

patch是什么?patch是一个带参数的装饰器,它可以用来装饰函数(方法),装饰类,还可以当做上下文管理器来使用。我们下面就会用它来装饰方法。

patch有什么用?它会用一个新对象覆盖旧对象,并且在离开了特定的作用域后,取消覆盖,把旧对象还给程序。这样解释有些抽象,结合我们当前的需求来看就会清晰很多。

我们的函数add_3里面用到了input,但其实真正有用的只是input的返回值。因为我们的测试就是基于这个返回值(我们期望的返回值是'5')。可是这个返回值又依赖于用户的输入,所以就导致程序会停下来,程序员需要输入5,然后测试才能继续进行。如果能绕开“程序暂停”、“程序员手动输入5”这两个步骤,直接得到input的返回值,问题就迎刃而解了。所以,我们就需要用patch来帮我们覆盖。这里,input就是要覆盖的旧对象,而新对象会由patch提供给我们。这个新对象是MagicMock对象,它的属性很多,我们先来看看马上就要使用的return_value。

return_value,顾名思义,就是返回值。我们规定了新对象的返回值是'5',这就相当于让input的返回值是5.而新对象不需要暂停和输入,所以我们的测试就可以自动进行,不需要手动干预。请看下面的代码实现:

import unittest
from unittest.mock import patch
from operation import add_3


class TestOperation(unittest.TestCase):
    # 传入的参数是说要覆盖的旧对象是内置的input函数
    @patch('builtins.input')
    # 第二个参数就是新对象
    def test_add_3(self,mock_input):
        # 模拟接收用户输入并返回'5'
        mock_input.return_value = '5'
        # 断言用户输入5时,add_3函数会返回8
        self.assertEqual(add_3(),8)



if __name__ == '__main__':
    unittest.main()

还有一点要注意:patch中说明要覆盖的旧对象时,一定要写清楚命名空间。如果这里只写一个input,会报错,因为没有说明input的命名空间。

确定命名空间的原则是:在哪里使用这个对象,哪里就是它的命名空间。所以,假设我们现在所在的文件是b.py,而函数func是a.py中定义的。我们在b.py的头部写了一个from a import func。那么在覆盖func的时候,patch中不能写'a.func',因为我们不是在a中使用func的,而是在b中使用。这时应该写'__main__.func'(运行时的命名空间已经是__main__了)。如果我们是导入模块a,而不是单独导入func,那么func的命名空间依然是a,覆盖的时候patch中要写‘a.func’。

我们现在要覆盖的是内置函数input,可以写成'builtins.input',也可以写'__main__.input',前者更清晰。

三、进一步思考,如果要测试的函数中有多个input语句怎么办?

 我们再来仔细看看mock_input.return_value='5'这个语句。其实它的意思可以用下面的代码来表示(具体的实现细节有很大区别,但是可以这样简单地理解):

class MagicMock:
    def __call__(self):
        return '5'

mock_input = MagicMock()

我们用mock_input覆盖了原来的input,所以程序碰到input语句,就会调用mock_input。而mock_input这个函数不需要参数,并且返回值是固定的'5'。因此看起来就好像用户输入了5一样。

对于只有一个input语句的被测试函数来说,我们只需要一个来自input的返回值,上面的这种实现方式足够了。但如果被测试的函数有两个input语句呢?看看下面的函数:

def add():
    while True:
        first = input('请输入第一个数>>>')
        if first.isdigit():
            break
    while True:
        second = input('请输入第二个数>>>')
        if second.isdigit():
            break
    return int(first) + int(second)

这回是两个数相加了,程序要从用户获取两次输入。如果我们还是用永远返回5的mock_input来覆盖input,就只能僵化地测试5+5=10. 如果我要测试3 + 5 = 8 呢?看来现在这个永远返回一个固定值的mock_input已经不能满足我们的需求了。我们需要一个第一次返回3,第二次返回5的可调用对象。这就要用到MagicMock对象的side_effect属性了。

side_effect属性的值可以是一个函数,可以是一个可迭代对象,也可以是一个异常。为了解决我们的问题,可迭代对象非常合适。如果我们设置mock_input.side_effect = ['3', '5'],那么第一次调用mock_input就会返回'3',第二次调用会返回'5'。这种调用一次就返回一个值,并且每个值还不一样的行为是不是很熟悉?它的背后就是一个迭代器。

请看代码实现:

import unittest
from unittest.mock import patch
from operation import add


class TestOperation(unittest.TestCase):
    @patch('builtins.input')
    def test_add(self,mock_input):
        # 模拟接收用户输入并返回'3','5'
        mock_input.side_effect = ['3','5']
        # 断言用户输入3 5的时候,返回8
        self.assertEqual(add(),8)
    

if __name__ == "__main__":
    unittest.main()

 

如果这篇博文帮到了你,就请给我点个吧(#^.^#)

有疑问也欢迎留言~博主可nice啦,在线秒回ヾ(◍°∇°◍)ノ゙

  • 11
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值