用单元测试为您的代码保驾护航

Important

本教程是 服务器框架 101 教程的扩展。请确保您已完成该教程,并使用您构建的 estate 模块作为本教程练习的基础。

参考 :YouTube 上的 Odoo’s Test Framework: Learn Best Practices (Odoo Experience 2020)。

编写测试是很有必要的,原因有很多。以下列举部分:

  • 确保代码日后不会被破坏

  • 明确代码的范围

  • 提供用例示例

  • 这是从技术层面为代码编写文档的一种方式

  • 在着手实现之前先定义目标,有助于您的编码工作

运行测试

在了解如何编写测试之前,我们需要先知道如何运行它们。

$ odoo-bin -h
Usage: odoo-bin [options]

Options:
--version             show program's version number and exit
-h, --help            show this help message and exit

[...]

Testing Configuration:
  --test-file=TEST_FILE
                      Launch a python test file.
  --test-enable       Enable unit tests.
  --test-tags=TEST_TAGS
                      Comma-separated list of specs to filter which tests to
                      execute. Enable unit tests if set. A filter spec has
                      the format: [-][tag][/module][:class][.method] The '-'
                      specifies if we want to include or exclude tests
                      matching this spec. The tag will match tags added on a
                      class with a @tagged decorator (all Test classes have
                      'standard' and 'at_install' tags until explicitly
                      removed, see the decorator documentation). '*' will
                      match all tags. If tag is omitted on include mode, its
                      value is 'standard'. If tag is omitted on exclude
                      mode, its value is '*'. The module, class, and method
                      will respectively match the module name, test class
                      name and test method name. Example: --test-tags
                      :TestClass.test_func,/test_module,external  Filtering
                      and executing the tests happens twice: right after
                      each module installation/update and at the end of the
                      modules loading. At each stage tests are filtered by
                      --test-tags specs and additionally by dynamic specs
                      'at_install' and 'post_install' correspondingly.
  --screencasts=DIR   Screencasts will go in DIR/{db_name}/screencasts.
  --screenshots=DIR   Screenshots will go in DIR/{db_name}/screenshots.
                      Defaults to /tmp/odoo_tests.

$ # run all the tests of account, and modules installed by account
$ # the dependencies already installed are not tested
$ # this takes some time because you need to install the modules, but at_install
$ # and post_install are respected
$ odoo-bin -i account --test-enable
$ # run all the tests in this file
$ odoo-bin --test-file=addons/account/tests/test_account_move_entry.py
$ # test tags can help you filter quite easily
$ odoo-bin --test-tags=/account:TestAccountMove.test_custom_currency_on_account_1

集成机器人

Note

本节仅适用于 Odoo 员工以及向 github.com/odoo 贡献代码的人。否则,我们强烈建议您搭建自己的 CI。

编写测试之后,重要的是确保在修改源代码时测试始终通过。为了将这项工作自动化,我们采用一种名为持续集成(CI)的开发实践。这就是为什么我们有一些机器人在不同时刻运行所有测试。无论您是否在 Odoo 工作,只要您想向 odoo/odoo、odoo/enterprise、odoo/upgrade 或 odoo.sh 合并代码,都必须经过 CI。如果您在做其他项目,则应该考虑添加自己的 CI。

Runbot

参考 :与此主题相关的文档可在 Runbot FAQ 中找到。

每次在 GitHub 上推送提交时,大多数测试都会在 Runbot 上运行。

您可以通过在 Runbot 仪表板上筛选来查看提交/分支的状态。

系统会为每个分支创建一个 bundle (包)。一个 bundle 由一个配置和多个批次组成。

一个 batch (批次)是一组构建,具体取决于 bundle 的参数。当所有构建都为绿色时,该批次即为绿色(即通过测试)。

一个 build (构建)就是启动一次服务器。它可以细分为多个子构建。通常会有社区版的构建、企业版的构建(仅当存在企业分支时,但您可以强制构建),以及该分支的迁移构建。如果每个子构建都为绿色,则该构建为绿色。

一个 sub-build (子构建)只执行完整构建所做工作的一部分。它用于加速 CI 流程,通常用于将安装后测试拆分到 4 个并行实例中运行。如果所有测试都通过且没有记录到错误/警告,则子构建为绿色。

Note

  • 无论做了什么修改,所有测试都会运行。修正错误消息中的一个错别字或重构整个模块都会触发相同的测试。所有模块也会被安装。这意味着即使 Runbot 是绿色的,某些功能也可能无法正常工作,即您的更改所依赖的模块,并不是更改所在模块所依赖的模块。

  • 本地化模块(即针对特定国家/地区的模块)不会安装在 Runbot 上(通用模块除外)。一些具有外部依赖的模块也可能被排除在外。

  • 有一个夜间构建会运行额外的测试:模块操作、本地化、单模块安装、针对非确定性缺陷的多重构建等。为了缩短执行时间,这些测试并不保留在标准 CI 中。

您还可以登录由 Runbot 构建的构建。有 3 个可用的用户:admin、demo 和 portal。密码与用户名相同。这有助于在不同版本上快速进行测试,而无需在本地构建。完整日志也可供查看,用于监控。

Robodoo

在获得召唤 robodoo 的权限之前,您很可能还需要积累一些经验,不过这里还是先说明几点注意事项。

Robodoo 就是那个以标签形式在您的 PR 上刷 CI 状态的角色,但它同时也是热心地将您的提交集成到主仓库的角色。

