# Viết test JavaScript và frontend trong Odoo 19

> 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.

**Date:** 2026-05-20
**Source:** <https://trobz.com/vi/insights/writing-javascript-frontend-tests-in-odoo/>

---



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

```python
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`.)

```bash
./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

```javascript
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ự.

```javascript
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`:

```python
# __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.**

```javascript
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.**

```javascript
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

```python
# 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")
```

```javascript
// 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:

```javascript
{ 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.

```python
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.

