测试 Odoo

测试应用的方式有很多种。在 Odoo 中,我们有三种测试:

  • Python 单元测试(见 Testing Python code):适用于测试模型业务逻辑

  • JS 单元测试(见 Testing JS code):适用于单独测试 JavaScript 代码

  • 漫游测试(见 Integration Testing):漫游测试模拟真实场景,确保 Python 与 JavaScript 两部分正确交互。

测试 Python 代码

Odoo 支持使用 Python 的 unittest 库 对模块进行测试。

要编写测试,只需在模块中定义一个 tests 子包,Odoo 会自动在其中查找测试模块。测试模块的名称应以 test_ 开头,并且要从 tests/__init__.py 中导入,例如:

your_module
├── ...
├── tests
|   ├── __init__.py
|   ├── test_bar.py
|   └── test_foo.py

而 __init__.py 中包含:

from . import test_foo, test_bar

Warning

未从 tests/__init__.py 导入的测试模块将不会被执行

测试运行器会照常运行任何测试用例,如官方 unittest 文档 中所述,但 Odoo 提供了许多与测试 Odoo 内容(主要是模块)相关的实用工具和辅助方法:

默认情况下,测试会在相应模块安装完成后立即运行一次。也可以将测试用例配置为在所有模块安装完成后再运行,而不是在模块安装后立即运行:

# coding: utf-8
from odoo.tests import HttpCase, tagged

# This test should only be executed after all modules have been installed.
@tagged('-at_install', 'post_install')
class WebsiteVisitorTests(HttpCase):
  def test_create_visitor_on_tracked_page(self):
      Page = self.env['website.page']

最常见的情况是使用 TransactionCase,并在每个方法中测试模型的某个属性:

class TestModelA(common.TransactionCase):
    def test_some_action(self):
        record = self.env['model.a'].create({'field': 'value'})
        record.some_action()
        self.assertEqual(
            record.field,
            expected_field_value)

    # other tests...

Note

测试方法必须以 test_ 开头

运行测试

如果在启动 Odoo 服务器时启用了 --test-enable,则在安装或更新模块时会自动运行测试。

测试选择

在 Odoo 中,可以为 Python 测试打标签,以便在运行测试时便于选择。

odoo.tests.common.BaseCase 的子类(通常经由 TransactionCase、SavepointCase 或 HttpCase)默认会自动打上 standard 和 at_install 标签。

调用方式

--test-tags 可用于在命令行上选择/筛选要运行的测试。它隐含了 --test-enable,因此使用 --test-tags 时无需再指定 --test-enable。

该选项默认为 +standard,意味着在使用 --test-enable 启动 Odoo 时,默认会运行打有 standard 标签(显式或隐式)的测试。

编写测试时,可以在 测试类 上使用 tagged() 装饰器来添加或移除标签。

装饰器的参数是以字符串形式给出的标签名。

Danger

tagged() 是类装饰器,对函数或方法没有影响

标签可以加上减号(-)前缀,表示 移除 该标签而不是添加或选中它。例如,如果不想让自己的测试默认被执行,可以移除 standard 标签:

from odoo.tests import TransactionCase, tagged

@tagged('-standard', 'nice')
class NiceTest(TransactionCase):
    ...

这个测试默认不会被选中;要运行它,必须显式选择相应的标签:

$ odoo-bin --test-tags nice

请注意,只有打有 nice 标签的测试才会被执行。要同时运行 nice 和 standard 测试,请为 --test-tags 提供多个值:在命令行上,各值是 相加 的(即选中带有 任意 一个指定标签的所有测试)

$ odoo-bin --test-tags nice,standard

配置开关参数也接受 + 和 - 前缀。+ 前缀是隐含的,因此完全可以省略。- 减号)前缀用于取消选中带有该前缀标签的测试,即使它们已被其他指定标签选中。例如,如果有一些同时打有 slow 标签的 standard 测试,您可以运行所有标准测试但 排除 慢速测试:

$ odoo-bin --test-tags 'standard,-slow'

