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:
- Make a change to a component
- Start the Odoo server (30-60 seconds)
- Navigate to the page where the component is used
- Manually interact with the component to verify it works
- Check edge cases by manually creating different scenarios
- 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.