odoo-test

Odoo Testing Toolkit Skill (v1.0)

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "odoo-test" with this command: npx skills add ahmed-lakosha/odoo-upgrade-skill/ahmed-lakosha-odoo-upgrade-skill-odoo-test

Odoo Testing Toolkit Skill (v1.0)

A comprehensive skill for generating, running, and analyzing tests across Odoo 14-19. Covers unit tests, integration tests, HTTP controller tests, mock data creation, and test coverage analysis. Includes CI/CD integration patterns for Azure DevOps pipelines.

Configuration

  • Supported Versions: Odoo 14, 15, 16, 17, 18, 19

  • Primary Version: Odoo 17

  • Test Patterns: 80+ documented patterns

  • Mock Data Generators: 20+ field-type-aware generators

  • Core Base Class: odoo.tests.common.TransactionCase

  • Test Runner: Built-in Odoo test framework + CLI scripts

Quick Reference

All Commands

Command Purpose Example

/odoo-test

Full testing workflow /odoo-test my_module

/test-generate

Generate test skeleton /test-generate --model my.model --module /path/to/module

/test-run

Run test suite /test-run my_module --tags post_install

/test-coverage

Analyze coverage /test-coverage /path/to/module

/test-data

Generate mock data /test-data --model res.partner --count 10

One-Liner Command Reference

Generate test skeleton for a model

python test_generator.py --model sale.order --module /c/odoo/odoo17/projects/myproject/my_module

Run tests for a module

python -m odoo -c conf/project17.conf -d project17 --test-enable -i my_module --stop-after-init

Run tests with specific tags

python -m odoo -c conf/project17.conf -d project17 --test-enable --test-tags=post_install --stop-after-init

Run specific test class

python -m odoo -c conf/project17.conf -d project17 --test-enable --test-tags=/my_module:TestMyModel --stop-after-init

Analyze coverage

python coverage_reporter.py --module /path/to/my_module

Generate 10 mock partner records

python mock_data_factory.py --model res.partner --count 10

Testing Architecture

Test Class Hierarchy

┌─────────────────────────────────────────────────────────────────────────────┐ │ ODOO TEST CLASS HIERARCHY │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ unittest.TestCase (Python standard) │ │ └── odoo.tests.common.BaseCase │ │ ├── TransactionCase ← MOST COMMON │ │ │ • Each test wrapped in transaction rolled back on completion │ │ │ • Full ORM access via self.env │ │ │ • Database state reset between tests │ │ │ • setUpClass() for shared expensive setup │ │ │ │ │ ├── SavepointCase (Odoo 14-15) / TransactionCase with savepoints │ │ │ • Allows partial rollback within a test │ │ │ • Useful for testing exception handling │ │ │ • Use self.cr.savepoint() context manager │ │ │ │ │ └── HttpCase ← FOR WEBSITE/API │ │ • Starts real HTTP server on localhost │ │ • Supports phantom_js() / browser_js() │ │ • Supports jsonrpc() / url_open() │ │ • Full route testing with authentication │ │ │ └─────────────────────────────────────────────────────────────────────────────┘

TransactionCase vs HttpCase vs SavepointCase

Feature TransactionCase SavepointCase HttpCase

DB Isolation Per test (rollback) Per class (savepoints) Per test (rollback)

HTTP server No No Yes (localhost)

Speed Fast Medium Slow

Best for ORM/business logic Exception testing Routes, UI, JSON

Auth control Direct self.env

Direct self.env

self.authenticate()

Access env.user

env.user

Via HTTP session

When to Use Each

TransactionCase - Business logic, CRUD, compute, constraints, workflows

class TestSaleOrder(TransactionCase): def test_order_confirmation(self): order = self.env['sale.order'].create({...}) order.action_confirm() self.assertEqual(order.state, 'sale')

HttpCase - Website routes, API endpoints, authenticated pages

class TestWebsiteController(HttpCase): def test_shop_page(self): self.authenticate('admin', 'admin') res = self.url_open('/shop') self.assertEqual(res.status_code, 200)

SavepointCase - When you need to test that an exception rolls back properly

class TestConstraints(TransactionCase): def test_constraint_rollback(self): with self.assertRaises(ValidationError): self.env['my.model'].create({'required_field': False})

Test Tagging System

Tag Decorator Reference

from odoo.tests import tagged

Most common - runs after all modules installed (stable environment)

@tagged('post_install', '-at_install') class TestMyModel(TransactionCase): pass

Runs during module install (early execution, limited env)

@tagged('at_install', '-post_install') class TestEarlyLogic(TransactionCase): pass

Standard tests (default, equivalent to post_install)

@tagged('standard') class TestStandard(TransactionCase): pass

Explicitly exclude from automatic runs

@tagged('-standard', 'manual') class TestManualOnly(TransactionCase): pass

Multiple tags

@tagged('post_install', '-at_install', 'sale', 'critical') class TestSaleIntegration(TransactionCase): pass

Tag Precedence Rules

Tag with '-' prefix = EXCLUSION (remove from selection) Tag without '-' = INCLUSION (add to selection)

Default run: --test-tags=standard Post-install: --test-tags=post_install (most common for production tests)

Examples: --test-tags=post_install → all post_install tagged tests --test-tags=my_module → all tests in module my_module --test-tags=/my_module:MyClass → specific class in module --test-tags=/my_module:MyClass.test_method → specific method

Built-in Odoo Tags

Tag When it runs Use case

standard

Default CI runs Unit tests, business logic

at_install

During install Basic module integrity

post_install

After all installs Integration, full env tests

slow

Skipped by default Long-running tests

external

Skipped by default External API tests

multi_company

Special flag Multi-company scenarios

Writing Tests

