This article is currently available in English only. Translation coming soon.
Odoo Tests: TransactionCase, HttpCase, Test Tags, and post_install
Odoo tests sit between unit and integration: they spin up a real database, install your module, and run scenarios against the live ORM. This is more expensive than pure unit tests but catches the bugs that matter (computed fields not firing, ORM constraints, view inheritance issues). The challenge is choosing the right test base class, applying the right tags, and structuring tests so they run fast enough for CI.
This article covers the test-class hierarchy, when to use each, and the patterns we apply to keep test suites fast and reliable. Examples target Odoo 17/18/19.
Key Takeaways
TransactionCaseis the default — runs in a transaction that rolls back per testHttpCaseadds an HTTP server for testing controllers and JS toursSavepointCase(deprecated in 19) merged intoTransactionCase- Tags via
@tagged('-standard', 'my_module')control which tests run whenpost_install=Truedefers tests until all modules install — needed for cross-module tests- Tour tests run real browsers; expensive but the only way to test JS interactions
- Mock external APIs via
unittest.mock.patch; never call real Stripe/SES/etc. in tests
The test-class hierarchy
BaseCase
└── TransactionCase # most common
└── SingleTransactionCase # share state across tests in class
└── HttpCase # adds HTTP server
TransactionCase
The workhorse. Each test method runs in its own transaction that rolls back at the end. Side effects don't leak between tests.
from odoo.tests.common import TransactionCase
class TestSaleOrder(TransactionCase):
def setUp(self):
super().setUp()
self.partner = self.env['res.partner'].create({
'name': 'Test Customer',
'email': '[email protected]',
})
self.product = self.env['product.product'].create({
'name': 'Test Product',
'list_price': 100.0,
})
def test_create_order(self):
order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [(0, 0, {
'product_id': self.product.id,
'product_uom_qty': 2,
'price_unit': 100.0,
})],
})
self.assertEqual(order.amount_total, 200.0)
def test_confirm_order(self):
order = self.env['sale.order'].create({...})
order.action_confirm()
self.assertEqual(order.state, 'sale')
SingleTransactionCase
Setup runs once, all tests share the same transaction. Faster for read-heavy tests but tests can interfere with each other:
from odoo.tests.common import SingleTransactionCase
class TestProductMath(SingleTransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.products = cls.env['product.product'].create([...]) # create once
def test_total(self):
...
Use only for tests that don't mutate shared data.
HttpCase
Adds an HTTP server. Required for testing controllers, JS tours, and anything that needs a real HTTP request:
from odoo.tests.common import HttpCase, tagged
@tagged('post_install', '-at_install')
class TestController(HttpCase):
def test_get_endpoint(self):
response = self.url_open('/my_module/api/data')
self.assertEqual(response.status_code, 200)
self.assertIn('expected_value', response.text)
def test_authenticated_endpoint(self):
self.authenticate('admin', 'admin')
response = self.url_open('/my_module/api/admin_only')
self.assertEqual(response.status_code, 200)
Test tags — controlling what runs
Tags filter test execution. Apply via @tagged() decorator:
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install', 'my_module', 'critical')
class TestCriticalFeature(TransactionCase):
...
Common tags:
standard— default tag; included unless excludedat_install— runs during module installationpost_install— runs after all modules installed (needed for cross-module deps)-at_install(negation) — does NOT run at install time- Custom tags (
my_module,slow,integration) — for selective runs
Run subset:
# Run only your module's post-install tests
./odoo-bin -i my_module --test-tags=my_module,post_install --stop-after-init
# Skip slow tests
./odoo-bin -i my_module --test-tags=-slow --stop-after-init
# Run only critical tests
./odoo-bin --test-tags=critical
post_install vs at_install timing
The difference matters when tests reference other modules:
| Scenario | Tag |
|---|---|
| Test only depends on your module + base | at_install (default) |
| Test depends on multiple modules being installed | post_install |
| Test depends on demo data | post_install |
| Test depends on cron job initialization | post_install |
| Test calls into auto-installed modules | post_install |
For modules that integrate with sales, accounting, etc., post_install is almost always the right choice. The default at_install runs your tests before, say, account is fully installed, which causes spurious failures.
@tagged('post_install', '-at_install')
class TestSaleAccountIntegration(TransactionCase):
"""Test runs after sale + account are fully installed."""
...
Tour tests — JS testing
Tour tests run real browser interactions via QUnit + Chrome. Expensive but essential for JS validation:
from odoo.tests.common import HttpCase, tagged
@tagged('post_install', '-at_install')
class TestUITour(HttpCase):
def test_my_tour(self):
self.start_tour(
"/odoo",
"my_module.my_tour",
login="admin",
)
// JS tour definition
import { registry } from "@web/core/registry";
registry.category("web_tour.tours").add("my_module.my_tour", {
test: true,
url: "/odoo",
steps: () => [
{ trigger: ".o_app[data-menu-xmlid='my_module.menu_root']", run: "click" },
{ trigger: "button.o_create", run: "click" },
{ trigger: "input[name='name']", run: "edit Test Record" },
{ trigger: "button.o_form_button_save", run: "click" },
{ trigger: ".o_form_status_indicator_buttons:not(:visible)" },
],
});
Tour tests are slow (15-60 seconds each); reserve them for critical UI flows.
Mocking external services
Never call real external APIs in tests. Use unittest.mock.patch:
from unittest.mock import patch, Mock
from odoo.tests.common import TransactionCase
class TestStripeWebhook(TransactionCase):
@patch('odoo.addons.my_module.models.payment.requests.post')
def test_webhook_processing(self, mock_post):
mock_post.return_value = Mock(
status_code=200,
json=lambda: {'status': 'success', 'id': 'evt_123'},
)
result = self.env['my.payment.processor'].create({...}).process()
self.assertEqual(result.status, 'success')
mock_post.assert_called_once()
Pattern works for any external API: Stripe, SES, S3, third-party HTTP services.
Test data — fixtures
For complex setups, use shared fixtures:
class TestBase(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({...})
cls.product = cls.env['product.product'].create({...})
cls.order = cls.env['sale.order'].create({...})
class TestOrderFlow(TestBase):
def test_confirm(self):
self.order.action_confirm()
self.assertEqual(self.order.state, 'sale')
class TestOrderCancel(TestBase):
def test_cancel(self):
self.order.action_cancel()
self.assertEqual(self.order.state, 'cancel')
Performance: keep tests fast
Common slowdowns and fixes:
| Pattern | Cost | Fix |
|---|---|---|
setUp instead of setUpClass for read-only data | High | Use class-level setup |
| Computed fields recomputing on every test | Medium | Pre-compute in fixture, or freeze |
| Tour tests for trivial JS validation | High | Use unit tests on the OWL component |
| Real HTTP calls (forgot to mock) | Very high | Mock everything external |
-i my_module for every run | Medium | Use -u to update without re-install when possible |
Aim for a full module test suite to run in < 5 minutes. ECOSIRE's largest Odoo customizations have ~200 tests running in 3-4 minutes.
CI integration
For GitHub Actions:
name: Odoo Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-22.04
services:
postgres:
image: postgres:17
env:
POSTGRES_USER: odoo
POSTGRES_PASSWORD: odoo
ports: ['5432:5432']
steps:
- uses: actions/checkout@v4
- name: Install Odoo
run: |
pip install -r requirements.txt
- name: Run Tests
run: |
./odoo-bin -d test_db -i my_module --test-enable \
--test-tags=my_module,post_install \
--stop-after-init --log-level=warn
Run on every PR; reject merge if tests fail.
Test organization patterns
my_module/
├── tests/
│ ├── __init__.py
│ ├── common.py # shared TestBase, fixtures
│ ├── test_models.py # ORM and business logic
│ ├── test_controllers.py # HTTP endpoints
│ ├── test_workflows.py # cross-module flows
│ ├── test_security.py # access control, record rules
│ └── test_tours.py # UI tour tests (slow)
Keep tour tests in their own file so you can selectively skip them during fast iteration.
Coverage measurement
For coverage, use coverage.py with the Odoo test runner:
coverage run --source=addons/my_module \
/opt/odoo/odoo-bin -d test_db -i my_module \
--test-enable --stop-after-init
coverage report --skip-covered --show-missing
coverage html # generates htmlcov/ directory
Aim for 70-80% coverage on business-logic modules. Coverage of pure-data modules (configuration, demo data) doesn't matter much; coverage of workflow logic catches real bugs.
Common test failure patterns
Failure: "no such record"
Cause: setUp committed but the test transaction sees a different snapshot. Fix: use setUpClass for fixtures and ensure they're created before any test runs.
Failure: "constraint violated" intermittent
Cause: tests sharing data with non-deterministic order. Fix: use addCleanup() to remove created records, or run with --test-tags to control order.
Failure: "tour did not complete"
Cause: a step's trigger never appeared (UI changed) or timed out. Fix: open the failing tour in a real browser, watch which step fails, and adjust the selector.
Failure: "module install failure"
Cause: your module's manifest has an error or a dependency is broken. Fix: check __manifest__.py for typos in depends; verify all referenced files exist.
Failure: "different result on CI vs local"
Cause: timezone, locale, or randomness. Fix: pin timezone in test setup (self.env.user.tz = 'UTC'), pin locale, and seed random data deterministically.
Frequently Asked Questions
Should I write unit tests for individual functions or always use TransactionCase?
For pure utility functions (no ORM access), use unittest.TestCase — they run instantly. For anything touching self.env, use TransactionCase. The line is "does this function need a database?" If no, plain unit test; if yes, TransactionCase.
What's the difference between SavepointCase and TransactionCase?
In Odoo 17 they were distinct (SavepointCase used PostgreSQL savepoints, TransactionCase used full transactions). In Odoo 19 they merged — TransactionCase covers both behaviors. For new code, just use TransactionCase.
How do I test record rules and ACLs?
Use with_user() to switch context:
def test_user_cannot_delete(self):
with self.assertRaises(AccessError):
self.record.with_user(self.regular_user).unlink()
Make sure your test users have the realistic group memberships you're checking against.
Why are my tests passing locally but failing in CI?
Common causes: (1) Test order dependency — a test relies on state from a prior test. (2) Time-sensitive tests using datetime.now() — freeze time with freezegun. (3) Random data that occasionally collides. (4) Missing demo data dependencies. Run tests in random order locally to surface these.
Can I run tests against a production-cloned database?
Technically yes, but never do it. Tests rollback their transaction but bugs in test setup can leak data, and prod databases have unpredictable state that breaks test assumptions. Always use a fresh test database (-d test_db --test-enable -i my_module).
Tests are the difference between confident deploys and Friday-night rollbacks. ECOSIRE's Odoo developer hire service places engineers who write tests as part of every module they ship. See our Odoo customization service for module development with full test coverage, or browse our Odoo modules catalog for production-tested modules.
تحریر
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.
ECOSIRE
Odoo ERP کے ساتھ اپنے کاروبار کو تبدیل کریں
آپ کے کاموں کو ہموار کرنے کے لیے ماہر Odoo کا نفاذ، حسب ضرورت، اور معاونت۔
متعلقہ مضامین
How to Add a Custom Button to an Odoo Form View (2026)
Add custom action buttons to Odoo 19 form views: Python action method, view inheritance, conditional visibility, confirmation dialogs. Production-tested.
How to Add a Custom Field in Odoo Without Studio (2026)
Add custom fields via custom module in Odoo 19: model inheritance, view extension, computed fields, store/non-store decisions. Code-first, version-controlled.
How to Add a Custom Report in Odoo Using External Layout
Build a branded PDF report in Odoo 19 using web.external_layout: QWeb template, paperformat, action binding. With print logo + footer overrides.