mcpField
O
odoo-test

odoo-test

github.com/ahmed-lakoshaテスト・品質76110 回インストール31 週間前に更新
リポジトリ

odoo-test について

odoo-test は、Odoo 14~19 のモジュールをテストする開発者向けスキルです。テストの生成と実行、モックデータ、カバレッジ分析、CI 向け結果報告を扱います。

主な機能

  1. 01テストのひな形生成
  2. 02モジュールやタグ別のテスト実行
  3. 03テスト用モックデータの作成
  4. 04テストカバレッジの分析
  5. 05JUnit 結果の出力

活用シーン

  1. 01Odoo のモデルや業務ロジックのテスト作成
  2. 02HTTP コントローラーや JSON-RPC エンドポイントのテスト
  3. 03モジュール開発中のタグ別テスト実行
  4. 04Azure 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
DB の分離テストごと(ロールバック)クラスごと(セーブポイント)テストごと(ロールバック)
HTTP サーバーなしなしあり(localhost)
速度高速中程度低速
最適な用途ORM/ビジネスロジック例外のテストルート、UI、JSON
認証制御直接 self.env直接 self.envself.authenticate()
アクセスenv.userenv.userHTTP セッション経由
それぞれを使う場面
# 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')
在庫/ストックのテスト
@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はいはいはいはいはいはい
ベースでの 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)

修正方法:時間のかかるテストを分割し、通常の CI から除外するには @tagged('slow') を使用します:

@tagged('slow', 'post_install', '-at_install', '-standard')
class TestSlowIntegration(TransactionCase):
    pass
8. テストで PostgreSQL の整合性エラーが発生する
psycopg2.errors.UniqueViolation: duplicate key value

修正方法:予期される DB エラーを捕捉するにはセーブポイントを使用します:

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 に記載されており、テスト用 DB にインストールされていることを確認します。


クイックスニペット

アサートメソッドのリファレンス
# 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'))

コメント