Skip to Content
  • Home
  • Book
  • Blog
  • About us
  • Help
  • Contact us
  •  [email protected]
  • Sign in
  • English (US) Español (BO)
  • Simplify It S.R.L.
    • Contact Us
Simplify It S.R.L.
      • Home
      • Book
      • Blog
      • About us
      • Help
      • Contact us
    •  [email protected]
    • English (US) Español (BO)
    • Sign in
    • Contact Us

    Chapter 16 Part 1: Testing and Debugging Fundamentals

  • All Blogs
  • OWL Book
  • Chapter 16 Part 1: Testing and Debugging Fundamentals
  • August 25, 2026 by
    Chapter 16 Part 1: Testing and Debugging Fundamentals
    Grover Menacho

    Why this chapter? "It works on my machine" isn't good enough when you're the one on the hook for a client's production instance — tests are what let you touch code with confidence after the first release, instead of dreading every change. What is Odoo trying to solve with this? A JS test framework that can boot a mock Odoo environment with the right services quickly, so testing a small addon doesn't require a full running server and database. Real-world application: Catching a regression in code review, before a routine update breaks a dashboard a client's sales team relies on every morning.

    Writing code that works today is good. Writing code that you can confidently change tomorrow without breaking everything is exceptional. The difference lies in having a comprehensive testing strategy and mastering debugging techniques.

    Testing is not just about catching bugs—it's about designing better components, documenting expected behavior, and creating a safety net that allows you to refactor and improve your code with confidence. When combined with effective debugging skills, testing transforms development from a series of hopeful experiments into a methodical, predictable process.

    Odoo provides a powerful JavaScript testing framework built on @odoo/hoot (the modern unit-test runner used since Odoo 17.4, replacing the older QUnit-based framework), specifically designed for testing OWL components in realistic conditions. This part of the chapter takes you from writing your first simple test to testing components that depend on services, plus the debugging tools and best practices you'll use every day.


    Why Testing Matters in OWL Development

    Before diving into the "how," let's understand the "why" of testing OWL components.

    The Reality of Component Interdependence

    OWL components don't exist in isolation. They: - Receive props from parents and pass props to children - Emit events that parents listen to - Use services to interact with Odoo's backend - Manipulate the DOM through templates - Manage local state that affects rendering

    Each of these interactions is a potential point of failure. Without tests, you're essentially flying blind—making changes and hoping nothing breaks.

    The Cost of Manual Testing

    Consider a typical development workflow without automated tests:

    1. Make a change to a component
    2. Start the Odoo server (30-60 seconds)
    3. Navigate to the page where the component is used
    4. Manually interact with the component to verify it works
    5. Check edge cases by manually creating different scenarios
    6. Repeat for every component that might be affected

    For a single small change, this process might take 5-10 minutes. Multiply that by dozens of changes per day, and you're spending hours on repetitive manual testing.

    The Benefits of Automated Testing

    Automated tests change this completely:

    • Speed: Tests run in seconds, not minutes
    • Consistency: Tests check the same things every time, in the same way
    • Coverage: Tests can check edge cases you might forget manually
    • Confidence: Green tests mean your changes haven't broken existing functionality
    • Documentation: Tests serve as executable examples of how components should behave

    Setting Up Your Testing Environment

    1. Test File Organization

    Odoo follows a clear convention for organizing test files:

    my_module/
    |-- static/src/
    |   |-- components/
    |   |   |-- counter/
    |   |   |   |-- counter.js
    |   |   |   |-- counter.xml
    |   |   |   |-- tests/
    |   |   |       |-- counter.test.js
    |   |   |-- task_list/
    |   |       |-- task_list.js
    |   |       |-- task_list.xml
    |   |       |-- tests/
    |   |           |-- task_list.test.js
    |   |-- services/
    |       |-- theme_store.js
    |       |-- tests/
    |           |-- theme_store.test.js
    

    This organization keeps tests close to the code they're testing, making them easy to find and maintain.

    2. Basic Test File Structure

    Every Odoo JavaScript test file follows this pattern:

    /** @odoo-module **/
    
    import { describe, test, beforeEach } from "@odoo/hoot";
    import { mountWithCleanup } from "@web/../tests/web_test_helpers";
    
    // Import the component you're testing
    import { Counter } from "../counter";
    
    describe("Counter", () => {
        beforeEach(() => {
            // Runs before every test in this describe() block
        });
    
        test("basic rendering", async () => {
            // Test implementation goes here
        });
    });
    

    Key helpers explained: - describe(): Groups related tests together, similar to a test suite - test(): Defines a single test case - beforeEach(): Runs setup code before each test in the surrounding describe() - mountWithCleanup(): Mounts a component into the test page and automatically unmounts it when the test ends — no manual target/env/.destroy() bookkeeping needed, unlike the older QUnit-based framework

    3. Adding Tests to Your Module

    Don't forget to include your test files in __manifest__.py:

    {
        'name': 'My Module',
        # ... other manifest data ...
        'assets': {
            'web.assets_backend': [
                'my_module/static/src/components/**/*.js',
                'my_module/static/src/components/**/*.xml',
            ],
            'web.assets_unit_tests': [
                'my_module/static/src/components/**/tests/*.test.js',
            ],
        },
    }
    

    Writing Your First Component Test

    Let's start with a simple but realistic example: testing a Counter component.

    The Counter Component

    First, here's the component we'll be testing:

    File: counter.js

    /** @odoo-module **/
    
    import { Component, useState } from "@odoo/owl";
    
    export class Counter extends Component {
        static template = xml`
            <div class="counter-widget">
                <h3>Counter: <span class="counter-value" t-esc="state.count"/></h3>
                <div class="counter-controls">
                    <button class="btn btn-secondary decrement-btn" 
                            t-on-click="decrement"
                            t-att-disabled="state.count <= props.min">
                        -
                    </button>
                    <button class="btn btn-primary reset-btn" t-on-click="reset">
                        Reset
                    </button>
                    <button class="btn btn-secondary increment-btn" 
                            t-on-click="increment"
                            t-att-disabled="state.count >= props.max">
                        +
                    </button>
                </div>
                <div class="counter-info" t-if="showInfo">
                    <small>Min: <t t-esc="props.min"/>, Max: <t t-esc="props.max"/></small>
                </div>
            </div>
        `;
    
        static props = {
            initialValue: { type: Number, optional: true },
            min: { type: Number, optional: true },
            max: { type: Number, optional: true },
            onValueChange: { type: Function, optional: true },
        };
    
        static defaultProps = {
            initialValue: 0,
            min: 0,
            max: 100,
        };
    
        setup() {
            this.state = useState({
                count: this.props.initialValue,
            });
        }
    
        get showInfo() {
            return this.props.min !== 0 || this.props.max !== 100;
        }
    
        increment() {
            if (this.state.count < this.props.max) {
                this.state.count++;
                this._notifyChange();
            }
        }
    
        decrement() {
            if (this.state.count > this.props.min) {
                this.state.count--;
                this._notifyChange();
            }
        }
    
        reset() {
            this.state.count = this.props.initialValue;
            this._notifyChange();
        }
    
        _notifyChange() {
            if (this.props.onValueChange) {
                this.props.onValueChange(this.state.count);
            }
        }
    }
    

    Comprehensive Test Suite

    Now let's write thorough tests for this component. We'll build the suite incrementally, a few tests at a time, so it's easier to see what each group is checking.

    File: counter.test.js

    First, the rendering tests — they check that the component displays the right thing given different props:

    /** @odoo-module **/
    
    import { describe, test, expect } from "@odoo/hoot";
    import { mountWithCleanup } from "@web/../tests/web_test_helpers";
    
    import { Counter } from "../counter";
    
    describe("Counter", () => {
        test("renders with default props", async () => {
            // Mount component with no props (should use defaults)
            await mountWithCleanup(Counter);
    
            // Check initial display
            expect(".counter-value").toHaveText("0");
    
            // Check that buttons exist
            expect(".increment-btn").toHaveCount(1);
            expect(".decrement-btn").toHaveCount(1);
            expect(".reset-btn").toHaveCount(1);
    
            // Check that info is not shown (default min/max)
            expect(".counter-info").toHaveCount(0);
        });
    
        test("renders with custom props", async () => {
            await mountWithCleanup(Counter, {
                props: {
                    initialValue: 5,
                    min: 2,
                    max: 8,
                },
            });
    
            // Check custom initial value
            expect(".counter-value").toHaveText("5");
    
            // Check that info is shown, with the custom min/max values
            expect(".counter-info").toHaveCount(1);
            expect(".counter-info").toHaveText("Min: 2, Max: 8");
        });
    });
    

    Key matchers explained: - expect(selector).toHaveText(text): Asserts the element matching selector has exactly this text content - expect(selector).toHaveCount(n): Asserts exactly n elements match selector (use 0 to assert something is absent)

    Next, the interaction tests — clicking the increment, decrement, and reset buttons and checking that the min/max boundaries are respected. These are added inside the same describe("Counter", ...) block shown above, and use click() from @odoo/hoot-dom, which simulates a real user click and waits for OWL to finish re-rendering before resolving:

        test("increment functionality", async () => {
            await mountWithCleanup(Counter, {
                props: { initialValue: 0, max: 3 },
            });
    
            // Test normal increment
            await click(".increment-btn");
            expect(".counter-value").toHaveText("1");
    
            await click(".increment-btn");
            expect(".counter-value").toHaveText("2");
    
            await click(".increment-btn");
            expect(".counter-value").toHaveText("3");
    
            // Test that button becomes disabled at max
            expect(".increment-btn").toHaveProperty("disabled", true);
    
            // Clicking a disabled button does nothing
            await click(".increment-btn");
            expect(".counter-value").toHaveText("3");
        });
    
        test("decrement functionality", async () => {
            await mountWithCleanup(Counter, {
                props: { initialValue: 3, min: 1 },
            });
    
            // Test normal decrement
            await click(".decrement-btn");
            expect(".counter-value").toHaveText("2");
    
            await click(".decrement-btn");
            expect(".counter-value").toHaveText("1");
    
            // Test that button becomes disabled at min
            expect(".decrement-btn").toHaveProperty("disabled", true);
    
            // Clicking a disabled button does nothing
            await click(".decrement-btn");
            expect(".counter-value").toHaveText("1");
        });
    
        test("reset functionality", async () => {
            await mountWithCleanup(Counter, {
                props: { initialValue: 5, min: 0, max: 10 },
            });
    
            // Change the value
            await click(".increment-btn");
            await click(".increment-btn");
            expect(".counter-value").toHaveText("7");
    
            // Test reset
            await click(".reset-btn");
            expect(".counter-value").toHaveText("5");
        });
    

    Remember to add import { click } from "@odoo/hoot-dom"; alongside the @odoo/hoot import at the top of the file.

    Finally, the callback and edge-case tests — verifying that onValueChange fires with the right values, and that unusual prop combinations (like min === max) don't crash the component. Same describe("Counter", ...) block as above, closed at the end. Notice each scenario gets its own test() — following the "keep tests independent" practice covered later in this chapter, instead of cramming multiple mounts into one test:

        test("onValueChange callback", async () => {
            const valueChanges = [];
            const onValueChange = (newValue) => {
                valueChanges.push(newValue);
            };
    
            await mountWithCleanup(Counter, {
                props: {
                    initialValue: 2,
                    onValueChange,
                },
            });
    
            // Test increment callback
            await click(".increment-btn");
            // Test decrement callback
            await click(".decrement-btn");
            // Test reset callback
            await click(".reset-btn");
    
            // increment to 3, decrement to 2, reset to 2
            expect(valueChanges).toEqual([3, 2, 2]);
        });
    
        test("increment and decrement are both disabled when min === max", async () => {
            await mountWithCleanup(Counter, {
                props: { initialValue: 5, min: 5, max: 5 },
            });
    
            expect(".increment-btn").toHaveProperty("disabled", true);
            expect(".decrement-btn").toHaveProperty("disabled", true);
        });
    
        test("initial value outside bounds is preserved", async () => {
            // Note: In a real implementation, you might want to clamp the initial value.
            // This test documents current behavior.
            await mountWithCleanup(Counter, {
                props: { initialValue: 100, min: 1, max: 10 },
            });
    
            expect(".counter-value").toHaveText("100");
        });
    });
    

    Key matchers explained: - expect(value).toEqual(other): Asserts deep equality between two values (arrays, objects) — use .toBe() instead for primitives like strings, numbers, and booleans - expect(selector).toHaveProperty(name, value): Asserts a DOM property (like disabled) on the matched element equals value


    Testing Components with Services

    Many real-world components depend on Odoo services. Here's how to test them.

    Component Using Services

    This TaskManager component loads tasks with the orm service on mount, and shows feedback with the notification service. Let's look at the data-loading half first:

    /** @odoo-module **/
    
    import { Component, useState } from "@odoo/owl";
    import { useService } from "@web/core/utils/hooks";
    
    export class TaskManager extends Component {
        static template = xml`
            <div class="task-manager">
                <h3>My Tasks</h3>
                <div class="task-input">
                    <input type="text" 
                           t-model="state.newTaskText"
                           placeholder="Add a new task..."
                           t-on-keydown="onKeydown"/>
                    <button class="btn btn-primary" 
                            t-on-click="addTask"
                            t-att-disabled="!state.newTaskText">
                        Add Task
                    </button>
                </div>
    
                <div class="task-list" t-if="state.loading">
                    <p>Loading tasks...</p>
                </div>
    
                <div class="task-list" t-else="">
                    <div t-foreach="state.tasks" t-as="task" t-key="task.id" 
                         class="task-item"
                         t-att-class="{ 'completed': task.completed }">
                        <input type="checkbox" 
                               t-att-checked="task.completed"
                               t-on-change="(ev) => this.toggleTask(task.id)"/>
                        <span class="task-text" t-esc="task.text"/>
                        <button class="btn btn-sm btn-danger delete-btn" 
                                t-on-click="() => this.deleteTask(task.id)">
                            Delete
                        </button>
                    </div>
                </div>
            </div>
        `;
    
        setup() {
            this.orm = useService("orm");
            this.notification = useService("notification");
    
            this.state = useState({
                tasks: [],
                newTaskText: "",
                loading: true,
            });
    
            this.loadTasks();
        }
    
        async loadTasks() {
            this.state.loading = true;
            try {
                const tasks = await this.orm.searchRead(
                    "project.task",
                    [["user_id", "=", this.orm.user.userId]],
                    ["id", "name", "stage_id"]
                );
    
                this.state.tasks = tasks.map(task => ({
                    id: task.id,
                    text: task.name,
                    completed: task.stage_id[1] === "Done"
                }));
            } catch (error) {
                this.notification.add("Failed to load tasks", { type: "danger" });
            } finally {
                this.state.loading = false;
            }
        }
    

    And the rest of the class — adding, toggling, and deleting tasks, plus the Enter-key shortcut. This is still the same TaskManager class, continued:

        async addTask() {
            if (!this.state.newTaskText.trim()) return;
    
            try {
                const [taskId] = await this.orm.create("project.task", {
                    name: this.state.newTaskText,
                    user_id: this.orm.user.userId,
                });
    
                this.state.tasks.push({
                    id: taskId,
                    text: this.state.newTaskText,
                    completed: false
                });
    
                this.state.newTaskText = "";
                this.notification.add("Task added successfully", { type: "success" });
            } catch (error) {
                this.notification.add("Failed to add task", { type: "danger" });
            }
        }
    
        async toggleTask(taskId) {
            const task = this.state.tasks.find(t => t.id === taskId);
            if (!task) return;
    
            try {
                // In a real implementation, you'd update the stage_id
                task.completed = !task.completed;
                this.notification.add(
                    task.completed ? "Task completed!" : "Task reopened", 
                    { type: "info" }
                );
            } catch (error) {
                // Revert on error
                task.completed = !task.completed;
                this.notification.add("Failed to update task", { type: "danger" });
            }
        }
    
        async deleteTask(taskId) {
            try {
                await this.orm.unlink("project.task", [taskId]);
                this.state.tasks = this.state.tasks.filter(t => t.id !== taskId);
                this.notification.add("Task deleted", { type: "info" });
            } catch (error) {
                this.notification.add("Failed to delete task", { type: "danger" });
            }
        }
    
        onKeydown(event) {
            if (event.key === "Enter") {
                this.addTask();
            }
        }
    }
    

    Testing with Mock Services

    To test TaskManager without a real Odoo server, we mock the RPC layer instead of hitting the database, using the onRpc() helper to intercept specific ORM calls. First, the tests that cover loading data and adding a new task:

    /** @odoo-module **/
    
    import { describe, test, expect } from "@odoo/hoot";
    import { click, edit, queryAllTexts } from "@odoo/hoot-dom";
    import { mountWithCleanup, onRpc } from "@web/../tests/web_test_helpers";
    
    import { TaskManager } from "../task_manager";
    
    describe("TaskManager", () => {
        test("loads and displays tasks", async () => {
            // Mock the ORM's search_read for the project.task model
            const mockTasks = [
                { id: 1, name: "Buy groceries", stage_id: [1, "To Do"] },
                { id: 2, name: "Walk the dog", stage_id: [2, "Done"] },
                { id: 3, name: "Write tests", stage_id: [1, "To Do"] },
            ];
            onRpc("project.task", "search_read", () => mockTasks);
    
            await mountWithCleanup(TaskManager);
    
            // Check that loading state is gone
            expect(".task-list p").toHaveCount(0);
    
            // Check that tasks are displayed
            expect(".task-item").toHaveCount(3);
            expect(queryAllTexts(".task-text")).toEqual([
                "Buy groceries",
                "Walk the dog",
                "Write tests",
            ]);
    
            // Check completed state (second task has stage_id "Done")
            expect(".task-item:nth-child(2)").toHaveClass("completed");
        });
    
        test("adds a new task", async () => {
            let createdTask = null;
            onRpc("project.task", "search_read", () => []); // Start with empty task list
            onRpc("project.task", "create", ({ args }) => {
                createdTask = args[0]; // Capture created task data
                return 123; // Mock id
            });
    
            await mountWithCleanup(TaskManager);
    
            // Type in new task and click add
            await edit(".task-input input", "New test task");
            await click(".btn-primary");
    
            // Verify ORM call was made
            expect(createdTask).not.toBe(null);
            expect(createdTask.name).toBe("New test task");
    
            // Verify task appears in UI
            expect(".task-item").toHaveCount(1);
            expect(queryAllTexts(".task-text")).toEqual(["New test task"]);
    
            // Verify input is cleared
            expect(".task-input input").toHaveValue("");
        });
    });
    

    Key helpers explained: - onRpc(model, method, callback): Intercepts a specific ORM call instead of hitting the real server; the callback receives { args, kwargs } (the arguments the component passed to orm.create/orm.write/etc.) and its return value becomes the RPC result - edit(selector, value): Types value into the matched input and fires the events a real user's typing would (from @odoo/hoot-dom) - queryAllTexts(selector): Returns the trimmed text content of every element matching selector, as an array — convenient for comparing a whole list at once

    The remaining two tests check error handling and the keyboard shortcut. They're added inside the same describe("TaskManager", ...) block shown above, closed at the end:

        test("handles RPC errors gracefully", async () => {
            const notificationMessages = [];
            onRpc("project.task", "search_read", () => {
                throw new Error("Network error");
            });
            mockService("notification", {
                add: (message, options) => {
                    notificationMessages.push({ message, options });
                },
            });
    
            await mountWithCleanup(TaskManager);
    
            // Check that error notification was shown
            expect(notificationMessages.length).toBe(1);
            expect(notificationMessages[0].message).toBe("Failed to load tasks");
            expect(notificationMessages[0].options.type).toBe("danger");
    
            // Check that loading state is cleared even on error
            expect(".task-list p").toHaveCount(0);
        });
    
        test("keyboard shortcuts work", async () => {
            let taskCreated = false;
            onRpc("project.task", "search_read", () => []);
            onRpc("project.task", "create", () => {
                taskCreated = true;
                return 456;
            });
    
            await mountWithCleanup(TaskManager);
    
            // Type task text and press Enter
            await edit(".task-input input", "Task via Enter key");
            await press("Enter");
    
            expect(taskCreated).toBe(true);
        });
    });
    

    Add import { mockService } from "@web/../tests/web_test_helpers"; and import { press } from "@odoo/hoot-dom"; alongside the other imports at the top of the file. mockService() replaces a real Odoo service with a fake implementation for the duration of the test — here it swaps out notification so we can capture the messages it receives instead of showing real toasts. press(key) simulates pressing a keyboard key on the currently focused element.


    Effective Debugging Techniques

    While tests catch many issues, you'll still need to debug problems. Here are professional debugging strategies:

    1. Using Browser DevTools Effectively

    Setting Strategic Breakpoints:

    // In your component
    increment() {
        debugger; // Execution will pause here
        if (this.state.count < this.props.max) {
            this.state.count++;
            this._notifyChange();
        }
    }
    

    Conditional Breakpoints: Right-click on a line number in DevTools Sources tab and select "Add conditional breakpoint":

    this.state.count > 5  // Only pause when count exceeds 5
    

    Watching Variables: In the DevTools debugger, add variables to the "Watch" panel to see how they change as you step through code.

    2. Strategic Console Logging

    Component Lifecycle Logging:

    setup() {
        console.log("Counter setup", this.props);
        this.state = useState({ count: this.props.initialValue });
    }
    
    increment() {
        console.log("Before increment:", this.state.count);
        if (this.state.count < this.props.max) {
            this.state.count++;
            console.log("After increment:", this.state.count);
            this._notifyChange();
        } else {
            console.log("Increment blocked - at max:", this.props.max);
        }
    }
    

    State Change Tracking:

    setup() {
        this.state = useState({ count: this.props.initialValue });
    
        // Log all state changes
        const originalState = this.state;
        Object.defineProperty(this, 'state', {
            get: () => originalState,
            set: (newState) => {
                console.log("State changing from", originalState, "to", newState);
                originalState = newState;
            }
        });
    }
    

    3. Component Inspector Tools

    OWL DevTools: Install the OWL DevTools browser extension to: - See the component tree structure - Inspect component props and state - Track component updates and re-renders

    Accessing Components from Console: OWL does not expose a supported, public way to grab a mounted component instance from a plain DOM element — that's exactly what the OWL DevTools extension above is for. If you need programmatic access while developing, expose the instance yourself, deliberately, as a dev-only escape hatch:

    // In the component, during development only:
    setup() {
        onMounted(() => {
            // Intentional debug hook — never rely on this in production code.
            this.el.__debugComponent = this;
        });
    }
    
    // Then, in the browser console:
    const counterComponent = document.querySelector('.counter-widget').__debugComponent;
    console.log(counterComponent.state);
    console.log(counterComponent.props);
    

    4. Debugging Common Issues

    Props Not Updating:

    // Check if parent is actually passing new props
    setup() {
        onWillUpdateProps((nextProps) => {
            console.log("Current props:", this.props);
            console.log("Next props:", nextProps);
            console.log("Props changed:", JSON.stringify(this.props) !== JSON.stringify(nextProps));
        });
    }
    

    State Not Triggering Re-render:

    // Ensure you're using useState correctly
    setup() {
        // Good - reactive state
        this.state = useState({ count: 0 });
    
        // Bad - not reactive
        this.state = { count: 0 };
    }
    

    Event Handlers Not Working:

    // Check event binding in template
    static template = xml`
        <!-- Good - proper binding -->
        <button t-on-click="increment">+</button>
    
        <!-- Bad - calling function immediately -->
        <button t-on-click="increment()">+</button>
    
        <!-- Good - arrow function for parameters -->
        <button t-on-click="() => this.incrementBy(5)">+5</button>
    `;
    

    Testing Best Practices

    1. Write Descriptive Test Names

    // Bad - vague test names
    test("test counter", async () => { ... });
    test("button test", async () => { ... });
    
    // Good - descriptive test names
    test("counter displays initial value from props", async () => { ... });
    test("increment button becomes disabled at maximum value", async () => { ... });
    

    2. Test Behavior, Not Implementation

    // Bad - testing implementation details
    test("state.count increases by 1", async () => {
        const counter = await mountWithCleanup(Counter);
        counter.state.count = 5; // Direct state manipulation
        expect(counter.state.count).toBe(5);
    });
    
    // Good - testing user-visible behavior
    test("clicking increment button increases displayed value", async () => {
        await mountWithCleanup(Counter);
        await click(".increment-btn");
        expect(".counter-value").toHaveText("1");
    });
    

    3. Use Arrange-Act-Assert Pattern

    test("reset button restores initial value", async () => {
        // Arrange
        await mountWithCleanup(Counter, { props: { initialValue: 5 } });
        await click(".increment-btn"); // Change value
    
        // Act
        await click(".reset-btn");
    
        // Assert
        expect(".counter-value").toHaveText("5");
    });
    

    4. Keep Tests Independent

    // Bad - tests depend on each other
    let sharedCounter;
    
    test("setup counter", async () => {
        sharedCounter = await mountWithCleanup(Counter);
        expect(sharedCounter).toBeTruthy();
    });
    
    test("increment counter", async () => {
        await click(".increment-btn"); // Depends on the previous test still being mounted
        // ...
    });
    
    // Good - each test is independent
    test("counter can be incremented", async () => {
        await mountWithCleanup(Counter);
        await click(".increment-btn");
        expect(".counter-value").toHaveText("1");
    });
    

    5. Test Edge Cases

    // One test per scenario keeps failures easy to pinpoint
    test("handles extreme values", async () => {
        await mountWithCleanup(Counter, {
            props: { initialValue: Number.MAX_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER },
        });
        expect(".counter-value").toHaveCount(1);
    });
    
    test("handles negative ranges", async () => {
        await mountWithCleanup(Counter, {
            props: { initialValue: -5, min: -10, max: 0 },
        });
        expect(".counter-value").toHaveText("-5");
    });
    
    test("handles min equal to max", async () => {
        await mountWithCleanup(Counter, {
            props: { initialValue: 5, min: 5, max: 5 },
        });
        expect(".increment-btn").toHaveProperty("disabled", true);
    });
    

    Common Pitfalls and Solutions

    Pitfall 1: Forgetting to await Async Interactions

    // Wrong - the assertion runs before the DOM has re-rendered
    test("flaky", async () => {
        await mountWithCleanup(Counter);
        click(".increment-btn"); // not awaited!
        expect(".counter-value").toHaveText("1");
    });
    
    // Correct - await the click; hoot's click() only resolves after OWL re-renders
    test("reliable", async () => {
        await mountWithCleanup(Counter);
        await click(".increment-btn");
        expect(".counter-value").toHaveText("1");
    });
    

    Pitfall 2: Leftover Components Between Tests

    The old QUnit-based framework required calling counter.destroy() yourself at the end of every test, and forgetting it left a mounted component behind for the next test to trip over. mountWithCleanup() fixes this footgun by design — it always unmounts the component automatically once the test ends. The pitfall now is reaching for OWL's plain mount() out of habit instead:

    // Wrong - plain mount() from @odoo/owl is never cleaned up automatically
    import { mount } from "@odoo/owl";
    
    test("increment works", async () => {
        await mount(Counter, document.body);
        await click(".increment-btn");
        // this component is still mounted when the next test starts
    });
    
    // Correct - mountWithCleanup() always unmounts after the test, pass or fail
    test("increment works", async () => {
        await mountWithCleanup(Counter);
        await click(".increment-btn");
    });
    

    Pitfall 3: Reaching for console.log Instead of the Component Inspector

    Sprinkling console.log everywhere works, but it's slow to iterate and easy to forget to remove afterward. For anything beyond a quick one-off check, install the OWL DevTools extension (see above) and inspect state/props directly on the live component tree — it's faster and never accidentally ships to production.

    Pitfall 4: Debugging Without Reading the Full Stack Trace

    It's tempting to jump straight to a debugger statement or a console.log when something breaks. Read the full stack trace in the console first — it usually names the exact component, method, and line, which tells you where to add a breakpoint instead of guessing.


    Exercises

    Exercise 1: Test a New Behavior

    Add a step prop to Counter (defaulting to 1) that controls how much increment/decrement change the count by. Write a test that mounts the component with props: { step: 5 } and verifies that one click on the increment button moves the value from 0 to 5.

    Exercise 2: Mock a Service Failure

    Using TaskManager, write a test where onRpc("project.task", "create", ...) throws (not just search_read) and verify that the component shows a "Failed to add task" notification instead of adding the task to the list.

    Exercise 3: Debug a Broken Handler

    Take onKeydown and intentionally break it by comparing event.key === "enter" (lowercase) instead of "Enter". Use a breakpoint or console logging to find the bug before looking at the fix, then correct it.


    You now have the fundamentals: why testing matters, how to set up your environment, how to write and run tests for components with and without services, how to debug issues with browser tools, and the best practices that keep a test suite trustworthy. In Part 2, we'll build on this foundation with advanced testing patterns, Test-Driven Development, performance and integration testing, continuous integration, and the techniques professional teams use to debug issues that only show up in production.

    TL;DR: Write @odoo/hoot tests with mountWithCleanup, mock the services a component depends on, and use the browser's OWL DevTools instead of scattering console.log around — that combination is what makes changing code later safe instead of scary.

    Try it yourself: This chapter's example is available as an installable Odoo 19 addon: simplifyit_owl_book_ch16_1_ex1. Installation instructions are in the repository README.

    What's Next?

    You can now write and trust a basic test suite. Chapter 16 Part 2 goes further — advanced testing patterns, Test-Driven Development, and the debugging techniques you need once a bug only shows up in production.

    in OWL Book
    Chapter 16 Part 2: Advanced Testing, TDD, and Production Debugging
    Reading the whole book? Take it with you — free, no sign-up.
    PDF EPUB All chapters
    Useful Links
    • Home
    • About us
    • Blog
    • The OWL 2.0 book
    • Help
    • Contact us
    About us

    Simplify It S.R.L. is an Odoo implementation and development firm based in La Paz, Bolivia, working with companies across Latin America and North America. We have been building on Odoo since version 6.1: custom modules, version migrations and Bolivian localization.

    We are also the authors of the OWL 2.0 book, published free chapter by chapter on our blog.

    Connect with us
    • Contact us
    • [email protected]
    • +591 65144144
    • La Paz, Bolivia
    Follow us
    Copyright © Simplify It S.R.L.
    English (US) | Español (BO)
    Powered by Odoo - Create a free website