← Back to Blog
Testing by

Writing JavaScript and Frontend Tests in Odoo 19

Writing JavaScript and Frontend Tests in Odoo 19

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

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

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

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.

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:

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

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.

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

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

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:

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

Ready to get the most out of Odoo?

Whether you are starting a new implementation, upgrading from an older version, or optimizing your current setup — our Odoo-first team is here to help.