当你编写不继承 BaseCase 的测试时,该测试不会带有默认标签,必须显式添加,才能被纳入默认测试套件。使用简单的 unittest.TestCase 时这是常见问题——它们不会被执行:

import unittest
from odoo.tests import tagged

@tagged('standard', 'at_install')
class SmallTest(unittest.TestCase):
    ...

除了标签之外,您还可以指定要测试的特定模块、类或函数。--test-tags 接受的格式的完整语法如下:

[-][tag][/module][:class][.method]

因此,如果想测试 stock_account 模块,可以使用:

$ odoo-bin --test-tags /stock_account

如果想测试一个名称唯一的特定函数,可以直接指定它:

$ odoo-bin --test-tags .test_supplier_invoice_forwarded_by_internal_user_without_supplier

这等价于

$ odoo-bin --test-tags /account:TestAccountIncomingSupplierInvoice.test_supplier_invoice_forwarded_by_internal_user_without_supplier

前提是测试的名称没有歧义。与普通标签一样,可以一次指定多个模块、类和函数,用 , 分隔。

特殊标签

  • standard:所有继承 BaseCase 的 Odoo 测试都隐含 standard 标签。--test-tags 也默认取值为 standard。

    这意味着在启用测试的情况下,未打标签的测试默认也会被执行。

  • at_install:表示该测试将在模块安装完成后、其他模块安装之前立即执行。这是一个默认的隐式标签。

  • post_install:表示该测试将在所有模块安装完成后执行。大多数情况下,HttpCase 测试都需要使用此标签。

    请注意,它与 at_install 并不互斥,但由于您通常不希望两者兼有,在给测试类打标签时,post_install 通常会与 -at_install 配对使用。

示例

Important

测试只会在已安装的模块中执行。如果您从干净的数据库开始,需要至少使用一次 -i 开关安装模块。之后就不再需要了,除非需要升级模块,此时可以使用 -u。为简单起见,以下示例中未写出这些开关。

仅运行 sale 模块的测试:

$ odoo-bin --test-tags /sale

运行 sale 模块的测试,但排除打有 slow 标签的测试:

$ odoo-bin --test-tags '/sale,-slow'

仅运行 stock 模块的测试或打有 slow 标签的测试:

$ odoo-bin --test-tags '-standard, slow, /stock'

Note

-standard 是隐式的(并非必需),写出它是为了清晰起见

测试 JS 代码

测试复杂系统是防止回归并保证基本功能仍然可用的重要保障。由于 Odoo 有相当庞大的 JavaScript 代码库,因此有必要对其进行测试。本节将讨论单独测试 JS 代码的实践:这些测试只停留在浏览器中,不应访问服务器。

QUnit 测试套件

Odoo 框架使用 QUnit 测试框架作为测试运行器。QUnit 定义了 测试 和 模块 (一组相关的测试)的概念,并为我们提供了一个基于 Web 的界面来执行测试。

例如,一个 pyUtils 测试大致如下:

QUnit.module('py_utils');

QUnit.test('simple arithmetic', function (assert) {
    assert.expect(2);

    var result = pyUtils.py_eval("1 + 2");
    assert.strictEqual(result, 3, "should properly evaluate sum");
    result = pyUtils.py_eval("42 % 5");
    assert.strictEqual(result, 2, "should properly evaluate modulo operator");
});

运行测试套件的主要方式是先启动一个 Odoo 服务器,然后在 Web 浏览器中导航到 /web/tests。随后测试套件将由浏览器的 JavaScript 引擎执行。

../../../_images/tests.png

这个 Web 界面有许多实用的功能:它可以只运行某些子模块,或筛选匹配某个字符串的测试;可以显示每一条断言(无论失败或通过)、重新运行特定的测试,等等。

Warning

测试套件运行时,请确保:

  • 浏览器窗口处于聚焦状态;

  • 窗口没有缩放。缩放级别必须正好是 100%。

如果不满足上述条件,某些测试将会失败,而且没有明确的解释。

测试基础设施

