# Writing JavaScript and Frontend Tests in Odoo 19

> Four ways to test the Odoo frontend: the Form helper for onchange chains, Hoot for OWL components, POS-specific mounts, and tours for full flows in a real browser.

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

---



For most Odoo developers, writing Python tests feels straightforward: the patterns are well-documented, the tooling is familiar (`TransactionCase`, `SavepointCase`, `HttpCase`), and you can usually find a close-enough example in Odoo's own code or the official docs.

Frontend tests are a different story. They often feel tedious and intimidating: bundling and assets, async rendering, services, DOM timing, and error messages that don't look like anything in your Python stack traces. Many teams end up avoiding JS tests until a refactor forces their hand.

This post hands you the JS-side keys. Four cases: the `Form` helper (server-side, UI-flavoured), Hoot for OWL units, POS-specific Hoot, and tours via `browser_js`. Same four-beat rhythm in every case (when to reach for it, minimum viable test, run and observe, debug checklist), so the post is also four applications of one mental model.

## Form-Helper Tests, the UI-Flavoured Server Test

Onchange chains, default propagation, x2many edits. If the only browser behaviour you'd be testing is "field A changed, field B updated", `Form` already does it. It runs as part of your normal Python suite, fails inside the same stack trace as the rest of your `TransactionCase` work, and never asks you to boot a browser.

### Minimum Viable Test

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

Three things are doing the work here. `Form(self.env['sale.order'])` opens a form bound to the model and fires defaults exactly as the browser would. Setting `partner_id` triggers the partner-pricelist onchange chain. The `with order_form.order_line.new() as line:` block is the x2many recipe: `new()` takes no arguments, returns a context manager, and yields a child form. Use `order_form.order_line.edit(0)` (zero-based integer index) to amend an existing line, or `order_form.order_line.remove(index=0)` to drop one.

### Run and Observe

`--test-tags` is the runner switch. The format is `[-][tag][/module][:class][.method]`, comma-separated, supporting module-qualified notation. (Source: `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
```

Useful patterns:

- `--test-tags :TestSaleOrder.test_confirm` runs a single method.
- `--test-tags /sale` runs every test tagged with the `sale` module.
- `--test-tags -slow,/sale` excludes the `slow` tag and includes the `sale` module.

### Debug Checklist

- **`new()` versus `edit(idx)` confusion** on x2many. `new()` creates a child line, `edit(0)` amends an existing one.
- **Missing `@api.onchange` decorator** on the field you expect to fire. The `Form` helper triggers onchanges declared via the decorator, not arbitrary computed fields.
- **Default versus computed surprises**. A computed field tagged `store=True` won't recompute mid-form the way an `@api.onchange` will. If your test expects a value mid-edit, an onchange has to push it.

## Hoot Component Tests

Pure component logic. A widget renders, you click a button, a class toggles or text changes.

### Minimum Viable Test

```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");
});
```

Five moves: import from `@odoo/hoot` (test runner), `@odoo/hoot-dom` (DOM helpers), and `@odoo/hoot-mock` (timer and animation control); mount the component with `mountWithCleanup` (auto-clears DOM after the test); assert with a DOM matcher; click; `await animationFrame()` so OWL flushes its render; assert again.

The OCA `edi-framework` 19.0 module has a more substantial, real-world example that mounts a widget via `mountView`, seeds records via `startServer()` + `pyEnv.partner.create()`, and uses `expect.waitForSteps()` to assert an ordered async flow.

```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"]);
});
```

For a stripped-down core example, `addons/web/static/tests/core/dialog_service.test.js` covers the dialog service.

The file has to live in `/static/tests/`, end with `.test.js`, and be registered in the `web.assets_unit_tests` bundle:

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

### Run and Observe

Boot Odoo, navigate to `/web/tests`. Hoot loads every registered `.test.js` and shows the runner UI. Filter by file or test name in the search box. Add `?debug=tests` to the URL for verbose logs.

For a single test in the URL, the Hoot UI also accepts a filter query string (`/web/tests?test=counter%20increments`); easier in practice is to copy-paste a unique substring of your test name into the on-page filter.

## POS Frontend Tests

POS-specific UI: numpad, receipt screen, orderline display, customer screen. This isn't a different framework. It's Hoot with a different mount recipe.

### Minimum Viable Test

Two patterns are in active use in core 19.0. Both live under `addons/point_of_sale/static/tests/`.

**Pattern A: `setupPosEnv` for components that need POS context.**

```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()` boots a POS store with seed data, enough for components that read from `store.models` or expect a current order. Used for `Orderline`, `ReceiptScreen`, and similar components. (See `addons/point_of_sale/static/tests/unit/components/orderline.test.js` and `receipt_screen.test.js` for the full pattern.)

**Pattern B: `noMainContainer` for fully isolated components.**

```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);
});
```

This is the recipe for components that don't need POS state at all (`OdooLogo`, `NumericInput`, plain `Input`). `noMainContainer: true` skips the full `MainComponentsContainer` wrapper; clearing the services registry prevents POS service registrations leaking from a prior test. (See `addons/point_of_sale/static/tests/generic_components/mount_generic_components.test.js`.)

### Run and Observe

Navigate to `/web/tests`, filter on a POS-specific substring like `orderline` or `numeric_input`. POS-specific helpers live under `addons/point_of_sale/static/tests/generic_helpers/` (dialog interactions, numpad, offline simulation); pull from those rather than reinventing them.

### Debug Checklist

- **Missing `noMainContainer: true`** on Pattern B. Without it, the test tries to mount the entire chrome, which fails on services that aren't registered.
- **POS store or order state not reset between tests.** `setupPosEnv()` returns a fresh store per call; if you cache it across tests, expect cross-contamination.

## Tours via `browser_js`

Cross-component flows. Real RPCs. The actual web client booted in a real browser. This is the highest-fidelity option, the most expensive to run, and the one you reach for when the bug only shows up with everything wired together.

### Minimum Viable Test

```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')" },
    ],
});
```

The Python side is a `HttpCase` tagged `post_install`. The JS side registers the tour in the `web_tour.tours` registry. Each step is a `trigger` (CSS selector to wait for) with an optional `run` action (`"click"`, `"edit ..."`, `"hover"`, or a function). The last step omits `run`; reaching its trigger is the success signal.

A working core example is `addons/calendar/tests/test_calendar_tour.py` paired with `addons/calendar/static/tests/tours/calendar_tour.js`. The Python class uses `HttpCaseWithUserDemo`, calls `self.start_tour("/odoo/calendar", "calendar_tour", login=user)`, and the JS side walks event creation, decline, and delete.

### Onboarding Tours

Onboarding tours register in the same `web_tour.tours` registry. Variant gating uses the `isActive` field on each step:

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

The onboarding-module-specific bootstrap (how onboarding tours auto-trigger on first login, the database flag, the kanban onboarding panel wiring) is its own topic and out of scope here.

### Run and Observe

In a browser, visit any URL with `?debug=<tour_name>` appended (for example, `http://localhost:8069/odoo?debug=confirm_sale_order_tour`). The tour runs against your real database, in your real browser, with the developer toolbar visible.

From the Python side, the relevant `start_tour` and `browser_js` knobs:

- `watch=True`: pops up a visible browser window. Local dev only; CI runs headless.
- `step_delay=N`: pauses N milliseconds between steps. Lets you actually watch what's happening.
- `cpu_throttling=N`: slows the simulated CPU to provoke race conditions. Useful when a tour passes locally and fails on slow CI.
- `debug=True`: fullscreen Chrome with DevTools open; sets `?debug=assets` for non-bundled JS so breakpoints land in real source.

```python
self.start_tour(
    "/odoo", "confirm_sale_order_tour",
    login="admin", watch=True, step_delay=300,
)
```

### Screenshots and Screencasts

`browser_js` captures both automatically.

- Screenshots: `{odoo_config['screenshots']}/{db}/screenshots/*.png`. The `screenshots` config key defaults to a `tests/` subdirectory under your data dir.
- Screencasts: PNG frames in `screencasts/frames-<timestamp>/`, auto-encoded to `.webm` if FFmpeg is on the PATH.
- From JS: `browser.take_screenshot()` inside a tour step grabs a frame on demand.

### Debug Checklist

- **Timing or race.** Raise `step_delay`, throttle CPU, watch the recorded screencast. If it passes when slowed and fails when fast, the tour needs a more specific `trigger` that actually means "the page is ready".

## Conclusion

These four cases are typical kinds of frontend work Odoo developers run into: `Form` helper, Hoot, POS tests, and tours. Getting comfortable with these four will pay off quickly, whether you write tests by hand, or you harness your agents to help you ship correct tests faster.