Complete CRUD Test Pattern

from odoo.tests import TransactionCase, tagged from odoo.exceptions import ValidationError, UserError

@tagged('post_install', '-at_install') class TestMyModel(TransactionCase): """Test suite for my.model CRUD operations and business logic."""

@classmethod
def setUpClass(cls):
    """Set up class-level fixtures shared across all tests in this class.
    Called once before any test method in the class.
    """
    super().setUpClass()
    # Create shared records (not rolled back between tests)
    cls.partner = cls.env['res.partner'].create({
        'name': 'Test Partner',
        'email': 'test@example.com',
    })
    cls.currency = cls.env.ref('base.USD')
    cls.company = cls.env.company

def setUp(self):
    """Set up per-test fixtures. Called before EACH test method."""
    super().setUp()
    # Create fresh records for each test (rolled back after each test)
    self.record = self.env['my.model'].create({
        'name': 'Test Record',
        'partner_id': self.partner.id,
        'amount': 100.0,
    })

# ─── CREATE TESTS ────────────────────────────────────────────────────────

def test_create_minimal(self):
    """Test creating a record with only required fields."""
    record = self.env['my.model'].create({'name': 'Minimal'})
    self.assertTrue(record.id, "Record should have been created with an ID")
    self.assertEqual(record.name, 'Minimal')
    self.assertEqual(record.state, 'draft')  # Default state

def test_create_full(self):
    """Test creating a record with all fields populated."""
    vals = {
        'name': 'Full Record',
        'partner_id': self.partner.id,
        'amount': 1500.50,
        'date': '2024-01-15',
        'notes': 'Test notes',
        'active': True,
    }
    record = self.env['my.model'].create(vals)
    self.assertEqual(record.name, vals['name'])
    self.assertEqual(record.partner_id, self.partner)
    self.assertAlmostEqual(record.amount, 1500.50, places=2)

def test_create_required_field_missing(self):
    """Test that creating without required fields raises an error."""
    with self.assertRaises(Exception):
        self.env['my.model'].create({})  # Missing 'name' (required)

# ─── READ/SEARCH TESTS ──────────────────────────────────────────────────

def test_search_by_name(self):
    """Test searching records by name."""
    results = self.env['my.model'].search([('name', '=', 'Test Record')])
    self.assertIn(self.record, results)

def test_search_domain(self):
    """Test complex domain search."""
    results = self.env['my.model'].search([
        ('amount', '>=', 50.0),
        ('partner_id', '=', self.partner.id),
    ])
    self.assertGreater(len(results), 0)

def test_name_get(self):
    """Test the display name of the record."""
    name = self.record.display_name
    self.assertIn('Test Record', name)

# ─── WRITE TESTS ─────────────────────────────────────────────────────────

def test_write_name(self):
    """Test updating the record name."""
    self.record.write({'name': 'Updated Name'})
    self.assertEqual(self.record.name, 'Updated Name')

def test_write_amount(self):
    """Test updating a numeric field."""
    self.record.write({'amount': 999.99})
    self.assertAlmostEqual(self.record.amount, 999.99, places=2)

def test_write_state_transition(self):
    """Test valid state transition."""
    self.record.action_confirm()
    self.assertEqual(self.record.state, 'confirmed')

# ─── DELETE TESTS ────────────────────────────────────────────────────────

def test_unlink(self):
    """Test deleting a record."""
    record_id = self.record.id
    self.record.unlink()
    result = self.env['my.model'].search([('id', '=', record_id)])
    self.assertFalse(result, "Record should have been deleted")

def test_unlink_confirmed_raises(self):
    """Test that confirmed records cannot be deleted."""
    self.record.action_confirm()
    with self.assertRaises(UserError):
        self.record.unlink()

Compute Field Tests

@tagged('post_install', '-at_install') class TestComputedFields(TransactionCase):

def test_amount_total_compute(self):
    """Test that amount_total correctly sums line amounts."""
    order = self.env['sale.order'].create({
        'partner_id': self.env.ref('base.res_partner_1').id,
    })
    self.env['sale.order.line'].create([
        {
            'order_id': order.id,
            'product_id': self.env.ref('product.product_product_1').id,
            'product_uom_qty': 2,
            'price_unit': 100.0,
        },
        {
            'order_id': order.id,
            'product_id': self.env.ref('product.product_product_2').id,
            'product_uom_qty': 1,
            'price_unit': 50.0,
        },
    ])
    # Force recompute in case it's not stored
    order.invalidate_recordset()
    self.assertAlmostEqual(order.amount_untaxed, 250.0, places=2)

def test_compute_depends_triggers(self):
    """Test that modifying a dependency triggers recompute."""
    record = self.env['my.model'].create({'base_amount': 100.0, 'tax_rate': 0.15})
    # Verify initial computed value
    self.assertAlmostEqual(record.total_with_tax, 115.0, places=2)
    # Change a dependency and verify recompute
    record.write({'base_amount': 200.0})
    self.assertAlmostEqual(record.total_with_tax, 230.0, places=2)

def test_stored_compute_persists(self):
    """Test that stored computed fields are saved to the database."""
    record = self.env['my.model'].create({'name': 'Compute Test', 'value': 42})
    record_id = record.id
    # Clear cache and reload from DB
    self.env.cr.execute("SELECT computed_field FROM my_model WHERE id = %s", [record_id])
    row = self.env.cr.fetchone()
    self.assertIsNotNone(row[0], "Stored computed field should be in DB")

def test_onchange_simulation(self):
    """Test onchange logic by calling the method directly."""
    record = self.env['my.model'].new({'partner_id': self.env.ref('base.res_partner_1').id})
    record._onchange_partner_id()
    # Verify that onchange populated expected fields
    self.assertTrue(record.currency_id, "Currency should be set from partner country")

