在Python中使用基于类的测试来保持测试的干燥性(详细教程)

111 阅读7分钟

编写测试是一件很糟糕的事情,但维护测试更是一场恶梦。当我们注意到我们推迟了一些简单的任务,只是因为我们害怕更新一些怪物的测试用例,我们开始寻找更有创意的方法来简化编写和维护测试的过程。

在这篇文章中,我将描述一种基于类的方法来编写测试。

在我们开始写代码之前,让我们设定一些目标:

  • 广泛性- 我们希望我们的测试能够覆盖尽可能多的场景。我们希望有一个坚实的平台来编写测试,这将使我们更容易适应变化,覆盖更多的领域。
  • 表现力- 好的测试会讲述一个故事。问题变得无关紧要,文件会丢失,但测试必须始终通过 - 这就是为什么我们把测试当作规格。写好测试可以帮助新人(和未来的自己)理解所有的边缘案例和开发过程中的微观决定。
  • 可维护性- 随着需求和实现的变化,我们希望以尽可能少的努力快速适应。

进入基于类的测试

关于测试的文章和教程总是给出简单的例子,如addsub 。我很少有机会测试这样简单的功能。我将采取一个更现实的例子,测试一个做登录的API端点:

POST /api/account/login
{
    username: <str>,
    password: <str>
}

我们要测试的场景是:

  • 用户成功登录。
  • 用户不存在。
  • 密码不正确。
  • 缺少或畸形的数据。
  • 用户已经通过认证。

我们测试的输入是:

  • 一个有效载荷,usernamepassword
  • 执行行动的客户端,匿名或已认证。

我们要测试的输出是:

  • 返回值,错误或有效载荷。
  • 响应状态代码。
  • 副作用。例如,成功登录后的最后登录日期。

在正确定义了输入和输出后,我们可以写一个基础测试类。

from unittest import TestCase
import requests

class TestLogin:
    """Base class for testing login endpoint."""

    @property
    def client(self):
        return requests.Session()

    @property
    def username(self):
        raise NotImplementedError()

    @property
    def password(self):
        raise NotImplementedError()

    @property
    def payload(self):
        return {
            'username': self.username,
            'password': self.password,
        }

    expected_status_code = 200
    expected_return_payload = {}

    def setUp(self):
        self.response = self.client.post('/api/account/login', json=payload)

    def test_should_return_expected_status_code(self):
        self.assertEqual(self.response.status, self.expected_status_code)

    def test_should_return_expected_payload(self):
        self.assertEqual(self.response.json(), self.expected_return_payload)
  • 我们定义了输入,clientpayload ,以及预期输出expected_*
  • 我们在测试过程中执行了登录动作setUp 。为了让特定的测试案例访问结果,我们在类实例上保留了响应。
  • 我们实现了两个常见的测试案例:
    • 测试预期的状态代码
    • 测试预期的返回值

善于观察的读者可能会注意到我们从属性中引发一个NotImplementedError 异常。这样,如果测试作者忘记设置测试所需的一个值,他们会得到一个有用的异常。

让我们用我们的TestLogin 类来写一个成功登录的测试:

class TestSuccessfulLogin(TestLogin, TestCase):
    username = 'Haki',
    password = 'correct-password'
    expected_status_code = 200
    expected_return_payload = {
        'id': 1,
        'username': 'Haki',
        'full_name': 'Haki Benita',
    }

    def test_should_update_last_login_date_in_user_model(self):
        user = User.objects.get(self.response.data['id'])
        self.assertIsNotNone(user.last_login_date)

通过阅读代码,我们可以知道一个usernamepassword 被发送。我们期待一个带有200状态代码的响应,以及关于用户的额外数据。我们扩展了这个测试,同时也检查了我们用户模型中的last_login_date 。这个特定的测试可能与所有的测试案例无关,所以我们只把它添加到成功的测试案例中。

让我们来测试一下登录失败的情况:

class TestInvalidPassword(TestLogin, TestCase):
    username = 'Haki'
    password = 'wrong-password'
    expected_status_code = 401

class TestMissingPassword(TestLogin, TestCase):
    payload = {'username': 'Haki'}
    expected_status_code = 400

class TestMalformedData(TestLogin, TestCase):
    payload = {'username': [1, 2, 3]}
    expected_status_code = 400

偶然发现这段代码的开发者将能够准确地告诉任何类型的输入应该发生什么。类的名称描述了场景,而属性的名称描述了输入。该类共同讲述了一个故事,很容易阅读和理解

最后两个测试直接设置有效载荷(不设置用户名和密码)。这不会引发NotImplementedError,因为我们直接覆盖了payload属性,也就是调用用户名和密码的那个。

