Your Custom Module Works Today. Will It Survive the Next Upgrade?
We audit Odoo custom modules for a living. The pattern we see most often: a module with 3,000 lines of Python, zero tests, and an owner who is terrified to touch it. Every minor Odoo upgrade is a coin flip. Every new developer who inherits it spends a week reading code before they dare change a line. And when something breaks in production, the debugging starts with "well, it worked on my machine."
Odoo 19 ships with a mature, batteries-included testing framework that most developers never use beyond copy-pasting the demo test from the scaffold command. The framework supports unit tests (isolated ORM operations), integration tests (multi-model workflows with rollback), HTTP tests (controller endpoints and authentication), and tour tests (full browser automation via JavaScript). It integrates with Python's unittest module, supports mock.patch for external dependencies, and can be wired into any CI pipeline.
This guide covers every test class available in Odoo 19, when to use each one, how to structure test files for real modules, and how to run them in development and CI. Every code sample is production-tested across our client portfolio.
Odoo 19 Test Classes Explained: TransactionCase, HttpCase, and SavepointCase
Odoo provides three primary test base classes. Choosing the wrong one is the #1 reason tests are slow, flaky, or don't catch real bugs. Here's when to use each:
| Base Class | Transaction Behavior | Use When | Performance |
|---|---|---|---|
TransactionCase | Rolls back after each test method | Testing individual ORM operations, computed fields, constraints | Moderate — fresh state per test, but setUp runs every time |
SavepointCase | Rolls back after each test class (uses savepoints between methods) | Testing multi-step workflows where tests share setup data | Fast — setUpClass runs once, methods share state via savepoints |
HttpCase | Commits to database, spawns a real HTTP server | Testing controllers, JSON-RPC, tour tests, portal pages | Slow — real HTTP requests, real browser for tours |
The critical distinction: TransactionCase and SavepointCase run inside a transaction that gets rolled back — your test data never touches the real database. HttpCase commits data to the database because the HTTP server runs in a separate thread that needs to read the test data. This means HttpCase tests can leave residual data if they fail mid-execution, and they cannot run in parallel safely.
# my_module/tests/__init__.py
# Import all test modules here — Odoo's test runner discovers them
# through this __init__.py file.
from . import test_sale_logic
from . import test_invoice_workflow
from . import test_controllers
from . import test_tour_checkout Odoo's test runner only discovers test methods that start with test_ and test classes that inherit from one of the base classes. A method named check_invoice_total will never run. A class that inherits from unittest.TestCase directly will run but won't have access to self.env — the Odoo environment, registry, and cursor are only injected by Odoo's base classes.
Writing Unit and Integration Tests for Odoo 19 Custom Modules
Let's build real tests for a custom module that adds a discount approval workflow to sale orders. The module adds a discount_requires_approval boolean, a discount_approved field, and a constraint that blocks confirmation of orders with unapproved discounts above 15%.
from odoo.tests.common import TransactionCase, tagged
from odoo.exceptions import ValidationError
@tagged('post_install', '-at_install')
class TestSaleDiscountApproval(TransactionCase):
"""Unit tests for the discount approval workflow."""
@classmethod
def setUpClass(cls):
"""Create shared test data once for the entire class.
In Odoo 19, setUpClass is the recommended way to create
expensive test fixtures. Use setUp() only when you need
fresh data for every single test method.
"""
super().setUpClass()
# Create a test customer
cls.partner = cls.env['res.partner'].create({
'name': 'Test Customer',
'email': 'test@example.com',
})
# Create a test product
cls.product = cls.env['product.product'].create({
'name': 'Test Widget',
'list_price': 100.0,
'type': 'consu',
})
# Create a sale order with a 20% discount (above threshold)
cls.order = cls.env['sale.order'].create({
'partner_id': cls.partner.id,
'order_line': [(0, 0, {
'product_id': cls.product.id,
'product_uom_qty': 10,
'price_unit': 100.0,
'discount': 20.0, # Above 15% threshold
})],
})
def test_discount_flag_set_above_threshold(self):
"""Orders with discount > 15% should require approval."""
self.assertTrue(
self.order.discount_requires_approval,
"Order with 20% discount should require approval"
)
def test_discount_flag_clear_below_threshold(self):
"""Orders with discount <= 15% should not require approval."""
order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [(0, 0, {
'product_id': self.product.id,
'product_uom_qty': 5,
'price_unit': 100.0,
'discount': 10.0, # Below threshold
})],
})
self.assertFalse(
order.discount_requires_approval,
"Order with 10% discount should NOT require approval"
)
def test_confirmation_blocked_without_approval(self):
"""Confirming an unapproved high-discount order should raise."""
with self.assertRaises(ValidationError):
self.order.action_confirm()
def test_confirmation_allowed_after_approval(self):
"""Approved orders should confirm normally."""
self.order.discount_approved = True
self.order.action_confirm()
self.assertEqual(
self.order.state, 'sale',
"Approved order should transition to 'sale' state"
)
def test_computed_discount_amount(self):
"""Verify the total discount amount computation."""
# 10 units * $100 * 20% = $200 total discount
self.assertAlmostEqual(
self.order.total_discount_amount,
200.0,
places=2,
msg="Total discount should be 200.00"
)Integration Tests with SavepointCase
When testing multi-step workflows — like creating a sale order, confirming it, generating an invoice, and validating the invoice — use SavepointCase. It runs setUpClass once and creates a database savepoint between each test method. This is significantly faster than TransactionCase for test classes that share expensive setup data.
from odoo.tests.common import SavepointCase, tagged
@tagged('post_install', '-at_install')
class TestInvoiceWorkflow(SavepointCase):
"""Integration tests for the full order-to-invoice flow.
SavepointCase is ideal here because each test method builds
on shared setup data, and the savepoint rollback between
methods is 10-50x faster than full transaction rollback.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({
'name': 'Integration Customer',
'email': 'integration@example.com',
})
cls.product = cls.env['product.product'].create({
'name': 'Service Package',
'list_price': 500.0,
'type': 'service',
'invoice_policy': 'order',
})
cls.pricelist = cls.env['product.pricelist'].create({
'name': 'Test Pricelist',
'currency_id': cls.env.ref('base.USD').id,
})
def test_01_create_and_confirm_order(self):
"""Create SO, approve discount, confirm."""
self.order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'pricelist_id': self.pricelist.id,
'order_line': [(0, 0, {
'product_id': self.product.id,
'product_uom_qty': 2,
'price_unit': 500.0,
'discount': 20.0,
})],
})
self.order.discount_approved = True
self.order.action_confirm()
self.assertEqual(self.order.state, 'sale')
def test_02_generate_invoice(self):
"""Generate invoice from confirmed SO."""
# Re-fetch order (savepoint may have rolled back)
orders = self.env['sale.order'].search([
('partner_id', '=', self.partner.id),
('state', '=', 'sale'),
])
if not orders:
# Re-create if savepoint rolled back
self.test_01_create_and_confirm_order()
orders = self.env['sale.order'].search([
('partner_id', '=', self.partner.id),
('state', '=', 'sale'),
])
order = orders[0]
invoice_wizard = self.env['sale.advance.payment.inv'].with_context(
active_ids=order.ids,
active_model='sale.order',
).create({
'advance_payment_method': 'delivered',
})
invoice_wizard.create_invoices()
self.assertTrue(
order.invoice_ids,
"Invoice should be created from the sale order"
)
def test_03_invoice_discount_matches_order(self):
"""Invoice lines should carry the same discount as SO lines."""
invoices = self.env['account.move'].search([
('partner_id', '=', self.partner.id),
('move_type', '=', 'out_invoice'),
])
for invoice in invoices:
for line in invoice.invoice_line_ids:
self.assertEqual(
line.discount, 20.0,
"Invoice line discount should match SO line"
) The @tagged('post_install', '-at_install') decorator tells Odoo to run these tests after all modules are installed, not during installation. Use post_install for tests that depend on other modules being fully loaded (sale, account, stock). Use at_install only for tests that validate your module's own installation — like checking that XML data records were created correctly.
JavaScript Tour Tests: Browser-Based Automation for Odoo 19 UI Flows
Tour tests are Odoo's answer to Selenium — but they run inside the Odoo JavaScript framework itself, which means they have access to the OWL component tree, the RPC layer, and the action manager. This makes them more reliable than external browser automation because they wait for Odoo's own rendering cycle instead of arbitrary timeouts.
A tour is a sequence of steps. Each step targets a DOM element (via CSS selector), performs an action (click, input text, drag), and optionally waits for a condition. The tour runner advances to the next step only when the current step's trigger element is visible and interactive.
/** @odoo-module **/
import { registry } from "@web/core/registry";
registry.category("web_tour.tours").add("test_discount_approval", {
test: true,
url: "/odoo/sales",
steps: () => [
// Step 1: Create a new quotation
{
trigger: ".o_list_button_add",
content: "Click 'New' to create a quotation",
run: "click",
},
// Step 2: Select the customer
{
trigger: ".o_field_widget[name='partner_id'] input",
content: "Search for the test customer",
run: "edit Test Customer",
},
{
trigger: ".ui-autocomplete .ui-menu-item a:contains('Test Customer')",
content: "Select customer from dropdown",
run: "click",
},
// Step 3: Add a product line with discount
{
trigger: ".o_field_x2many_list_row_add a",
content: "Click 'Add a line'",
run: "click",
},
{
trigger: ".o_field_widget[name='product_id'] input",
content: "Search for product",
run: "edit Test Widget",
},
{
trigger: ".ui-autocomplete .ui-menu-item a:contains('Test Widget')",
content: "Select product",
run: "click",
},
{
trigger: ".o_field_widget[name='discount'] input",
content: "Set discount to 20%",
run: "edit 20",
},
// Step 4: Verify the approval banner appears
{
trigger: ".o_discount_approval_banner",
content: "Discount approval banner should be visible",
run: () => {
const banner = document.querySelector(
".o_discount_approval_banner"
);
if (!banner) {
throw new Error("Discount approval banner not found");
}
},
},
// Step 5: Approve the discount
{
trigger: ".btn_approve_discount",
content: "Click approve discount button",
run: "click",
},
// Step 6: Confirm the order
{
trigger: ".o_sale_order_confirm",
content: "Confirm the sale order",
run: "click",
},
{
trigger: ".o_statusbar_status .o_arrow_button_current:contains('Sales Order')",
content: "Verify order is confirmed",
run: () => {}, // No action — just verify element exists
},
],
});
To trigger this tour from a Python test, use HttpCase:
from odoo.tests import HttpCase, tagged
@tagged('post_install', '-at_install')
class TestDiscountApprovalTour(HttpCase):
"""Run the discount approval UI tour.
HttpCase starts a real HTTP server and a headless Chrome
browser. The tour JS file is loaded automatically because
it's in static/tests/tour/.
"""
def test_discount_approval_tour(self):
# Create prerequisite data that the tour expects
self.env['res.partner'].create({
'name': 'Test Customer',
'email': 'test@example.com',
})
self.env['product.product'].create({
'name': 'Test Widget',
'list_price': 100.0,
'type': 'consu',
})
# Run the tour as the admin user
self.start_tour(
"/odoo/sales",
"test_discount_approval",
login="admin",
timeout=120,
) When a tour step fails, Odoo logs the step index and trigger selector, but the error is often cryptic. To debug interactively, add ?debug=assets to the URL and run the tour manually from the browser console: odoo.__DEBUG__.services["web_tour.tour_service"].startTour("test_discount_approval"). You'll see each step highlight the target element and can watch exactly where it fails.
Running Odoo 19 Tests: CLI Flags, Test Tags, and CI Integration
Odoo's test runner is controlled entirely through command-line flags passed to odoo-bin. There is no separate test command — tests run as part of module installation or update when the --test-enable flag is present.
# ── Run ALL tests for a specific module ──
python odoo-bin \
-d odoo_test \
--addons-path=odoo/addons,custom_addons \
-i my_module \
--test-enable \
--stop-after-init \
--log-level=test
# ── Run only tests tagged 'post_install' ──
# (skip at_install tests that run during module installation)
python odoo-bin \
-d odoo_test \
--addons-path=odoo/addons,custom_addons \
-u my_module \
--test-enable \
--test-tags=post_install \
--stop-after-init
# ── Run a specific test class ──
python odoo-bin \
-d odoo_test \
--addons-path=odoo/addons,custom_addons \
-u my_module \
--test-enable \
--test-tags=/my_module:TestSaleDiscountApproval \
--stop-after-init
# ── Run tests for multiple modules ──
python odoo-bin \
-d odoo_test \
--addons-path=odoo/addons,custom_addons \
-i my_module,my_other_module \
--test-enable \
--stop-after-init
# ── Run with coverage reporting ──
pip install coverage
coverage run odoo-bin \
-d odoo_test \
--addons-path=odoo/addons,custom_addons \
-i my_module \
--test-enable \
--stop-after-init \
--log-level=test
coverage report --include="custom_addons/my_module/*" --show-missing
coverage html --include="custom_addons/my_module/*"Understanding --test-tags Syntax
| Flag Value | What It Runs | When to Use |
|---|---|---|
--test-tags=post_install | Only @tagged('post_install') tests | Default for CI — tests that need all modules loaded |
--test-tags=at_install | Only @tagged('at_install') tests | Testing module installation hooks and XML data |
--test-tags=/my_module | All tests in my_module | Running a single module's tests in development |
--test-tags=/my_module:TestClassName | Specific test class | Debugging a single failing test class |
--test-tags=-at_install,post_install | post_install but NOT at_install | Skip installation tests, run only post-install |
For CI integration, the critical detail is that Odoo's test runner always exits with code 0 — even when tests fail. You must parse the log output to detect failures. Here's the pattern we use in every CI pipeline:
#!/bin/bash
# scripts/run_tests.sh — CI-safe Odoo test runner
set -euo pipefail
DB_NAME="${1:-odoo_test}"
MODULES="${2:-my_module}"
LOG_FILE="/tmp/odoo_test_output.log"
echo "=== Running tests for: $MODULES ==="
python odoo-bin \
-d "$DB_NAME" \
--addons-path=odoo/addons,custom_addons \
-u "$MODULES" \
--test-enable \
--test-tags=post_install \
--stop-after-init \
--log-level=test \
2>&1 | tee "$LOG_FILE"
# Count failures and errors
ERRORS=$(grep -c "ERROR.*odoo\.\(tests\|modules\)" "$LOG_FILE" || true)
FAILS=$(grep -c "FAIL:" "$LOG_FILE" || true)
echo "=== Results: $ERRORS errors, $FAILS failures ==="
if [ "$ERRORS" -gt 0 ] || [ "$FAILS" -gt 0 ]; then
echo "::error::Tests failed: $ERRORS errors, $FAILS failures"
grep -E "(ERROR|FAIL:)" "$LOG_FILE"
exit 1
fi
echo "All tests passed."
exit 0Mocking External Services and ORM Methods in Odoo 19 Tests
Custom modules often integrate with external APIs — payment gateways, shipping carriers, tax calculators, email services. You don't want your test suite making real API calls. Python's unittest.mock.patch works seamlessly with Odoo's test framework. The key is knowing where to patch.
In Odoo, you patch the method where it's looked up, not where it's defined. If your custom module imports a utility function from a library, you patch it on your module's namespace, not the library's.
from unittest.mock import patch, MagicMock
from odoo.tests.common import TransactionCase, tagged
@tagged('post_install', '-at_install')
class TestShippingRateCalculation(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({
'name': 'Shipping Customer',
'street': '123 Test St',
'city': 'New York',
'zip': '10001',
'country_id': cls.env.ref('base.us').id,
})
@patch('odoo.addons.my_module.models.sale_order.requests.post')
def test_shipping_rate_api_called(self, mock_post):
"""Verify the shipping API is called with correct payload."""
# Configure the mock to return a fake API response
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
'rate': 12.50,
'currency': 'USD',
'carrier': 'UPS Ground',
'estimated_days': 5,
}
mock_post.return_value = mock_response
# Trigger the shipping rate calculation
order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'partner_shipping_id': self.partner.id,
})
order._compute_shipping_rate()
# Verify the API was called
mock_post.assert_called_once()
call_args = mock_post.call_args
payload = call_args[1]['json']
self.assertEqual(payload['destination_zip'], '10001')
# Verify the rate was stored
self.assertAlmostEqual(
order.shipping_rate, 12.50, places=2
)
@patch('odoo.addons.my_module.models.sale_order.requests.post')
def test_shipping_api_failure_handled(self, mock_post):
"""API failure should not crash — should log and set rate to 0."""
mock_post.side_effect = ConnectionError("API timeout")
order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'partner_shipping_id': self.partner.id,
})
# Should not raise
order._compute_shipping_rate()
self.assertEqual(
order.shipping_rate, 0.0,
"Shipping rate should default to 0 on API failure"
)
def test_override_env_context(self):
"""Test with a modified environment context.
Odoo's with_context() and with_user() methods let you
test behavior under different contexts without mocking.
"""
# Test as a non-admin user
sales_user = self.env['res.users'].create({
'name': 'Sales Rep',
'login': 'salesrep@test.com',
'groups_id': [(6, 0, [
self.env.ref('sales_team.group_sale_salesman').id,
])],
})
order_as_user = self.env['sale.order'].with_user(
sales_user
).create({
'partner_id': self.partner.id,
})
# Sales rep should not be able to approve discounts
with self.assertRaises(Exception):
order_as_user.action_approve_discount()4 Odoo Testing Mistakes That Give False Confidence
Testing Against Demo Data That Doesn't Match Production
Odoo's demo data creates a handful of products, a few partners, and some sample orders. Your production database has 50,000 products with 12 pricelist rules, partners with complex fiscal positions, and orders spanning 3 years of history. A test that passes against demo data can fail spectacularly in production because the computed field assumed a single pricelist, the search domain didn't account for archived records, or the workflow breaks when there are more than 1,000 order lines.
Create your own test data in setUpClass — never rely on demo data for assertions. For performance-sensitive code, add a dedicated test that creates 1,000+ records and validates timing with self.assertLess(elapsed, 2.0).
Using at_install When You Need post_install
Tests tagged with at_install run during module installation, before dependent modules are fully loaded. If your test creates a sale order and expects the accounting module to generate journal entries, it will fail — because account might not be installed yet when your test runs. The error message is usually an unhelpful KeyError: 'account.move' that looks like a bug in your code but is actually a test ordering issue.
Default to @tagged('post_install', '-at_install') for all tests. Only use at_install when explicitly testing that your module's data files loaded correctly.
Forgetting to Invalidate the Cache After Direct SQL
When tests use self.env.cr.execute() to run raw SQL (common for performance-critical operations or data fixes), the ORM cache still holds the old values. Subsequent reads via record.field_name return stale data, and your assertion passes even though the database has the wrong value — or fails when the database is correct but the cache is stale. This creates tests that are nondeterministic depending on cache state.
Always call self.env.invalidate_all() after raw SQL in tests. Better yet, avoid raw SQL in tests entirely — use the ORM so your tests verify the same code path that production uses.
HttpCase Tests Leaking Data Between Runs
Unlike TransactionCase, HttpCase commits data to the database. If a tour test creates a sale order and then fails halfway through, that order persists in the test database. The next test run finds an unexpected existing order and fails with a different error. This cascading failure pattern makes HttpCase tests appear "flaky" when they're actually suffering from test pollution.
Use unique, timestamped names for test data in HttpCase tests (e.g., Test Customer 1710345600). In CI, always start with a fresh database — never reuse a test database between pipeline runs. Add a tearDown method that cleans up created records explicitly.
What a Tested Codebase Saves Your Business
Writing tests adds 20-30% to initial development time. Here's what that investment returns:
Automated tests catch the "I changed one field and broke three workflows" class of bugs before they reach users. The remaining 15% are edge cases that tests inform you to write next.
Tests are executable documentation. A new developer reads test_sale_discount.py and understands the discount approval workflow in 10 minutes — no meetings, no stale wiki pages.
When upgrading from Odoo 19 to 20, a test suite tells you exactly which custom modules break. Without tests, you discover breakage in production over the next 3 months.
The hidden ROI is refactoring confidence. Untested code becomes legacy code the moment it's written — nobody dares restructure it because there's no safety net. Tested code can be refactored, optimized, and extended with confidence. The test suite is the difference between "we can't touch that module" and "let's improve it."
Optimization Metadata
Complete guide to Odoo 19 testing: TransactionCase, SavepointCase, HttpCase, tour tests, mock/patch, running tests with --test-enable, coverage, and CI integration.
1. "Odoo 19 Test Classes Explained: TransactionCase, HttpCase, and SavepointCase"
2. "Writing Unit and Integration Tests for Odoo 19 Custom Modules"
3. "JavaScript Tour Tests: Browser-Based Automation for Odoo 19 UI Flows"
4. "4 Odoo Testing Mistakes That Give False Confidence"