以下是测试基础设施中最重要的组成部分的高层次概述:

  • 有一个名为 web.qunit_suite 的资源包。这个包包含主要代码(assets common + assets backend)、一些库、QUnit 测试运行器以及下面列出的测试包。

  • 有一个名为 web.tests_assets 的包,包含测试套件所需的大部分资源和工具:自定义 QUnit 断言、测试辅助工具、懒加载资源等。

  • 另一个资源包 web.qunit_suite_tests 包含所有测试脚本。测试文件通常就是添加到这个包中的。

  • web 中有一个映射到 /web/tests 路由的 控制器。该控制器只是渲染 web.qunit_suite 模板。

  • 要执行测试,只需将浏览器指向 /web/tests 路由即可。在这种情况下,浏览器会下载所有资源,然后由 QUnit 接管。

  • qunit_config.js 中有一些代码,会在测试通过或失败时向控制台输出一些信息。

  • 我们希望 runbot 也运行这些测试,因此有一个测试(位于 test_js.py)只是简单地启动一个浏览器并指向 web/tests 地址。注意,browser_js 方法会启动一个无头 Chrome 实例。

模块化与测试

基于 Odoo 的设计方式,任何插件都可以修改系统其他部分的行为。例如,voip 插件可以修改 FieldPhone 部件以使用额外的功能。从测试系统的角度来看,这并不理想,因为这意味着只要安装了 voip 插件,web 插件中的一个测试就会失败(注意 runbot 是在安装了所有插件的情况下运行测试的)。

与此同时,我们的测试系统又有其优点,因为它能检测到其他模块何时破坏了某些核心功能。对于这个问题没有完美的解决方案。目前我们是逐个案例来解决的。

通常,修改其他部分的行为并不是一个好主意。以我们的 voip 示例来说,更干净的做法无疑是新增一个 FieldVOIPPhone 部件,并修改少数需要它的视图。这样,FieldPhone 部件不受影响,两者都可以被测试。

添加新的测试用例

假设我们正在维护一个插件 my_addon,并且想为某段 JavaScript 代码添加一个测试(例如位于 my_addon.utils 中的某个工具函数 myFunction)。添加新测试用例的流程如下:

  1. 创建一个新文件 my_addon/static/tests/utils_tests.js。该文件包含用于添加 QUnit 模块 my_addon > utils 的基础代码。

    odoo.define('my_addon.utils_tests', function (require) {
    "use strict";
    
    var utils = require('my_addon.utils');
    
    QUnit.module('my_addon', {}, function () {
    
        QUnit.module('utils');
    
    });
    });
    
  2. 在 my_addon/assets.xml 中,将该文件添加到主测试资源中:

    <?xml version="1.0" encoding="utf-8"?>
    <odoo>
        <template id="qunit_suite_tests" name="my addon tests" inherit_id="web.qunit_suite_tests">
            <xpath expr="//script[last()]" position="after">
                <script type="text/javascript" src="/my_addon/static/tests/utils_tests.js"/>
            </xpath>
        </template>
    </odoo>
    
  3. 重启服务器并更新 my_addon,或者在界面上操作(以确保新的测试文件已被加载)

  4. 在 utils 子测试套件定义之后添加一个测试用例:

    QUnit.test("some test case that we want to test", function (assert) {
        assert.expect(1);
    
        var result = utils.myFunction(someArgument);
        assert.strictEqual(result, expectedResult);
    });
    
  5. 访问 /web/tests/ 以确保该测试被执行

辅助函数与专用断言

