Se rendre au contenu

Écrire des tests automatisés en Odoo 19 — TransactionCase, HttpCase, @tagged, Form

Saison « Dépassement tech v19 » · Article 1/5
26 avril 2026 par
Écrire des tests automatisés en Odoo 19 — TransactionCase, HttpCase, @tagged, Form
B.Mustapha

Saison « Dépassement tech v19 » · Article 1/5

Écrire des tests automatisés en Odoo 19

Tester un module à tous les niveaux — TransactionCase pour l'ORM et mail.thread, Form pour les wizards TransientModel, HttpCase pour les controllers HTTP et JSON. Cas pratique : 19 tests sur odooskills_helpdesk.

~13 minutes de lecture · niveau intermédiaire · nouveau parcours Qualité & Tests

Sortie console Odoo 19 — 19 tests exécutés, aucun échec, 6.34 secondes
Objectif de l'article — passer de zéro test à cette sortie. 19 tests couvrant l'ORM, un wizard, des controllers HTTP et JSON. 6,34 secondes. Reproductible avec --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_helpdesk hérité de mail.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.

Pyramide des tests Odoo 19 — TransactionCase à la base, HttpCase au milieu, Tours au sommet
La pyramide à garder en tête : 40-200+ TransactionCase par module, 10-30 HttpCase, et seulement 3-8 tours navigateur pour les parcours UI qui méritent vraiment d'être validés en bout de chaîne.

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 de TransactionCase et 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_user sans password= — dans setUpClass on se contente d'un user interne pour les tests ORM. Les tests HttpCase qui ont besoin de s'authentifier créent un autre user dans leur propre setUp() avec un password connu (on le verra en section 5).
  • with_context(skip_mail=True) — le module odooskills_helpdesk teste 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 dans setUpClass. 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 à passer skip_mail=True systé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 utilise mail.template ou 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 Form est la forme v19. Le vieux from odoo.tests.common import Form déclenche un DeprecationWarning à chaque exécution.
  • active_id=ticket.id, pas active_ids=ticket.ids. Le wizard close opère sur un ticket à la fois et lit active_id dans son default_get(). Utiliser le pluriel donnerait ticket_id vide 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'API Form ré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 :

  1. Ne pas hériter de HelpdeskCommon pour les tests HTTP. HttpCase active un mode registry spécifique qui entre en conflit avec une double base. Hérite directement de HttpCase, crée tes fixtures dans setUp.
  2. 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.
  3. En-tête X-Odoo-Database sur 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.
  4. CSRF OFF par défaut en type='json', ON en type='http'. Pour poster en HTTP POST depuis un test, soit tu ajoutes csrf=False sur la route (et tu sécurises autrement), soit tu récupères le token csrf_token via self.session.

6. Les 5 décorateurs v19 qui changent la donne

DécorateurCe qu'il faitCas 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 :

19 tests odooskills_helpdesk exécutés — tous OK en 6.34 secondes
19 tests exécutés en 6,34 s — 8 pour 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

  • SavepointCase n'existe plus. En v19, tenter from odoo.tests import SavepointCase lève un ImportError. La classe a fusionné avec TransactionCase qui gère désormais un savepoint par test automatiquement — la distinction n'avait plus lieu d'être. Hérite de TransactionCase partout.
  • Import de Form depuis odoo.tests. La vieille ligne from odoo.tests.common import Form dé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_logger s'importe de odoo.tools, pas de odoo.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 :

Exemple d'échec — AssertionError parce que state n'est pas tracké
Lecture rapide d'un échec : le nom du test, le fichier et la ligne, l'assertion qui a failli, et le message d'assertion que tu as rédigé. D'où l'importance d'écrire des messages clairs : 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 :

  1. Lis le traceback du bas vers le haut. La dernière ligne indique quoi. Les lignes précédentes indiquent .
  2. Exécute un seul test. --test-tags=/odooskills_helpdesk:TestTicket.test_write_resolve_tracked_field isole le test concerné.
  3. 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.
  • HttpCase pour les controllers — url_open() pour HTTP, JSON-RPC enveloppe dans params / result.
  • Form pour 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.
  • SavepointCase est 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
Sécuriser Odoo 19 en production : Nginx, SSL Let's Encrypt et VPS Debian
Bloc 6 · Infrastructure — Article 1/2 Sécuriser Odoo 19 en production : Nginx, SSL Let's Encrypt, VPS Debian Passer d'un Odoo de dev en localhost à un Odoo…