Mock server

Rationale

测试用例的复杂度跨度很大;从测试简单辅助函数的返回值,到渲染整个 webclient 以模拟跨越多个组件/服务的交互。

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

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

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

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

1

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

概述

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

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

一旦模拟服务器启动,它会劫持 所有 服务器请求,并对每个请求在其 映射 中检查其注册的某个路由是否匹配请求的 URL。其预定义路由中最突出的例子是 /web/dataset/call_kw,它负责在适当的模拟模型上调用 ORM 方法。

注解

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

配置

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

但这并不意味着它毫无用处,因为它已经能处理一些预定义的路由,例如负责获取 menus 和 translations 的路由,这些路由由 services 在 env 生成时立即产生。

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

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

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

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

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

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

    • 其他 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 时,回调应为一个 路由 (route)回调,接收一个 Request_ 对象:

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

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

这对大多数用例来说已经足够,但有时回调需要返回一个带有自定义 status 或 headers 的 Response_ 对象。

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

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

注解

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

onRpc:带方法名

当第一个参数是 不以 "/" 开头的 string 或 strings 列表时,回调应为一个 ORM 回调,仅在请求的 method 与作为参数传入的匹配时才被调用。

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

  • 请求体中包含的 spread 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" 模型,模拟模型使用简化的 mapping (映射)来提供视图 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>
`;

Spawning a mock server

与大多数情况相同,一个测试中只能有一个服务端处于活跃状态。

如上所述,创建 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 的快捷方式。