如果没有帮助,测试 Odoo 的某些部分相当困难。尤其是视图,因为它们会与服务器通信并可能执行许多 RPC,这些都需要被模拟。这就是为什么我们开发了一些专门的辅助函数,位于 test_utils.js 中。

  • 模拟测试函数:这些函数帮助搭建测试环境。最重要的用途是模拟 Odoo 服务器给出的响应。这些函数使用一个 mock server,这是一个 JavaScript 类,用于模拟最常见模型方法的响应:read、search_read、nameget 等。

  • DOM 辅助函数:用于在某个特定目标上模拟事件/操作。例如,testUtils.dom.click 会在目标上执行一次点击。请注意,这比手动操作更安全,因为它还会检查目标是否存在并且可见。

  • create 辅助函数:它们可能是 test_utils.js 导出的最重要的函数。这些辅助函数用于创建带有模拟环境的部件,并处理大量细节以尽可能模拟真实的条件。其中最重要的无疑是 createView。

  • qunit 断言:QUnit 可以通过专用断言进行扩展。对于 Odoo,我们经常需要测试某些 DOM 属性,因此我们编写了一些断言来提供帮助。例如,containsOnce 断言接受一个部件/jQuery/HtmlElement 和一个选择器,然后检查目标中该 CSS 选择器是否恰好有一个匹配。

例如,借助这些辅助函数,一个简单的表单测试大致如下:

QUnit.test('simple group rendering', function (assert) {
    assert.expect(1);

    var form = testUtils.createView({
        View: FormView,
        model: 'partner',
        data: this.data,
        arch: '<form string="Partners">' +
                '<group>' +
                    '<field name="foo"/>' +
                '</group>' +
            '</form>',
        res_id: 1,
    });

    assert.containsOnce(form, 'table.o_inner_group');

    form.destroy();
});

请注意 testUtils.createView 辅助函数和 containsOnce 断言的使用。此外,表单控制器在测试结束时也被正确销毁了。

最佳实践

以下条目不分先后:

  • 所有测试文件都应添加到 some_addon/static/tests/ 中

  • 修复缺陷时,请确保测试在未修复缺陷时失败,而在修复后通过。 这可以确保修复确实有效。

  • 尽量只编写让测试运行所需的最少代码。

  • 通常,两个小测试比一个大测试更好。 更小的测试更易于理解和修复。

  • 测试结束后务必进行清理。 例如,如果测试实例化了一个组件,就应在最后将其销毁。

  • 不必追求完整彻底的代码覆盖率。 但添加一些测试大有帮助:它可以确保你的代码没有完全崩溃,而且每当修复一个缺陷后,向现有测试套件中添加测试要容易得多。

  • 如果要检查某个否定断言(例如,HtmlElement 不具有特定的 css 类),请尽量在同一测试中添加肯定断言(例如,执行一个改变状态的操作)。这有助于避免测试在将来失效(例如,当 css 类被更改时)。

提示

  • 只运行一个测试:可以(临时!)将 QUnit.test(…) 定义改为 QUnit.only(…)。 这有助于确保 QUnit 只运行这一个特定的测试。

  • 调试标志:大多数 create 工具函数都有调试模式(通过 debug: true 参数激活)。 在这种情况下,目标组件将放入 DOM 中,而不是隐藏的 qunit 专用夹具,并且会记录更多信息。例如,所有模拟的网络通信都会显示在控制台中。

  • 在处理失败的测试时,通常会添加调试标志,然后注释掉测试的结尾部分(尤其是 destroy 调用)。 这样就可以直接查看组件的状态,甚至可以通过点击/与其交互来操作组件。

集成测试

分别测试 Python 代码和 JS 代码非常有用,但这并不能证明 Web 客户端和服务器能够协同工作。 为此,我们可以编写另一种测试:漫游测试(tour)。漫游测试是某些有趣业务流程的迷你场景,它描述了应当遵循的一系列步骤。随后,测试运行器会创建一个 PhantomJs 浏览器,将其指向正确的 url,并根据场景模拟点击和输入。

编写测试漫游

结构

要为 your_module 编写测试漫游,请先创建所需的文件:

your_module
├── ...
├── static
|   └── tests
|       └── tours
|           └── your_tour.js
├── tests
|   ├── __init__.py
|   └── test_calling_the_tour.py
└── __manifest__.py

接下来,您可以:

  • 更新 __manifest__.py,将 your_tour.js 添加到资源中。

    'assets': {
        'web.assets_tests': [
            'your_module/static/tests/tours/your_tour.js',
        ],
    },
    
  • 更新 tests 文件夹中的 __init__.py,以导入 test_calling_the_tour。

