模拟服务器

缘由

测试用例的复杂度各不相同:从测试一个简单辅助函数的返回值,到渲染整个 Web 客户端以模拟多个组件/服务之间的交互。

在后一种情况下,许多交互会触发服务器请求,若没有这些请求,组件或功能将无法正常工作。然而,重要的是这些请求 不要 落到实际服务器上,因为它们可能影响数据库,而这绝不是测试应该做的事情。

为解决这个问题,应该拦截每个请求,并用一个模拟实际服务器响应的函数来替换它,该函数使用测试(即伪造)数据。

由于其中一些请求非常常见(例如 ORM 调用,如 web_search_read 或 web_save,或其他方法如 get_views),因此对于每个会创建 env 的测试,默认都实现了一个模拟服务器 1。

这些模拟服务器对每个测试独立运行,可以分别配置,并为 Odoo 中最常用的路由提供开箱即用的辅助函数。

1

一旦创建环境就需要模拟服务器,因为某些 services 在启动时就会发送服务器请求。

概述

模拟服务器本身其实相当简单:它是一个对象,包含所有已定义模拟模型的 集合,以及路由与返回测试数据的回调之间的 映射。

模拟模型本身承载了大部分 CRUD 逻辑,以及用于模拟服务器记录的数据。

模拟服务器一旦启动,就会接管 所有 服务器请求,并对每个请求在其 映射 中检查是否有已注册的路由与请求的 URL 匹配。其预定义路由中最典型的例子是 /web/dataset/call_kw,它负责在相应的模拟模型上调用 ORM 方法。

注解

与 Hoot 未提供的大多数测试辅助函数一样,与模拟服务器相关的辅助函数和类可以在 "@web/../tests/web_test_helpers" 模块中找到。

配置

默认情况下,模拟服务器是 “空” 的,即它没有定义任何模拟模型。

但这并不意味着它毫无用处,因为它已经会处理一些预定义路由,例如负责获取 menus 和 translations 的路由,这些路由会在 env 创建时由 services 启动。

但这意味着 ORM 方法会失败,因为它们所针对的模型尚未定义。

要创建并定义一个模拟模型,需要两样东西:

  • 一个继承 models.Model 类的 class;

    • 以 _ 为前缀的特殊键用作元数据容器,与 Python 中一样(例如 _name、_order、_description 等) 2 3;

    • _records 保存表示伪造记录数据的对象列表;

    • _views 可以是视图类型与 XML arch 的映射;

    • 其他 public class fields 会被解释为字段(通过调用 fields 中相应的方法);

    • 也可以在此定义模型特定的方法(例如 "res.users" 的 has_group)。

  • 使用上面定义的类调用 defineModels。

2

这些特殊键中只有一部分会产生实际效果。例如 _inherit 不会按预期工作,请优先使用标准的类扩展方式。

3

每个测试都可以修改它们而无需考虑清理:对特殊键所做的任何更改都会在测试结束时还原。

下面是一个简单伪造的 "res.partner" 模型的基本示例:

import { defineModels, fields, models } from "@web/../tests/web_test_helpers";

class ResPartner extends models.Model {
    _name = "res.partner";

    name = fields.Char({ required: true );

    _records = [
        { name: "Mitchel Admin" },
    ];

    _views = {
        form: /* xml */`
            <form>
                <field name="name" />
            </form>
        `,
        list: /* xml */`
            <list>
                <field name="display_name" />
            </list>
        `,
    };
}

defineModels({ ResPartner });

这段代码将使这些数据可用于当前测试文件中的 所有 测试。当然,也可以在给定测试的 内部 定义类并调用 defineModels,以将该模型的作用范围限制在当前测试中。

其他方法,如 defineMenus、defineActions 或 defineParams,也可用于配置当前的模拟服务器。它们的大部分 API 都相当直接(即接收类似 JSON 的菜单、动作等描述)。

模拟模型:请求

许多测试用例只需要一个或少数几个模拟模型即可工作。但有时,在模型内部实现模拟逻辑过于麻烦,或者某个 路由 (即服务器请求 URL)根本不对应任何 Python 模型。

在这种情况下,应调用 onRpc 方法,将路由或 ORM 方法关联到一个回调。

注解

多个 onRpc 调用可以关联到同一个路由 / ORM 方法;此时它们将按照从最后定义到最先定义的顺序依次调用。返回 非 null 且非 undefined 的值将中断当前调用链,并将该值作为服务器请求的最终结果返回。

它可以通过 4 种不同的方式使用:

onRpc:使用路由("/")

当第一个参数是以 "/" 开头的 string 时,该回调应为 路由 回调,接收一个 Request_ 对象:

onRpc("/route/to/test", async (request) => {
    const { ids }  = await request.json();
    expect.step(ids);
    return {};
});

默认情况下,这些回调的返回值会被包装在模拟 Response_ 对象的 body 中。

这对大多数用例来说没问题,但有时回调需要返回带有自定义 status 或 headers 的 Response_ 对象。

在这种情况下,可以传入一个 可选 字典作为第 3 个参数,以指定该回调是否被视为 “pure”,即其返回值应原样返回给服务器调用方:

onRpc(
    "/not/found",
    () => new Response("{}", { status: 404 }),
    { pure: true }
);

注解

使用 “pure” 请求回调还可以返回 Response_ 对象以外的任何内容,此时返回值仍会被包装在模拟 Response_ 的 body 中,以符合 fetch_ / XMLHttpRequest_ API 的要求。

onRpc:使用方法名

当第一个参数是 不 以 "/" 开头的 string,或是 strings 列表时,该回调应为 ORM 回调,仅在请求的 method 与参数中给定的一致时才会被调用。

该回调将接收到一个包含以下内容的对象:

