--test-enable local.Ce que tu vas apprendre
TransactionCase
Base de la pyramide — ORM, mail.thread, contraintes, rollback auto entre tests.
Form
Tester un wizard TransientModel comme si tu cliquais dans l'UI.
HttpCase
Frapper les controllers HTTP et JSON avec url_open() et authenticate().
5 décorateurs v19
@tagged, @users, @warmup, mute_logger, no_retry.
Prérequis
- Odoo 19 installé (T01 Ubuntu ou équivalent) — base PostgreSQL dédiée aux tests, jamais la prod.
- Un module custom à tester — ici
odooskills_helpdeskhérité demail.thread, avec un wizard et des controllers HTTP + JSON. - Familiarité avec les modèles Odoo, les méthodes surchargées, les wizards et les controllers.
- Aucune connaissance préalable en testing Python requise — on part du vide.
1. La pyramide des tests en Odoo 19
Avant d'écrire une ligne, il faut savoir où tu places chaque test. Odoo 19 te donne trois niveaux, et la règle est la même que partout : plus un test est bas, plus il est rapide, plus tu en écris.
Concrètement, tu choisis la classe de base selon ce que tu testes :
TransactionCase— dès que tu touches à l'ORM, à mail.thread, à une contrainte, à un wizard : c'est le défaut. Chaque test tourne dans son propre savepoint, rollback automatique à la fin.HttpCase— dès que tu testes une route, un endpoint JSON, une session authentifiée. Hérite deTransactionCaseet ajoute un serveur HTTP interne plus un browser headless.SingleTransactionCase— rare, mais utile pour des tests read-only qui partagent une fixture lourde sans rollback entre eux.
Les tours (scénarios Chrome headless via start_tour()) restent
au sommet : puissants mais lents et fragiles. On ne les couvre pas dans cet article —
la valeur rapide est plus bas.
2. Le setup commun — tests/common.py
Tes fixtures (users, partners, catégories) se créent une fois dans
setUpClass et sont réutilisées par toutes les classes qui étendent
cette base. C'est le gain principal : zéro duplication entre tes
fichiers de test.
# odooskills_helpdesk/tests/common.py
from odoo.tests import TransactionCase, new_test_user
class HelpdeskCommon(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Utilisateur interne dédié aux tests (pas de password —
# les tests qui ont besoin de s'authentifier en HttpCase créent
# leur propre user dans setUp() avec un password connu)
cls.user_helpdesk = new_test_user(
cls.env,
login='user_helpdesk_test',
groups='base.group_user',
)
# Partenaire client standard
cls.partner_customer = cls.env['res.partner'].create({
'name': 'Client Test',
'email': 'client.test@example.com',
})
# Catégorie racine (category_child créée dans setUp() des tests
# qui l'utilisent, pour éviter les conflits de contrainte UNIQUE
# entre passes de test)
cls.category_root = cls.env['helpdesk.ticket.category'].create({
'name': 'Technique',
})
# Tag pour filtrage Many2many
cls.tag_urgent = cls.env['helpdesk.ticket.tag'].create({
'name': 'Urgent',
'color': 1,
})
# Ticket réutilisable en lecture seule dans les tests
cls.ticket_template = cls.env['helpdesk.ticket'].with_context(
skip_mail=True
).create({
'name': 'Ticket de test commun',
'partner_id': cls.partner_customer.id,
'category_id': cls.category_root.id,
})
Trois détails qui font la différence :
new_test_usersanspassword=— danssetUpClasson se contente d'un user interne pour les tests ORM. Les testsHttpCasequi ont besoin de s'authentifier créent un autre user dans leur propresetUp()avec un password connu (on le verra en section 5).with_context(skip_mail=True)— le moduleodooskills_helpdeskteste cette clé de contexte dans ses overrides create/write pour neutraliser l'envoi réel d'emails. À adapter si ton module utilise une autre convention.- Fixtures enfants en local — une sous-catégorie
(
category_child) est créée dans le test qui l'utilise, pas danssetUpClass. Sinon tu tombes sur des conflits de contrainte UNIQUE quand la suite est relancée.
3. Tester l'ORM — TransactionCase
Premier pattern : un test qui vérifie la séquence auto-attribuée à la création
d'un ticket (pattern HLP/%(range_year)s/%(range_number)04d).
# odooskills_helpdesk/tests/test_ticket.py
from odoo.exceptions import ValidationError
from odoo.tests import tagged
from .common import HelpdeskCommon
@tagged('post_install', '-at_install')
class TestTicket(HelpdeskCommon):
def _make_ticket(self, **kwargs):
"""Fabrique un ticket de test en désactivant les mails."""
defaults = {
'name': 'Test Ticket',
'partner_id': self.partner_customer.id,
}
defaults.update(kwargs)
return self.env['helpdesk.ticket'].with_context(skip_mail=True).create(defaults)
def test_create_ticket_assigns_sequence(self):
"""create() doit remplir `reference` avec le format HLP/..."""
ticket = self._make_ticket(name='Ticket séquence')
self.assertTrue(ticket.reference, "La référence ne doit pas être vide.")
self.assertIn('HLP/', ticket.reference,
"La référence doit commencer par HLP/.")
def test_unlink_ticket_done_state_raises(self):
"""unlink() sur un ticket résolu doit lever ValidationError."""
ticket = self._make_ticket(name='Ticket résolu non supprimable')
ticket.action_resolve()
self.env.flush_all()
self.env.invalidate_all()
with self.assertRaises(ValidationError):
ticket.unlink()
Trois points-clés de rigueur v19 illustrés ici :
- Le helper
_make_ticket()— tu vas créer des tickets dans presque tous tes tests. Le centraliser évite la répétition et t'oblige à passerskip_mail=Truesystématiquement. @tagged('post_install', '-at_install')— on exclut at_install (défaut) et on ajoute post_install. Les tests tourneront uniquement une fois toutes les dépendances chargées. Sans ça, un test qui utilisemail.templateou une séquence peut échouer faute de data démo disponible.self.env.flush_all()+self.env.invalidate_all()— à appeler après une mutation (action_resolve) si l'assertion suivante lit un champ tracké, calculé ou en cascade. En v19 c'est explicite : l'ORM ne flush plus implicitement avant une lecture.
Le module odooskills_helpdesk lève ValidationError (pas
UserError) dans sa contrainte unlink. Toujours vérifier
l'exception exacte dans le modèle métier — si tu captures la mauvaise, ton
assertRaises passera silencieusement à travers.
4. Tester un wizard — l'API Form
Les wizards (TransientModel) se testent comme si tu remplissais
le formulaire à la main. L'API Form exécute les
onchange, respecte les required, déclenche les computes —
exactement comme le client web.
# odooskills_helpdesk/tests/test_ticket_wizard.py
from odoo.tests import Form, tagged # Depuis odoo.tests, PAS odoo.tests.common
from .common import HelpdeskCommon
@tagged('post_install', '-at_install')
class TestCloseWizard(HelpdeskCommon):
def _make_ticket(self, name='Ticket Wizard Test', **kwargs):
defaults = {'name': name, 'partner_id': self.partner_customer.id}
defaults.update(kwargs)
return self.env['helpdesk.ticket'].with_context(skip_mail=True).create(defaults)
def test_wizard_save_and_apply(self):
"""Saisir les champs dans le Form puis action_close()
→ ticket en 'done' + message dans le chatter."""
ticket = self._make_ticket()
msg_count_before = len(ticket.message_ids)
wizard_form = Form(
self.env['helpdesk.ticket.close.wizard'].with_context(
active_id=ticket.id, # Singulier — pas active_ids
active_model='helpdesk.ticket',
)
)
wizard_form.resolution_reason = 'resolved'
wizard_form.resolution_note = 'Problème résolu lors du test.'
wizard_form.hours_spent = 2.5
wizard_form.notify_partner = True
wizard = wizard_form.save()
wizard.action_close()
self.env.flush_all()
self.env.invalidate_all()
self.assertEqual(ticket.state, 'done')
self.assertGreater(len(ticket.message_ids), msg_count_before,
"Un message doit être posté après clôture.")
self.assertIn('Problème résolu lors du test.', ticket.resolution_note or '')
Quatre détails qui comptent :
- L'import
from odoo.tests import Formest la forme v19. Le vieuxfrom odoo.tests.common import Formdéclenche un DeprecationWarning à chaque exécution. active_id=ticket.id, pasactive_ids=ticket.ids. Le wizard close opère sur un ticket à la fois et lit active_id dans sondefault_get(). Utiliser le pluriel donneraitticket_idvide et les défauts ne se chargeraient pas.- Renseigne tous les champs que le vrai wizard attend
(
resolution_reason,resolution_note,hours_spent,notify_partner). L'APIFormrésout les onchange à chaque affectation — tu reproduis fidèlement l'UI. .save()committe dans le TransientModel ; tu appelles ensuite la méthode du wizard (action_close,action_apply) pour exécuter la logique métier.
5. Tester les controllers — HttpCase
Pour les routes type='http' (pages) et type='json'
(endpoints API), on monte d'un cran : HttpCase démarre un vrai serveur
HTTP interne et te donne url_open() pour le frapper.
# odooskills_helpdesk/tests/test_controllers.py
import json
from odoo.tests import HttpCase, tagged, new_test_user
@tagged('post_install', '-at_install')
class TestControllers(HttpCase):
def setUp(self):
super().setUp()
# User interne avec password CONNU pour authenticate()
self.test_user = new_test_user(
self.env,
login='ctrl_test_user',
password='ctrl_test_pass_123',
groups='base.group_user,base.group_system',
)
self.partner = self.env['res.partner'].create({
'name': 'Client Controller Test',
'email': 'ctrl.test@example.com',
})
self.ticket = self.env['helpdesk.ticket'].with_context(skip_mail=True).create({
'name': 'Ticket Controller Test',
'partner_id': self.partner.id,
})
self.env.flush_all()
self.ticket_ref = self.ticket.reference
def _db_header(self):
"""En-tête X-Odoo-Database nécessaire aux requêtes sans session."""
return {'X-Odoo-Database': self.env.cr.dbname}
def test_ticket_page_public_status_200(self):
"""GET /helpdesk/status/<ref> avec référence valide → HTTP 200."""
response = self.url_open(
f'/helpdesk/status/{self.ticket_ref}',
headers=self._db_header(),
)
self.assertEqual(response.status_code, 200)
self.assertIn(self.ticket_ref.encode(), response.content)
def test_json_create_ticket_authenticated(self):
"""POST /api/v1/tickets avec action=create → retourne id et reference."""
self.authenticate('ctrl_test_user', 'ctrl_test_pass_123')
payload = {
'jsonrpc': '2.0',
'method': 'call',
'params': {
'action': 'create',
'name': 'Ticket via API JSON test',
'partner_id': self.partner.id,
'channel': 'email',
},
}
response = self.url_open(
'/api/v1/tickets',
data=json.dumps(payload),
headers={'Content-Type': 'application/json'},
)
self.assertEqual(response.status_code, 200)
result = response.json().get('result', {})
self.assertIn('id', result)
self.assertIn('reference', result)
Quatre pièges à connaître :
- Ne pas hériter de
HelpdeskCommonpour les tests HTTP.HttpCaseactive un mode registry spécifique qui entre en conflit avec une double base. Hérite directement deHttpCase, crée tes fixtures danssetUp. - Le JSON-RPC Odoo demande l'enveloppe complète. Tu envoies
{"jsonrpc": "2.0", "method": "call", "params": {...}}et tu reçois{"jsonrpc": "2.0", "result": {...}}(ou{"error": {...}}en cas d'échec). Omettre jsonrpc ou method fait répondre le dispatcher avec une erreur peu lisible. - En-tête
X-Odoo-Databasesur les routes publiques testées sans session authentifiée. Sans lui, Odoo ne sait pas sur quelle base dispatcher la requête dans un test multi-base. - CSRF OFF par défaut en
type='json', ON entype='http'. Pour poster en HTTP POST depuis un test, soit tu ajoutescsrf=Falsesur la route (et tu sécurises autrement), soit tu récupères le token csrf_token viaself.session.
6. Les 5 décorateurs v19 qui changent la donne
| Décorateur | Ce qu'il fait | Cas d'usage |
|---|---|---|
@tagged(*tags) |
Classe ou exclut un test de l'exécution. Syntaxe '-tag' pour retirer. |
Toujours @tagged('post_install', '-at_install') quand ton test dépend de data démo ou de séquences. |
@users(*logins) |
Exécute la méthode une fois par login, avec subTest et un env re-initialisé pour chaque passage. |
Valider la même règle métier côté user standard et côté manager. |
@warmup |
Exécute la fonction deux fois : la première sans assertion (cache ORM à chauffer), la seconde avec. | Indispensable avec assertQueryCount pour éviter les faux positifs dus au cache froid. |
mute_logger(name) |
Supprime les logs d'un module pendant le test. S'importe de odoo.tools. |
Masquer les tracebacks attendus d'un assertRaises(UserError) qui passe par odoo.http. |
@no_retry |
Désactive le retry automatique (ODOO_TEST_FAILURE_RETRIES). |
Test non déterministe que tu veux réparer, pas masquer. |
Exemple combiné — un test de perf serré, muté, tagué post-install :
from odoo.tools import mute_logger
from odoo.tests import tagged, warmup
@tagged('post_install', '-at_install', 'perf')
class TestTicketPerf(HelpdeskCommon):
@warmup
@mute_logger('odoo.sql_db')
def test_ticket_list_query_count(self):
# Pré-crée 100 tickets (dans le warmup, les requêtes ne comptent pas)
self.env['helpdesk.ticket'].create([
{'name': f'T{i}', 'partner_id': self.partner_customer.id}
for i in range(100)
])
self.env.flush_all()
self.env.invalidate_all()
# Budget requête serré
with self.assertQueryCount(8):
tickets = self.env['helpdesk.ticket'].search([])
tickets.mapped('partner_id.name')
7. On exécute la suite
La commande CLI classique :
venv/bin/python odoo/odoo-bin \
-c config/odoo.conf \
--addons-path=odoo/addons,addons/odooskills-blog/modules \
-d vs19_odooskills_test \
-u odooskills_helpdesk \
--test-enable \
--test-tags=/odooskills_helpdesk \
--stop-after-init \
--log-level=test
Trois drapeaux importants :
--test-enable— active l'exécution des tests Python du module.--test-tags=/odooskills_helpdesk— restreint aux tests de ce module uniquement (le préfixe/cible un module, pas un tag).--stop-after-init— arrête le serveur dès que les tests sont finis, idéal pour CI.
Voici ce que tu dois voir quand c'est vert :
TestTicket, 3 pour
TestCloseWizard, 6 pour TestControllers, plus 2 tests
hérités du module helpdesk de base.8. Pièges v19 à connaître
⚠️ 5 changements à retenir par rapport à Odoo 17 / 18
SavepointCasen'existe plus. En v19, tenterfrom odoo.tests import SavepointCaselève un ImportError. La classe a fusionné avecTransactionCasequi gère désormais un savepoint par test automatiquement — la distinction n'avait plus lieu d'être. Hérite deTransactionCasepartout.- Import de
Formdepuisodoo.tests. La vieille lignefrom odoo.tests.common import Formdéclenche un DeprecationWarning. Migre dès maintenant. flush_all()+invalidate_all()explicites. L'ORM ne synchronise plus implicitement avant une lecture. Après une mutation qui doit être vue par l'assertion, appelle les deux. C'est la cause numéro un des tests qui « passent en local et échouent en CI ».@tagged('post_install')ajoute,'-at_install'retire. Les deux notations coexistent dans la v19. Le minus n'est pas de la soustraction arithmétique : il retire un tag par défaut de la liste d'exécution.mute_loggers'importe deodoo.tools, pas deodoo.tests. Piège courant hérité de vieux tutos v15. En v19, c'est sans équivoque :from odoo.tools import mute_logger.
9. Et quand un test échoue ?
Le meilleur moyen de comprendre la lecture d'un échec, c'est d'en voir un volontairement.
Imagine que tu aies oublié tracking=True sur le champ state —
ton test qui assertait la présence d'un message de tracking tombe rouge :
self.assertTrue(tracking,
"state change should be tracked") te donne la piste immédiatement.Trois réflexes avant de toucher au test lui-même :
- Lis le traceback du bas vers le haut. La dernière ligne indique quoi. Les lignes précédentes indiquent où.
- Exécute un seul test.
--test-tags=/odooskills_helpdesk:TestTicket.test_write_resolve_tracked_fieldisole le test concerné. - Vérifie les flush/invalidate avant l'assertion. 90 % des flaky tests v19 viennent d'un cache non synchronisé.
À retenir
TransactionCaseà la base — 40 à 200 tests par module, rapides, stables.HttpCasepour les controllers —url_open()pour HTTP, JSON-RPC enveloppe dansparams/result.Formpour les wizards — même ergonomie que l'UI, plus testable.@tagged('post_install', '-at_install')sur les classes qui touchent des séquences, mail.thread, ou data démo.flush_all()+invalidate_all()avant chaque assertion qui suit une mutation — c'est le réflexe v19.SavepointCaseest mort.from odoo.tests import Form.from odoo.tools import mute_logger. Trois lignes à retenir.
Voir aussi — Parcours Framework / ORM
T15 — Méthodes modèle
Surcharger create, write, unlink proprement — c'est ce que tes tests vont couvrir.
T19 — Wizards TransientModel
Anatomie d'un wizard — base nécessaire pour le tester avec l'API Form.
T23 — Controllers HTTP & API REST
Les routes que cet article t'apprend à tester avec HttpCase.
Articles complémentaires
T22 — Actions serveur & cron
Tester ir.cron et base.automation avec CronMixinCase — une suite complémentaire pour automatiser tes tests de triggers.
T21 — Email templates & mail.thread
Les tracked fields testés ici y sont définis. Pour valider les envois d'email, MailCommon fournit les fixtures prêtes à l'emploi.
T12 — Contraintes & computes
Les @api.constrains et @api.depends que tu teste en section 3 — article de base à avoir lu avant de s'attaquer aux assertQueryCount.
Télécharge le Guide Technique Odoo
Un module Odoo 19 fonctionnel, 20+ articles techniques, environnements de dev et pipeline complet — le tout en PDF.
Télécharger le guide