Constraint Tests

@tagged('post_install', '-at_install') class TestConstraints(TransactionCase):

def test_sql_constraint_unique_name(self):
    """Test SQL unique constraint prevents duplicate names."""
    self.env['my.model'].create({'name': 'Unique Name', 'code': 'UNAME'})
    from psycopg2 import IntegrityError
    with self.assertRaises(IntegrityError):
        # Must be in a separate transaction savepoint
        with self.env.cr.savepoint():
            self.env['my.model'].create({'name': 'Different', 'code': 'UNAME'})

def test_python_constraint_amount_positive(self):
    """Test Python @constrains decorator validation."""
    from odoo.exceptions import ValidationError
    with self.assertRaises(ValidationError):
        self.env['my.model'].create({'name': 'Negative', 'amount': -100.0})

def test_python_constraint_date_range(self):
    """Test date range constraint."""
    from odoo.exceptions import ValidationError
    with self.assertRaises(ValidationError):
        self.env['my.model'].create({
            'name': 'Bad Dates',
            'date_start': '2024-12-31',
            'date_end': '2024-01-01',  # End before start
        })

def test_constraint_on_write(self):
    """Test that constraints fire on write, not just create."""
    from odoo.exceptions import ValidationError
    record = self.env['my.model'].create({'name': 'Valid', 'amount': 100.0})
    with self.assertRaises(ValidationError):
        record.write({'amount': -50.0})

Wizard Tests

@tagged('post_install', '-at_install') class TestWizard(TransactionCase):

def test_wizard_create_and_confirm(self):
    """Test wizard creation and confirmation."""
    record = self.env['my.model'].create({'name': 'Parent', 'amount': 500.0})
    wizard = self.env['my.wizard'].with_context(
        active_model='my.model',
        active_id=record.id,
        active_ids=[record.id],
    ).create({
        'reason': 'Testing cancellation',
    })
    result = wizard.action_confirm()
    # Verify state changed
    self.assertEqual(record.state, 'cancelled')
    # If wizard returns an action, verify structure
    if result:
        self.assertIn('type', result)

def test_wizard_onchange(self):
    """Test wizard field dependencies."""
    wizard = self.env['my.wizard'].new({
        'partner_id': self.env.ref('base.res_partner_1').id,
    })
    wizard._onchange_partner_id()
    self.assertTrue(wizard.currency_id)

def test_wizard_required_fields(self):
    """Test wizard raises UserError when required action fields missing."""
    from odoo.exceptions import UserError
    record = self.env['my.model'].create({'name': 'Test'})
    wizard = self.env['my.wizard'].with_context(
        active_ids=[record.id],
    ).create({})
    with self.assertRaises(UserError):
        wizard.action_confirm()

HTTP Controller Tests

Basic Route Testing

from odoo.tests import HttpCase, tagged

@tagged('post_install', '-at_install') class TestWebsiteRoutes(HttpCase): """Test HTTP routes and website controllers."""

def test_public_page_accessible(self):
    """Test that a public page returns 200 without authentication."""
    res = self.url_open('/my-page')
    self.assertEqual(res.status_code, 200)
    self.assertIn('Expected Content', res.text)

def test_authenticated_page_redirects_unauthenticated(self):
    """Test that protected pages redirect to login."""
    res = self.url_open('/my-account')
    # Should redirect to login (302) or show login (200 with login form)
    self.assertIn(res.status_code, [200, 301, 302])

def test_authenticated_route(self):
    """Test route that requires authentication."""
    self.authenticate('admin', 'admin')
    res = self.url_open('/my-account')
    self.assertEqual(res.status_code, 200)

def test_portal_user_access(self):
    """Test portal user can access their own records."""
    portal_user = self.env['res.users'].create({
        'name': 'Portal Test User',
        'login': 'portal_test@example.com',
        'groups_id': [(4, self.env.ref('base.group_portal').id)],
    })
    self.authenticate(portal_user.login, 'portal_test@example.com')
    res = self.url_open('/my/orders')
    self.assertEqual(res.status_code, 200)

JSON RPC Controller Testing

@tagged('post_install', '-at_install') class TestJsonController(HttpCase): """Test JSON-RPC API endpoints."""

def test_json_endpoint_success(self):
    """Test a JSON endpoint returns correct data."""
    self.authenticate('admin', 'admin')
    result = self.jsonrpc(
        url='/web/dataset/call_kw',
        method='execute_kw',
        params={
            'model': 'res.partner',
            'method': 'search_read',
            'args': [[['name', 'ilike', 'Azure']]],
            'kwargs': {'fields': ['name', 'email'], 'limit': 5},
        }
    )
    self.assertIsInstance(result, list)

def test_custom_json_route(self):
    """Test a custom JSON controller endpoint."""
    self.authenticate('admin', 'admin')
    result = self.jsonrpc(
        url='/api/my-endpoint',
        method='call',
        params={'record_id': 1, 'action': 'validate'},
    )
    self.assertEqual(result.get('status'), 'ok')

def test_json_route_validation_error(self):
    """Test JSON endpoint returns error structure on invalid input."""
    self.authenticate('admin', 'admin')
    res = self.url_open(
        '/api/my-endpoint',
        data='{"jsonrpc": "2.0", "method": "call", "params": {"record_id": -999}}',
        headers={'Content-Type': 'application/json'},
    )
    data = res.json()
    self.assertIn('error', data)

def test_post_form_submission(self):
    """Test a POST form submission via website."""
    res = self.url_open(
        '/contact',
        data={
            'name': 'Test Contact',
            'email': 'test@test.com',
            'message': 'Test message from automated test',
        },
    )
    self.assertIn(res.status_code, [200, 302])

Mock Data Creation

Pattern Overview

@classmethod def setUpClass(cls): super().setUpClass() # Using env.ref() for existing XML ID records cls.partner_azure = cls.env.ref('base.res_partner_1') cls.product_service = cls.env.ref('product.product_product_1') cls.currency_usd = cls.env.ref('base.USD') cls.company_main = cls.env.ref('base.main_company') cls.user_admin = cls.env.ref('base.user_admin')

# Create fresh test records
cls.partner_test = cls.env['res.partner'].create({
    'name': 'Automated Test Partner',
    'email': 'autotest@example.com',
    'phone': '+1-555-0100',
    'street': '123 Test Street',
    'city': 'Test City',
    'country_id': cls.env.ref('base.us').id,
    'customer_rank': 1,
})

cls.product_test = cls.env['product.product'].create({
    'name': 'Test Product',
    'type': 'service',
    'list_price': 100.0,
    'standard_price': 60.0,
    'uom_id': cls.env.ref('uom.product_uom_unit').id,
})

Creating Realistic Batch Records

def _create_batch_orders(self, count=5): """Helper to create multiple test sale orders.""" partners = self.env['res.partner'].create([ { 'name': f'Test Partner {i}', 'email': f'partner{i}@test.com', } for i in range(count) ]) orders = self.env['sale.order'].create([ { 'partner_id': partner.id, 'date_order': fields.Datetime.now(), } for partner in partners ]) return orders

Model-Specific Mock Data

res.partner - Customer

partner = env['res.partner'].create({ 'name': 'Acme Corporation', 'is_company': True, 'email': 'contact@acme.com', 'phone': '+1-800-ACME', 'street': '100 Main St', 'city': 'Springfield', 'state_id': env.ref('base.state_us_53').id, 'country_id': env.ref('base.us').id, 'zip': '12345', 'vat': 'US123456789', 'customer_rank': 5, 'supplier_rank': 1, })

res.users - Internal User

user = env['res.users'].create({ 'name': 'Test Salesperson', 'login': 'test_salesperson@company.com', 'email': 'test_salesperson@company.com', 'groups_id': [(4, env.ref('sales_team.group_sale_salesman').id)], 'company_id': env.company.id, 'company_ids': [(4, env.company.id)], })

product.product - Storable Product

product = env['product.product'].create({ 'name': 'Office Chair', 'type': 'product', # storable 'list_price': 299.99, 'standard_price': 150.00, 'categ_id': env.ref('product.product_category_all').id, 'uom_id': env.ref('uom.product_uom_unit').id, 'uom_po_id': env.ref('uom.product_uom_unit').id, })

sale.order - Sales Order with Lines

sale_order = env['sale.order'].create({ 'partner_id': partner.id, 'partner_invoice_id': partner.id, 'partner_shipping_id': partner.id, 'date_order': fields.Datetime.now(), 'validity_date': fields.Date.add(fields.Date.today(), days=30), 'order_line': [(0, 0, { 'product_id': product.id, 'product_uom_qty': 3, 'price_unit': 299.99, })], })

account.move - Vendor Bill

bill = env['account.move'].create({ 'move_type': 'in_invoice', 'partner_id': partner.id, 'invoice_date': fields.Date.today(), 'invoice_date_due': fields.Date.add(fields.Date.today(), days=30), 'currency_id': env.ref('base.USD').id, 'invoice_line_ids': [(0, 0, { 'name': 'Services rendered', 'quantity': 10, 'price_unit': 200.0, 'account_id': env['account.account'].search([ ('account_type', '=', 'expense'), ], limit=1).id, })], })

hr.employee - Employee

employee = env['hr.employee'].create({ 'name': 'John Doe', 'job_id': env.ref('hr.job_consultant').id, 'department_id': env.ref('hr.dep_it').id, 'work_email': 'john.doe@company.com', 'work_phone': '+1-555-0101', 'company_id': env.company.id, 'resource_calendar_id': env.ref('resource.resource_calendar_std').id, })

stock.picking - Delivery Order

picking = env['stock.picking'].create({ 'partner_id': partner.id, 'picking_type_id': env.ref('stock.picking_type_out').id, 'location_id': env.ref('stock.stock_location_stock').id, 'location_dest_id': env.ref('stock.stock_location_customers').id, 'move_ids': [(0, 0, { 'name': product.name, 'product_id': product.id, 'product_uom_qty': 5, 'product_uom': product.uom_id.id, 'location_id': env.ref('stock.stock_location_stock').id, 'location_dest_id': env.ref('stock.stock_location_customers').id, })], })

Test Isolation

Transaction Rollback Mechanism

┌─────────────────────────────────────────────────────────────────────────────┐ │ TRANSACTION ISOLATION IN TESTS │ ├─────────────────────────────────────────────────────────────────────────────┤ │ │ │ Database State: [module data] [demo data] [setUpClass data] │ │ │ │ SAVEPOINT ──────────────────────────────────────────────────────────────── │ │ │ test_one() → create records → assert → ROLLBACK to savepoint │ │ │ │ │ SAVEPOINT ──────────────────────────────────────────────────────────────── │ │ │ test_two() → create records → assert → ROLLBACK to savepoint │ │ │ │ │ SAVEPOINT ──────────────────────────────────────────────────────────────── │ │ │ test_three() → create records → assert → ROLLBACK to savepoint │ │ │ │ After ALL tests: ROLLBACK entire setUpClass data │ │ Database returns to exactly pre-test state │ │ │ └─────────────────────────────────────────────────────────────────────────────┘

SetUp and TearDown Patterns

@tagged('post_install', '-at_install') class TestWithSetup(TransactionCase):

@classmethod
def setUpClass(cls):
    """Runs ONCE before all tests. Use for expensive operations."""
    super().setUpClass()
    # These records persist across all test methods in this class
    cls.company = cls.env.ref('base.main_company')
    cls.currency = cls.env.ref('base.USD')
    cls.partner = cls.env['res.partner'].create({
        'name': 'Shared Test Partner',
        'email': 'shared@test.com',
    })
    # Disable mail sending during tests
    cls.env = cls.env(context=dict(cls.env.context, no_mail_send=True))

def setUp(self):
    """Runs BEFORE each test method. Use for per-test state."""
    super().setUp()
    # Fresh record per test, automatically rolled back
    self.record = self.env['my.model'].create({
        'name': 'Per-Test Record',
        'partner_id': self.partner.id,
    })

@classmethod
def tearDownClass(cls):
    """Runs ONCE after all tests in class. Clean up class-level resources."""
    # Usually not needed - transaction rollback handles cleanup
    super().tearDownClass()

def tearDown(self):
    """Runs AFTER each test method."""
    # Usually not needed - savepoint rollback handles cleanup
    super().tearDown()

def test_example(self):
    # self.partner is available (from setUpClass)
    # self.record is fresh (from setUp)
    self.assertTrue(self.record.id)

Disabling Side Effects in Tests

Prevent emails from being sent

def setUp(self): super().setUp() # Method 1: Context flag self.env = self.env(context={**self.env.context, 'mail_notrack': True})

# Method 2: Mock the send method
def _mock_send(self, *args, **kwargs):
    return True
self.patch(type(self.env['mail.mail']), '_send', _mock_send)

Prevent scheduled actions

def setUp(self): super().setUp() self.env['ir.config_parameter'].sudo().set_param( 'mail.catchall.domain', 'test.example.com' )

Bypass security for testing business logic only

def test_without_security(self): record = self.env['my.model'].sudo().create({'name': 'Test'}) # Uses sudo() to bypass access rights - focus on business logic

Running Tests

By Module

Install and test a module

python -m odoo -c conf/project17.conf -d project17
--test-enable -i my_module --stop-after-init

Update and test an existing module

python -m odoo -c conf/project17.conf -d project17
--test-enable -u my_module --stop-after-init

Test multiple modules

python -m odoo -c conf/project17.conf -d project17
--test-enable -u module1,module2 --stop-after-init

By Tags

Run only post_install tagged tests

python -m odoo -c conf/project17.conf -d project17
--test-enable --test-tags=post_install --stop-after-init

Run standard tests only

python -m odoo -c conf/project17.conf -d project17
--test-enable --test-tags=standard --stop-after-init

Run tests for specific module

python -m odoo -c conf/project17.conf -d project17
--test-enable --test-tags=my_module --stop-after-init

By Class or Method

Run a specific test class

python -m odoo -c conf/project17.conf -d project17
--test-enable --test-tags=/my_module:TestMyModel --stop-after-init

Run a specific test method

python -m odoo -c conf/project17.conf -d project17
--test-enable --test-tags=/my_module:TestMyModel.test_create --stop-after-init

Exclude a tag and run the rest

python -m odoo -c conf/project17.conf -d project17
--test-enable --test-tags=standard,-slow --stop-after-init

Test Output Interpretation

[INFO] odoo.tests.result: STARTING tests [INFO] odoo.tests: Computed test module list for test runner [OK] odoo.tests: my_module.tests.test_my_model.TestMyModel.test_create_minimal [OK] odoo.tests: my_module.tests.test_my_model.TestMyModel.test_create_full [FAIL] odoo.tests: my_module.tests.test_my_model.TestMyModel.test_constraint_fails AssertionError: ValidationError not raised [ERROR] odoo.tests: my_module.tests.test_my_model.TestMyModel.test_db_access psycopg2.OperationalError: database connection closed

[INFO] Ran 3 tests in 4.231s [ERROR] 1 error, 1 failure

Log Level for Test Debugging

Verbose test output

python -m odoo -c conf/project17.conf -d project17
--test-enable -u my_module
--log-level=debug --log-handler=odoo.tests:DEBUG
--stop-after-init

Show test SQL queries

python -m odoo -c conf/project17.conf -d project17
--test-enable -u my_module
--log-level=debug --log-handler=odoo.sql_db:DEBUG
--stop-after-init

Coverage Analysis

Manual Coverage Inspection Pattern

To find untested methods in your module:

  • List all public methods in your model files

  • Cross-reference against test files

  • Calculate coverage percentage

Example: Find methods without tests using coverage_reporter.py

python coverage_reporter.py
--module /c/odoo/odoo17/projects/myproject/my_module
--output report.json

Python Coverage with odoo-coverage

Install coverage tool

pip install coverage

Run with coverage measurement

coverage run --source=my_module
-m odoo -c conf/project17.conf -d project17
--test-enable -u my_module --stop-after-init

Generate HTML report

coverage html -d htmlcov/

Generate terminal report

coverage report --show-missing

Coverage Configuration (.coveragerc)

[run] source = my_module omit = my_module/tests/* my_module/migrations/* my_module/manifest.py

[report] exclude_lines = pragma: no cover def repr raise NotImplementedError if TYPE_CHECKING: @abstractmethod

[html] directory = htmlcov title = My Module Test Coverage

Coverage Targets by Code Type

Code Type Target Coverage Rationale

Business logic methods 90%+ Critical paths must be tested

Compute fields 85%+ Core data integrity

Constraints 100% Security and data integrity

Controllers (HTTP routes) 80%+ API contract validation

Wizards 75%+ User workflow coverage

XML views N/A Tested via integration

manifest.py

N/A Not executable logic

Integration with DevOps

Azure DevOps Pipeline Integration

azure-pipelines.yml - Odoo Test Stage

stages:

  • stage: OdooTests displayName: 'Odoo Module Tests' jobs:
    • job: RunTests displayName: 'Run Unit and Integration Tests' pool: vmImage: 'ubuntu-22.04' steps:
      • task: UsePythonVersion@0 inputs: versionSpec: '3.10'

      • script: | pip install -r requirements.txt displayName: 'Install Dependencies'

      • script: | python -m odoo
        -c conf/test.conf
        -d test_db
        --test-enable
        -u my_module
        --stop-after-init
        --log-level=test
        2>&1 | tee test_output.log displayName: 'Run Odoo Tests'

      • task: PublishTestResults@2 condition: always() inputs: testResultsFormat: 'JUnit' testResultsFiles: 'test_results.xml' testRunTitle: 'Odoo Module Tests' failTaskOnFailedTests: true

Generating JUnit XML from Odoo Tests

test_runner.py handles this - generates JUnit XML compatible output

Usage:

python test_runner.py
--module my_module
--config conf/project17.conf
--database project17
--output-format junit
--output test_results.xml

Posting Results to Azure DevOps API

import requests import base64

def post_test_results_to_azure( organization, project, pat_token, test_run_name, results ): """Post test results to Azure DevOps Test Plans.""" headers = { 'Authorization': 'Basic ' + base64.b64encode( f':{pat_token}'.encode() ).decode(), 'Content-Type': 'application/json', } base_url = f'https://dev.azure.com/{organization}/{project}/_apis'

# Create test run
run_response = requests.post(
    f'{base_url}/test/runs?api-version=7.0',
    headers=headers,
    json={
        'name': test_run_name,
        'isAutomated': True,
        'state': 'InProgress',
    }
)
run_id = run_response.json()['id']

# Add test results
test_results = [
    {
        'testCaseTitle': r['name'],
        'automatedTestName': r['full_name'],
        'outcome': 'Passed' if r['passed'] else 'Failed',
        'durationInMs': r['duration_ms'],
        'errorMessage': r.get('error', ''),
        'stackTrace': r.get('traceback', ''),
    }
    for r in results
]
requests.post(
    f'{base_url}/test/runs/{run_id}/results?api-version=7.0',
    headers=headers,
    json=test_results,
)

# Complete test run
requests.patch(
    f'{base_url}/test/runs/{run_id}?api-version=7.0',
    headers=headers,
    json={'state': 'Completed'},
)
return run_id

Common Test Patterns by Module Type

Sales Module Tests

@tagged('post_install', '-at_install') class TestSaleOrders(TransactionCase):

@classmethod
def setUpClass(cls):
    super().setUpClass()
    cls.partner = cls.env.ref('base.res_partner_1')
    cls.product = cls.env.ref('product.product_product_5')
    cls.pricelist = cls.env.ref('product.list0')

def _create_sale_order(self, qty=1, price=100.0):
    """Helper to create a minimal sale order."""
    return 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,
        })],
    })

def test_create_sale_order(self):
    """Test creating a sale order in draft state."""
    order = self._create_sale_order()
    self.assertEqual(order.state, 'draft')
    self.assertEqual(len(order.order_line), 1)

def test_confirm_sale_order(self):
    """Test confirming a sale order changes state to 'sale'."""
    order = self._create_sale_order()
    order.action_confirm()
    self.assertEqual(order.state, 'sale')

def test_invoice_from_sale_order(self):
    """Test creating an invoice from a confirmed sale order."""
    order = self._create_sale_order(qty=2, price=500.0)
    order.action_confirm()
    # Create invoice
    invoice = order._create_invoices()
    self.assertTrue(invoice)
    self.assertEqual(invoice.move_type, 'out_invoice')
    self.assertEqual(invoice.state, 'draft')
    # Post the invoice
    invoice.action_post()
    self.assertEqual(invoice.state, 'posted')
    self.assertAlmostEqual(invoice.amount_untaxed, 1000.0, places=2)

def test_cancel_sale_order(self):
    """Test cancelling a draft order."""
    order = self._create_sale_order()
    order.action_cancel()
    self.assertEqual(order.state, 'cancel')

def test_cancel_confirmed_order_raises(self):
    """Test that cancelling a confirmed order with stock moves raises error."""
    from odoo.exceptions import UserError
    order = self._create_sale_order()
    order.action_confirm()
    # Depending on stock integration, this may raise UserError
    # This test documents expected behavior

HR Module Tests

@tagged('post_install', '-at_install') class TestHREmployee(TransactionCase):

@classmethod
def setUpClass(cls):
    super().setUpClass()
    cls.department = cls.env['hr.department'].create({
        'name': 'Test IT Department',
    })
    cls.job = cls.env['hr.job'].create({
        'name': 'Test Software Engineer',
        'department_id': cls.department.id,
    })

def _create_employee(self, name='Test Employee'):
    return self.env['hr.employee'].create({
        'name': name,
        'job_id': self.job.id,
        'department_id': self.department.id,
        'work_email': f'{name.lower().replace(" ", ".")}@company.com',
        'company_id': self.env.company.id,
    })

def test_create_employee(self):
    """Test creating an employee."""
    emp = self._create_employee()
    self.assertTrue(emp.id)
    self.assertEqual(emp.department_id, self.department)

def test_employee_archive(self):
    """Test archiving an employee."""
    emp = self._create_employee('Archive Me')
    emp.toggle_active()
    self.assertFalse(emp.active)

def test_attendance_checkin(self):
    """Test employee check-in if hr_attendance is installed."""
    if 'hr.attendance' not in self.env:
        self.skipTest("hr_attendance module not installed")
    emp = self._create_employee()
    attendance = self.env['hr.attendance'].create({
        'employee_id': emp.id,
        'check_in': fields.Datetime.now(),
    })
    self.assertTrue(attendance.id)

Account Module Tests

@tagged('post_install', '-at_install') class TestAccountMove(TransactionCase):

@classmethod
def setUpClass(cls):
    super().setUpClass()
    cls.partner = cls.env.ref('base.res_partner_2')
    cls.account_receivable = cls.env['account.account'].search([
        ('account_type', '=', 'asset_receivable'),
        ('company_id', '=', cls.env.company.id),
    ], limit=1)
    cls.account_revenue = cls.env['account.account'].search([
        ('account_type', '=', 'income'),
        ('company_id', '=', cls.env.company.id),
    ], limit=1)

def _create_invoice(self, amount=100.0):
    return self.env['account.move'].create({
        'move_type': 'out_invoice',
        'partner_id': self.partner.id,
        'invoice_date': fields.Date.today(),
        'invoice_line_ids': [(0, 0, {
            'name': 'Test Service',
            'quantity': 1,
            'price_unit': amount,
            'account_id': self.account_revenue.id,
        })],
    })

def test_create_draft_invoice(self):
    """Test creating a draft invoice."""
    invoice = self._create_invoice()
    self.assertEqual(invoice.state, 'draft')
    self.assertAlmostEqual(invoice.amount_untaxed, 100.0, places=2)

def test_post_invoice(self):
    """Test posting (confirming) an invoice."""
    invoice = self._create_invoice(500.0)
    invoice.action_post()
    self.assertEqual(invoice.state, 'posted')
    self.assertTrue(invoice.name)  # Name assigned on posting

def test_register_payment(self):
    """Test registering a payment against an invoice."""
    invoice = self._create_invoice(200.0)
    invoice.action_post()
    # Register payment
    payment_wizard = self.env['account.payment.register'].with_context(
        active_model='account.move',
        active_ids=invoice.ids,
    ).create({
        'payment_date': fields.Date.today(),
        'amount': 200.0,
    })
    payment_wizard.action_create_payments()
    self.assertEqual(invoice.payment_state, 'paid')

Inventory / Stock Tests

@tagged('post_install', '-at_install') class TestInventory(TransactionCase):

@classmethod
def setUpClass(cls):
    super().setUpClass()
    cls.product = cls.env['product.product'].create({
        'name': 'Test Storable',
        'type': 'product',
    })
    cls.warehouse = cls.env.ref('stock.warehouse0')
    cls.location_stock = cls.env.ref('stock.stock_location_stock')
    cls.location_customer = cls.env.ref('stock.stock_location_customers')

def test_create_picking(self):
    """Test creating a stock picking."""
    picking = self.env['stock.picking'].create({
        'partner_id': self.env.ref('base.res_partner_1').id,
        'picking_type_id': self.env.ref('stock.picking_type_out').id,
        'location_id': self.location_stock.id,
        'location_dest_id': self.location_customer.id,
        'move_ids': [(0, 0, {
            'name': self.product.name,
            'product_id': self.product.id,
            'product_uom_qty': 5.0,
            'product_uom': self.product.uom_id.id,
            'location_id': self.location_stock.id,
            'location_dest_id': self.location_customer.id,
        })],
    })
    self.assertEqual(picking.state, 'draft')
    self.assertEqual(len(picking.move_ids), 1)

def test_validate_picking(self):
    """Test validating a picking (immediate transfer)."""
    # First ensure stock is available
    self.env['stock.quant'].with_context(inventory_mode=True).create({
        'product_id': self.product.id,
        'location_id': self.location_stock.id,
        'quantity': 100.0,
    })
    picking = self.env['stock.picking'].create({
        'picking_type_id': self.env.ref('stock.picking_type_out').id,
        'location_id': self.location_stock.id,
        'location_dest_id': self.location_customer.id,
        'move_ids': [(0, 0, {
            'name': self.product.name,
            'product_id': self.product.id,
            'product_uom_qty': 5.0,
            'product_uom': self.product.uom_id.id,
            'location_id': self.location_stock.id,
            'location_dest_id': self.location_customer.id,
        })],
    })
    picking.action_confirm()
    picking.action_assign()
    # Set done quantities
    for move_line in picking.move_line_ids:
        move_line.qty_done = move_line.product_uom_qty
    picking.button_validate()
    self.assertEqual(picking.state, 'done')

Version Compatibility

Test Framework Differences Odoo 14-19

Feature Odoo 14 Odoo 15 Odoo 16 Odoo 17 Odoo 18 Odoo 19

SavepointCase

Yes Yes Deprecated Removed Removed Removed

TransactionCase

Yes Yes Yes Yes Yes Yes

HttpCase

Yes Yes Yes Yes Yes Yes

--test-tags format Basic Basic Enhanced Enhanced Enhanced Enhanced

setUpClass

Yes Yes Yes Yes Yes Yes

assertRaises context Yes Yes Yes Yes Yes Yes

browser_js

Yes Yes Yes Deprecated Removed Removed

phantom_js

Yes Yes Yes Yes Yes Yes

Mock patch in base Manual Manual Built-in Built-in Built-in Built-in

Version-Specific Import Changes

Odoo 14 - SavepointCase still valid

from odoo.tests.common import SavepointCase, TransactionCase, HttpCase

Odoo 15 - SavepointCase deprecated

from odoo.tests.common import TransactionCase, HttpCase from odoo.tests import tagged

Odoo 16-19 - Use TransactionCase with savepoints for rollback control

from odoo.tests import TransactionCase, HttpCase, tagged

SavepointCase was removed; use TransactionCase with self.cr.savepoint()

Version-Safe Test Template

Works across Odoo 14-19

try: from odoo.tests.common import SavepointCase as TestBase except ImportError: from odoo.tests.common import TransactionCase as TestBase

from odoo.tests import tagged

@tagged('post_install', '-at_install') class TestCompatible(TestBase): """Version-compatible test class.""" pass

Field API Changes

Odoo 14-15: fields.Date.from_string('2024-01-01')

Odoo 16+: fields.Date.to_date('2024-01-01') (also works in 14-15)

Odoo 14-16: self.env['ir.sequence'].next_by_code('my.sequence')

Odoo 17+: Same API - no change

Odoo 14-17: order.write({'state': 'cancel'})

Odoo 18+: May require specific action methods depending on model

Troubleshooting

Common Test Failures and Fixes

  1. Module Not Found in Test Discovery

ERROR: no test found in my_module

Fix: Ensure tests/ directory has init.py and imports test files:

my_module/tests/init.py

from . import test_my_model from . import test_other

Also ensure manifest.py has:

'installable': True,

No 'tests' key needed - discovered automatically

  1. ImportError in Test File

ImportError: cannot import name 'SavepointCase' from 'odoo.tests.common'

Fix: Replace SavepointCase with TransactionCase (removed in Odoo 16).

  1. Access Rights Error

AccessError: my_module.my_model: Permission denied

Fix: Use .sudo() for admin-level test setup, or add user to proper group:

def setUp(self): super().setUp() # Use admin env for setup self.record = self.env['my.model'].sudo().create({...})

# Or add current user to required group
self.env.user.write({
    'groups_id': [(4, self.env.ref('my_module.group_manager').id)]
})

4. Database Not Reset Between Tests

If setUpClass data is being modified by tests unintentionally:

Fix: Never mutate cls.* attributes in test methods. Use setUp for mutable records.

  1. Compute Field Not Triggering

AssertionError: 0 != 100.0 (computed field returned default)

Fix: Invalidate cache after dependency changes:

record.write({'base_amount': 200.0}) record.invalidate_recordset(['total_amount']) # Odoo 16+

Or

record._compute_total_amount() # Direct call for stored fields

  1. Email Sent During Tests (Slows Execution)

Fix: Disable mail tracking in context:

@classmethod def setUpClass(cls): super().setUpClass() cls.env = cls.env(context={ **cls.env.context, 'mail_notrack': True, 'no_reset_password': True, 'tracking_disable': True, })

  1. Test Timing Out

Test exceeded maximum time limit (300s)

Fix: Split slow tests, use @tagged('slow') to exclude from regular CI:

@tagged('slow', 'post_install', '-at_install', '-standard') class TestSlowIntegration(TransactionCase): pass

  1. PostgreSQL Integrity Error in Test

psycopg2.errors.UniqueViolation: duplicate key value

Fix: Use savepoints to catch expected DB errors:

def test_unique_constraint(self): self.env['my.model'].create({'code': 'UNIQUE'}) with self.assertRaises(Exception): with self.env.cr.savepoint(): self.env['my.model'].create({'code': 'UNIQUE'})

  1. HttpCase Authentication Fails

AssertionError: Expected 200, got 403

Fix: Ensure user exists and password is correct:

def test_requires_admin(self): # Use built-in admin (always available in tests) self.authenticate('admin', 'admin') res = self.url_open('/admin-only-route') self.assertEqual(res.status_code, 200)

  1. Field Not Found on Model

AttributeError: 'my.model' model has no field 'my_field'

Fix: Ensure module with the field is in depends in manifest.py and installed in test DB.

Quick Snippets

Assert Methods Reference

Equality

self.assertEqual(a, b) # a == b self.assertNotEqual(a, b) # a != b self.assertAlmostEqual(a, b, places=2) # float comparison self.assertIs(a, b) # a is b (identity) self.assertIsNone(a) # a is None self.assertIsNotNone(a) # a is not None

Boolean

self.assertTrue(x) # bool(x) is True self.assertFalse(x) # bool(x) is False

Membership

self.assertIn(a, b) # a in b self.assertNotIn(a, b) # a not in b self.assertIn(record, recordset) # Odoo recordset check

Collections

self.assertEqual(len(records), 3) # Count check self.assertGreater(len(records), 0)

Exceptions

self.assertRaises(ValidationError, lambda: record.write({...})) with self.assertRaises(UserError) as ctx: record.action_confirm() self.assertIn('specific message', str(ctx.exception))

Recordsets (Odoo-specific patterns)

self.assertFalse(empty_recordset) self.assertTrue(non_empty_recordset) self.assertEqual(record, expected_record) # Recordset equality

Useful Test Utilities

Skip test conditionally

def test_feature(self): if not self.env['account.move']._module_installed('account_lock'): self.skipTest('account_lock module not installed') # ...

Generate unique names to avoid conflicts

import uuid unique_name = f'Test_{uuid.uuid4().hex[:8]}'

Freeze time (Odoo 16+)

from unittest.mock import patch from datetime import date with patch('odoo.fields.Date.today', return_value=date(2024, 6, 15)): record = self.env['my.model'].create({'date': fields.Date.today()}) self.assertEqual(record.date, date(2024, 6, 15))

Access Odoo configuration

param_value = self.env['ir.config_parameter'].sudo().get_param('my.param')

Run as different user

record_as_user = record.with_user(self.env.ref('base.user_demo'))

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

odoo-upgrade

No summary provided by upstream source.

Repository SourceNeeds Review
General

odoo-frontend

No summary provided by upstream source.

Repository SourceNeeds Review
General

odoo-report

No summary provided by upstream source.

Repository SourceNeeds Review
General

odoo-i18n

No summary provided by upstream source.

Repository SourceNeeds Review