更多内容

Javascript

  1. 通过注册漫游测试来完成设置。

    /** @odoo-module **/
    import tour from 'web_tour.tour';
    tour.register('rental_product_configurator_tour', {
        url: '/web',  // Here, you can specify any other starting url
        test: true,
    }, [
        // Your sequence of steps
    ]);
    
  2. 添加您想要的任意步骤。

每个步骤至少包含一个触发条件(trigger)。你可以使用 预定义步骤,也可以编写自己的个性化步骤。

下面是一些步骤示例:

Example

// First step
tour.stepUtils.showAppsMenuItem(),
// Second step
{
    trigger: '.o_app[data-menu-xmlid="your_module.maybe_your_module_menu_root"]',
    edition: 'community'  // Optional
}, {
    // Third step
},

Example

{
    trigger: '.js_product:has(strong:contains(Chair floor protection)) .js_add',
    extra_trigger: '.oe_advanced_configurator_modal',  // This ensure we are in the wizard
},

Example

{
    trigger: 'a:contains("Add a product")',
    // Extra-trigger to make sure a line is added before trying to add another one
    extra_trigger: '.o_field_many2one[name="product_template_id"] .o_external_button',
},

以下是自定义步骤可用的部分参数:

  • trigger:在其上 run 操作的选择器/元素。引导会等待该元素存在且可见后,才 在其上 run 操作。

  • extra_trigger:步骤 run 的可选次要条件。会像 trigger 元素一样等待其出现,但操作不会在该额外触发条件上执行。

    适用于设置前置条件,或两个互不相关的条件。

  • run:在 trigger 元素上执行的操作。

    默认尝试在 trigger 为 input 时将其内容设置为 Text,否则对其执行 click。

    动作也可以是:

    • 一个同步函数,执行时以触发条件的 Tip 作为上下文 this,并以操作辅助方法作为参数。

    • 某个动作辅助函数的名称,它将在 trigger 元素上运行:

      click

      点击该元素,并执行所有相关的中间事件。

      text content

      点击(聚焦)该元素,然后将 content 设置为元素的值(input 时)、选项(select 时)或内容。

      dblclick, tripleclick

      与 click 相同,但会重复执行多次。

      clicknoleave

      默认情况下,click 及其变体会在触发元素上触发”退出”事件(mouseout、mouseleave)。该辅助方法会抑制这些事件(注意:后续点击其他元素也不会隐式触发这些事件)。

      text_blur

      与 text 类似,但编辑后会跟随 focusout 和 blur 事件。

      drag_and_drop target

      模拟将 trigger 元素拖放到 target 上。

  • edition:可选,

    • 若未指定 edition,该步骤在社区版与企业版中均生效。

    • 有时某个步骤在企业版和社区版中不同,此时可以编写两个步骤,分别对应企业版和社区版。

    • 通常,需要为涉及主菜单的步骤指定 edition(版本),因为社区版与企业版的主菜单不同。

  • position:可选,"top"、"right"、"bottom" 或 "left"。运行交互式引导时,提示框相对于 target 的位置。

  • content:可选但建议使用,交互式漫游测试中提示框的内容,同时会记录到控制台,因此对于追踪和调试自动运行的漫游测试非常有用。

  • auto:若引导(tour)为交互式,则指示引导管理器是否应等待用户执行操作,默认为 false。

  • in_modal:若设置,则只在最顶层模态窗口中查找 trigger 元素,默认为 false。

  • timeout:等待步骤能够 run 的时长(以毫秒为单位),10000(10 秒)。

Important

漫游测试的最后一步(或几步)应始终将客户端恢复到 “稳定” 状态(例如没有正在进行的编辑),并确保所有副作用(网络请求)都已运行完毕,以避免在清理阶段出现竞态条件或错误。

Python

要在 Python 测试中启动引导,需让测试类继承 HTTPCase,并调用 start_tour:

