Way4Tech

Part 7. Making it hold up · lesson 33 of 40

Testing OWL components with Hoot

After this lesson you can: Write and run a Hoot test for a component, with a mocked server and mocked services.

Written against Odoo 19.0 and 18.0. Last checked 2026-10-09.

Odoo 18 replaced QUnit with Hoot, its own test framework. Odoo 17 and earlier use QUnit, and the two look different enough that mixing tutorials will waste your afternoon.

Where tests live

'assets': {
    'web.assets_unit_tests': [
        'owl_course/static/tests/**/*.test.js',
    ],
},

Files end .test.js. Run them at /web/tests in the browser, with developer mode on. The filter box takes a module name or a test name.

A first test

// static/tests/stars_field.test.js
import { expect, test } from "@odoo/hoot";
import { click, queryAll, queryFirst } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { defineModels, fields, models, mountView, onRpc } from "@web/../tests/web_test_helpers";

class Product extends models.Model {
    name = fields.Char();
    rating = fields.Integer();

    _records = [
        { id: 1, name: "Chair", rating: 3 },
        { id: 2, name: "Desk", rating: 0 },
    ];
}

defineModels([Product]);

test("stars render the current value", async () => {
    await mountView({
        type: "form",
        resModel: "product",
        resId: 1,
        arch: `
            <form>
                <field name="rating" widget="stars"/>
            </form>`,
    });

    expect(queryAll(".fa-star")).toHaveLength(3);
    expect(queryAll(".fa-star-o")).toHaveLength(2);
});

test("clicking a star writes the value", async () => {
    onRpc("web_save", ({ args }) => {
        expect(args[1].rating).toBe(5);
        expect.step("saved");
    });

    await mountView({
        type: "form",
        resModel: "product",
        resId: 1,
        arch: `<form><field name="rating" widget="stars"/></form>`,
    });

    await click(queryAll(".fa-star-o")[1]);     // the fifth star
    await animationFrame();
    await click(".o_form_button_save");
    await animationFrame();

    expect.verifySteps(["saved"]);
});

The pieces

defineModels declares fake models with fields and records. No database, no Python, and tests run in milliseconds.

mountView mounts a real Odoo view with an arch you write inline. This is the important one: you test your widget inside a genuine form view, with the record, the dirty state and the save button all real.

onRpc intercepts a server call. You assert on the arguments and decide what comes back.

animationFrame waits for OWL to finish rendering. Forgetting it is the single most common cause of a flaky Odoo test. After any interaction that triggers a render, await it.

expect.step and expect.verifySteps assert that things happened in order. The usual way to prove a server call was made exactly once.

Mounting a component on its own

For a component that is not a field widget:

import { expect, test } from "@odoo/hoot";
import { click } from "@odoo/hoot-dom";
import { animationFrame } from "@odoo/hoot-mock";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
import { Counter } from "@owl_course/counter/counter";

test("counter increments", async () => {
    await mountWithCleanup(Counter, { props: { start: 2 } });

    expect("button").toHaveText("Count: 2");
    await click("button");
    await animationFrame();
    expect("button").toHaveText("Count: 3");
});

mountWithCleanup unmounts afterwards, so one test cannot leak into the next.

Mocking a service

Because components ask for services by name, replacing one is easy, and this is the practical payoff of Part 5.

import { mockService } from "@web/../tests/web_test_helpers";

test("shows a notification on failure", async () => {
    mockService("notification", {
        add(message, options) {
            expect.step(`${options.type}: ${message}`);
        },
    });

    onRpc("action_post", () => { throw makeServerError({ message: "No period" }); });

    await mountView({ /* ... */ });
    await click(".o_post_button");
    await animationFrame();

    expect.verifySteps(["danger: No period"]);
});

Querying

@odoo/hoot-dom gives CSS selectors plus a few extras:

queryFirst(".o_field_widget");
queryAll("tr.o_data_row");
queryOne(".o_form_view");           // throws unless exactly one matches
queryText(".o_breadcrumb");

await click(".btn-primary");
await edit("new value");
await fill("text");
await press("Enter");
await hover(".o_row");

queryOne is worth preferring. A selector that silently matches three elements is a test that passes for the wrong reason.

What to test

Be selective. Tests you regret writing are tests that assert markup.

Worth testing: a widget writes the right value; a component calls the server once, not per keystroke; an error path shows the message; a guard actually guards; the race condition resolves to the newest result.

Not worth testing: that a div has a class; that a label says what the arch says; anything that will change the first time a designer touches it.

For a module you sell or maintain, the test that pays for itself most reliably is the one asserting a server call is made with the exact arguments you expect. That is what breaks silently on upgrade.

Running them

Open /web/tests, filter, and watch. In CI:

odoo --test-enable --test-tags /my_module --stop-after-init -d test_db -i my_module

JavaScript tests run through the browser, so a headless browser is needed in CI. Odooโ€™s own tooling does this with Chrome headless.

Odoo 17 and earlier

QUnit, with QUnit.module, QUnit.test, assert.strictEqual, getFixture and makeView. The ideas map across; the names do not. If you support both, the tests are the one place you will genuinely need two versions.

What is next

Debugging what you cannot reproduce: a minified bundle on somebody elseโ€™s machine.

Back to the full course