mcpField
O
odoo-test

odoo-test

github.com/ahmed-lakosha测试与质量76110 次安装31 周前更新
代码仓库

关于 odoo-test

odoo-test 是面向 Odoo 开发者的测试技能,支持 Odoo 14–19。它涵盖测试生成与运行、模拟数据、覆盖率分析及 CI 结果报告。

主要功能

  1. 01生成测试框架
  2. 02按模块或标签运行测试
  3. 03创建模拟测试数据
  4. 04分析测试覆盖率
  5. 05导出 JUnit 结果

适用场景

  1. 01为 Odoo 模型和业务逻辑编写测试
  2. 02测试 HTTP 控制器与 JSON-RPC 端点
  3. 03开发模块时运行指定标签的测试
  4. 04为 Azure DevOps 准备测试结果

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/ahmed-lakosha/odoo-plugins --skill odoo-test

安装后在 Claude Code 中输入/odoo-test确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

scripts/
coverage_reporter.py
mock_data_factory.py
test_generator.py
test_runner.py

详细介绍

Odoo 测试工具包技能(v2.0)

v2.0 架构:所有测试操作均可通过 /odoo-test 子命令或自然语言使用。

一项全面的技能,支持在 Odoo 14-19 中生成、运行和分析测试。涵盖单元测试、集成测试、HTTP 控制器测试、模拟数据创建和测试覆盖率分析。包括 Azure DevOps 流水线的 CI/CD 集成模式。

配置

  • 支持的版本:Odoo 14、15、16、17、18、19
  • 主要版本:Odoo 17
  • 测试模式:80+ 个已记录模式
  • 模拟数据生成器:20+ 个支持字段类型的生成器
  • 核心基类:odoo.tests.common.TransactionCase
  • 测试运行器:Odoo 内置测试框架 + CLI 脚本

快速参考

所有命令
命令用途示例
/odoo-test完整测试工作流/odoo-test my_module
/test-generate生成测试框架/test-generate --model my.model --module /path/to/module
/test-run运行测试套件/test-run my_module --tags post_install
/test-coverage分析覆盖率/test-coverage /path/to/module
/test-data生成模拟数据/test-data --model res.partner --count 10
单行命令参考
# 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

测试架构

测试类层次结构
┌─────────────────────────────────────────────────────────────────────────────┐
│                    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、HttpCase 与 SavepointCase
特性TransactionCaseSavepointCaseHttpCase
数据库隔离每项测试(回滚)每个类(保存点)每项测试(回滚)
HTTP 服务否否是(localhost)
速度快中等慢
最适用场景ORM/业务逻辑异常测试路由、UI、JSON
身份验证控制直接使用 self.env直接使用 self.envself.authenticate()
访问方式env.userenv.user通过 HTTP 会话
何时使用各类测试
# 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})

测试标签系统

标签装饰器参考
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 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
Odoo 内置标签
标签运行时机用途
standard默认 CI 运行单元测试、业务逻辑
at_install安装期间基本模块完整性
post_install所有模块安装后集成测试、完整环境测试
slow默认跳过长时间运行的测试
external默认跳过外部 API 测试
multi_company特殊标记多公司场景

编写测试

完整 CRUD 测试模式
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': '[email protected]',
        })
        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()
计算字段测试
@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")
约束测试
@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})
向导测试
@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 控制器测试

基本路由测试
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': '[email protected]',
            'groups_id': [(4, self.env.ref('base.group_portal').id)],
        })
        self.authenticate(portal_user.login, '[email protected]')
        res = self.url_open('/my/orders')
        self.assertEqual(res.status_code, 200)
JSON RPC 控制器测试
@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': '[email protected]',
                'message': 'Test message from automated test',
            },
        )
        self.assertIn(res.status_code, [200, 302])

创建模拟数据

模式概览
@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': '[email protected]',
        '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,
    })
创建逼真的批量记录
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
特定模型的模拟数据
# res.partner - Customer
partner = env['res.partner'].create({
    'name': 'Acme Corporation',
    'is_company': True,
    'email': '[email protected]',
    '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': '[email protected]',
    'email': '[email protected]',
    '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': '[email protected]',
    '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,
    })],
})

测试隔离

事务回滚机制
┌─────────────────────────────────────────────────────────────────────────────┐
│                     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 和 TearDown 模式
@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': '[email protected]',
        })
        # 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)
禁用测试中的副作用
# 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

运行测试