def test_your_test(self):
    # Optional Setup
    self.start_tour("/web", 'your_module.your_tour_name', login="admin")
    # Optional verifications

调试技巧

在浏览器中观察引导

有两种方式,各有取舍:

watch=True

通过测试套件在本地运行漫游测试时,可以在 browser_js 或 start_tour 调用中添加 watch=True 参数:

self.start_tour("/web", code, watch=True)

这将自动打开一个 Chrome 窗口,并在其中运行漫游测试。

优点
  • 只要漫游测试带有 Python 准备/外围代码或包含多个步骤,也始终可用

  • 完全自动运行(只需选择启动漫游测试的那个测试)

  • 事务性的(应该 总是可以多次运行)

缺点
  • 仅在本地可用

  • 仅当测试/漫游测试能在本地正确运行时才可用

通过浏览器运行

也可以通过浏览器界面启动引导,例如调用

odoo.startTour(tour_name);

在 JavaScript 控制台中调用,或通过设置 URL 中的 ?debug=tests 来启用 测试模式,然后在调试菜单中选择 开始引导 并挑选一个引导:

../../../_images/tours.png
优点
  • 更易于运行

  • 可用于生产或测试站点,而不仅限于本地实例

  • 允许以 “引导”(手动步骤)模式运行

缺点
  • 对于涉及 Python 准备工作的测试漫游更难使用

  • 受漫游测试副作用影响,可能无法多次运行

Tip

也可以使用这种方法来观察需要 Python 准备工作的漫游测试或与其交互:

  • 在相关漫游测试启动之前(start_tour 或 browser_js 调用)添加 python 断点

  • 命中断点后,在浏览器中打开该实例

  • 运行漫游测试

此时,Python 的准备工作对浏览器可见,漫游测试也就可以运行了。

如果还希望测试在之后继续执行,可以根据漫游测试的副作用考虑注释掉 start_tour 或 browser_js 调用。

browser_js 测试期间的截图和屏幕录像

从命令行运行使用 HttpCase.browser_js 的测试时,Chrome 浏览器以无头模式运行。默认情况下,如果测试失败,会在失败时刻截取一张 PNG 截图并写入

'/tmp/odoo_tests/{db_name}/screenshots/'

自 Odoo 13.0 起新增了两个用于控制此行为的命令行参数:--screenshots 和 --screencasts

内省/调试步骤

在尝试修复 / 调试漫游测试(tour)时,(失败时的)截图不一定足够。这种情况下,查看某一步或每一步中发生了什么会很有用。

在“引导”(onboarding)场景中(这类漫游测试大多由用户显式驱动)这相当容易,而在运行“测试”漫游测试或通过测试套件运行漫游测试时就比较复杂了。此时有两个主要技巧:

  • 让某个步骤的动作为 run() { debugger; }。

    可以将其添加到现有步骤中,也可以作为一个新的专用步骤。一旦步骤的 trigger 匹配成功,就会停止所有 JavaScript 执行。

    优点
    • 非常简单

    • 一旦恢复执行,引导就会重新开始

    缺点
    • 由于所有 JavaScript 均被阻塞,页面交互受限

    • 调试引导管理器内部的作用不大

  • 添加一个触发条件永不成功且 timeout 非常长的步骤。

    浏览器会等待 trigger 直到 timeout 超时后引导才失败;这允许开发者在准备好恢复执行之前检查页面并与之交互,方法是手动使 trigger 生效(此处可使用一个无意义的类,将其添加到页面任意可见元素上即可触发)。

    优点
    • 可以与页面进行交互

    • 便于应用到会超时的步骤,只需添加一个较长的 timeout 再观察即可

    • 没有(在此场景下)无用的调试器 UI

    缺点
    • 更偏手工操作,恢复执行时尤其如此

性能测试

查询计数

测试性能的方法之一是度量数据库查询。手动方式可通过 --log-sql 命令行参数进行测试。如果想确定某个操作的最大查询数,可以使用集成在 Odoo 测试类中的 assertQueryCount() 方法。

with self.assertQueryCount(11):
    do_something()