Why Test Odoo Modules?
Untested Odoo modules break silently during upgrades, introduce regressions when modified, and cause data corruption in production. Testing catches these issues before they reach users. Odoo provides a comprehensive testing framework — use it.
Test Types
| Type | Base Class | Tests | Speed |
|---|---|---|---|
| Unit (Model) | TransactionCase | ORM operations, business logic, constraints | Fast |
| Unit (Saved) | SavepointCase | Same as above, shared setup across tests | Faster |
| HTTP | HttpCase | Controllers, API endpoints, web routes | Medium |
| UI Tour | HttpCase + tour | Full browser UI workflows | Slow |
TransactionCase — Model Testing
# tests/test_equipment.py
from odoo.tests import TransactionCase, tagged
from odoo.exceptions import ValidationError
@tagged('post_install', '-at_install')
class TestEquipment(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.department = cls.env['hr.department'].create({
'name': 'Engineering',
})
cls.employee = cls.env['hr.employee'].create({
'name': 'Alice',
'department_id': cls.department.id,
})
def test_create_equipment(self):
"""Test basic equipment creation."""
equipment = self.env['equipment.item'].create({
'name': 'Laptop #42',
'serial_number': 'SN-042',
'department_id': self.department.id,
'assigned_to': self.employee.id,
})
self.assertEqual(equipment.name, 'Laptop #42')
self.assertEqual(equipment.department_id, self.department)
def test_serial_number_unique(self):
"""Serial number must be unique."""
self.env['equipment.item'].create({
'name': 'Item 1',
'serial_number': 'SN-001',
})
with self.assertRaises(Exception): # IntegrityError
self.env['equipment.item'].create({
'name': 'Item 2',
'serial_number': 'SN-001', # Duplicate
})
def test_computed_warranty_days(self):
"""Test warranty days computation."""
from datetime import date, timedelta
equipment = self.env['equipment.item'].create({
'name': 'Test',
'serial_number': 'SN-TEST',
'warranty_expiry': date.today() + timedelta(days=30),
})
self.assertEqual(equipment.days_until_warranty, 30)
def test_constraint_name_length(self):
"""Name must be at least 3 characters."""
with self.assertRaises(ValidationError):
self.env['equipment.item'].create({
'name': 'AB', # Too short
'serial_number': 'SN-SHORT',
})HttpCase — Controller Testing
@tagged('post_install', '-at_install')
class TestEquipmentAPI(HttpCase):
def test_api_list_equipment(self):
"""Test the equipment list API endpoint."""
self.authenticate('admin', 'admin')
response = self.url_open('/api/v1/equipment')
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertTrue(data.get('success'))
def test_api_requires_auth(self):
"""Unauthenticated requests should fail."""
response = self.url_open('/api/v1/equipment')
self.assertIn(response.status_code, [401, 403])Test Tags
# Run specific test tags:
# python odoo-bin -d testdb --test-tags /equipment_tracking
# Common tags:
@tagged('post_install', '-at_install') # Run after module install
@tagged('post_install', 'at_install') # Run both times
@tagged('-standard', 'slow') # Excluded from standard runsRunning Tests
# Run all tests for a module
python odoo-bin -d testdb --test-tags /equipment_tracking --stop-after-init
# Run a specific test class
python odoo-bin -d testdb --test-tags /equipment_tracking.TestEquipment --stop-after-init
# Run with verbose output
python odoo-bin -d testdb --test-tags /equipment_tracking --log-level=test:DEBUG --stop-after-initTest File Structure
your_module/
├── tests/
│ ├── __init__.py # Import all test files
│ ├── test_equipment.py # Model tests
│ ├── test_api.py # Controller tests
│ └── test_security.py # Access control tests
├── __init__.py
└── __manifest__.py# tests/__init__.py
from . import test_equipment
from . import test_api
from . import test_securityCommon Testing Patterns
Test Access Control
def test_user_cannot_delete(self):
"""Regular users cannot delete equipment."""
user = self.env['res.users'].create({...})
equipment = self.env['equipment.item'].sudo().create({...})
with self.assertRaises(AccessError):
equipment.with_user(user).unlink()Test Computed Fields
def test_total_cost(self):
"""Total cost should sum line costs."""
order = self.env['purchase.order'].create({...})
# Create lines...
self.assertAlmostEqual(order.total_cost, 1500.00, places=2)Test Workflow Transitions
def test_confirm_workflow(self):
"""Confirming should change state and create delivery."""
order = self.env['sale.order'].create({...})
self.assertEqual(order.state, 'draft')
order.action_confirm()
self.assertEqual(order.state, 'sale')
self.assertTrue(order.picking_ids)AI-Assisted Test Writing
Claude Code generates comprehensive test suites from model definitions. It covers CRUD, constraints, computed fields, access control, and workflows — often generating more thorough tests than manual writing.
Getting Started
Deploy a test database on DeployMonkey and run your tests against it. Use Claude Code to generate initial test suites, then refine them manually for edge cases specific to your business logic.