测试 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
前提是测试的名称没有歧义。与普通标签一样,可以一次指定多个模块、类和函数,用 , 分隔。
示例¶
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 引擎执行。
这个 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)。添加新测试用例的流程如下:
创建一个新文件 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'); }); });
在 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>
重启服务器并更新 my_addon,或者在界面上操作(以确保新的测试文件已被加载)
在 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); });
访问 /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¶
通过注册漫游测试来完成设置。
/** @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 ]);
添加您想要的任意步骤。
每个步骤至少包含一个触发条件(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 来启用 测试模式,然后在调试菜单中选择 开始引导 并挑选一个引导:
- 优点
更易于运行
可用于生产或测试站点,而不仅限于本地实例
允许以 “引导”(手动步骤)模式运行
- 缺点
对于涉及 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()