Bốn cách kiểm thử frontend Odoo: helper Form cho chuỗi onchange, Hoot cho component OWL, cách mount riêng cho POS, và tour cho các luồng đầy đủ trên trình duyệt thật.

Với phần lớn lập trình viên Odoo, viết test Python là chuyện thẳng thắn: khuôn mẫu đã được ghi chép đầy đủ, công cụ thì quen thuộc (TransactionCase, SavepointCase, HttpCase), và thường bạn tìm được một ví dụ đủ gần ngay trong mã nguồn Odoo hoặc trong tài liệu chính thức.

Test frontend lại là chuyện khác. Chúng thường gây cảm giác lằng nhằng và đáng ngại: chuyện đóng gói và asset, render bất đồng bộ, service, thời điểm của DOM, và những thông báo lỗi chẳng giống bất cứ thứ gì trong các stack trace Python của bạn. Nhiều đội rốt cuộc né test JS cho tới khi một lần refactor buộc họ phải đối mặt.

Bài này trao cho bạn chùm chìa khóa phía JS. Bốn trường hợp: helper Form (chạy phía máy chủ, mang hương vị giao diện), Hoot cho các unit của OWL, Hoot dành riêng cho POS, và tour qua browser_js. Cùng một nhịp bốn phách ở mọi trường hợp (khi nào thì dùng, bài test tối thiểu chạy được, chạy và quan sát, danh sách kiểm tra khi debug), nên bài viết này cũng chính là bốn lần áp dụng một mô hình tư duy.

Test bằng helper Form, bài test phía máy chủ mang hương vị giao diện

Chuỗi onchange, việc lan truyền giá trị mặc định, sửa các dòng x2many. Nếu hành vi trình duyệt duy nhất bạn định kiểm là “trường A đổi thì trường B cập nhật”, Form đã làm được rồi. Nó chạy như một phần của bộ test Python bình thường, hỏng ngay trong cùng stack trace với phần còn lại trong TransactionCase của bạn, và không bao giờ bắt bạn khởi động một trình duyệt.

Bài test tối thiểu chạy được

from odoo.tests.common import TransactionCase, Form

class TestSaleOrderForm(TransactionCase):
    def test_order_line_subtotal_recomputes(self):
        order_form = Form(self.env['sale.order'])
        order_form.partner_id = self.env.ref('base.res_partner_2')
        with order_form.order_line.new() as line:
            line.product_id = self.env.ref('product.product_product_4')
            line.product_uom_qty = 3
        order = order_form.save()
        self.assertEqual(order.amount_untaxed, 3 * order.order_line.price_unit)

Có ba thứ đang gánh việc ở đây. Form(self.env['sale.order']) mở một form gắn với model và kích hoạt các giá trị mặc định đúng như trình duyệt sẽ làm. Việc gán partner_id kích hoạt chuỗi onchange của bảng giá theo đối tác. Khối with order_form.order_line.new() as line: là công thức cho x2many: new() không nhận tham số, trả về một context manager, và cho ra một form con. Dùng order_form.order_line.edit(0) (chỉ số nguyên đếm từ 0) để sửa một dòng có sẵn, hoặc order_form.order_line.remove(index=0) để bỏ một dòng.

Chạy và quan sát

--test-tags là công tắc của trình chạy test. Định dạng là [-][tag][/module][:class][.method], ngăn nhau bằng dấu phẩy, có hỗ trợ ký hiệu kèm tên module. (Nguồn: odoo/tools/config.py:287-301, odoo/tests/tag_selector.py:11-20.)

./odoo-bin -d mydb --test-enable --test-tags :TestSaleOrderForm.test_order_line_subtotal_recomputes -i sale --stop-after-init

Vài mẫu hữu dụng:

  • --test-tags :TestSaleOrder.test_confirm chạy đúng một phương thức.
  • --test-tags /sale chạy mọi test được gắn thẻ theo module sale.
  • --test-tags -slow,/sale loại thẻ slow và lấy module sale.