按模块运行
# 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
按标签运行
# 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
按类或方法运行
# 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
测试输出解读
[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
测试调试日志级别
# 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

覆盖率分析

手动检查覆盖率模式

查找模块中未经测试的方法:

  1. 列出模型文件中的所有公共方法
  2. 与测试文件交叉核对
  3. 计算覆盖率百分比
# Example: Find methods without tests using coverage_reporter.py
python coverage_reporter.py \
    --module /c/odoo/odoo17/projects/myproject/my_module \
    --output report.json
使用 odoo-coverage 检查 Python 覆盖率
# 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
覆盖率配置(.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
按代码类型划分的覆盖率目标
代码类型覆盖率目标原因
业务逻辑方法90%+必须测试关键路径
计算字段85%+核心数据完整性
约束100%安全性和数据完整性
控制器(HTTP 路由)80%+API 契约验证
向导75%+用户工作流覆盖
XML 视图不适用通过集成测试
__manifest__.py不适用不含可执行逻辑

与 DevOps 集成

Azure DevOps 流水线集成
# 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
从 Odoo 测试生成 JUnit XML
# 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
将结果发布到 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

按模块类型划分的常见测试模式

销售模块测试
@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 模块测试
@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)
会计模块测试
@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')
库存 / Stock 测试
@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')

版本兼容性

Odoo 14-19 测试框架差异
功能Odoo 14Odoo 15Odoo 16Odoo 17Odoo 18Odoo 19
SavepointCase是是已弃用已移除已移除已移除
TransactionCase是是是是是是
HttpCase是是是是是是
--test-tags 格式基本基本增强增强增强增强
setUpClass是是是是是是
assertRaises 上下文是是是是是是
browser_js是是是已弃用已移除已移除
phantom_js是是是是是是
基类中的 Mock patch手动手动内置内置内置内置
特定版本的导入变更
# 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()
版本安全的测试模板
# 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
字段 API 变更
# 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

故障排除

常见测试失败及修复方法
1. 测试发现时找不到模块
ERROR: no test found in my_module

修复方法:确保 tests/ 目录中有 __init__.py,并且导入了测试文件:

# my_module/tests/__init__.py
from . import test_my_model
from . import test_other

同时确保 __manifest__.py 中包含:

'installable': True,
# No 'tests' key needed - discovered automatically
2. 测试文件中出现 ImportError
ImportError: cannot import name 'SavepointCase' from 'odoo.tests.common'

修复方法:将 SavepointCase 替换为 TransactionCase(已在 Odoo 16 中移除)。

3. 访问权限错误
AccessError: my_module.my_model: Permission denied

修复方法:在管理员级别的测试设置中使用 .sudo(),或将用户添加到相应的组:

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. 测试之间未重置数据库
# If setUpClass data is being modified by tests unintentionally:

修复方法:切勿在测试方法中修改 cls.* 属性。使用 setUp 创建可变记录。

5. 计算字段未触发
AssertionError: 0 != 100.0 (computed field returned default)

修复方法:更改依赖项后使缓存失效:

record.write({'base_amount': 200.0})
record.invalidate_recordset(['total_amount'])  # Odoo 16+
# Or
record._compute_total_amount()  # Direct call for stored fields
6. 测试期间发送了电子邮件(导致执行变慢)

修复方法:在上下文中禁用邮件跟踪:

@classmethod
def setUpClass(cls):
    super().setUpClass()
    cls.env = cls.env(context={
        **cls.env.context,
        'mail_notrack': True,
        'no_reset_password': True,
        'tracking_disable': True,
    })
7. 测试超时
Test exceeded maximum time limit (300s)

修复方法:拆分耗时测试,使用 @tagged('slow') 将其排除在常规 CI 之外:

@tagged('slow', 'post_install', '-at_install', '-standard')
class TestSlowIntegration(TransactionCase):
    pass
8. 测试中出现 PostgreSQL 完整性错误
psycopg2.errors.UniqueViolation: duplicate key value

修复方法:使用保存点捕获预期的数据库错误:

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'})
9. HttpCase 身份验证失败
AssertionError: Expected 200, got 403

修复方法:确保用户存在且密码正确:

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)
10. 模型上找不到字段
AttributeError: 'my.model' model has no field 'my_field'

修复方法:确保包含该字段的模块已列在 __manifest__.py 的 depends 中,并已安装到测试数据库中。


快速代码片段

断言方法参考
# 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
实用测试工具
# 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'))

评论

支持 Markdown