一个好的测试应该能帮助你找到问题所在。

让我们看看一个失败的测试案例的输出:

FAIL: test_should_return_expected_status_code (tests.test_login.TestInvalidPassword)
------------------------------------------------------
Traceback (most recent call last):
  File "../tests/test_login.py", line 28, in test_should_return_expected_status_code
    self.assertEqual(self.response.status_code, self.expected_status_code)
AssertionError: 400 != 401
------------------------------------------------------

看一下失败的测试报告,很明显是哪里出了问题。当密码无效时,我们期待状态代码401,但我们收到的是400。

让我们把事情变得更难一些,测试一个试图登录的认证用户。

class TestAuthenticatedUserLogin(TestLogin, TestCase):
    username = 'Haki'
    password = 'correct-password'

    @property
    def client(self):
        session = requests.session()
        session.auth = ('Haki', 'correct-password')
        return session

    expected_status_code = 400

这一次,我们不得不覆盖客户端属性来验证会话。

对我们的测试进行检验

为了说明我们的新测试案例有多大的弹性,让我们看看当我们引入新的需求和变化时,我们如何修改基类。

  • 我们做了一些重构,端点改成/api/user/login
class TestLogin:
    # ...
    def setUp(self):
        self.response = self.client.post(
            '/api/user/login',
            json=payload,
        )
  • 有人决定如果我们使用不同的序列化格式(msgpack、xml、yaml)可以加快事情的进展:
class TestLogin:
    # ...
    def setUp(self):
        self.response = self.client.post(
            '/api/account/login',
            data=encode(payload),
        )
  • 产品人员想走向全球,现在我们需要测试不同的语言
class TestLogin:
    language = 'en'

    # ...

    def setUp(self):
        self.response = self.client.post(
            '/{}/api/account/login'.format(self.language),
            json=payload,
        )

以上的变化都没有设法破坏我们现有的测试。

更进一步

在采用这种技术时,有几件事需要考虑。

加快事情进展

setUp 对类中的每个测试用例都要执行 "测试用例"(测试用例是以 开始的函数)。为了加快速度,test_***最好在 中执行动作。setUpClass**这改变了一些事情。例如,我们使用的属性应该被设置为类的属性或 s。@classmethod

使用固定装置

当使用Django的固定装置时,该动作应该在setUpTestData中进行:

class TestLogin:
    fixtures = (
        'test/users',
    )

    @classmethod
    def setUpTestData(cls):
        super().setUpTestData()
        cls.response = cls.get_client().post('/api/account/login', json=payload)

Django在setUpTestData ,所以通过调用super,动作是在固定装置加载后执行的。

另一个关于Django和请求的简单说明。我使用了requests 包,但Django和流行的Djangorestframework ,提供了他们自己的客户端。 django.test.Client是Django的客户端,而 rest_framework.test.APIClient是DRF的客户端。

测试异常

当一个函数引发异常时,我们可以扩展基类,用try ... catch 来包装动作。

class TestLoginFailure(TestLogin):

    @property
    def expected_exception(self):
        raise NotImplementedError()

    def setUp(self):
        try:
            super().setUp()
        except Exception as e:
            self.exception = e

    def test_should_raise_expected_exception(self):
        self.assertIsInstance(
            self.exception,
            self.expected_exception
        )

如果你熟悉 assertRaises上下文,我没有在这种情况下使用它,因为在setUp ,测试不应该失败。

创建混合器

测试案例在本质上是重复的。通过混合器,我们可以抽象出测试用例的共同部分,并组成新的用例。比如说:

  • TestAnonymousUserMixin - 用匿名的API客户端来填充测试。
  • TestRemoteResponseMixin - 模拟来自远程服务的响应。

后面的,可能看起来像这样:

from unittest import mock

class TestRemoteServiceXResponseMixin:
    mock_response_data = None

    @classmethod
    @mock.patch('path.to.function.making.remote.request')
    def setUpTestData(cls, mock_remote)
        mock_remote.return_value = cls.mock_response_data
        super().setUpTestData()

结论

有人曾经说过,重复比错误的抽象更便宜。我非常同意这一点。如果你的测试不容易适应一个模式,那么这个解决方案可能不是正确的。仔细决定抽象什么是很重要的。你抽象的越多,你的测试就越灵活。但是,随着基类中参数的堆积,测试变得更难编写和维护,我们又回到了原点。

说了这么多,我们发现这种技术在不同的情况下和不同的框架(如Tornado和Django)中都很有用。随着时间的推移,它已经证明了自己对变化的弹性和易于维护的特点。这就是我们要实现的目标,我们认为它是成功的!