Danh sách kiểm tra khi debug

  • Nhầm giữa new() và edit(idx) trên x2many. new() tạo một dòng con, edit(0) sửa một dòng có sẵn.
  • Thiếu decorator @api.onchange trên trường bạn mong nó kích hoạt. Helper Form kích hoạt các onchange khai báo qua decorator, không kích hoạt các trường tính toán bất kỳ.
  • Bất ngờ giữa giá trị mặc định và trường tính toán. Một trường tính toán gắn store=True sẽ không tính lại giữa chừng khi đang mở form theo cách một @api.onchange làm. Nếu test của bạn mong có một giá trị ngay giữa lúc chỉnh sửa, phải có một onchange đẩy giá trị đó ra.

Test component bằng Hoot

Thuần logic component. Một widget render ra, bạn nhấp một nút, một class được bật tắt hoặc chữ thay đổi.

Bài test tối thiểu chạy được

import { test, expect } from "@odoo/hoot";
import { click, queryOne } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
import { CounterButton } from "@my_module/components/counter_button";

test("counter increments on click", async () => {
    await mountWithCleanup(CounterButton, { props: { start: 0 } });
    expect(".o_counter_value").toHaveText("0");
    await click(queryOne(".o_counter_button"));
    await animationFrame();
    expect(".o_counter_value").toHaveText("1");
});

Năm nước đi: import từ @odoo/hoot (trình chạy test), @odoo/hoot-dom (các helper cho DOM) và @odoo/hoot-mock (điều khiển bộ đếm thời gian và animation); mount component bằng mountWithCleanup (tự dọn DOM sau mỗi test); khẳng định bằng một matcher DOM; nhấp chuột; await animationFrame() để OWL xả xong lượt render; rồi khẳng định lại.

Module edi-framework 19.0 của OCA có một ví dụ thực tế dày dặn hơn: nó mount một widget qua mountView, gieo dữ liệu bằng startServer() cộng pyEnv.partner.create(), và dùng expect.waitForSteps() để khẳng định một luồng bất đồng bộ có thứ tự.

class Partner extends models.Model {
    _name = "partner";
    name = fields.Char({});
    edi_config = fields.Json({ default: {} });
    edi_create_exchange_record(exchange_type_id) {
        expect.step("EDI Launched for " + exchange_type_id);
        return { type: "ir.actions.act_window_close" };
    }
}

defineMailModels();
defineModels([Partner]);

test("EDI OCA Test widget", async () => {
    const pyEnv = await startServer();
    const partner = pyEnv.partner.create({
        name: "Awesome partner",
        edi_config: {
            1: { form: { btn: { label: "EDI Task 01" } }, type: { id: 1 } },
            2: { form: { btn: { label: "EDI Task 02" } }, type: { id: 2 } },
            3: { form: {} }, // no form => not shown
        },
    });
    await mountView({
        type: "form",
        resId: partner,
        resModel: "partner",
        arch: `<form><field name="edi_config" widget="edi_configuration" /></form>`,
    });
    await click(".o_field_edi_configuration .o_edi_action");
    await expect.waitForSteps(["EDI Launched for 1"]);
});

Nếu muốn một ví dụ tối giản từ bản lõi, addons/web/static/tests/core/dialog_service.test.js bao phủ dialog service.

File phải nằm trong /static/tests/, có đuôi .test.js, và được đăng ký vào bundle web.assets_unit_tests:

# __manifest__.py
"assets": {
    "web.assets_unit_tests": [
        "my_module/static/tests/**/*.test.js",
    ],
},

Chạy và quan sát

Khởi động Odoo, vào /web/tests. Hoot nạp mọi file .test.js đã đăng ký và hiện giao diện trình chạy. Lọc theo tên file hoặc tên test trong ô tìm kiếm. Thêm ?debug=tests vào URL để có log chi tiết.

Để chạy đúng một test qua URL, giao diện Hoot cũng nhận một chuỗi truy vấn để lọc (/web/tests?test=counter%20increments); trên thực tế, chép một đoạn chữ đặc trưng trong tên test rồi dán vào ô lọc trên trang còn dễ hơn.

Test frontend cho POS

Giao diện riêng của POS: bàn phím số, màn hình biên lai, phần hiển thị dòng đơn hàng, màn hình hướng về khách. Đây không phải một framework khác. Vẫn là Hoot, chỉ khác công thức mount.

Bài test tối thiểu chạy được

