Odoo tests are frequently skipped until late in a project, then written in a hurry to satisfy a CI gate, and rarely maintained as the module evolves. The result is a test suite that gives false confidence - it passes because it tests the happy path with hardcoded IDs, and it breaks the moment the demo data changes or a dependency module is updated.
This guide covers how to write tests that are actually useful: isolated, deterministic, focused on business logic, and maintainable across Odoo minor versions.
Test base classes#
All Odoo tests inherit from one of three base classes in odoo.tests.common:
TransactionCase#
Each test method runs in a transaction that is rolled back after the test. This means:
- Tests are isolated - records created in one test don't affect another.
- No database cleanup is needed.
- The rollback is at the database level, so triggers and constraints are evaluated.
from odoo.tests.common import TransactionCase
class TestSaleOrder(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({
'name': 'Test Customer',
'customer_rank': 1,
})
def test_confirm_creates_delivery(self):
order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [(0, 0, {
'product_id': self.env.ref('product.product_product_1').id,
'product_uom_qty': 2,
'price_unit': 100,
})],
})
order.action_confirm()
self.assertEqual(order.state, 'sale')
self.assertEqual(len(order.picking_ids), 1)SavepointCase (Odoo 14 and earlier)#
Similar to TransactionCase but uses savepoints - the setUpClass method can create shared records that are visible to all test methods in the class, rolled back at the class level. This was the standard pattern for sharing expensive setup (e.g., a full sale order with lines) across many test methods.
In Odoo 16+, TransactionCase itself supports setUpClass with savepoints, making SavepointCase redundant. Check your target version.
SingleTransactionCase (rare)#
All test methods in the class share a single transaction. Useful for sequential workflow tests (create → confirm → invoice) where each step builds on the previous. Use sparingly - test order matters and failures leave the database in a broken state for subsequent tests.
Test discovery and tagging#
Odoo discovers tests in any tests/ directory inside a module, in files named test_.py. All Test classes in those files are picked up automatically.
Tags#
Tags control which tests run in a given test suite:
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestMyModule(TransactionCase):
pass| Tag | Meaning |
|---|---|
at_install | Runs immediately after the module installs (before other modules) |
post_install | Runs after all modules are installed |
standard | Default tag; included in normal test runs |
slow | Excluded from normal runs; needs explicit --test-tags slow |
The - prefix on a tag means "remove this tag." @tagged('post_install', '-at_install') is the most common pattern: run after all modules are ready, not during install.
For CI, you typically run:
python odoo-bin --test-enable --test-tags your_module -d test_dbOr to run only a specific class:
python odoo-bin --test-enable --test-tags your_module.TestSaleOrder -d test_dbTesting ORM methods#
The core of most Odoo tests is verifying that ORM operations produce the expected records and field values.
Use env.ref for external IDs, not integer IDs#
Never hardcode integer record IDs in tests. IDs vary between databases. Use self.env.ref('module.xml_id') to get records by external ID:
# Bad
product = self.env['product.product'].browse(1)
# Good
product = self.env.ref('product.product_product_1')
# Also good - create the record in setUp
product = self.env['product.template'].create({
'name': 'Test Widget',
'type': 'product',
'list_price': 50.0,
})Testing constraints#
Constraints should be tested explicitly. Use assertRaises:
from odoo.exceptions import ValidationError, UserError
def test_negative_quantity_raises(self):
with self.assertRaises(ValidationError):
self.env['my.model'].create({'quantity': -1})
def test_unconfirmed_order_cannot_invoice(self):
order = self.env['sale.order'].create({...})
# order is in draft state
with self.assertRaises(UserError):
order.action_create_invoice()Testing computed fields#
Computed fields are evaluated lazily (when the field is accessed). Just accessing the field in your test triggers the computation:
def test_subtotal_calculation(self):
line = self.env['sale.order.line'].create({
'order_id': self.order.id,
'product_id': self.product.id,
'product_uom_qty': 3,
'price_unit': 25.0,
})
self.assertAlmostEqual(line.price_subtotal, 75.0, places=2)For stored computed fields, you can also verify the database value with self.env.cr.execute.
Testing onchanges#
Onchanges run in the browser when a field changes, and must be explicitly triggered in tests:
def test_partner_onchange_sets_pricelist(self):
order = self.env['sale.order'].new({
'partner_id': self.partner.id,
})
order._onchange_partner_id()
self.assertEqual(order.pricelist_id, self.partner.property_product_pricelist)Use self.env['model'].new(values) (not create) to create a virtual record that doesn't persist to the database. This is the correct context for testing onchanges.
Testing wizards#
Wizards (TransientModel) are tested by creating them with the correct context and calling their action methods:
def test_invoice_wizard_creates_invoice(self):
order = self.env['sale.order'].create({...})
order.action_confirm()
wizard = self.env['sale.advance.payment.inv'].with_context(
active_ids=[order.id],
active_model='sale.order',
).create({'advance_payment_method': 'delivered'})
wizard.create_invoices()
self.assertEqual(len(order.invoice_ids), 1)
self.assertEqual(order.invoice_ids[0].state, 'draft')Testing HTTP controllers#
Testing controllers requires a different base class: odoo.tests.common.HttpCase. It starts a real HTTP server and provides methods for making requests:
from odoo.tests.common import HttpCase, tagged
@tagged('post_install', '-at_install')
class TestMyController(HttpCase):
def test_public_route_returns_json(self):
response = self.url_open('/my/api/endpoint')
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn('result', data)
def test_authenticated_route(self):
self.authenticate('admin', 'admin')
response = self.url_open('/my/secure/endpoint')
self.assertEqual(response.status_code, 200)HttpCase tests are slower than TransactionCase tests because they spin up the HTTP server. Tag them with slow if you want to exclude them from fast CI runs.
CI integration#
For a typical CI pipeline, split your test runs:
- Fast suite (
--test-tags standard,-slow) - allTransactionCasetests. Runs in under 2 minutes for a well-maintained module. - Slow suite (
--test-tags slow) -HttpCaseand integration tests. Runs on merge to main only.
Example GitHub Actions step:
- name: Run fast tests
run: |
python odoo-bin --test-enable --test-tags my_module,-slow --stop-after-init -d test_db -i my_moduleCommon mistakes#
Testing with admin user. self.env uses the admin user by default. Admin bypasses record rules. Tests that pass with admin may fail for regular users. Test with a realistic user:
def test_record_rule_restricts_access(self):
user = self.env['res.users'].create({...})
with self.assertRaises(AccessError):
self.env['my.model'].with_user(user).search([])Relying on demo data. Demo data is not guaranteed to be present in CI. Create all required records in setUp or setUpClass. The only safe env.ref calls are for records from core modules that are always installed (base, account, product) - not from optional modules or demo data.
Testing implementation details instead of behavior. Testing that a method calls another method (mock-based testing) couples the test to the implementation. Test the observable output: the state of records after an operation, the journal entries created, the email sent. If the implementation changes but the behavior is correct, the test should still pass.
For the broader Odoo development context, see the model inheritance guide and the ORM performance guide. For security testing, see the Odoo security model guide.

