¿Por qué este capítulo? "Funciona en mi máquina" no alcanza cuando eres tú quien responde por la instancia de producción de un cliente — las pruebas son lo que te permite tocar código con confianza después de la primera entrega, en vez de temerle a cada cambio. ¿Qué trata de resolver Odoo con esto? Un framework de pruebas JS que pueda levantar un entorno Odoo simulado con los servicios correctos rápidamente, así probar un addon pequeño no requiere un servidor y una base de datos completos corriendo. Aplicación en la vida real: Detectar una regresión en una revisión de código, antes de que una actualización de rutina rompa un dashboard que el equipo de ventas de un cliente usa todas las mañanas.
Escribir código que funciona hoy es bueno. Escribir código que puedas modificar con confianza mañana sin romper todo es excepcional. La diferencia radica en tener una estrategia integral de pruebas y dominar las técnicas de depuración.
Las pruebas no son solo para encontrar errores: se tratan de diseñar mejores componentes, documentar el comportamiento esperado y crear una red de seguridad que te permita refactorizar y mejorar tu código con confianza. Cuando se combinan con técnicas efectivas de depuración, las pruebas transforman el desarrollo de una serie de experimentos esperanzadores en un proceso metódico y predecible.
Odoo proporciona un poderoso framework de pruebas JavaScript construido sobre @odoo/hoot (el runner de pruebas unitarias moderno, usado desde Odoo 17.4, que reemplazó al framework anterior basado en QUnit), específicamente diseñado para probar componentes OWL en condiciones realistas. Esta parte del capítulo te lleva desde escribir tu primera prueba simple hasta probar componentes que dependen de servicios, además de las herramientas de depuración y las buenas prácticas que usarás día a día.
Por qué Importan las Pruebas en el Desarrollo OWL
Antes de sumergirnos en el "cómo", entendamos el "por qué" de probar componentes OWL.
La Realidad de la Interdependencia de Componentes
Los componentes OWL no existen de forma aislada. Ellos: - Reciben props de los padres y pasan props a los hijos - Emiten eventos que los padres escuchan - Usan servicios para interactuar con el backend de Odoo - Manipulan el DOM a través de plantillas - Administran el estado local que afecta el renderizado
Cada una de estas interacciones es un punto potencial de falla. Sin pruebas, básicamente estás volando a ciegas, haciendo cambios y esperando que nada se rompa.
El Costo de las Pruebas Manuales
Considera un flujo de trabajo típico de desarrollo sin pruebas automatizadas:
- Hacer un cambio en un componente
- Iniciar el servidor de Odoo (30-60 segundos)
- Navegar a la página donde se usa el componente
- Interactuar manualmente con el componente para verificar que funciona
- Verificar casos límite creando manualmente diferentes escenarios
- Repetir para cada componente que podría verse afectado
Para un solo cambio pequeño, este proceso podría tomar 5-10 minutos. Multiplica eso por docenas de cambios por día, y estás gastando horas en pruebas manuales repetitivas.
Los Beneficios de las Pruebas Automatizadas
Las pruebas automatizadas cambian esto completamente:
- Velocidad: Las pruebas se ejecutan en segundos, no en minutos
- Consistencia: Las pruebas verifican las mismas cosas cada vez, de la misma manera
- Cobertura: Las pruebas pueden verificar casos límite que podrías olvidar manualmente
- Confianza: Las pruebas verdes significan que tus cambios no han roto la funcionalidad existente
- Documentación: Las pruebas sirven como ejemplos ejecutables de cómo deben comportarse los componentes
Configurando tu Entorno de Pruebas
1. Organización de Archivos de Prueba
Odoo sigue una convención clara para organizar archivos de prueba:
mi_modulo/
|-- 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
Esta organización mantiene las pruebas cerca del código que están probando, haciéndolas fáciles de encontrar y mantener.
2. Estructura Básica de Archivo de Prueba
Cada archivo de prueba JavaScript de Odoo sigue este patrón:
/** @odoo-module **/
import { describe, test, beforeEach } from "@odoo/hoot";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
// Importar el componente que estás probando
import { Counter } from "../counter";
describe("Counter", () => {
beforeEach(() => {
// Se ejecuta antes de cada prueba en este bloque describe()
});
test("renderizado básico", async () => {
// La implementación de la prueba va aquí
});
});
Helpers clave explicados:
- describe(): Agrupa pruebas relacionadas, como una suite de pruebas
- test(): Define un caso de prueba individual
- beforeEach(): Ejecuta código de configuración antes de cada prueba dentro del describe() que la contiene
- mountWithCleanup(): Monta un componente en la página de pruebas y lo desmonta automáticamente al terminar la prueba — sin necesidad de gestionar target/env/.destroy() a mano como con el framework antiguo basado en QUnit
3. Agregando Pruebas a tu Módulo
No olvides incluir tus archivos de prueba en __manifest__.py:
{
'name': 'Mi Módulo',
# ... otros datos del manifest ...
'assets': {
'web.assets_backend': [
'mi_modulo/static/src/components/**/*.js',
'mi_modulo/static/src/components/**/*.xml',
],
'web.assets_unit_tests': [
'mi_modulo/static/src/components/**/tests/*.test.js',
],
},
}
Escribiendo tu Primera Prueba de Componente
Comencemos con un ejemplo simple pero realista: probar un componente Counter.
El Componente Counter
Primero, aquí está el componente que vamos a probar:
Archivo: counter.js
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
export class Counter extends Component {
static template = xml`
<div class="counter-widget">
<h3>Contador: <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">
Reiniciar
</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>Mín: <t t-esc="props.min"/>, Máx: <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);
}
}
}
Suite de Pruebas Integral
Ahora escribamos pruebas exhaustivas para este componente. Las construiremos de forma incremental, unas pocas a la vez, para que sea más fácil ver qué verifica cada grupo.
Archivo: counter.test.js
Primero, las pruebas de renderizado — verifican que el componente muestra lo correcto según distintas 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("renderiza con props por defecto", async () => {
// Montar componente sin props (debería usar valores por defecto)
await mountWithCleanup(Counter);
// Verificar visualización inicial
expect(".counter-value").toHaveText("0");
// Verificar que los botones existen
expect(".increment-btn").toHaveCount(1);
expect(".decrement-btn").toHaveCount(1);
expect(".reset-btn").toHaveCount(1);
// Verificar que la info no se muestra (min/max por defecto)
expect(".counter-info").toHaveCount(0);
});
test("renderiza con props personalizadas", async () => {
await mountWithCleanup(Counter, {
props: {
initialValue: 5,
min: 2,
max: 8,
},
});
// Verificar valor inicial personalizado
expect(".counter-value").toHaveText("5");
// Verificar que se muestra la info, con los valores min/max personalizados
expect(".counter-info").toHaveCount(1);
expect(".counter-info").toHaveText("Mín: 2, Máx: 8");
});
});
Matchers clave explicados:
- expect(selector).toHaveText(texto): Afirma que el elemento que coincide con selector tiene exactamente ese texto
- expect(selector).toHaveCount(n): Afirma que exactamente n elementos coinciden con selector (usa 0 para afirmar que algo está ausente)
A continuación, las pruebas de interacción — hacer clic en los botones de incremento, decremento y reinicio, y verificar que se respetan los límites min/max. Se agregan dentro del mismo bloque describe("Counter", ...) mostrado arriba, y usan click() de @odoo/hoot-dom, que simula un clic real de usuario y espera a que OWL termine de re-renderizar antes de resolver:
test("funcionalidad de incremento", async () => {
await mountWithCleanup(Counter, {
props: { initialValue: 0, max: 3 },
});
// Probar incremento normal
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");
// Probar que el botón se desactiva en el máximo
expect(".increment-btn").toHaveProperty("disabled", true);
// Hacer clic en un botón desactivado no hace nada
await click(".increment-btn");
expect(".counter-value").toHaveText("3");
});
test("funcionalidad de decremento", async () => {
await mountWithCleanup(Counter, {
props: { initialValue: 3, min: 1 },
});
// Probar decremento normal
await click(".decrement-btn");
expect(".counter-value").toHaveText("2");
await click(".decrement-btn");
expect(".counter-value").toHaveText("1");
// Probar que el botón se desactiva en el mínimo
expect(".decrement-btn").toHaveProperty("disabled", true);
// Hacer clic en un botón desactivado no hace nada
await click(".decrement-btn");
expect(".counter-value").toHaveText("1");
});
test("funcionalidad de reinicio", async () => {
await mountWithCleanup(Counter, {
props: { initialValue: 5, min: 0, max: 10 },
});
// Cambiar el valor
await click(".increment-btn");
await click(".increment-btn");
expect(".counter-value").toHaveText("7");
// Probar reinicio
await click(".reset-btn");
expect(".counter-value").toHaveText("5");
});
Recuerda agregar import { click } from "@odoo/hoot-dom"; junto al import de @odoo/hoot al principio del archivo.
Por último, las pruebas de callback y de casos límite — verifican que onValueChange se dispara con los valores correctos, y que combinaciones inusuales de props (como min === max) no rompen el componente. Mismo bloque describe("Counter", ...) de arriba, cerrado al final. Nota que cada escenario tiene su propio test() — siguiendo la práctica de "mantener las pruebas independientes" que se cubre más adelante en este capítulo, en vez de amontonar varios montajes en una sola prueba:
test("callback onValueChange", async () => {
const valueChanges = [];
const onValueChange = (newValue) => {
valueChanges.push(newValue);
};
await mountWithCleanup(Counter, {
props: {
initialValue: 2,
onValueChange,
},
});
// Probar callback de incremento
await click(".increment-btn");
// Probar callback de decremento
await click(".decrement-btn");
// Probar callback de reinicio
await click(".reset-btn");
// incrementar a 3, decrementar a 2, reiniciar a 2
expect(valueChanges).toEqual([3, 2, 2]);
});
test("incremento y decremento están desactivados cuando 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("el valor inicial fuera de límites se preserva", async () => {
// Nota: En una implementación real, podrías querer limitar el valor inicial.
// Esta prueba documenta el comportamiento actual.
await mountWithCleanup(Counter, {
props: { initialValue: 100, min: 1, max: 10 },
});
expect(".counter-value").toHaveText("100");
});
});
Matchers clave explicados:
- expect(valor).toEqual(otro): Afirma igualdad profunda entre dos valores (arrays, objetos) — usa .toBe() en vez de esto para primitivos como strings, números y booleanos
- expect(selector).toHaveProperty(nombre, valor): Afirma que una propiedad del DOM (como disabled) del elemento coincidente es igual a valor
Probando Componentes con Servicios
Muchos componentes del mundo real dependen de servicios de Odoo. Así es como probarlos.
Componente que Usa Servicios
Este componente TaskManager carga tareas con el servicio orm al montarse, y muestra retroalimentación con el servicio notification. Veamos primero la mitad que carga los datos:
/** @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>Mis Tareas</h3>
<div class="task-input">
<input type="text"
t-model="state.newTaskText"
placeholder="Agregar una nueva tarea..."
t-on-keydown="onKeydown"/>
<button class="btn btn-primary"
t-on-click="addTask"
t-att-disabled="!state.newTaskText">
Agregar Tarea
</button>
</div>
<div class="task-list" t-if="state.loading">
<p>Cargando tareas...</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)">
Eliminar
</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("Error al cargar tareas", { type: "danger" });
} finally {
this.state.loading = false;
}
}
Y el resto de la clase — agregar, marcar y eliminar tareas, más el atajo de la tecla Enter. Sigue siendo la misma clase TaskManager, continuada:
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("Tarea agregada exitosamente", { type: "success" });
} catch (error) {
this.notification.add("Error al agregar tarea", { type: "danger" });
}
}
async toggleTask(taskId) {
const task = this.state.tasks.find(t => t.id === taskId);
if (!task) return;
try {
// En una implementación real, actualizarías el stage_id
task.completed = !task.completed;
this.notification.add(
task.completed ? "¡Tarea completada!" : "Tarea reabierta",
{ type: "info" }
);
} catch (error) {
// Revertir en caso de error
task.completed = !task.completed;
this.notification.add("Error al actualizar tarea", { 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("Tarea eliminada", { type: "info" });
} catch (error) {
this.notification.add("Error al eliminar tarea", { type: "danger" });
}
}
onKeydown(event) {
if (event.key === "Enter") {
this.addTask();
}
}
}
Probando con Servicios Mock
Para probar TaskManager sin un servidor Odoo real, hacemos mock de la capa RPC en lugar de golpear la base de datos, usando el helper onRpc() para interceptar llamadas ORM específicas. Primero, las pruebas que cubren cargar datos y agregar una nueva tarea:
/** @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("carga y muestra tareas", async () => {
// Mock de search_read del ORM para el modelo project.task
const mockTasks = [
{ id: 1, name: "Comprar comida", stage_id: [1, "Por Hacer"] },
{ id: 2, name: "Pasear al perro", stage_id: [2, "Completado"] },
{ id: 3, name: "Escribir pruebas", stage_id: [1, "Por Hacer"] },
];
onRpc("project.task", "search_read", () => mockTasks);
await mountWithCleanup(TaskManager);
// Verificar que el estado de carga se fue
expect(".task-list p").toHaveCount(0);
// Verificar que las tareas se muestran
expect(".task-item").toHaveCount(3);
expect(queryAllTexts(".task-text")).toEqual([
"Comprar comida",
"Pasear al perro",
"Escribir pruebas",
]);
// Verificar estado completado (la segunda tarea tiene stage_id "Completado")
expect(".task-item:nth-child(2)").toHaveClass("completed");
});
test("agrega una nueva tarea", async () => {
let createdTask = null;
onRpc("project.task", "search_read", () => []); // Comenzar con lista de tareas vacía
onRpc("project.task", "create", ({ args }) => {
createdTask = args[0]; // Capturar datos de la tarea creada
return 123; // ID mock
});
await mountWithCleanup(TaskManager);
// Escribir nueva tarea y hacer clic en agregar
await edit(".task-input input", "Nueva tarea de prueba");
await click(".btn-primary");
// Verificar que se hizo la llamada ORM
expect(createdTask).not.toBe(null);
expect(createdTask.name).toBe("Nueva tarea de prueba");
// Verificar que la tarea aparece en la UI
expect(".task-item").toHaveCount(1);
expect(queryAllTexts(".task-text")).toEqual(["Nueva tarea de prueba"]);
// Verificar que el input se limpia
expect(".task-input input").toHaveValue("");
});
});
Helpers clave explicados:
- onRpc(modelo, método, callback): Intercepta una llamada ORM específica en vez de golpear el servidor real; el callback recibe { args, kwargs } (los argumentos que el componente pasó a orm.create/orm.write/etc.) y su valor de retorno se convierte en el resultado del RPC
- edit(selector, valor): Escribe valor en el input coincidente y dispara los eventos que la escritura real de un usuario dispararía (de @odoo/hoot-dom)
- queryAllTexts(selector): Devuelve el texto recortado de cada elemento que coincide con selector, como un array — útil para comparar toda una lista de una sola vez
Las dos pruebas restantes verifican el manejo de errores y el atajo de teclado. Se agregan dentro del mismo bloque describe("TaskManager", ...) mostrado arriba, cerrado al final:
test("maneja errores RPC elegantemente", async () => {
const notificationMessages = [];
onRpc("project.task", "search_read", () => {
throw new Error("Error de red");
});
mockService("notification", {
add: (message, options) => {
notificationMessages.push({ message, options });
},
});
await mountWithCleanup(TaskManager);
// Verificar que se mostró la notificación de error
expect(notificationMessages.length).toBe(1);
expect(notificationMessages[0].message).toBe("Error al cargar tareas");
expect(notificationMessages[0].options.type).toBe("danger");
// Verificar que el estado de carga se limpia incluso en caso de error
expect(".task-list p").toHaveCount(0);
});
test("los atajos de teclado funcionan", async () => {
let taskCreated = false;
onRpc("project.task", "search_read", () => []);
onRpc("project.task", "create", () => {
taskCreated = true;
return 456;
});
await mountWithCleanup(TaskManager);
// Escribir texto de la tarea y presionar Enter
await edit(".task-input input", "Tarea por tecla Enter");
await press("Enter");
expect(taskCreated).toBe(true);
});
});
Agrega import { mockService } from "@web/../tests/web_test_helpers"; e import { press } from "@odoo/hoot-dom"; junto a los demás imports al inicio del archivo. mockService() reemplaza un servicio real de Odoo por una implementación falsa durante la prueba — aquí sustituye notification para poder capturar los mensajes que recibe en vez de mostrar toasts reales. press(tecla) simula presionar una tecla del teclado sobre el elemento que tiene el foco actualmente.
Técnicas Efectivas de Depuración
Aunque las pruebas detectan muchos problemas, aún necesitarás depurar problemas. Aquí están las estrategias profesionales de depuración:
1. Usando las DevTools del Navegador Efectivamente
Estableciendo Breakpoints Estratégicos:
// En tu componente
increment() {
debugger; // La ejecución se pausará aquí
if (this.state.count < this.props.max) {
this.state.count++;
this._notifyChange();
}
}
Breakpoints Condicionales: Haz clic derecho en un número de línea en la pestaña Sources de DevTools y selecciona "Agregar breakpoint condicional":
this.state.count > 5 // Solo pausar cuando count exceda 5
Observando Variables: En el debugger de DevTools, agrega variables al panel "Watch" para ver cómo cambian mientras recorres el código.
2. Logging Estratégico en la Consola
Logging del Ciclo de Vida del Componente:
setup() {
console.log("Configuración del Counter", this.props);
this.state = useState({ count: this.props.initialValue });
}
increment() {
console.log("Antes del incremento:", this.state.count);
if (this.state.count < this.props.max) {
this.state.count++;
console.log("Después del incremento:", this.state.count);
this._notifyChange();
} else {
console.log("Incremento bloqueado - en el máximo:", this.props.max);
}
}
Seguimiento de Cambios de Estado:
setup() {
this.state = useState({ count: this.props.initialValue });
// Registrar todos los cambios de estado
const originalState = this.state;
Object.defineProperty(this, 'state', {
get: () => originalState,
set: (newState) => {
console.log("Estado cambiando de", originalState, "a", newState);
originalState = newState;
}
});
}
3. Herramientas de Inspección de Componentes
OWL DevTools: Instala la extensión de navegador OWL DevTools para: - Ver la estructura del árbol de componentes - Inspeccionar props y estado de componentes - Rastrear actualizaciones y re-renderizados de componentes
Accediendo Componentes desde la Consola: OWL no expone una forma pública y soportada de obtener la instancia de un componente montado a partir de un elemento del DOM — para eso está justamente la extensión OWL DevTools de arriba. Si necesitas acceso programático mientras desarrollas, expón la instancia tú mismo, de forma deliberada, como un atajo solo para depuración:
// En el componente, solo durante el desarrollo:
setup() {
onMounted(() => {
// Gancho de depuración intencional — nunca confíes en esto en producción.
this.el.__debugComponent = this;
});
}
// Luego, en la consola del navegador:
const counterComponent = document.querySelector('.counter-widget').__debugComponent;
console.log(counterComponent.state);
console.log(counterComponent.props);
4. Depurando Problemas Comunes
Props No Se Actualizan:
// Verificar si el padre realmente está pasando nuevas props
setup() {
onWillUpdateProps((nextProps) => {
console.log("Props actuales:", this.props);
console.log("Próximas props:", nextProps);
console.log("Props cambiaron:", JSON.stringify(this.props) !== JSON.stringify(nextProps));
});
}
Estado No Desencadena Re-renderizado:
// Asegurarse de usar useState correctamente
setup() {
// Bueno - estado reactivo
this.state = useState({ count: 0 });
// Malo - no reactivo
this.state = { count: 0 };
}
Manejadores de Eventos No Funcionan:
// Verificar vinculación de eventos en la plantilla
static template = xml`
<!-- Bueno - vinculación apropiada -->
<button t-on-click="increment">+</button>
<!-- Malo - llamando función inmediatamente -->
<button t-on-click="increment()">+</button>
<!-- Bueno - función flecha para parámetros -->
<button t-on-click="() => this.incrementBy(5)">+5</button>
`;
Mejores Prácticas de Pruebas
1. Escribir Nombres Descriptivos de Pruebas
// Malo - nombres de prueba vagos
test("probar contador", async () => { ... });
test("prueba de botón", async () => { ... });
// Bueno - nombres de prueba descriptivos
test("el contador muestra el valor inicial de las props", async () => { ... });
test("el botón de incremento se desactiva en el valor máximo", async () => { ... });
2. Probar Comportamiento, No Implementación
// Malo - probando detalles de implementación
test("state.count aumenta en 1", async () => {
const counter = await mountWithCleanup(Counter);
counter.state.count = 5; // Manipulación directa del estado
expect(counter.state.count).toBe(5);
});
// Bueno - probando comportamiento visible para el usuario
test("hacer clic en el botón de incremento aumenta el valor mostrado", async () => {
await mountWithCleanup(Counter);
await click(".increment-btn");
expect(".counter-value").toHaveText("1");
});
3. Usar el Patrón Organizar-Actuar-Afirmar
test("el botón de reinicio restaura el valor inicial", async () => {
// Organizar
await mountWithCleanup(Counter, { props: { initialValue: 5 } });
await click(".increment-btn"); // Cambiar valor
// Actuar
await click(".reset-btn");
// Afirmar
expect(".counter-value").toHaveText("5");
});
4. Mantener las Pruebas Independientes
// Malo - las pruebas dependen unas de otras
let sharedCounter;
test("configurar contador", async () => {
sharedCounter = await mountWithCleanup(Counter);
expect(sharedCounter).toBeTruthy();
});
test("incrementar contador", async () => {
await click(".increment-btn"); // Depende de que la prueba anterior siga montada
// ...
});
// Bueno - cada prueba es independiente
test("el contador puede ser incrementado", async () => {
await mountWithCleanup(Counter);
await click(".increment-btn");
expect(".counter-value").toHaveText("1");
});
5. Probar Casos Límite
// Una prueba por escenario mantiene los fallos fáciles de identificar
test("maneja valores extremos", async () => {
await mountWithCleanup(Counter, {
props: { initialValue: Number.MAX_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER },
});
expect(".counter-value").toHaveCount(1);
});
test("maneja rangos negativos", async () => {
await mountWithCleanup(Counter, {
props: { initialValue: -5, min: -10, max: 0 },
});
expect(".counter-value").toHaveText("-5");
});
test("maneja min igual a max", async () => {
await mountWithCleanup(Counter, {
props: { initialValue: 5, min: 5, max: 5 },
});
expect(".increment-btn").toHaveProperty("disabled", true);
});
Errores Comunes y Soluciones
Error Común 1: Olvidar await en Interacciones Asíncronas
// Malo - la aserción se ejecuta antes de que el DOM se haya vuelto a renderizar
test("inestable", async () => {
await mountWithCleanup(Counter);
click(".increment-btn"); // ¡no se esperó!
expect(".counter-value").toHaveText("1");
});
// Correcto - esperar el clic; click() de hoot solo resuelve después de que OWL re-renderiza
test("confiable", async () => {
await mountWithCleanup(Counter);
await click(".increment-btn");
expect(".counter-value").toHaveText("1");
});
Error Común 2: Componentes que Sobreviven entre Pruebas
El antiguo framework basado en QUnit exigía llamar tú mismo a counter.destroy() al final de cada prueba, y olvidarlo dejaba un componente montado con el que la siguiente prueba tropezaba. mountWithCleanup() elimina este error de raíz — siempre desmonta el componente automáticamente cuando termina la prueba. El error ahora es recurrir por costumbre al mount() plano de OWL en su lugar:
// Malo - el mount() plano de @odoo/owl nunca se limpia automáticamente
import { mount } from "@odoo/owl";
test("el incremento funciona", async () => {
await mount(Counter, document.body);
await click(".increment-btn");
// este componente sigue montado cuando empieza la siguiente prueba
});
// Correcto - mountWithCleanup() siempre desmonta al terminar la prueba, pase o falle
test("el incremento funciona", async () => {
await mountWithCleanup(Counter);
await click(".increment-btn");
});
Error Común 3: Recurrir a console.log en Vez del Inspector de Componentes
Poner console.log por todos lados funciona, pero es lento de iterar y fácil de olvidar quitar después. Para cualquier cosa más allá de una verificación puntual rápida, instala la extensión OWL DevTools (ver arriba) e inspecciona state/props directamente sobre el árbol de componentes en vivo — es más rápido y nunca termina accidentalmente en producción.
Error Común 4: Depurar Sin Leer el Stack Trace Completo
Es tentador saltar directo a un debugger o a un console.log cuando algo se rompe. Primero lee el stack trace completo en la consola — normalmente indica el componente, método y línea exactos, lo que te dice dónde poner un breakpoint en vez de adivinar.
Ejercicios
Ejercicio 1: Probar un Nuevo Comportamiento
Agrega una prop step a Counter (con valor por defecto 1) que controle cuánto cambian increment/decrement el conteo. Escribe una prueba que monte el componente con props: { step: 5 } y verifique que un solo clic en el botón de incremento mueve el valor de 0 a 5.
Ejercicio 2: Hacer Mock de un Fallo de Servicio
Usando TaskManager, escribe una prueba donde onRpc("project.task", "create", ...) lance un error (no solo search_read) y verifica que el componente muestra una notificación de "Error al agregar tarea" en vez de agregar la tarea a la lista.
Ejercicio 3: Depurar un Manejador Roto
Toma onKeydown y rómpelo intencionalmente comparando event.key === "enter" (minúscula) en vez de "Enter". Usa un breakpoint o logging en consola para encontrar el error antes de mirar la solución, y luego corrígelo.
Ahora tienes los fundamentos: por qué importan las pruebas, cómo configurar tu entorno, cómo escribir y ejecutar pruebas para componentes con y sin servicios, cómo depurar problemas con herramientas del navegador, y las buenas prácticas que mantienen una suite de pruebas confiable. En la Parte 2 construiremos sobre esta base con patrones de pruebas avanzados, Desarrollo Dirigido por Pruebas, pruebas de rendimiento e integración, integración continua, y las técnicas que los equipos profesionales usan para depurar problemas que solo aparecen en producción.
TL;DR: Escribe pruebas con @odoo/hoot usando mountWithCleanup, haz mock de los servicios de los que depende un componente, y usa las OWL DevTools del navegador en vez de esparcir console.log — esa combinación es lo que hace que cambiar código después sea seguro en vez de aterrador.
Pruébalo tú mismo: El ejemplo de este capítulo está disponible como addon instalable de Odoo 19: simplifyit_owl_book_ch16_1_ex1. Las instrucciones de instalación están en el README del repositorio.
¿Qué Sigue?
Ya puedes escribir y confiar en una suite de pruebas básica. El Capítulo 16 Parte 2 va más allá — patrones de pruebas avanzados, Desarrollo Dirigido por Pruebas, y las técnicas de depuración que necesitas cuando un bug solo aparece en producción.