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_confirmruns a single method.--test-tags /saleruns every test tagged with thesalemodule.--test-tags -slow,/saleexcludes theslowtag and includes thesalemodule.
Debug checklist
new()versusedit(idx)confusion on x2many.new()creates a child line,edit(0)amends an existing one.- Missing
@api.onchangedecorator on the field you expect to fire. TheFormhelper triggers onchanges declared via the decorator, not arbitrary computed fields. - Default versus computed surprises. A computed field tagged
store=Truewon’t recompute mid-form the way an@api.onchangewill. 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: trueon 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=assetsfor 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. Thescreenshotsconfig key defaults to atests/subdirectory under your data dir. - Screencasts: PNG frames in
screencasts/frames-<timestamp>/, auto-encoded to.webmif 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 specifictriggerthat 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.