Có hai khuôn mẫu đang được dùng trong bản lõi 19.0. Cả hai nằm dưới addons/point_of_sale/static/tests/.

Khuôn mẫu A: setupPosEnv cho các component cần ngữ cảnh POS.

import { test, expect } from "@odoo/hoot";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
import { setupPosEnv } from "@point_of_sale/../tests/unit/utils";
import { Orderline } from "@point_of_sale/app/components/orderline/orderline";

test("orderline displays quantity and note", async () => {
    const store = await setupPosEnv();
    const order = store.add_new_order();
    const line = order.add_product(store.models["product.product"].get(5), { quantity: 2 });
    line.note = "no onions";
    await mountWithCleanup(Orderline, { props: { line } });
    expect(".orderline").toHaveCount(1);
    expect(".info-list").toHaveText(/2/);
    expect(".orderline-note").toHaveText("no onions");
});

setupPosEnv() khởi động một store POS với dữ liệu gieo sẵn, đủ dùng cho các component đọc từ store.models hoặc trông đợi có một đơn hàng hiện hành. Dùng cho Orderline, ReceiptScreen và các component tương tự. (Xem addons/point_of_sale/static/tests/unit/components/orderline.test.js và receipt_screen.test.js để có khuôn mẫu đầy đủ.)

Khuôn mẫu B: noMainContainer cho các component hoàn toàn tách biệt.

import { test, expect } from "@odoo/hoot";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
import { registry } from "@web/core/registry";
import { NumericInput } from "@point_of_sale/app/generic_components/numeric_input/numeric_input";

test("numeric input renders and accepts digits", async () => {
    registry.category("services").content = {};
    await mountWithCleanup(NumericInput, {
        noMainContainer: true,
        props: { value: "0", onChange: () => {} },
    });
    expect(".numeric-input").toHaveCount(1);
});

Đây là công thức cho những component không cần chút trạng thái POS nào (OdooLogo, NumericInput, Input thuần). noMainContainer: true bỏ qua lớp bọc MainComponentsContainer đầy đủ; còn việc xóa registry service ngăn các đăng ký service của POS rò rỉ từ một test trước đó. (Xem addons/point_of_sale/static/tests/generic_components/mount_generic_components.test.js.)

Chạy và quan sát

Vào /web/tests, lọc theo một đoạn chữ đặc trưng của POS như orderline hay numeric_input. Các helper riêng cho POS nằm dưới addons/point_of_sale/static/tests/generic_helpers/ (tương tác với dialog, bàn phím số, giả lập offline); hãy lấy từ đó thay vì tự phát minh lại.

Danh sách kiểm tra khi debug

  • Thiếu noMainContainer: true ở khuôn mẫu B. Không có nó, test sẽ cố mount toàn bộ khung giao diện, và hỏng ở những service chưa được đăng ký.
  • Trạng thái store hoặc đơn hàng của POS không được đặt lại giữa các test. setupPosEnv() trả về một store mới ở mỗi lần gọi; nếu bạn cache nó qua nhiều test, hãy chuẩn bị tinh thần bị nhiễm chéo.

Tour qua browser_js

Các luồng đi xuyên nhiều component. RPC thật. Web client thật khởi động trong một trình duyệt thật. Đây là lựa chọn trung thực nhất, chạy tốn kém nhất, và là thứ bạn tìm tới khi lỗi chỉ lộ ra lúc mọi thứ được nối vào nhau.

Bài test tối thiểu chạy được

# my_module/tests/test_my_tour.py
import odoo.tests
from odoo.tests import HttpCase

@odoo.tests.tagged('post_install', '-at_install')
class TestConfirmSaleOrderTour(HttpCase):
    def test_confirm_sale_order(self):
        self.start_tour("/odoo", "confirm_sale_order_tour", login="admin")
// my_module/static/tests/tours/confirm_sale_order_tour.js
import { registry } from "@web/core/registry";

registry.category("web_tour.tours").add("confirm_sale_order_tour", {
    steps: () => [
        { trigger: ".o_app[data-menu-xmlid='sale.sale_menu_root']", run: "click" },
        { trigger: "button.o_list_button_add", run: "click" },
        { trigger: ".o_field_widget[name='partner_id'] input", run: "edit Deco Addict" },
        { trigger: ".ui-menu-item:contains('Deco Addict')", run: "click" },
        { trigger: "button[name='action_confirm']", run: "click" },
        { trigger: ".o_statusbar_status .btn-primary:contains('Sales Order')" },
    ],
});