当最后一个批次为绿色时,审查者可以要求 robodoo 合并您的 PR(这更像是一次 rebase 而不是 merge)。随后它将进入 mergebot。

Mergebot

Mergebot 是合并 PR 之前的最后一个测试阶段。

它会取出您分支中尚未存在于目标分支上的提交,将其放入暂存区(staging),然后再重新运行一次测试——即使您只在社区版中做了修改,也会包括企业版。

这一步可能会失败,并出现 Staging failed 错误消息。这可能是由于以下原因:

  • 目标分支上已存在的非确定性缺陷。如果您是 Odoo 员工,可以在这里查看:https://runbot.odoo.com/runbot/errors

  • 您引入的、但此前未在 CI 中被检测到的非确定性缺陷

  • 与紧接在您之前合并的另一个提交以及您正尝试合并的内容之间存在不兼容

  • 如果您只在社区版仓库中做了修改,则可能是与企业仓库不兼容

在要求 merge bot 重试之前,请务必确认问题并非出自您自己:将您的分支变基(rebase)到目标分支上,并在本地重新运行测试。

模块

由于 Odoo 是模块化的,测试也必须是模块化的。这意味着测试定义在添加相应功能的模块中,并且测试不能依赖您的模块未依赖的其他模块所提供的功能。

参考:与该主题相关的文档可在 Special Tags 中找到。

from odoo.tests.common import TransactionCase
from odoo.tests import tagged

# The CI will run these tests after all the modules are installed,
# not right after installing the one defining it.
@tagged('post_install', '-at_install')  # add `post_install` and remove `at_install`
class PostInstallTestCase(TransactionCase):
    def test_01(self):
        ...

@tagged('at_install')  # this is the default
class AtInstallTestCase(TransactionCase):
    def test_01(self):
        ...

如果您要测试的行为可能会因安装另一个模块而改变,则需要确保设置了 at_install 标签;否则,您可以使用 post_install 标签来加速 CI,并确保该行为在不应该改变时不会改变。

编写测试

参考:与该主题相关的文档可在 Python unittest 和 Testing Odoo 中找到。

在编写测试之前,需要考虑以下几点

  • 测试应当独立于数据库中当前的数据(包括演示数据)

  • 测试不应通过残留/修改数据来影响数据库。这通常由测试框架通过回滚来实现。因此,您绝不能在测试中调用 cr.commit 业务代码中的其他任何地方也不应调用)。

  • 对于缺陷修复,测试应在应用修复之前失败,在修复之后通过。

  • 不要测试已经在其他地方测试过的内容;您可以信任 ORM。业务模块中的大多数测试应当只测试业务流程。

  • 您不应该需要将数据刷入数据库。

Note

请记住,onchange 只在表单视图中生效,而在 Python 中修改属性时并不会触发。这一点在测试中同样适用。如果您想模拟表单视图,可以使用 odoo.tests.common.Form。

测试应当放在模块根目录下的 tests 文件夹中。每个测试文件名都应以 test_ 开头,并在测试文件夹的 __init__.py 中导入。您不应在模块的 __init__.py 中导入测试文件夹/模块。

estate
├── models
│   ├── *.py
│   └── __init__.py
├── tests
│   ├── test_*.py
│   └── __init__.py
├── __init__.py
└── __manifest__.py

所有测试都应继承 odoo.tests.common.TransactionCase。您通常会定义一个 setUpClass 以及各个测试。编写完 setUpClass 后,类中就可以使用 env,并可以开始与 ORM 交互了。

这些测试类构建在 Python 的 unittest 模块之上。

from odoo.tests.common import TransactionCase
from odoo.exceptions import UserError
from odoo.tests import tagged

# The CI will run these tests after all the modules are installed,
# not right after installing the one defining it.
@tagged('post_install', '-at_install')
class EstateTestCase(TransactionCase):

    @classmethod
    def setUpClass(cls):
        # add env on cls and many other things
        super(EstateTestCase, cls).setUpClass()

        # create the data for each tests. By doing it in the setUpClass instead
        # of in a setUp or in each test case, we reduce the testing time and
        # the duplication of code.
        cls.properties = cls.env['estate.property'].create([...])

    def test_creation_area(self):
        """Test that the total_area is computed like it should."""
        self.properties.living_area = 20
        self.assertRecordValues(self.properties, [
           {'name': ..., 'total_area': ...},
           {'name': ..., 'total_area': ...},
        ])


    def test_action_sell(self):
        """Test that everything behaves like it should when selling a property."""
        self.properties.action_sold()
        self.assertRecordValues(self.properties, [
           {'name': ..., 'state': ...},
           {'name': ..., 'state': ...},
        ])

        with self.assertRaises(UserError):
            self.properties.forbidden_action_on_sold_property()

Note

为了更好的可读性,请根据测试的范围将测试拆分到多个文件中。您还可以创建一个 Common 类,让大多数测试继承它;这个公共类可以定义模块的全部设置。例如,account 中就是这样做的。

Exercise

更新代码,使得任何人都无法:

  • 为已售出的房产创建报价

  • 出售一个没有任何已接受报价的房产

并为这两种情况创建测试。另外,请检查能够出售的房产在出售后被正确标记为已售出。

Exercise

有人不断破坏取消勾选 Garden(花园)复选框时对 Garden Area(花园面积)和 Orientation(朝向)的重置。请确保这种问题不再发生。

Tip

提示:请记住上文稍早处关于 Form 的说明。