Skip to content

Odoo Testing Guide: Write and Run Python Tests Like a Pro

DeployMonkey Team · March 22, 2026 14 min read

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

ClassUse CaseTransaction
TransactionCaseORM operations, business logicRolled back after each test
SingleTransactionCaseTests that share stateOne transaction for all tests
HttpCaseController tests, toursCommitted (use carefully)
SavepointCaseHeavy setup, multiple testsSavepoint 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-init

Test 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.