Phía Python là một HttpCase gắn thẻ post_install. Phía JS đăng ký tour vào registry web_tour.tours. Mỗi bước là một trigger (bộ chọn CSS cần chờ xuất hiện) kèm một hành động run tùy chọn ("click", "edit ...", "hover", hoặc một hàm). Bước cuối bỏ run; chạm được tới trigger của nó chính là tín hiệu thành công.

Một ví dụ chạy được trong bản lõi là addons/calendar/tests/test_calendar_tour.py đi cùng addons/calendar/static/tests/tours/calendar_tour.js. Lớp Python dùng HttpCaseWithUserDemo, gọi self.start_tour("/odoo/calendar", "calendar_tour", login=user), còn phía JS đi qua các bước tạo sự kiện, từ chối và xóa.

Tour hướng dẫn ban đầu

Tour hướng dẫn ban đầu cũng đăng ký vào chính registry web_tour.tours. Việc chặn theo biến thể dùng trường isActive trên từng bước:

{ isActive: ["enterprise"], trigger: ".enterprise-only-btn", run: "click" }
{ isActive: ["mobile"], trigger: ".mobile-menu", run: "click" }

Phần khởi động riêng của module onboarding (cách tour hướng dẫn tự chạy ở lần đăng nhập đầu, cờ trong database, cách nối vào bảng onboarding của kanban) là một chủ đề riêng và nằm ngoài phạm vi bài này.

Chạy và quan sát

Trên trình duyệt, vào một URL bất kỳ với phần ?debug=<tour_name> nối thêm (ví dụ http://localhost:8069/odoo?debug=confirm_sale_order_tour). Tour chạy trên chính database thật của bạn, trong chính trình duyệt thật của bạn, với thanh công cụ dành cho lập trình viên đang hiện.

Từ phía Python, các núm vặn đáng quan tâm của start_tour và browser_js:

  • watch=True: bật ra một cửa sổ trình duyệt nhìn thấy được. Chỉ dùng khi phát triển trên máy; CI chạy chế độ không giao diện.
  • step_delay=N: dừng N mili giây giữa các bước. Để bạn thật sự xem được chuyện gì đang diễn ra.
  • cpu_throttling=N: làm chậm CPU mô phỏng để khiêu khích các tình huống tranh chấp. Hữu ích khi một tour chạy tốt trên máy mà hỏng trên CI chậm.
  • debug=True: Chrome toàn màn hình với DevTools mở sẵn; đặt ?debug=assets để JS không bị đóng gói, nhờ đó breakpoint rơi đúng vào mã nguồn thật.
self.start_tour(
    "/odoo", "confirm_sale_order_tour",
    login="admin", watch=True, step_delay=300,
)

Ảnh chụp màn hình và video ghi màn hình

browser_js tự bắt cả hai.

  • Ảnh chụp: {odoo_config['screenshots']}/{db}/screenshots/*.png. Khóa cấu hình screenshots mặc định trỏ vào một thư mục con tests/ dưới data dir của bạn.
  • Video ghi màn hình: các khung hình PNG trong screencasts/frames-<timestamp>/, tự mã hóa thành .webm nếu có FFmpeg trong PATH.
  • Từ phía JS: gọi browser.take_screenshot() trong một bước của tour để chụp một khung hình theo yêu cầu.

Danh sách kiểm tra khi debug

  • Vấn đề thời điểm hoặc tranh chấp. Hãy tăng step_delay, bóp CPU, xem lại video đã ghi. Nếu chạy chậm thì đạt mà chạy nhanh thì hỏng, tour cần một trigger cụ thể hơn, một trigger thật sự mang nghĩa “trang đã sẵn sàng”.

Kết luận

Bốn trường hợp này là những dạng công việc frontend điển hình mà lập trình viên Odoo hay gặp: helper Form, Hoot, test cho POS, và tour. Quen tay với bốn thứ đó sẽ sinh lợi rất nhanh, dù bạn tự tay viết test hay huy động các agent giúp mình ra test đúng nhanh hơn.