Testing Framework
Guidelines for writing and reviewing tests in Odoo development projects.
How to read this document: The first four sections (Quality Assurance Deliverables through Running Tests) are essential reading for all developers — they cover the core patterns, examples, and conventions needed to write tests. If you're new to Odoo testing, start with the examples in Unit Tests and Integration Tests — they are designed to be copied and adapted. The Guidelines section and beyond are reference material for test strategy, advanced patterns, and project-level practices.
Table of Contents
- Quality Assurance Deliverables: Test Base Classes, Unit Tests, Integration Tests, Tour Tests, Demo Recordings
- Coverage Requirements
- Demo Recording Workflow
- Test Organization: Directory Structure, Naming Conventions, TC Traceability, Coverage Map
- Running Tests: Local, pytest-odoo, CI, Per-Project Commands, Tag Strategy
- Guidelines: Good Practices, Anti-Patterns, Coverage Gap Tracking, Scenario Design, Context Flags, Date-Sensitive Testing, Access Rights Testing, Transaction Pitfalls, Performance Testing
- Review Checklist
- Version Notes (v15–v19)
- Resources
The goal of this framework is to establish consistent testing practices across all Odoo development at much-GmbH. This process complements the Code Review guidelines and ensures:
- Quality Assurance: Tests catch bugs before they reach production and prevent regressions
- Executable Specifications: Tests describe expected behavior and fail when that behavior changes
- Consultant Communication: Demo recordings demonstrate features clearly to consultants and customers
- Safe Refactoring: Comprehensive test coverage enables confident code modifications
This process integrates with the CI/CD pipeline for automated test execution.
Quality Assurance Deliverables
This framework defines four types of deliverables. The first three are automated tests that run in CI and catch regressions. The fourth is a communication deliverable that bridges engineering work and consultant understanding.
Choosing the Right Test Base Class
Before writing a test, pick the correct base class. This decision affects transaction behavior, rollback guarantees, and available capabilities.
| Base Class | Rollback | Use When |
|---|---|---|
| TransactionCase | Per method (savepoint) | Model logic, computed fields, constraints, workflows. The default choice for almost all tests. |
| HttpCase | No rollback (data persists) | Controller/HTTP endpoint tests, tour tests, browser-based tests. Data stays in the DB after the test. |
| SingleTransactionCase | Per class (shared state across methods) | Sequential workflows where method order matters. Rarely needed — prefer TransactionCase unless you have a specific reason. |
Important: HttpCase commits the transaction so the HTTP server thread can see test data. This means test data is not cleaned up automatically. Use HttpCase only when you genuinely need HTTP capabilities, and be aware that your test database will accumulate data.
Version note: SavepointCase existed in Odoo 15 and earlier. In Odoo 16+, TransactionCase absorbed its behavior (savepoint-based rollback). If migrating tests from v15, replace SavepointCase with TransactionCase — they are now identical.
Unit Tests
Purpose: Verify isolated business logic scoped to a single model or method.
Characteristics:
- Fast execution (milliseconds to low seconds)
- Database transactions rolled back after each test (TransactionCase)
- Tests one logical unit at a time — a single computed field, constraint, or method
- External dependencies (API calls, file systems) should be mocked
Note: In Odoo, even unit tests use the database via TransactionCase. The distinction from integration tests is one of scope — unit tests target a single model's logic, while integration tests verify cross-model workflows.
When to Use:
- Computed fields with business logic
- Constraint methods (_check_*)
- Helper/utility methods
- Data transformations
- Mathematical calculations
- State machine transitions
Design for Testability: Unit tests are only possible when the code is structured into small, focused methods. A single method that computes values, validates data, and triggers side effects cannot be unit tested — you end up writing integration tests disguised as unit tests. Break logic into pieces: one method per responsibility.
Example — Computed field and constraint:
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
discount_amount = fields.Monetary(
compute='_compute_discount_amount', store=True,
)
@api.depends('price_unit', 'discount', 'product_uom_qty')
def _compute_discount_amount(self):
for line in self:
line.discount_amount = (
line.price_unit * line.product_uom_qty * line.discount / 100
)
@api.constrains('product_uom_qty')
def _check_quantity(self):
for line in self:
if line.product_uom_qty < 0:
raise ValidationError("Line quantities cannot be negative.")
Example — Unit tests for the model above:
from odoo.exceptions import ValidationError
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('post_install', '-at_install')
class TestSaleOrderLineDiscount(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'Test Customer'})
cls.product = cls.env['product.product'].create({'name': 'Test Product'})
def _create_order_line(self, qty=10, price=50.0, discount=0.0):
"""Helper to create an order with a single line."""
order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [(0, 0, {
'product_id': self.product.id,
'product_uom_qty': qty,
'price_unit': price,
'discount': discount,
})],
})
return order.order_line
def test_discount_amount_basic(self):
"""10 units * 50.0 price * 20% discount = 100.0"""
line = self._create_order_line(qty=10, price=50.0, discount=20.0)
self.assertAlmostEqual(line.discount_amount, 100.0)
def test_discount_amount_zero_discount(self):
"""No discount applied — discount amount should be 0."""
line = self._create_order_line(qty=5, price=30.0, discount=0.0)
self.assertEqual(line.discount_amount, 0.0)
def test_discount_amount_full_discount(self):
"""100% discount — discount amount equals full line value."""
line = self._create_order_line(qty=2, price=100.0, discount=100.0)
self.assertAlmostEqual(line.discount_amount, 200.0)
@tagged('post_install', '-at_install')
class TestSaleOrderLineConstraints(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'Test Customer'})
cls.product = cls.env['product.product'].create({'name': 'Test Product'})
def _create_order_line(self, qty):
"""Helper to create an order with a single line at given quantity."""
return self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [(0, 0, {
'product_id': self.product.id,
'product_uom_qty': qty,
})],
})
def test_negative_quantity_raises_error(self):
"""Negative line quantity must raise ValidationError."""
with self.assertRaises(ValidationError):
self._create_order_line(qty=-5)
def test_zero_quantity_is_allowed(self):
"""Zero quantity is valid (e.g., free samples, service lines)."""
order = self._create_order_line(qty=0)
self.assertTrue(order, "Order with zero quantity should be created.")
When to extract logic into a separate method: For simple computations like the discount example above, testing through the compute directly (by creating a record) is fine. However, when business logic has many branches — tiered pricing rules, conditional discounts based on customer category, multi-step calculations — extracting the logic into a pure method (no ORM, no side effects) lets you test every branch quickly without creating records. The compute method handles the ORM work (looping over self, reading fields), and the extracted method handles the rules. Use your judgement: if you need more than 3-4 test cases to cover all branches, extraction will save time.
Key takeaways from the examples:
- Test one behavior per method: happy path, edge cases, and error conditions each get their own test
- Use setUpClass to create shared records once, not per test
- Use helper methods (like _create_order_line) to reduce boilerplate across tests
- Descriptive test names and docstrings explain what and why, not how
- For complex branching logic, extract pure calculation methods to test without ORM overhead
Integration Tests
Purpose: Verify that multiple components work together correctly, including cross-model interactions and complete workflows.
Characteristics:
- Slower execution (seconds)
- Database transactions rolled back after each test
- Tests complete workflows end-to-end across multiple models
- May use Form class to simulate UI onchange behavior
When to Use:
- Multi-step workflows (e.g., SO → Invoice → Payment)
- ORM operations (create, write, unlink) with cross-model side effects
- Scheduled actions (crons)
- Cross-model computations
- API endpoints and controllers
- External system integrations (with mocked responses)
Example — Mocking an external API integration:
External integrations (Shopware, Shopify, PayPal, etc.) cannot be called during tests. Use unittest.mock.patch to replace the HTTP call and control the response.
from unittest.mock import patch
@tagged('post_install', '-at_install')
class TestShopwareSync(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.product = cls.env['product.product'].create({
'name': 'Test Product',
'list_price': 25.0,
})
def _mock_shopware_response(self, status_code=200, json_data=None):
"""Helper to build a mock response object."""
mock_response = MagicMock()
mock_response.status_code = status_code
mock_response.json.return_value = json_data or {}
return mock_response
@patch('odoo.addons.my_module.models.product.requests.post')
def test_sync_product_success(self, mock_post):
"""Successful sync should update the external ID on the product."""
mock_post.return_value = self._mock_shopware_response(
status_code=200,
json_data={'id': 'sw-12345'},
)
self.product.action_sync_to_shopware()
self.assertEqual(self.product.shopware_id, 'sw-12345')
mock_post.assert_called_once()
@patch('odoo.addons.my_module.models.product.requests.post')
def test_sync_product_api_error(self, mock_post):
"""API error should raise UserError, not silently fail."""
mock_post.return_value = self._mock_shopware_response(
status_code=500,
json_data={'error': 'Internal Server Error'},
)
with self.assertRaises(UserError):
self.product.action_sync_to_shopware()
self.assertFalse(self.product.shopware_id)
Key points for mocking external integrations:
- Patch at the import location (odoo.addons.my_module.models.product.requests.post), not at requests.post globally
- Test both success and failure responses — the error handling path is where most bugs hide
- Use assert_called_once() to verify the call was made (or not made when it shouldn't be)
- Build mock responses with helpers to keep tests readable
Example — Integration test for a cross-model workflow:
@tagged('post_install', '-at_install')
class TestSaleToInvoiceWorkflow(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'Test Customer'})
cls.product = cls.env['product.product'].create({
'name': 'Test Product',
'list_price': 100.0,
})
def _create_confirmed_order(self, qty=1):
"""Helper to create and confirm a sale order."""
order = self.env['sale.order'].create({
'partner_id': self.partner.id,
'order_line': [(0, 0, {
'product_id': self.product.id,
'product_uom_qty': qty,
})],
})
order.action_confirm()
return order
def test_confirm_sale_creates_invoice(self):
"""Confirming a SO and creating an invoice should carry over line data."""
order = self._create_confirmed_order(qty=3)
self.assertEqual(order.state, 'sale')
invoice = order._create_invoices()
self.assertEqual(len(invoice), 1)
self.assertEqual(invoice.partner_id, self.partner)
self.assertAlmostEqual(
invoice.amount_untaxed, 300.0,
msg="Invoice amount should match 3 units * 100.0",
)
Example — Testing onchange behavior with the Form class:
Direct create() and write() calls do not trigger @api.onchange methods. If the feature under test depends on onchange behavior (which is common — partner sets pricelist, product sets UoM, etc.), you must use the Form class to simulate what the user experiences in the UI.
from odoo.tests.common import Form
@tagged('post_install', '-at_install')
class TestSaleOrderOnchange(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({
'name': 'Test Customer',
'property_product_pricelist': cls.env.ref('product.list0').id,
})
cls.product = cls.env['product.product'].create({
'name': 'Test Product',
'list_price': 100.0,
})
def test_partner_onchange_sets_pricelist(self):
"""Setting partner on SO should populate pricelist via onchange."""
form = Form(self.env['sale.order'])
form.partner_id = self.partner
order = form.save()
self.assertEqual(
order.pricelist_id,
self.partner.property_product_pricelist,
)
def test_product_onchange_sets_line_defaults(self):
"""Adding a product to an SO line should populate name and price."""
form = Form(self.env['sale.order'])
form.partner_id = self.partner
with form.order_line.new() as line:
line.product_id = self.product
order = form.save()
self.assertEqual(order.order_line.price_unit, 100.0)
self.assertTrue(order.order_line.name, "Product name should be set by onchange")
Key points for Form class usage:
- Use form.field = value to set fields — onchanges fire automatically
- For One2many lines, use form.line_ids.new() to add and form.line_ids.edit(index) to modify
- form.save() returns the created record
- The Form class respects field visibility and readonly attributes from the view
- To test against a specific view: Form(self.env['model'], view='module.view_xml_id')
Example — Testing cron jobs / scheduled actions:
Three patterns cover most cron testing cases: existence checks, batch processing verification, and error recovery.
@tagged('post_install', '-at_install')
class TestSchedulerBatching(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(
cls.env.context, tracking_disable=True,
))
cls.warehouse = cls.env.ref('stock.warehouse0')
cls.product = cls.env['product.product'].create({
'name': 'Test Product',
'type': 'product',
})
def test_cron_exists_and_active(self):
"""The scheduled action should be registered and active after install."""
cron = self.env.ref(
'my_module.ir_cron_reschedule_orders',
raise_if_not_found=False,
)
self.assertTrue(cron, "Reschedule cron should exist")
self.assertTrue(cron.active, "Reschedule cron should be active")
def test_batch_processing(self):
"""Scheduler should process records in configurable batches."""
self.env['ir.config_parameter'].set_param(
'my_module.batch_size', '2',
)
# Create 5 records that need processing
for i in range(5):
self.env['stock.warehouse.orderpoint'].create({
'product_id': self.product.id,
'warehouse_id': self.warehouse.id,
'product_min_qty': 0,
'product_max_qty': 10 * (i + 1),
})
# Run scheduler — use_new_cursor=False is critical in tests
# (tests run in a single transaction, new cursors would not see test data)
self.env['procurement.group']._run_scheduler_tasks(
use_new_cursor=False,
company_id=self.env.company.id,
)
# Assert all 5 were processed (3 batches of 2+2+1)
orderpoints = self.env['stock.warehouse.orderpoint'].search([
('product_id', '=', self.product.id),
])
self.assertTrue(
all(op.qty_to_order > 0 for op in orderpoints),
"All orderpoints should have been processed across batches",
)
def test_error_in_batch_does_not_lose_prior_results(self):
"""A failed batch should not roll back results from earlier batches."""
# Setup: create records where batch 2 will fail (e.g., missing route)
# ...
with self.assertLogs('odoo.addons.my_module', level='ERROR'):
self.env['procurement.group']._run_scheduler_tasks(
use_new_cursor=False,
company_id=self.env.company.id,
)
# Assert: batch 1 results are preserved despite batch 2 failure
# ...
Key points for cron testing:
- use_new_cursor=False — Always pass this in tests. Odoo schedulers often use use_new_cursor=True in production for crash recovery, but in tests the new cursor cannot see uncommitted test data
- Existence checks — Verify the cron XML ID exists and is active after module install. This catches manifest typos and missing data files
- Batch boundaries — Test with record counts that don't divide evenly into the batch size (e.g., 5 records with batch size 2) to verify the last partial batch is handled
- Error recovery — Verify that a failure in one batch doesn't roll back or corrupt results from earlier batches
Dedicated test module for cross-module regression:
Individual modules should have their own unit and integration tests. However, for projects with multiple custom modules that interact (e.g., a custom sale module + a custom stock module + a custom invoice module), consider creating a dedicated test module (e.g., my_project_tests) that depends on all project modules and tests the workflows that cross module boundaries. This catches regressions that individual module tests miss — "I changed the sale flow and broke the stock picking creation."
File naming: Individual module tests use model-based naming (test_sale_order.py, test_product.py). The cross-module test module uses feature-based naming, because E2E tests span multiple models and the feature is what organizes the test, not the model.
Convention: One feature tag per file. This keeps the mapping simple — test_core_reservation_logic.py uses bm_core_reservation, test_scheduler_batching.py uses bm_scheduler_batching.
Structure:
bm_automated_tests/
├── __init__.py
├── __manifest__.py # depends on ALL project modules
└── tests/
├── __init__.py
├── test_base.py # Layered base classes (see above)
├── test_core_reservation_logic.py # Tag: bm_core_reservation (31 tests)
├── test_master_data_changes.py # Tag: bm_master_data (18 tests)
├── test_rfq_date_calculations.py # Tag: bm_rfq_dates (16 tests)
├── test_premature_reservation.py # Tag: bm_premature_reservation (7 tests)
├── test_receipt_date_independence.py # Tag: bm_receipt_date_independence (7 tests)
├── test_scheduler_batching.py # Tag: bm_scheduler_batching (7 tests)
├── test_discount_propagation.py # Tag: bm_discount_propagation (6 tests)
├── test_method_c_dates.py # Tag: bm_method_c_dates (6 tests)
├── test_pa_phantom_lines.py # Tag: bm_phantom_lines (5 tests)
├── test_pa_retrieval_rfq.py # Tag: bm_pa_retrieval (4 tests)
└── test_pa_line_separation.py # Tag: bm_pa_separation (2 tests)
Testing super() chains across modules:
One of the key purposes of the cross-module test module is verifying that super() chains work correctly when multiple custom modules override the same method. For example:
SaleOrder.action_confirm()
→ [bm_sale_purchase_agreements] _action_confirm()
→ super() → [bm_stock_reservation] _action_confirm()
→ super() → base Odoo (creates pickings, runs procurement)
If bm_sale_purchase_agreements is installed without bm_stock_reservation, the chain breaks. Individual module tests can't catch this — each module's tests only run with that module installed. The cross-module test module installs all project modules together, so the full super() chain is exercised.
When writing cross-module E2E tests, verify end-to-end outcomes (e.g., "confirming an SO creates the correct RFQ lines with the correct dates") rather than testing each override in isolation. If the chain is broken, the final outcome will be wrong — you don't need to assert each intermediate super() call.
Reusable base test classes:
When multiple test files share similar setup logic, define configurable base classes in common.py (or test_base.py for the cross-module test module) instead of duplicating setUpClass everywhere.
Single-Layer: Class Attribute Overrides
For simple projects, a single base class with overridable class attributes is sufficient.
# tests/common.py
class TestSaleCommon(TransactionCase):
_product_name = 'Test Product'
_product_type = 'consu'
_product_price = 100.0
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({
'name': 'Test Customer',
})
cls.product = cls.env['product.product'].create({
'name': cls._product_name,
'type': cls._product_type,
'list_price': cls._product_price,
})
# tests/test_sale_service.py
class TestSaleService(TestSaleCommon):
_product_type = 'service'
_product_price = 50.0
def test_service_order_no_delivery(self):
"""Service products should not create delivery orders."""
# ... test uses cls.partner and cls.product (service, 50.0)
Each test file only overrides what differs — product type, price, partner configuration — while inheriting all the common setup.
Multi-Layer: Unit vs. E2E Separation
For complex projects, a single base class grows too heavy. Unit tests need core data (product, vendor, warehouse) but not workflow helpers. E2E tests need everything. A two-layer hierarchy keeps unit tests fast and E2E tests expressive.
# tests/test_base.py (cross-module test module)
class TestReservationLogicBase(TransactionCase):
"""Layer 1: Core data — product, vendor, warehouse, orderpoint, company settings.
Used by: unit tests that verify individual computations and constraints.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(
cls.env.context, tracking_disable=True,
))
cls.company = cls.env.ref('base.main_company')
cls.warehouse = cls.env.ref('stock.warehouse0')
cls.partner = cls.env['res.partner'].create({
'name': 'Test Vendor',
})
cls.product = cls.env['product.product'].create({
'name': 'Test Product',
'type': 'product',
})
cls.supplierinfo = cls.env['product.supplierinfo'].create({
'partner_id': cls.partner.id,
'product_tmpl_id': cls.product.product_tmpl_id.id,
'delay': 5,
})
cls.orderpoint = cls.env['stock.warehouse.orderpoint'].create({
'product_id': cls.product.id,
'warehouse_id': cls.warehouse.id,
'product_min_qty': 0,
'product_max_qty': 100,
})
# Company-level lead times (documented for date assertion traceability)
cls.company.write({
'security_lead': 4,
'po_lead': 2,
'days_to_purchase': 3,
})
class TestFormWorkflowBase(TestReservationLogicBase):
"""Layer 2: Adds SO/PO form helpers, carrier setup, confirmation workflows.
Used by: E2E integration tests that verify complete business flows.
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.carrier = cls.env['delivery.carrier'].create({
'name': 'Test Carrier',
'delivery_type': 'fixed',
'fixed_price': 10.0,
})
def _create_and_confirm_so(self, qty=10, commitment_date=None):
"""Helper: create SO with defaults and confirm it."""
vals = {
'partner_id': self.partner.id,
'order_line': [(0, 0, {
'product_id': self.product.id,
'product_uom_qty': qty,
})],
}
if commitment_date:
vals['commitment_date'] = commitment_date
order = self.env['sale.order'].create(vals)
order.action_confirm()
return order
def _get_rfq_lines_for_product(self, product):
"""Helper: find draft PO lines for a product."""
return self.env['purchase.order.line'].search([
('product_id', '=', product.id),
('order_id.state', '=', 'draft'),
])
Unit tests inherit from Layer 1 (lighter, faster):
# tests/test_core_reservation_logic.py
@tagged('post_install', '-at_install', 'much_unit', 'bm_core_reservation', 'bm_regressions')
class TestCoreReservationLogic(TestReservationLogicBase):
def test_orderpoint_triggers_procurement(self):
"""Orderpoint below min qty should trigger procurement."""
# ... uses cls.product, cls.orderpoint — no workflow helpers needed
E2E tests inherit from Layer 2 (full setup with helpers):
# tests/test_sale_to_stock_workflow.py
@tagged('post_install', '-at_install', 'much_integration', 'bm_core_reservation', 'bm_regressions')
class TestSaleToStockWorkflow(TestFormWorkflowBase):
def test_confirmed_so_creates_procurement(self):
"""Confirming an SO should create RFQ lines for the product."""
order = self._create_and_confirm_so(qty=5)
rfq_lines = self._get_rfq_lines_for_product(self.product)
self.assertTrue(rfq_lines, "RFQ lines should exist after SO confirmation")
When to Add a Second Layer
A single-layer base class is the right starting point. Add a second layer when:
- Your E2E tests need workflow helpers (form simulation, multi-step confirmation flows) that unit tests don't use
- The base class setUpClass has grown large enough that unit tests pay for setup they never use (e.g., creating carriers, payment terms, or delivery routes)
- You have a cross-module test module where the split between computation tests and workflow tests is clear
Don't add layers preemptively — a single common.py base class with attribute overrides covers most projects. The second layer earns its keep when setup time and helper methods start diverging between test types.
Tour Tests
Purpose: Automated browser tests that simulate a real user navigating the Odoo web client. They combine a Python test class (HttpCase) with JavaScript step definitions that click buttons, fill fields, and navigate views.
Current Position:
Tour tests are a valid testing tool in Odoo's framework, but they are not a standard requirement in our development workflow. The overhead of creating and debugging tour tests — particularly getting CSS selectors right, handling async timing, and troubleshooting failures — makes them impractical as a routine deliverable alongside ticket implementation, unit tests, and integration tests.
In a consulting context where engineering time is billed hourly, the time spent trial-and-erroring a tour test typically exceeds the time a person would take to walk through the same UI workflow manually.
When They Can Be Justified:
- CI smoke tests for critical paths (e.g., "the application loads and the main workflow doesn't crash")
- Stable, long-lived modules where the same UI workflow needs repeated regression coverage
- Simple forms or views where the creation effort is genuinely low
- Internal tooling or products where the team owns the maintenance long-term
When They Are Not Worth the Investment:
- Most client-facing ticket implementations (use Demo Recordings instead)
- Workflows with complex conditional logic or dynamic fields
- Features that change frequently during active development
- Flows depending on external state, timing, or background processes
Key Challenges:
- Selector fragility: Odoo's rendered DOM differs significantly from view XML, making trigger selectors hard to get right and easy to break
- Debugging difficulty: When a tour fails, error messages often point to a timeout rather than the actual issue
- Async timing: The web client loads views via RPC, requiring strategic delays that make tests slower and harder to tune
- Dual-layer complexity: Tour tests require both Python and JavaScript, increasing the surface area for errors
Looking Ahead:
Tour testing is an area we are monitoring. As AI-assisted development improves and tooling matures, the overhead may decrease to the point where tours become practical for broader use. For now, teams should be aware of the capability and consider it selectively, particularly for smoke testing in CI.
Demo Recordings
Purpose: Provide clear, reproducible video demonstrations of new features for consultants and customers. This is the primary method for communicating implementations to non-engineering stakeholders.
Important: Demo recordings are a communication deliverable, not a test. They do not run in CI, they do not assert behavior, and they do not catch regressions automatically. They complement — but do not replace — automated tests.
Why This Is Our Standard:
Demo recordings are the most time-efficient way to bridge engineering work and consultant understanding. Compared to automated tours:
- No brittle CSS selectors to create or maintain
- No debugging cycle — a person walks through the UI once and records
- Shows the complete picture: settings, data setup, feature in action, edge cases
- Engineer has full control over pacing, emphasis, and narrative
- Can cover multiple scenarios in a single recording session
- Protects engineers by documenting exactly what was delivered
Demo Recording Components:
| Component | Purpose |
|---|---|
| Demo Data Generator | Python script to create the scenarios needed for the recording |
| Recording Guide | Step-by-step screen recording instructions with talking points |
| Screen Recording | Final output for consultants and customers |
Settings in the recording: If the feature requires specific Odoo settings to be enabled (e.g., multi-company, units of measure, fiscal positions), the screen recording should start by showing the relevant Settings page and enabling them. This gives consultants and customers the full picture of what needs to be configured, not just the feature in action.
Show the full flow, not just the end result: The recording should demonstrate the complete journey, not skip to the finished state. For example, if the feature adds a new "Waiting Payment" state to sale orders, don't just show an SO already in that state — start from creating a new quotation and walk through each step until you reach the new state. The stakeholder needs to understand how to get there, not just that it exists. This also serves as a manual verification that the entire flow works as expected — that the proper triggers fire when they should, automations kick in at the right step, and nothing breaks along the way. It's not just proof of delivery, it's proof the flow is intact.
Data generator approach: The demo data generator creates the minimum records needed for the recording (partners, products, configuration). Where possible, it should leverage existing demo/sample data already in the database rather than creating everything from scratch. The generator is specifically for the screen recording — automated tests have their own setup in setUpClass.
Coverage Requirements
By Development Type
| Development Type | Unit Tests | Integration Tests | Demo Recording | Tour Test |
|---|---|---|---|---|
| New Module | Required | Required | Required | Optional (CI smoke) |
| Bug Fix | Required | Required | Required | No |
| Feature Extension | Required | Required | Required | No |
| New Field/View | Required | Required | Required | No |
| New Wizard | Required | Required | Required | No |
| New Report | Required | Required | Required | No |
| Hotfix | Required | Required | Deferred* | No |
| Refactoring | Required | Required | Required | Maintain existing |
*Hotfix exception: hotfixes ship with unit + integration tests immediately. The demo recording is delivered within 48 hours post-deploy, or replaced by a text description in the PR if the fix has no UI-visible impact.
Where we are today: Unit tests and demo recordings are consistently delivered across the team. Integration tests are written in some projects but not yet standard everywhere. The target is all three deliverables for every development type — this table represents the standard we are working toward, and the expectation for all new work going forward.
The scope of each deliverable varies with the development type — a bug fix tests the fix and verifies the workflow still works, a new field tests its computation and its impact on related models, a hotfix tests the regression end-to-end — but skipping a deliverable entirely is not acceptable without explicit justification in the PR.
Decision Flowchart
┌──────────────────────┐
│ New change/ticket │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ UNIT TESTS │
│ Always required │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ INTEGRATION TESTS │
│ Always required │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ DEMO RECORDING │
│ Always required │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ Is it a critical UI │
│ path, simple enough │
│ to justify the │
│ overhead? │
└──────────┬───────────┘
┌────┴────┐
YES NO
│ │
┌─────────▼──────┐ │
│ TOUR TEST │ │
│ (Optional, │ │
│ team lead │ ▼
│ approval) │ Done
└────────────────┘
Note: Consider recreating this chart in a visual tool (e.g., LucidChart) for the final published version.
Minimum Requirements
Every scenario requires unit tests, integration tests, and a demo recording. The table below specifies the minimum scope for each.
| Scenario | Minimum Test Scope |
|---|---|
| New computed field | 1 unit test per edge case + 1 integration test verifying field in workflow |
| New constraint | 1 unit test valid + 1 invalid + 1 integration test in context |
| New button/action | Unit tests for the method logic + 1 integration test for the full action |
| New wizard | Unit tests for wizard logic + 1 integration test for the complete flow |
| New view/page | Unit tests for any defaults/computes + 1 integration test for side effects |
| New report | Unit tests for report logic + 1 integration test for report generation |
| Bug fix | 1 unit test reproducing the bug + 1 integration test verifying the fix |
| API endpoint | Unit tests for input validation + 1 integration test per HTTP method |
| Complex workflow | Unit tests per component + integration tests covering multiple scenarios |
Demo Recording Workflow
Purpose
Demo recordings serve as the primary communication channel between engineering and consultants. They:
- Show the complete picture: settings → data setup → feature in action
- Cover multiple scenarios in one recording
- Don't suffer from selector brittleness or CI flakiness
- Protect engineers by documenting exactly what was delivered
Maintenance note: Unlike automated tests, demo recordings do not signal when they become outdated. When a feature changes significantly, the team is responsible for re-recording. Link recordings to specific ticket numbers so they can be identified when features are revisited.
Components
Every user-facing feature should include:
| Component | Location | Purpose |
|---|---|---|
| Data Generator | plans/<ticket-folder>/demo-data.py | Creates exact scenario data |
| Recording Guide | plans/<ticket-folder>/recording-guide.md | Step-by-step recording instructions |
| Screen Recording | Google Drive | Final consultant deliverable |
Step 1: Identify What to Demonstrate
From the implementation plan and requirements, determine:
- Which UI changes need to be shown (new fields, buttons, views, workflows)
- Which settings need to be enabled for the feature to be visible
- What data scenarios best showcase the feature
Step 2: Identify User Personas
If the feature involves permissions or group-based access, determine which user personas are needed:
| Persona | Purpose | Example |
|---|---|---|
| Admin | Configuration, setup, full access | base.group_system |
| Manager | Has the feature-specific permission | Feature group + app group |
| Basic User | Does NOT have the feature permission | App user group only |
Create the minimum set of users needed to demonstrate permission differences. Use descriptive DEMO-prefixed names (e.g., "DEMO Release Manager", "DEMO Warehouse User").
Step 3: Check Required Settings
Before creating demo data, document which Odoo settings need to be enabled:
- Settings toggles: res.config.settings fields
- Groups/permissions: Specific groups required
- Company settings: Company-level configuration
The screen recording should start by showing these settings being enabled.
Step 4: Query Existing Data
Before generating demo data, check what already exists in the database. If existing records can serve the demo, use them. Only create new data when:
- The feature introduces new workflows, states, or transitions (existing data won't demonstrate the flow)
- No suitable records exist for the scenarios you need to show
- You need specific edge cases that don't occur naturally
Step 5: Create Demo Data (if needed)
Create plans/<ticket-folder>/demo-data.py and run via odoo-bin shell.
./odoo-bin shell -d demo_db < plans/<ticket-folder>/demo-data.py
Demo data rules:
- Prefix all records with DEMO for easy identification and cleanup
- Use env.cr.commit() at the end (odoo-shell does not auto-commit)
- Workflow-first: When demonstrating new states or transitions, create records in the prior state with all conditions met, so the action button triggers the transition during the recording. Don't create records already sitting in the target state.
- Create one record per scenario — don't create 10 records when 3 cover all the cases
- Print a verification summary at the end with record IDs, user logins/passwords, and states
Step 6: Screen Recording Guide
Create plans/<ticket-folder>/recording-guide.md with step-by-step instructions. Every scenario must specify which user to be logged in as.
Scenario ordering:
- Configuration/setup scenarios (as Admin)
- Happy path scenarios (as Admin or Manager)
- Permission-granted scenarios (as Manager) — user WITH the permission can act
- Permission-denied scenarios (as Basic User) — user WITHOUT the permission cannot act
- Edge cases / validation errors (as appropriate user)
Rules:
- Every scenario has a user — always state who to log in as
- Permission scenarios come in pairs — show the same action from both a permitted and denied user
- Be specific — exact menu paths, record names, expected results
- Menu navigation must be thorough — trace the full path from top-level app to the specific view
Step 7: Record and Upload
Run the demo data script, use the recording guide as reference, and record your screen. Use whatever screen recording tool you prefer — macOS built-in recorder, OBS.
Tips:
- Pause briefly on important UI elements so the viewer can follow
- Show validation feedback and error messages when relevant
- Keep it concise — shorter recordings are more likely to be watched
Data privacy: Demo recordings may show customer-like data. Never use real client data — use DEMO-prefixed synthetic records (as specified in Step 5). If recording on a client database, ensure no real PII (names, emails, addresses, financial data) is visible on screen. Recordings are stored on Google Drive; follow the company's data retention policy for the shared folder.
Upload: Google Drive (much-GmbH shared folder), then link in the ticket/PR description.
Naming Convention
[Module]_[Feature]_Demo_YYYYMMDD
Examples:
- sale_credit_limit_Demo_20260213
- stock_batch_transfer_Demo_20260213
- account_payment_matching_Demo_20260213
Test Organization
Directory Structure
my_module/
└── tests/
├── __init__.py
├── common.py # Shared test setup, base classes, helpers
├── test_<model>.py # Unit tests (one file per model)
├── test_<workflow>.py # Integration tests (one file per workflow)
└── test_<module>_tour.py # Tour test wrapper (if applicable)
tests/common.py should contain shared test fixtures, base classes, and helper methods used across multiple test files. Typical contents include a base TestCase class with setUpClass that creates common records (users, companies, products) so individual test files don't duplicate setup logic. See the Reusable base test classes example in the Integration Tests section.
Naming Conventions
| Element | Convention | Example |
|---|---|---|
| Unit test file | test_<model>.py | test_sale_order.py |
| Integration test file | test_<workflow>.py | test_sale_workflow.py |
| Common test setup | common.py | common.py |
| Test class | Test<Module> | TestSaleOrder |
| Test method | test_<behavior> | test_compute_margin_percentage |
| Tour test JS | test_<module>_tour.js | test_sale_tour.js |
Test Method Names with TC/Ticket References
When tests map to manual test cases or specific tickets, encode the reference in the method name. This makes it possible to answer "is TC7 automated?" or "which tests cover ticket #49022?" with a simple grep.
Patterns:
| Source | Convention | Example |
|---|---|---|
| Manual test case | test_<tc_id>_<description> | test_tc7_consolidation_pricing_pa |
| Ticket + scenario | test_<ticket>_<scenario>_<description> | test_49022_3_push_outside_unreserves |
| Acceptance criteria | test_<tc_id>_<description> | test_a0_backward_scheduling_business_days |
The docstring adds the human-readable context:
def test_tc7_consolidation_pricing_pa(self):
"""PA consolidation & pricing with min/max orderpoint — TC-7."""
...
def test_49022_3_push_outside_unreserves(self):
"""Push outside window should unreserve — Ticket #49022, scenario 3."""
...
def test_a0_backward_scheduling_business_days(self):
"""All dates on weekdays with custom lead times — TC-A0."""
...
The generic test_<behavior> convention (from the table above) remains the default for tests that don't trace back to a specific TC or ticket. Use the reference-based naming only when a traceable mapping exists.
Test Case Coverage Map
For projects with manual test cases or acceptance criteria, maintain a coverage map that links each TC to its automated test(s). This serves two purposes:
- Visibility — consultants and project leads can see at a glance what's automated vs. manual-only
- Gap detection — TCs with no automated tests are explicitly flagged as known gaps, not oversights
Format: A markdown table in the project's KB, tests/COVERAGE.md, or the cross-module test module. Keep it simple:
# Test Case Coverage Map
| TC | Description | Automated Tests | Status |
| --- | --- | --- | --- |
| TC-1 | Basic SO confirmation | `test_tc1_basic_so_confirmation` | Automated |
| TC-7 | PA consolidation pricing | `test_tc7_consolidation_pricing_pa` | Automated |
| TC-12 | Credit limit + reservation | — | Manual only (complex multi-module interaction) |
| TC-15 | Concurrent SO confirmations | — | Manual only (race condition, hard to test deterministically) |
When to update: Every time a test is added for a TC, update the map. Every time a TC is identified as untestable, add it with a reason. The map is a living document — stale maps are worse than no map, because they give false confidence.
Relationship to the Coverage Gap Register: TCs marked "Manual only" in this map feed directly into the Coverage Gap Register (see the Coverage Gap Tracking section under Guidelines). The coverage map tracks what is and isn't automated; the gap register explains why and helps prioritize future test work.
Running Tests
Local Development
# Run all tests for a module
./odoo-bin -d test_db --test-tags /my_module -i my_module --stop-after-init
# Run specific test file
./odoo-bin -d test_db --test-tags /my_module.test_sale_order -i my_module --stop-after-init
# Run with coverage report
coverage run ./odoo-bin -d test_db --test-tags /my_module -i my_module --stop-after-init
coverage report -m --include="addons/my_module/*"
# Run tour test (requires browser)
./odoo-bin -d test_db --test-tags /my_module.test_my_module_tour -i my_module
Per-Project Run Commands
The generic examples above are a starting point. Each project should document its exact run commands — including virtualenv activation, config file path, database name, and tag composition — in a tests/RUN.md or equivalent location in the project KB.
Developers joining a project should be able to copy-paste a command and run tests immediately, without guessing which database, config, or venv to use.
Template:
# 1. Activate the project's test virtualenv
source <venv_path>/bin/activate
# 2. Run a single feature tag during development
python <odoo_path>/odoo-bin \
-c <project_path>/<project>.conf \
-d <test_db> --test-enable \
--test-tags=<project_prefix>_<feature_name> \
-u <test_module> --stop-after-init
# 3. Run the full regression suite before merging
python <odoo_path>/odoo-bin \
-c <project_path>/<project>.conf \
-d <test_db> --test-enable \
--test-tags=<project_prefix>_regressions \
-u <test_module> --stop-after-init
Each project fills in the placeholders with its actual values and commits the result to tests/RUN.md. For example:
# 1. Activate the project's test virtualenv
source ~/venvs/odoo17/bin/activate
# 2. Run a single feature tag during development
python ~/odoo17/odoo-bin \
-c ~/projects/baustoff_metall/bm_v17.conf \
-d bm_test --test-enable \
--test-tags=bm_core_reservation \
-u bm_automated_tests --stop-after-init
# 3. Run the full regression suite before merging
python ~/odoo17/odoo-bin \
-c ~/projects/baustoff_metall/bm_v17.conf \
-d bm_test --test-enable \
--test-tags=bm_regressions \
-u bm_automated_tests --stop-after-init
What to document:
| Item | Why |
|---|---|
| Virtualenv path and activation | Different projects may use different Python versions or venvs |
| Config file path (-c) | Contains DB host, port, addons path — wrong config = wrong results |
| Test database name (-d) | Prevents accidentally running tests against a development or production database |
| Module to update (-u) | For cross-module test modules, this is the test module, not the feature module |
| Feature tag commands | Copy-paste commands for each feature tag, not just the regression tag |
Alternative: pytest-odoo
The OCA ecosystem uses pytest-odoo to run Odoo tests through pytest, enabling standard pytest features like fixtures, parametrize, markers, and coverage reporting.
pip install pytest-odoo
pytest --odoo-database=test_db addons/my_module/tests/
This is fully compatible with Odoo's TransactionCase and HttpCase — existing tests work without modification. The main benefit is integration with coverage.py and CI tools that expect pytest output. Whether to use native odoo-bin --test-tags or pytest-odoo is a project-level decision; both are valid.
CI Pipeline
Tests are automatically executed on every PR. The pipeline:
- Creates a fresh test database
- Installs the module with dependencies
- Runs all tests tagged post_install
- Reports failures and coverage metrics
Test Tags
Standard Tags
Every test class needs at minimum the Odoo install-phase tags:
- @tagged('post_install', '-at_install') — Standard for most tests (run after module install)
- @tagged('-standard', 'slow') — Exclude from standard runs (long-running or resource-heavy tests)
Tag Strategy: Two Levels
In practice, you need two levels of custom tags:
- Feature tags — scoped to a specific feature or ticket, used during active development for fast iteration
- Project regression tag — applied to every test class, used for full-suite runs before merging
Why both levels matter: During development, running the entire regression suite (potentially hundreds of tests) takes minutes. Running only the 5-10 tests for the feature you're touching takes seconds. Feature tags let developers iterate fast while the regression tag catches cross-feature regressions before code is merged.
Naming convention: <project_prefix>_<feature_name> for feature tags, <project_prefix>_regressions for the regression tag.
Example — a test class with both levels:
@tagged('post_install', '-at_install', 'much_unit', 'bm_core_reservation', 'bm_regressions')
class TestCoreReservationLogic(TransactionCase):
...
# Fast: run only the feature you're working on
./odoo-bin -d test_db --test-tags=bm_core_reservation --stop-after-init
# Full: run the entire regression suite before merging
./odoo-bin -d test_db --test-tags=bm_regressions --stop-after-init
The regression tag can also be set as the default in .odoo.conf:
test_tag = bm_regressions
Feature Tag Inventory
Each project should maintain an inventory of its feature tags. This makes it easy to see what's covered, how tests are distributed, and which tag to use when working on a specific area.
Example from the Baustoff project (11 feature tags, 188 tests):
| Tag | Tests | Feature |
|---|---|---|
| bm_core_reservation | 31 | Core reservation window, backward scheduling, procurement |
| bm_master_data | 18 | Lead time changes, rescheduling |
| bm_rfq_dates | 16 | RFQ date formulas (L/K/M) |
| bm_premature_reservation | 7 | Window boundary, push/pull, partial stock |
| bm_receipt_date_independence | 7 | Receipt date isolation |
| bm_scheduler_batching | 7 | Batched scheduler, error recovery |
| bm_discount_propagation | 6 | SOL to POL discount flow |
| bm_method_c_dates | 6 | Method C date calculation |
| bm_phantom_lines | 5 | PA phantom line prevention |
| bm_pa_retrieval | 4 | PA matching and pricing |
| bm_pa_separation | 2 | PA line separation |
Convention: One feature tag per test file. This keeps the mapping simple — test_core_reservation_logic.py uses bm_core_reservation, test_scheduler_batching.py uses bm_scheduler_batching. When a test file grows large enough to split, the feature tag naturally carries over to both files.
Where to document: Maintain the tag inventory in the project's KB, tests/README.md, or the cross-module test module's manifest description. Keep it updated as new feature tags are added.
Guidelines
Good Practices
- Descriptive test names — Method names should describe the behavior being tested (e.g., test_margin_calculation_excludes_discounts)
- One assertion focus per test — Each test should verify one logical behavior; multiple related assertions are acceptable
- Use common.py for shared setup — Define base test classes with shared setUpClass data; individual test files inherit from these
- Use setUpClass for shared data — Create common test records once, not per test; improves test performance
- Test edge cases explicitly — Zero values, empty collections, boundary conditions; each edge case deserves its own test method
- Use Form class for onchange testing — When testing fields that rely on @api.onchange, use odoo.tests.Form to simulate the UI behavior accurately
- Isolate test data — Never assume records from other tests exist; each test class should create its own data in setUpClass and avoid mutating shared records across test methods
- Prefix demo records with DEMO — Easy identification in database, simple cleanup when needed
Use assertRecordValues for recordset assertions — Odoo provides self.assertRecordValues(recordset, [dict, ...]) to check multiple records and fields in one call. It gives clear diffs on failure. Gotcha: record order must match the dict list order — sort the recordset first if order isn't guaranteed. python self.assertRecordValues(order.order_line.sorted('sequence'), [ {'product_id': self.product_a.id, 'product_uom_qty': 5, 'price_unit': 100.0}, {'product_id': self.product_b.id, 'product_uom_qty': 3, 'price_unit': 50.0}, ])
Test with specific users, not sudo() — Create test users with the appropriate groups and run operations with record.with_user(user). This catches access control bugs that sudo() masks. See the Access Rights Testing section for patterns.
Anti-Patterns
- Multiple unrelated assertions in one test — Makes failures hard to diagnose; split into separate test methods
- Hardcoded record IDs — Tests become brittle and database-dependent; always create test data in setup
- Mutating shared setUpClass records — If one test method modifies a shared record, subsequent tests may fail unpredictably; create per-test records for methods that write data
- No assertion messages for complex checks — When assertTrue(complex_condition) fails, what failed? Add descriptive messages
- Testing Odoo core functionality — Don't test that ORM create/write/unlink works; focus on your custom logic
- Using sudo() everywhere in tests — sudo() bypasses access rights, hiding permission bugs that will surface in production. Only use sudo() when the test intentionally needs superuser context (e.g., setup operations). For the test action itself, use with_user()
- Tour tests as a default requirement — The overhead does not justify routine use; reserve for selective CI smoke tests when the cost-benefit is clear
Coverage Gap Tracking
Not everything can or should be automated. The problem isn't having gaps — it's having gaps you don't know about. A coverage gap register makes testing debt visible, prevents false confidence, and helps prioritize future test work.
What Goes in the Register
A gap is something you consciously chose not to test automatically, along with the reason. Typical reasons:
- Complex multi-module interaction — too many moving parts to set up reliably in a transaction test
- Concurrency / race conditions — hard to reproduce deterministically in a single-threaded test runner
- Edge case not in requirements — theoretically possible but not specified or expected in practice
- Not yet in scope — planned for a future sprint or ticket
- Partially covered — an existing test covers part of the scenario but lacks a dedicated test for the full case
Format
Maintain the register in tests/COVERAGE_GAPS.md or the project KB. Keep it simple — one row per gap:
# Coverage Gap Register
| Area | Gap | Reason | Ticket |
| --- | --- | --- | --- |
| Credit limit + reservation | Credit check revert blocks reservation | Complex multi-module interaction | — |
| Concurrent SO confirmations | Race condition on orderpoint qty | Hard to test deterministically | — |
| Mixed by_date + by_quantity routes | Same SO, different products | Edge case not in requirements | — |
| Partial delivery impact | Reservation window changes post-delivery | Not yet in scope | BM-234 |
| PA period switching | Mid-SO period boundary crossing | Partially covered in TC-11 | — |
The optional Ticket column links the gap to a backlog item when one exists, making it clear whether the gap is tracked debt or an accepted risk.
When to Update
- Adding a gap: When a test case is identified during development or review but deliberately skipped — add it during the PR, not after
- Closing a gap: When a test is written that covers a previously registered gap — remove the row and link to the PR that added the test
- During retrospectives: Review the register periodically to decide which gaps are worth closing based on bug frequency and risk
Relationship to the Coverage Map
The Test Case Coverage Map tracks which manual TCs have automated tests. TCs marked "Manual only" in that map are candidates for this register. The coverage map answers what, the gap register explains why not and what's the risk.
Bug-to-Test Reverse Index
A related practice: when bug fixes are merged, track which tests cover each bug. This makes it easy to verify regression coverage and flag bugs that have no automated test.
# Bug Coverage Index
| Bug | Test Tag(s) | Key Tests | Status |
| --- | --- | --- | --- |
| BUG-01 (premature reservation) | bm_premature_reservation | test_49022_1..8 | Covered |
| BUG-02 (calendar vs business day) | bm_premature_reservation, bm_master_data | test_49022_1..8, test_md9b | Covered |
| BUG-03..11 (PA phantom lines) | bm_phantom_lines | test_49013_1..5 | Covered |
| BUG-15 (dynamic discount) | bm_discount_propagation | test_disc1..5 | Partial |
| BUG-16 (tracking MissingError) | — | — | **No test** |
BUG-16 having no test is explicitly flagged — it's known debt, not an oversight. During code review, a bug fix PR merged without a regression test should be flagged and either justified (added to the gap register with a reason) or rejected until a test is added.
Scenario Design with Dimension Matrices
For features with multiple interacting variables, "test happy path and edge cases" isn't specific enough — it leaves test selection to intuition, which leads to either gaps or redundancy. A dimension matrix makes the decision explicit.
How It Works
Identify the independent variables (dimensions) that affect the feature's behavior and list the meaningful values for each. The cross-product gives you the theoretical scenario space. You then choose which combinations to test and document which ones you skip and why.
Example: A pricing feature might depend on customer type (regular / VIP / wholesale), payment term (immediate / 30 days / 60 days), and currency (EUR / USD). That's 3 x 3 x 2 = 18 combinations. You don't write 18 tests — you pick the combinations that represent real usage, boundaries where behavior changes, and areas where bugs have bitten before. The rest go into the gap register with a reason.
What to Do with the Matrix
- Test the diagonals first — one test per dimension value, varying one thing at a time, to confirm each value works in isolation
- Then test the risky intersections — combinations where two dimensions interact in non-obvious ways (e.g., VIP discount + multi-currency rounding)
- Skip the boring middle — if two combinations behave identically from a code path perspective, testing both adds no value
- Feed skipped combinations into the gap register — "not tested because same code path as TC-3" is a valid reason
When to Use This
Use a dimension matrix when the feature has 3+ independent variables and the team is debating what to test. For simpler features — a computed field, a constraint, a button action — the standard "happy path + edge cases + errors" approach is sufficient.
Context Flags in Tests
Odoo tests frequently need context flags for performance, correctness, or to control side effects. Understanding when to use them — and when their use is a code smell — is a practical skill that affects every test suite.
Performance: Disable Mail Tracking
Mail tracking and logging add significant overhead in tests. Disabling them in setUpClass speeds up test execution without affecting the logic under test. There are two approaches:
tracking_disable=True (OCA standard) — a single flag that disables mail tracking, activity creation, and notification logic. This is the most comprehensive option and the convention in OCA community code.
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
mail_notrack=True + mail_create_nolog=True — more granular. mail_notrack disables field change tracking; mail_create_nolog skips the "created" log message. Use this when you need to disable tracking but still want other mail features (e.g., activity creation) to work.
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(cls.env.context, mail_notrack=True, mail_create_nolog=True))
Which to use: Default to tracking_disable=True unless you have a reason to be granular. If your test needs to verify mail-related behavior (message posting, activity scheduling), don't use either — let the full mail stack run for that specific test.
Apply this to base test classes so all tests inherit the performance benefit. If a specific test needs to verify mail tracking behavior, override the context for that test only.
Correctness: Test-Only Workarounds
Some Odoo framework behaviors only surface in tests (e.g., cursor handling differences between production and test environments). When a workaround is needed, always document why with a comment.
# Workaround: _track_finalize enters infinite loop during _procure_orderpoint_confirm
# in test environment due to cursor close behavior. Production is unaffected (separate cursor).
order.with_context(mail_notrack=True)._procure_orderpoint_confirm(...)
The comment is critical — without it, the next developer will either remove the flag (reintroducing the issue) or cargo-cult it into unrelated tests.
Control Flow: Feature Flags in Tests
Custom context flags can skip specific side effects to isolate the behavior under test.
# Skip auto-scheduler during SO confirmation so we can test confirmation logic
# without scheduler interference — scheduler is tested separately in test_scheduler_batching.py
order.with_context(skip_so_auto_scheduler=True).action_confirm()
This is acceptable when:
- The skipped behavior is tested in its own dedicated tests
- The flag is documented in both the test and the production code that reads it
- The test name or docstring makes it clear what is being isolated
When Context Flags Are a Code Smell
Context flags become problematic when they mask real behavior rather than isolating it:
- Skipping validation to make a test pass — If you need skip_check=True for a test to work, the test data is wrong, not the validation
- Accumulating undocumented flags — If a test needs 4+ context flags to run, the code under test may have too many responsibilities
- Flags that only exist for tests — A flag like is_testing=True that changes production behavior is a sign that the code needs refactoring, not a test workaround
Date-Sensitive Testing
Many Odoo features involve date calculations — lead times, reservation windows, scheduled dates, payment terms, aging reports. Tests for these features break silently if dates are hardcoded, and produce confusing failures on weekends or over time.
Rule: Always Use Relative Dates
Never hardcode absolute dates in tests. Compute them relative to today so the test remains valid regardless of when it runs.
from datetime import date, timedelta
# Bad — breaks when this date is in the past, or on a weekend
commitment_date = date(2026, 3, 15)
# Good — always 14 calendar days from now
commitment_date = date.today() + timedelta(days=14)
Business Days vs. Calendar Days
When the feature under test uses business days (weekdays only), the test must compute dates accordingly. A helper method keeps this consistent across the test suite.
# tests/common.py or test_base.py
def _add_business_days(self, start_date, business_days):
"""Add N business days (Mon-Fri) to a start date."""
current = start_date
added = 0
while added < business_days:
current += timedelta(days=1)
if current.weekday() < 5:
added += 1
return current
# Usage in tests
def test_backward_scheduling_business_days(self):
"""Scheduled date should be commitment date minus lead time in business days."""
commitment = self._add_business_days(date.today(), 14)
order = self._create_so(commitment_date=commitment)
order.action_confirm()
expected_receipt = self._add_business_days(
date.today(),
14 - self.company.security_lead - self.company.po_lead,
)
self.assertEqual(order.picking_ids.scheduled_date.date(), expected_receipt)
Be explicit in test names and docstrings about whether the calculation uses business days or calendar days — this is a common source of confusion.
Document Lead Time Configuration
When tests depend on company-level lead times (security lead, purchase lead, days to purchase), document the values used in the test fixture. A comment block or a constants section in the base test class prevents developers from having to trace through setUpClass to understand why a date assertion expects a specific value.
class TestReservationBase(TransactionCase):
"""
Standard test lead times (business days):
security_lead: 4
po_lead: 2
days_to_purchase: 3
supplier_delay: 5
Total pipeline: 14 business days
"""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.company = cls.env.ref('base.main_company')
cls.company.write({
'security_lead': 4,
'po_lead': 2,
'days_to_purchase': 3,
})
Controlling "Today" in Tests
When the code under test calls fields.Date.today() or datetime.now() internally and you need to control what "today" means, patch it directly on the Odoo fields class. This is the most reliable approach across all Odoo versions.
from unittest.mock import patch
from odoo import fields
def test_payment_term_due_date(self):
"""30-day payment term from June 15 should be due July 15."""
with patch.object(fields.Date, 'today', return_value=date(2026, 6, 15)):
invoice = self._create_invoice(payment_term='30_days')
self.assertEqual(invoice.invoice_date_due, date(2026, 7, 15))
Why not freezegun? freezegun intercepts Python's datetime.date.today() and datetime.datetime.now(), but Odoo's date/datetime fields use their own implementation (fields.Date.today(), fields.Date.context_today(), and in some versions SQL now()). freezegun may not intercept all of these depending on the Odoo version, leading to tests that pass on one version and fail on another. Patching fields.Date.today directly is explicit and version-safe.
When to use freezegun anyway: If you've verified it works with your Odoo version and need to freeze time for an entire test method (not just one call), freezegun is more ergonomic:
from freezegun import freeze_time
@freeze_time('2026-06-15')
def test_overdue_detection(self):
"""Invoices past due date should be flagged as overdue."""
...
Prefer relative dates over either approach when possible — frozen/patched dates are absolute and can obscure intent. Use time control only when relative dates cannot express the scenario (specific day-of-week, multiple internal calls to today(), date-dependent behavior like overdue detection).
Access Rights Testing
ERP systems handle sensitive data — access control bugs are among the most common and dangerous issues. Tests should verify that permissions work correctly, not just that business logic runs under superuser.
Pattern: Test Users with Specific Groups
Create test users in setUpClass with the exact groups the feature requires. Test operations with with_user() instead of sudo().
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
cls.sale_manager = cls.env['res.users'].create({
'name': 'Test Sale Manager',
'login': 'test_sale_manager',
'groups_id': [(6, 0, [
cls.env.ref('sales_team.group_sale_manager').id,
])],
})
cls.basic_user = cls.env['res.users'].create({
'name': 'Test Basic User',
'login': 'test_basic_user',
'groups_id': [(6, 0, [
cls.env.ref('base.group_user').id,
])],
})
Pattern: Permission Pairs
Test the same action from both a permitted and denied user. This catches both false positives (user can do something they shouldn't) and false negatives (user can't do something they should).
def test_manager_can_confirm_order(self):
"""Sale manager should be able to confirm quotations."""
order = self._create_quotation()
order.with_user(self.sale_manager).action_confirm()
self.assertEqual(order.state, 'sale')
def test_basic_user_cannot_confirm_order(self):
"""Basic user without sale manager group should get AccessError."""
order = self._create_quotation()
with self.assertRaises(AccessError):
order.with_user(self.basic_user).action_confirm()
What to Test
- Model access rights (ir.model.access.csv) — can the user create/read/write/unlink?
- Record rules (ir.rule) — can the user see records from other companies/teams?
- Group-gated buttons — does the button action enforce the required group?
- Multi-company isolation — can a user in Company A access records from Company B?
For modules with custom security groups, access rights tests are not optional — they should be part of the standard test suite alongside unit and integration tests.
Transaction and Cursor Pitfalls
Odoo's test framework uses savepoints for test isolation. Several common patterns break this isolation in ways that produce confusing, hard-to-debug failures.
cr.commit() Breaks Test Isolation
If production code calls self.env.cr.commit() — common in cron jobs, queue workers, and some batch operations — it destroys the test savepoint. All subsequent tests in the class may see corrupted state or fail unexpectedly.
Solution: Mock the commit when testing code that calls it:
from unittest.mock import patch
def test_cron_processes_records(self):
"""Test cron logic without letting cr.commit() break the savepoint."""
with patch.object(self.env.cr, 'commit'):
self.env['my.model'].run_scheduled_action()
# Assertions here — savepoint is intact
Alternatively, call the cron's business logic method directly instead of triggering the ir.cron record.
cr.rollback() Can Destroy setUpClass Data
A cr.rollback() in production code (e.g., error recovery) can roll back past the test method's savepoint, wiping out records created in setUpClass. If you see MissingError or ValueError in tests after a rollback, this is likely the cause.
HttpCase Does Not Roll Back
Unlike TransactionCase, HttpCase commits the transaction so the HTTP server thread can see test data. Data created during an HttpCase test persists in the database after the test finishes. Be aware of this when using HttpCase — it can pollute the test database and cause surprising interactions between test classes.
Cache Invalidation
After ORM operations, cached field values on existing recordsets can be stale. This is especially common when code modifies records through a different environment or after raw SQL.
# If a test sees unexpected stale values after a write:
record.invalidate_recordset() # invalidate this specific recordset
# or
self.env['model.name'].invalidate_model() # invalidate all records of a model
Version note: invalidate_cache() is deprecated in Odoo 17+. Use invalidate_recordset() or invalidate_model() instead.
Performance Testing with assertQueryCount
Odoo provides assertQueryCount to verify that operations don't regress on the number of database queries. This catches N+1 query patterns — one of the most common performance issues in Odoo development.
def test_confirm_order_query_count(self):
"""SO confirmation should not scale linearly with line count."""
order = self._create_order_with_lines(line_count=10)
# Flush pending operations so we only measure the confirmation
self.env.flush_all()
with self.assertQueryCount(self.sale_manager, 45):
order.with_user(self.sale_manager).action_confirm()
Use this selectively on performance-critical operations (order confirmation, invoice creation, stock moves). The exact count is less important than establishing a baseline — if the count jumps from 45 to 200 after a code change, something regressed.
Tip: Use self.env.flush_all() before the assertion to ensure pending lazy writes don't inflate the count.
Review Checklist
A reference for reviewing tests in a pull request. Use it as a guide, not a strict audit — the goal is catching gaps, not ticking every box.
Tests
- Unit tests and integration tests are present for the implementation
- tests/common.py is used when multiple test files share setup logic
- Tests are tagged with both the feature tag and the project regression tag
- Happy path, edge cases, and error conditions are covered
- Tests follow the naming conventions from the Test Organization section
- Access rights are tested for features with custom security groups
Test Quality
- One logical behavior per test method
- Helper methods reduce boilerplate across tests
- Test data is created in setUpClass, not hardcoded IDs
- No testing of Odoo core functionality — focus is on custom logic
- Operations run with with_user(), not sudo() (unless superuser is intentional)
- Date-sensitive tests use relative dates or freezegun, not hardcoded dates
- Context flags (tracking_disable, mail_notrack) are documented with comments when used for workarounds
- Form class is used when testing onchange-dependent behavior
Coverage Tracking
- Coverage gap register is updated when gaps are identified during the PR
- Bug fix PRs include a regression test, or the gap is documented with a reason
- TC/ticket reference is encoded in the test method name when a traceable mapping exists
Demo Recording
- Screen recording is linked in the ticket/PR
- Recording shows the full flow, not just the end result
- Settings configuration is visible when relevant
Before Approving
- Project regression suite passes locally
- No test data contains real PII or client data (use synthetic/anonymized data)
See also: Code Review guidelines.
Version Notes
much-GmbH develops across Odoo versions 15 through 19. The testing framework has evolved across these versions. This section documents the differences that affect how tests are written and run.
Test Base Classes
| Version | TransactionCase | SavepointCase | HttpCase |
|---|---|---|---|
| 15 | Transaction-level rollback (full rollback per method) | Savepoint-based rollback (more efficient) | Available |
| 16+ | Absorbed SavepointCase behavior — now uses savepoints by default | Removed. Replace with TransactionCase. | Available |
Migration action: When upgrading tests from v15, replace all SavepointCase imports with TransactionCase. No other changes needed — the behavior is identical in v16+.
Cache Invalidation
| Version | Method | Status |
|---|---|---|
| 15-16 | self.env.cache.invalidate() or record.invalidate_cache() | Available |
| 17+ | record.invalidate_recordset() or Model.invalidate_model() | Current standard |
| 17+ | invalidate_cache() | Deprecated — still works but emits warnings |
Migration action: Replace invalidate_cache() with invalidate_recordset() (for specific records) or invalidate_model() (for all records of a model).
Form Class
| Version | Behavior |
|---|---|
| 15 | Basic Form class, limited Many2many support |
| 16 | Improved One2many handling |
| 17 | Better handling of invisible/readonly fields, improved Many2many support |
| 18 | Further refinements, better error messages on invalid operations |
The Form class API is stable across versions — code written for v15 generally works in v18. The differences are in edge case handling and error reporting.
Tag Filtering
| Version | --test-tags syntax |
|---|---|
| 15 | Basic: --test-tags=my_tag |
| 16 | Module scoping: --test-tags=/my_module |
| 17+ | Class/method scoping: --test-tags=/my_module:TestClass.test_method |
Migration action: Tags written for v15 work in v17+. The new syntax only adds capabilities — it doesn't break existing tag usage.
Frontend / Tour Tests
| Version | Framework | Notes |
|---|---|---|
| 15 | OWL 1 | browser_js() for tour tests |
| 16-17 | OWL 2 | Tour step API largely compatible, some CSS selector changes due to new component rendering |
| 18-19 | OWL 2 (refined) | Better error reporting, improved timeout handling in browser_js() |
Migration action: JS tour tests often need selector updates when upgrading due to DOM structure changes (new component rendering, different class names). This is one of the reasons tour tests have high maintenance cost across version upgrades.
assertQueryCount and Performance Utilities
| Version | Availability |
|---|---|
| 15-16 | assertQueryCount available, basic |
| 17+ | Improved assertQueryCount, warmup context manager for more accurate measurement |
Other Version-Specific Notes
- v15 → v16: fields.Date.today() behavior standardized. Some edge cases with context_today() resolved.
- v17: assertRecordValues became more widely used in Odoo's own test suite — adopt it as the standard assertion for recordset checks.
- v18: Test runner performance improvements — faster module loading during test execution.
- v19: Refer to the Odoo 19 testing documentation for version-specific changes. Update this section as projects adopt v19.
Resources
Odoo Testing Documentation (by version):
Community & Tooling: