Why Test Odoo Modules?
Untested Odoo modules break on upgrade, break when dependencies change, and break when data varies from what the developer assumed. Tests catch these before they reach production. Odoo provides a robust testing framework built on Python's unittest.
Test Classes
| Class | Use Case | Transaction |
|---|---|---|
TransactionCase | ORM operations, business logic | Rolled back after each test |
SingleTransactionCase | Tests that share state | One transaction for all tests |
HttpCase | Controller tests, tours | Committed (use carefully) |
SavepointCase | Heavy setup, multiple tests | Savepoint per test |
Basic Test
# tests/__init__.py
from . import test_equipment
# tests/test_equipment.py
from odoo.tests.common 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.equipment = cls.env['equipment.item'].create({
'name': 'Laptop',
'serial_number': 'SN-001',
'department_id': cls.department.id,
'status': 'available',
'purchase_cost': 1500.00,
})
def test_create_equipment(self):
"""Test basic equipment creation."""
self.assertEqual(self.equipment.name, 'Laptop')
self.assertEqual(self.equipment.status, 'available')
def test_negative_cost_rejected(self):
"""Test that negative purchase cost raises ValidationError."""
with self.assertRaises(ValidationError):
self.equipment.write({'purchase_cost': -100})
def test_serial_unique(self):
"""Test that duplicate serial numbers are rejected."""
with self.assertRaises(Exception): # IntegrityError
self.env['equipment.item'].create({
'name': 'Another Laptop',
'serial_number': 'SN-001', # Duplicate!
})
def test_warranty_computation(self):
"""Test days_until_warranty computed field."""
from datetime import date, timedelta
future_date = date.today() + timedelta(days=30)
self.equipment.warranty_expiry = future_date
self.assertEqual(self.equipment.days_until_warranty, 30)Running Tests
# Run all tests for a module
python odoo-bin -d testdb --test-tags /equipment_tracking --stop-after-init
# Run 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 --stop-after-init --log-level=test
# Run all post_install tests
python odoo-bin -d testdb --test-tags post_install --stop-after-initTest Tags
# Control when tests run:
@tagged('post_install', '-at_install') # Run after install (most common)
@tagged('at_install') # Run during install
@tagged('-standard', 'custom') # Exclude from standard, add custom tag
# Run by tag:
# --test-tags custom
# --test-tags -slow (exclude slow tests)Testing Patterns
Test Access Rights
def test_user_cannot_delete(self):
"""Regular users should not be able to delete equipment."""
user = self.env['res.users'].create({
'name': 'Test User',
'login': 'testuser',
'groups_id': [(6, 0, [self.env.ref('equipment_tracking.group_equipment_user').id])],
})
with self.assertRaises(Exception):
self.equipment.with_user(user).unlink()Test Computed Fields
def test_computed_field_updates(self):
"""Test that changing dependency triggers recomputation."""
self.equipment.warranty_expiry = False
self.assertEqual(self.equipment.days_until_warranty, 0)
from datetime import date, timedelta
self.equipment.warranty_expiry = date.today() + timedelta(days=60)
self.assertEqual(self.equipment.days_until_warranty, 60)Test Workflow
def test_status_workflow(self):
"""Test equipment status transitions."""
self.assertEqual(self.equipment.status, 'available')
self.equipment.action_assign()
self.assertEqual(self.equipment.status, 'in_use')
self.equipment.action_return()
self.assertEqual(self.equipment.status, 'available')Test with Mock
from unittest.mock import patch
def test_external_api_call(self):
"""Test method that calls external API."""
with patch('odoo.addons.my_module.models.my_model.requests.post') as mock_post:
mock_post.return_value.json.return_value = {'status': 'ok'}
result = self.record.sync_with_external()
self.assertTrue(result)
mock_post.assert_called_once()HTTP Tests
from odoo.tests.common import HttpCase
@tagged('post_install', '-at_install')
class TestEquipmentController(HttpCase):
def test_api_endpoint(self):
"""Test REST API endpoint."""
self.authenticate('admin', 'admin')
response = self.url_open('/api/equipment', data='{}')
self.assertEqual(response.status_code, 200)Common Mistakes
- No setUpClass — Creating test data in each test method is slow. Use setUpClass for shared data.
- Forgetting super().setUpClass() — Breaks the test framework setup.
- Testing in at_install — Module dependencies may not be ready. Use post_install.
- Not testing edge cases — Empty values, None, zero, negative numbers, very long strings.
- Tests depend on demo data — Demo data may not be installed. Create your own test data.
AI + Testing
Claude Code generates Odoo test cases from your models. Describe the business rules, and it creates comprehensive tests: happy path, edge cases, access rights, and workflow tests.