  • 请求体中包含的 展开 params 值(通常为:args、kwargs、model 和 method);

  • 一个 parent() 函数,调用它会调用定义在 当前回调之前 的那个 ORM 回调;

  • 一个 route 键,包含请求的 pathname,通常为 /web/dataset/call_kw;

  • request 对象。

onRpc("web_read", async ({ args, parent }) => {
    const result = parent();
    expect.step(args[0]); // Contains the list of IDs
    result.some_meta_data = { foo: "bar" };
    return result;
});

onRpc:使用模型名和方法名

当满足以下条件时:

  • 第一个参数是 不 以 "/" 开头的 string,或是 strings 列表;

  • 第二个参数同样是 string 或 strings 列表;

则该回调应为 ORM 回调,仅在请求的 method 和 model 都与参数中给定的一致时才会被调用。

这与上面的形式完全相同,只是增加了一个 model 过滤条件:

onRpc("web_read", "res.partner", ({ args }) => {
    expect.step(args[0]);
});

onRpc:适用于 每个 ORM 方法/模型

当 唯一 的参数是回调时,它应为一个 ORM 回调,将在 每次 ORM 调用时被调用:

onRpc(({ method }) => {
    expect.step(method); // Will step every ORM method call on every model
});

模拟模型:字段

模型字段可以通过 2 种方式声明:

  • 作为 public class fields;

  • 在 _fields 特殊键下。例如:

    test("test view with date fields", async () => {
        // `_fields` can be assigned over, or extended directly.
        ResPartner._fields.date = fields.Date({ string: "Registration date" });
    });
    

字段构造函数可以接收一个参数字典来规定其行为。其中一些是必需的,例如关系字段,它们需要一个 relation 属性才能正常工作。

与实际的 Python 服务器字段相比,模拟字段能做的事情有限,但最基本的属性应当都受支持:readonly、required、string 等。

compute 和 related 在最基本的用例中可以工作,但不要指望它们像在实际服务器上那样可靠地运行。

注解

每个创建的模型都预定义了 4 个默认字段:id、display_name、created_at 和 updated_at。它们的行为与服务器端的对应字段一致(例如 id 是递增的,display_name 拥有与服务器端对应字段类似的 compute 函数),如有需要可以覆盖。

模拟模型:记录

模型记录在 加载模型时 根据 _records 特殊键中包含的每个对象生成。它们会根据当前模型上可用的字段进行验证;如果某个属性与模型上定义的字段不匹配,就会抛出错误。

重要

_records 在模型加载之后(即模拟服务器启动之后) 不能 被修改。此键仅用于生成初始记录。如果需要在模型创建 之后 添加记录,请通过 UI 中可用的组件进行,或直接对模拟服务器实例发起 ORM 调用。

模拟模型:视图

由于实际视图需要声明 "ir.ui.view" 模型,模拟模型使用简化的 映射 来提供视图 arch。

_view 特殊键是一个字典,其 键 为视图类型(可选附带视图 ID),其 值 为 XML arch 的字符串表示。

默认情况下,视图 ID 为 false,但可以通过用逗号分隔视图类型与其 ID 的键来显式指定:

// Will simulate a list view with no ID (false).
ResPartner._views.list = /* xml */ `
    <list>
        <field name="display_name" />
    </list>
`;

// Will simulate a form view with ID 418.
ResPartner._views["form,418"] = /* xml */ `
    <form>
        <field name="name" />
        <field name="date" />
    </form>
`;

启动模拟服务器

与大多数情况一样,对于给定的测试只能有一个服务器处于活动状态。

如上所述,创建 env 会自动部署一个模拟服务器。

这意味着下面这些方法也 都会 创建模拟服务器,因为它们确实创建了 env:

然而,某些底层特性可能需要在 没有 环境的情况下启动模拟服务器。为此,可以单独调用 makeMockServer 辅助函数来初始化一个模拟服务器。

注解

makeMockServer 应 仅 供底层特性使用,例如在没有环境的情况下测试 rpc 函数。它并非用于获取当前模拟服务器实例的手段。为此,请参阅 MockServer.current。

注解

需要注意的是,在模拟服务器启动之后对 makeMockServer 的后续调用会被直接忽略。

与服务器交互

虽然大多数服务器交互预期由测试用例中产生的生产代码直接或间接完成,但有时直接绕过 UI 调用模拟服务器也很有意义(例如,模拟另一个用户在其他地方以某种方式修改了数据库)。

这可以通过获取包含当前模拟服务器实例的 MockServer.current 静态属性来实现(仅在初始化之后):

// Most common ORM methods are provided out of the box by server models,
// and are synchronous. Although, be careful that this will NOT trigger a
// UI re-render, and will ONLY affect the (fake) database.
const ids = MockServer.env["res.partner"].create([
    { name: "foo" },
    { name: "bar" },
]);

小技巧

MockServer.env 只是 MockServer.current.env 的快捷方式。