Test custom Odoo modules from the command line
Install a custom Odoo module on a scratch database, run only its tests, avoid port clashes with a dev server, and write tests that keep passing after upgrades.
On this page
This document shows how to run the tests of a custom Odoo module with odoo-bin, how to keep the run from colliding with a dev server, and how to write tests that survive upgrades. Use it when you add or change a module and want to prove it installs and behaves before it reaches a real database.
The examples come from Avunu's public avunu-odoo-addons repository, which targets Odoo 18.0. The commands assume an Odoo checkout with an odoo.conf next to it, as in those modules' READMEs. If you use odoo-nix, odoo test <MODULE> is a shortcut for an update-and-test run; see Odoo Development Environment.
Run a module's tests
Use a scratch database, install the module into it, run the tests, and exit:
python odoo/odoo-bin -c odoo.conf -d <TEST_DB> -i <MODULE_NAME> \
--test-enable --test-tags /<MODULE_NAME> --stop-after-initEach option does one job:
-d <TEST_DB>names the database. Use a throwaway one, never a database with data you care about.-i <MODULE_NAME>installs the module (and its dependencies) on that database. Installation is what triggers the tests.--test-enableturns the test runner on.--test-tags /<MODULE_NAME>limits the run to tests that belong to that module. Without it you also run the standard tests of every module that gets installed along the way.--stop-after-initmakes Odoo exit when loading and testing finish, instead of staying up as a server.
Several modules in one run work the same way. Give -i a comma-separated list and give --test-tags a comma-separated list of /module entries:
python odoo/odoo-bin -c odoo.conf -d <TEST_DB> \
-i <MODULE_ONE>,<MODULE_TWO> \
--test-enable --test-tags /<MODULE_ONE>,/<MODULE_TWO> \
--stop-after-initNote
In Odoo 18, setting --test-tags also enables tests, so --test-enable is redundant when you pass tags. We keep both because it makes the intent obvious in a pasted command.
Narrow the run while you work
The tag spec has the form [-][tag][/module][:class][.method], and the options help in Odoo 18 shows the same grammar. Use it to re-run one class or one method while you iterate:
python odoo/odoo-bin -c odoo.conf -d <TEST_DB> -u <MODULE_NAME> \
--test-tags /<MODULE_NAME>:<TEST_CLASS>.<TEST_METHOD> --stop-after-initHere -u updates a module that is already installed on the database, which is faster than creating a new database each time. A leading - excludes matching tests instead of including them.
Run the whole standard suite of an updated module
You can also skip --test-tags and use only --test-enable with an update. The web_theme_carbon module documents this for its cascade tests:
odoo -u web_theme_carbon --test-enable --stop-after-initWith only --test-enable, Odoo runs every test tagged standard in the modules being installed or updated, with no filter by module. An update also covers the installed modules that depend on the one you named, so their tests run too.
Avoid port clashes with a dev server
A test run still starts Odoo's HTTP machinery, and tests built on HttpCase make real requests to it. If your dev server is already listening on the default ports, the test process cannot bind them.
Give the test run its own ports:
python odoo/odoo-bin -c odoo.conf -d <TEST_DB> -i <MODULE_NAME> \
--test-enable --test-tags /<MODULE_NAME> --stop-after-init \
--http-port 8098 --gevent-port 8097The defaults are 8069 for --http-port and 8072 for --gevent-port. The values 8098 and 8097 are the ones we use; any free pair works.
Turn off a dev mail catcher
Some dev environments run a server-wide mail catcher that intercepts every outgoing message. That breaks tests that assert on what was sent. In the odoo-nix dev shell we use, the mail_cloudflare module's README turns it off for the run with an environment variable:
ODOO_MAILCATCH_ENABLED=0 python odoo/odoo-bin -c odoo.conf -d <TEST_DB> \
-i mail_cloudflare --test-enable --test-tags /mail_cloudflare --stop-after-initThat variable belongs to that dev environment. If your setup uses a different catcher, turn it off its own way before testing mail code.
Install freezegun
Odoo's test framework imports freezegun, and Odoo 18 uses it for time-frozen tests. Our development setup treats it as required to run any Odoo test.
This catches people out with uv. Odoo lists freezegun only under tests_require, which uv does not install. We list it explicitly in pyproject.toml in the dev dependency group so it is always present:
[dependency-groups]
dev = ["debugpy", "ruff", "watchdog", "freezegun", "websocket-client"]If a run fails with ModuleNotFoundError: No module named 'freezegun', add the package to the environment. Do not remove it from the project later because nothing in your own tests imports it.
Write tests that survive upgrades
Most failed test runs after an upgrade come from three causes: a wrong test phase, a test class that never gets collected, and tests that depend on things you do not control. Handle each one when you write the test.
Tag every test class with a phase
Odoo gives test classes the standard and at_install tags by default. at_install tests run right after your module installs, while the registry is only partly loaded. post_install tests run after every module has loaded.
Our modules tag nearly every test class like this:
from odoo.tests import TransactionCase, tagged
@tagged("post_install", "-at_install")
class TestMyFeature(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env["res.partner"].create({"name": "Test Partner"})
def test_partner_is_created(self):
self.assertTrue(self.partner.id)The -at_install part removes the default tag, so the class runs in exactly one phase. Odoo logs a warning for a class that is neither or both.
Why this matters for upgrades: a module may load before modules that add fields or constraints to the models it touches. Two examples from our repository:
base_import_pdf_by_template_engineloads beforeaccount. On a database withaccountinstalled,res.partnergets a NOT NULL column whose ORM default is not attached yet mid-load. Anat_installtest that creates a partner throughBaseCommonfails with aNotNullViolationbefore it reaches the module's own code.hr_timesheet_time_control_systraycreates projects in its tests. A project touches fields owned by modules that load after it, so the registry has to be complete first.
When a test creates records on shared models such as partners or projects, use post_install.
Make sure plain tests are collected
If a test needs no database, it is tempting to subclass unittest.TestCase. Under --test-tags, that class is silently skipped, with no warning. Odoo's tag filter only runs tests that have a test_tags attribute, and only Odoo's own base classes assign it.
Use BaseCase for pure-Python tests instead. This is how import_via_xberg tests its helper functions:
from odoo.tests.common import BaseCase
class TestTransposeTableCells(BaseCase):
def test_header_row_and_data_rows_split_correctly(self):
...After you add a new test file, run with --test-tags and confirm the count of tests moved. A class that collects zero tests looks exactly like a passing run.
Also remember to import each new test file in tests/__init__.py, as every module in the repository does:
from . import test_cloudflare_api
from . import test_ir_mail_serverKeep tests off the network
Tests that call out to a real service break when the service changes, and they leak data. Mock the boundary instead.
The mail_cloudflare module ships a small harness in tests/common.py. Its MockCloudflareCase.mock_cloudflare() context manager patches the HTTP session so no request leaves the machine, records every request in self.cf_requests, and serves scripted responses:
with self.mock_cloudflare():
mail.send()For smaller cases, unittest.mock.patch on the single function that talks to the outside world is enough. Both approaches keep the suite fast and deterministic.
Warning
The mail_cloudflare mock patches current_test to False, because Odoo's mail sending short-circuits in test mode. For that reason you must not call url_open from an HttpCase inside that context manager. Keep HTTP calls outside the with block.
Reuse core test bases and check what they need
Build on Odoo's own bases, such as TransactionCase and MailCommon, rather than reimplementing setup. Odoo maintains them across releases. Our CloudflareCommon class extends MailCommon and adds one extra mail server.
When a base needs more than you expect, say so in the test. For example, HttpCase has to really log in, and a module that enforces password rules (in our case password_security) rejects the default password new_test_user uses. Our UI test passes an explicit strong password, a throwaway value that exists only in the test database (never reuse a real one):
user = new_test_user(
self.env,
login="systray_tech_ui",
password="<STRONG_TEST_PASSWORD>",
groups="base.group_user,hr_timesheet.group_hr_timesheet_user,project.group_project_user",
)Test what the server will not tell you
A broken import path, a QWeb template that does not parse, or a missing SCSS variable does not stop the server from starting. The page just comes out empty. That is why hr_timesheet_time_control_systray has a test_timer_systray_ui.py that runs the real navbar in a browser, and why web_theme_carbon has tests/test_cascade.py, which inspects the compiled asset bundles to confirm the module's CSS wins over Odoo's !important rules.
If your module ships JavaScript, templates or styles, add at least one test of that kind.
Compare against a frozen reference when refactoring
When you change behavior you inherit from upstream, keep the old implementation inside the test as a reference and assert the new one matches it. base_import_pdf_by_template_engine does this with a frozen copy of the upstream function in test_table_info_data.py. When the upstream code changes in a later release, the comparison tells you what moved.
Checklist before you push
The module installs on a fresh scratch database with
-i.--test-tags /<MODULE_NAME>runs the number of tests you expect, not zero.Every class is tagged for exactly one phase, and DB-free classes use
BaseCase.No test reaches the network.
freezegunis in the environment's dev dependencies.Ports are overridden if a dev server is running.
Related documents
Sources
This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.