本文目前仅提供英文版本。翻译即将推出。
By the end of this recipe, you will write Odoo 19 unit tests that exercise your custom modules end-to-end with database isolation, useful assertions, and CI-ready parallelism. Skill required: Python developer comfortable with unittest framework. Time required: 60 minutes for the first test, 5 to 15 minutes per additional test once the pattern clicks. ECOSIRE has shipped over 1,800 Odoo tests for clients (and we maintain 1,898 tests on ecosire.com itself), and the recipe below is the playbook.
The reason Odoo tests are different from generic Python tests: they need a database, a populated user, and a properly configured registry — all of which TransactionCase provides automatically. Skip this and you end up running tests that pass in isolation but fail in CI because of registry state.
What you will need
- Odoo version: 17, 18, or 19. The test framework is identical.
- Custom module under test.
- Time: 60 minutes setup, 5-15 min per test thereafter.
Step-by-step
1. Add the test file structure
custom_module/
__manifest__.py
models/
tests/
__init__.py
test_partner_segment.py
tests/__init__.py:
from . import test_partner_segment
In __manifest__.py, no change needed — Odoo auto-discovers tests/.
Verification: odoo-bin --test-enable -i custom_module --stop-after-init runs without test errors.
2. Write the first test using TransactionCase
tests/test_partner_segment.py:
from odoo.tests import TransactionCase, tagged
@tagged('-at_install', 'post_install', 'custom_module')
class TestPartnerSegment(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Set up test data once for the whole class
cls.partner = cls.env['res.partner'].create({
'name': 'Test Co',
'email': '[email protected]',
})
def test_default_segment_is_smb(self):
"""A new partner should default to SMB segment."""
partner = self.env['res.partner'].create({'name': 'Default Test'})
self.assertEqual(partner.customer_segment, 'smb')
def test_strategic_requires_revenue(self):
"""Marking a partner Strategic with low revenue should raise."""
from odoo.exceptions import ValidationError
with self.assertRaises(ValidationError):
self.partner.write({'customer_segment': 'strategic'})
def test_revenue_recompute_on_order(self):
"""Confirming a sale order should update partner.total_revenue."""
product = self.env.ref('product.product_product_1')
order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [(0, 0, {'product_id': product.id, 'product_uom_qty': 1, 'price_unit': 1500})],
})
order.action_confirm()
# Trigger recomputation explicitly if needed
self.partner.invalidate_recordset(['total_revenue'])
self.assertEqual(self.partner.total_revenue, 1500.0)
The @tagged decorator marks tests with metadata so you can run subsets via --test-tags=custom_module.
Verification: odoo-bin --test-enable -i custom_module --test-tags=custom_module --stop-after-init runs and shows 3 passed.
3. Use setUpClass vs setUp
setUpClassruns once per test class. Use for expensive shared fixtures.setUpruns before every test. Use for per-test mutable state.
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.shared_partner = cls.env['res.partner'].create({'name': 'Shared'})
def setUp(self):
super().setUp()
self.fresh_order = self.env['sale.order'].create({'partner_id': self.shared_partner.id})
Verification: each test gets a fresh fresh_order but reuses the shared_partner.
4. Assert exactly what matters
self.assertEqual(actual, expected)
self.assertNotEqual(actual, expected)
self.assertTrue(condition)
self.assertFalse(condition)
self.assertIn(item, container)
self.assertAlmostEqual(actual, expected, places=2) # for floats
self.assertRaises(SomeException, callable, *args)
self.assertRecordValues(record, [{'field': value, ...}]) # Odoo-specific helper
assertRecordValues is the most Odoo-idiomatic assertion:
self.assertRecordValues(self.partner, [
{'name': 'Test Co', 'customer_segment': 'smb', 'total_revenue': 0.0},
])
Verification: every assertion has a clear failure message.
5. Mock external dependencies
Odoo tests run in a transaction that rolls back, so DB writes are safe. But external HTTP calls aren't. Mock them:
from unittest.mock import patch
def test_email_send_on_create(self):
with patch('odoo.addons.mail.models.mail_mail.MailMail._send') as mock_send:
self.env['res.partner'].create({'name': 'Email Test', 'email': '[email protected]'})
# Assert the mock was called once
self.assertEqual(mock_send.call_count, 1)
For Odoo's IAP (in-app-purchase) services, mock at the iap.account level. For HTTP calls, mock requests.post/requests.get.
Verification: the test does not actually send an email or make an HTTP call.
6. Use HttpCase for end-to-end web tests
For tests involving the HTTP layer (controllers, browser-side interactions), use HttpCase:
from odoo.tests import HttpCase
@tagged('-at_install', 'post_install')
class TestPartnerController(HttpCase):
def test_partner_search_endpoint(self):
self.authenticate('admin', 'admin')
response = self.url_open('/api/partner/search?name=Test')
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn('results', data)
Verification: the HTTP request returns the expected status and payload.
7. Tag and run subsets
@tagged('integration')
class TestIntegrationFlow(TransactionCase):
...
@tagged('unit')
class TestSegmentLogic(TransactionCase):
...
Run only unit tests: odoo-bin --test-enable --test-tags=unit -i custom_module --stop-after-init. Run integration tests separately in CI to keep the dev loop fast.
Verification: tagged runs filter as expected.
8. Wire into CI
Add to your GitHub Actions workflow:
- name: Run Odoo tests
run: |
docker exec odoo /opt/odoo/odoo-bin --test-enable --stop-after-init \
-i custom_module \
--test-tags=unit,custom_module \
-d test_db \
--log-level=test
CI fails on any test failure. Verification: a deliberately broken test fails CI; a passing test passes CI.
Common mistakes
- Not using
TransactionCase. Plainunittest.TestCasedoesn't roll back DB writes, polluting other tests. - Forgetting
@tagged('post_install'). By default tests run mid-install, before all dependent modules are loaded;post_installwaits. - Tests that depend on external state. Other tests, network conditions, real time. Mock everything.
- Asserting on side effects (logs, emails) instead of state. Test the database after, not the in-flight events.
- Slow
setUpClass. Class-level setup runs once but per-test isolation is via transaction rollback. Don't create heavy state per test if you don't need it.
Going further
Test coverage: install coverage.py and run coverage run odoo-bin .... Aim for 80%+ on business-critical modules. Configure .coveragerc to exclude __init__.py, migration scripts, and test files themselves from the coverage calculation.
Property-based testing: use hypothesis to generate test cases automatically. Useful for finding edge cases. Decorate a test method with @given(text(), integers(1, 1000)) and Hypothesis runs it with hundreds of random inputs.
Performance tests: write tests that assert response time bounds (e.g., a query must return in under 200ms). Use time.perf_counter(). Run on a CI machine with consistent specs to avoid noise.
Snapshot tests: assert that a generated PDF/Excel matches a stored fixture. Useful for reports. Compare hash of generated output against a committed reference file. When the report intentionally changes, regenerate and re-commit the fixture.
Test data factories: build helper functions like create_sale_order(amount=100, customer=None) that create valid test data with sensible defaults. Saves dozens of lines of setup per test.
Parallel test execution: with proper test isolation, you can run tests in parallel via pytest -n 4 (with pytest-xdist). Cuts test time 4x. Requires careful avoidance of shared state.
Mutation testing: tools like mutmut mutate your code and run tests. If tests still pass, your tests aren't sensitive enough. Reveals weak assertions.
Smoke tests for production: write a small set of tests that hit the production-equivalent endpoints (read-only). Run after every deploy to catch regressions before users do.
Database fixtures vs ORM creation: for very large fixtures, consider committing a JSON fixture file and loading via _load_xml_data / load_data. Faster than creating records via ORM in setUp.
Assertion helpers: build domain-specific helpers like self.assert_invoice_balanced(invoice) that encapsulate complex multi-field checks. Tests become more readable and intent-focused.
Continuous integration matrix: run tests against multiple Postgres versions, Python versions, and Odoo versions to catch compatibility issues early.
For comprehensive test suite implementation including coverage targets and CI integration, ECOSIRE Odoo support ships full test coverage as part of every customization. Pair this with how to set up the Odoo development environment.
Frequently Asked Questions
Should I write unit tests or integration tests?
Both. Unit for business-rule logic (fast, isolated). Integration for cross-module flows (sales > inventory > accounting). Unit tests should be 80%+ of your suite by count; integration tests catch the cross-module bugs unit tests miss.
How do I test Many2one and Many2many?
Same as scalar fields. self.assertEqual(partner.country_id, self.env.ref('base.us')) for Many2one. For Many2many, self.assertEqual(partner.category_id.ids, [tag1.id, tag2.id]). Sort the IDs for deterministic comparison: sorted(partner.category_id.ids) == sorted([tag1.id, tag2.id]).
Why does my test pass locally but fail in CI?
Almost always state contamination. Check setUp is creating fresh records, not relying on records from previous tests. Also: timezone differences (CI may be UTC, local may be US/Pacific), locale (CI defaults to en_US), and database state (tests assume a clean DB).
How do I debug a failing test?
odoo-bin shell -d test_db then run the test methods step-by-step. Or sprinkle self.env.cr.commit() before failure points to inspect DB state externally. Or use breakpoint() and run the test under a debugger like pytest --pdb.
How do I avoid creating millions of records in setUp?
Use setUpClass (runs once per class). For per-test isolation, transactions roll back automatically. If a test needs many records, factor out the bulk-create into a helper and reuse.
Should I commit fixture data or create it programmatically?
For complex domain objects (chart of accounts, products with variants), commit XML fixtures and load via env.ref references. For simple test data, programmatic create is more readable.
How do I test methods that send emails?
Mock mail.mail._send so emails don't actually send. Or use MockSmtp from odoo.tests which captures sent emails for assertion: self.assertEqual(len(MockSmtp.sent_emails), 1).
How do I test webhook receivers (controllers)?
Use HttpCase with self.url_open(url, data=...) for synthetic HTTP requests. Verify status code and response body.
What about flaky tests?
Tests that fail intermittently are usually time-based (clock drift) or order-dependent (test A breaks test B). Tag them @tagged('flaky') and exclude from main runs while you fix the root cause.
How fast should tests run?
Unit tests: under 5 seconds per file. Integration: under 30 seconds. If a test takes longer, factor out the slow setup or split into smaller tests.
For audit-grade test suites with coverage thresholds and CI gates, ECOSIRE Odoo customization ships test-driven development as standard. Or read how to debug Odoo RPC calls for runtime debugging.
作者
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
相关文章
如何将自定义按钮添加到 Odoo 表单视图 (2026)
将自定义操作按钮添加到 Odoo 19 表单视图:Python 操作方法、视图继承、条件可见性、确认对话框。经过生产测试。
如何在没有 Studio 的情况下在 Odoo 中添加自定义字段 (2026)
通过 Odoo 19 中的自定义模块添加自定义字段:模型继承、视图扩展、计算字段、存储/非存储决策。代码优先,版本控制。
如何使用外部布局在 Odoo 中添加自定义报告
使用 web.external_layout 在 Odoo 19 中构建品牌 PDF 报告:QWeb 模板、paperformat、操作绑定。带有印刷徽标+页脚覆盖。