Initial commit
This commit is contained in:
471
skills/odoo-test-creator/SKILL.md
Normal file
471
skills/odoo-test-creator/SKILL.md
Normal file
@@ -0,0 +1,471 @@
|
||||
---
|
||||
name: odoo-test-creator
|
||||
description: Creates comprehensive test suites for Odoo 16.0 modules following Siafa project standards. This skill should be used when creating tests for Odoo modules, such as "Create tests for this module" or "Generate test cases for stock_location_usage_restriction" or "Add unit tests to validate this functionality". The skill provides test templates, patterns, and best practices specific to Odoo 16.0 Enterprise with knowledge of database constraints and common pitfalls in the Siafa codebase.
|
||||
---
|
||||
|
||||
# Odoo Test Creator
|
||||
|
||||
## Overview
|
||||
|
||||
Create production-ready test suites for Odoo 16.0 Enterprise modules that follow Siafa project standards, handle database constraints properly, and provide comprehensive test coverage.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when:
|
||||
- Creating tests for new Odoo modules
|
||||
- Adding test coverage to existing modules
|
||||
- Validating model logic, constraints, and workflows
|
||||
- Testing inherited/extended Odoo models
|
||||
- Ensuring compliance with Siafa testing standards
|
||||
|
||||
## Test Creation Workflow
|
||||
|
||||
### Step 1: Analyze Module Structure
|
||||
|
||||
Examine the module to understand what needs testing:
|
||||
|
||||
1. **Identify Components to Test:**
|
||||
- Models (new models or inherited models)
|
||||
- Computed fields and @api.depends
|
||||
- Constraints (@api.constrains and _sql_constraints)
|
||||
- Onchange methods (@api.onchange)
|
||||
- Business logic methods
|
||||
- State transitions and workflows
|
||||
- Wizards and transient models
|
||||
- Reports (if applicable)
|
||||
|
||||
2. **Review Module Dependencies:**
|
||||
- Check `__manifest__.py` for dependencies
|
||||
- Identify which models from dependencies will be used
|
||||
- Plan to use existing records when possible
|
||||
|
||||
3. **Check for Special Requirements:**
|
||||
- Database constraints (NOT NULL, UNIQUE)
|
||||
- Multi-company considerations
|
||||
- Access rights and permissions
|
||||
- Integration points with other modules
|
||||
|
||||
### Step 2: Set Up Test File Structure
|
||||
|
||||
Create the test file following Siafa standards:
|
||||
|
||||
```python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
|
||||
class TestModuleName(TransactionCase):
|
||||
"""Test cases for module_name functionality."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
super().setUp()
|
||||
|
||||
# Initialize model references
|
||||
self.Model = self.env['model.name']
|
||||
|
||||
# Set up test data (Step 3)
|
||||
```
|
||||
|
||||
**Critical Import Pattern:**
|
||||
- ✅ Use `from odoo.tests.common import TransactionCase`
|
||||
- ❌ NOT `from odoo.tests import TransactionCase`
|
||||
|
||||
### Step 3: Set Up Test Data
|
||||
|
||||
Use the appropriate pattern based on database constraints:
|
||||
|
||||
#### Pattern A: Use Existing Records (Preferred)
|
||||
|
||||
Avoid database constraint issues by using existing records:
|
||||
|
||||
```python
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.Model = self.env['model.name']
|
||||
|
||||
# Use existing records from database
|
||||
self.warehouse = self.env['stock.warehouse'].search([], limit=1)
|
||||
if not self.warehouse:
|
||||
self.skipTest("No warehouse available for testing")
|
||||
|
||||
self.product = self.env['product.product'].search([('type', '=', 'product')], limit=1)
|
||||
if not self.product:
|
||||
self.skipTest("No storable product available for testing")
|
||||
|
||||
self.partner = self.env['res.partner'].search([], limit=1)
|
||||
if not self.partner:
|
||||
self.skipTest("No partner available for testing")
|
||||
```
|
||||
|
||||
**When to use:** For models with complex database constraints (products, partners, companies).
|
||||
|
||||
#### Pattern B: Create with .sudo() (When Necessary)
|
||||
|
||||
Create new records when specific test data is required:
|
||||
|
||||
```python
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.Model = self.env['model.name']
|
||||
|
||||
# Create test data with .sudo() to bypass permissions
|
||||
self.vendor = self.env['res.partner'].sudo().create({
|
||||
'name': 'Test Vendor',
|
||||
'is_company': True,
|
||||
'supplier_rank': 1,
|
||||
})
|
||||
|
||||
self.product = self.env['product.product'].sudo().create({
|
||||
'name': 'Test Product',
|
||||
'type': 'product',
|
||||
'purchase_method': 'receive',
|
||||
'list_price': 100.0,
|
||||
'standard_price': 80.0,
|
||||
})
|
||||
```
|
||||
|
||||
**When to use:** When specific field values are required for tests or existing records may not have the right attributes.
|
||||
|
||||
#### Pattern C: Class-Level Setup (For Shared Data)
|
||||
|
||||
Use `setUpClass` for data shared across all test methods:
|
||||
|
||||
```python
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Set up test data shared across all test methods."""
|
||||
super().setUpClass()
|
||||
|
||||
cls.vendor = cls.env['res.partner'].sudo().create({
|
||||
'name': 'Test Vendor',
|
||||
'is_company': True,
|
||||
})
|
||||
```
|
||||
|
||||
**When to use:** For immutable test data that doesn't change between tests (saves database operations).
|
||||
|
||||
### Step 4: Write Test Methods
|
||||
|
||||
Create test methods following these guidelines:
|
||||
|
||||
#### Test Naming Convention
|
||||
|
||||
```python
|
||||
def test_01_descriptive_name(self):
|
||||
"""Test description in docstring."""
|
||||
pass
|
||||
|
||||
def test_02_another_scenario(self):
|
||||
"""Test another scenario."""
|
||||
pass
|
||||
```
|
||||
|
||||
**Numbering:** Use `01`, `02`, etc. to control execution order.
|
||||
|
||||
#### Test Coverage Areas
|
||||
|
||||
Create tests for each component identified in Step 1:
|
||||
|
||||
**A. CRUD Operations**
|
||||
|
||||
```python
|
||||
def test_01_create_record(self):
|
||||
"""Test creating a new record with valid data."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
self.assertTrue(record)
|
||||
self.assertEqual(record.name, 'Test Record')
|
||||
self.assertEqual(record.state, 'draft')
|
||||
|
||||
def test_02_update_record(self):
|
||||
"""Test updating an existing record."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
record.write({'name': 'Updated Record'})
|
||||
|
||||
self.assertEqual(record.name, 'Updated Record')
|
||||
```
|
||||
|
||||
**B. Computed Fields**
|
||||
|
||||
```python
|
||||
def test_03_computed_field(self):
|
||||
"""Test computed field calculation."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'quantity': 10,
|
||||
'unit_price': 5.0,
|
||||
})
|
||||
|
||||
self.assertEqual(record.total_amount, 50.0)
|
||||
|
||||
# Test recomputation on dependency change
|
||||
record.write({'quantity': 20})
|
||||
self.assertEqual(record.total_amount, 100.0)
|
||||
```
|
||||
|
||||
**C. Constraints**
|
||||
|
||||
```python
|
||||
def test_04_constraint_validation(self):
|
||||
"""Test constraint prevents invalid data."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
with self.assertRaises(ValidationError) as context:
|
||||
record.write({'amount': -10.0})
|
||||
|
||||
self.assertIn('must be positive', str(context.exception).lower())
|
||||
```
|
||||
|
||||
**D. Onchange Methods**
|
||||
|
||||
```python
|
||||
def test_05_onchange_method(self):
|
||||
"""Test onchange method updates dependent fields."""
|
||||
record = self.Model.new({
|
||||
'name': 'Test Record',
|
||||
})
|
||||
|
||||
record.partner_id = self.partner
|
||||
record._onchange_partner_id()
|
||||
|
||||
# Verify onchange updated related fields
|
||||
# self.assertEqual(record.expected_field, expected_value)
|
||||
```
|
||||
|
||||
**E. State Transitions**
|
||||
|
||||
```python
|
||||
def test_06_state_transition(self):
|
||||
"""Test state transition workflow."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
self.assertEqual(record.state, 'draft')
|
||||
|
||||
record.action_confirm()
|
||||
self.assertEqual(record.state, 'confirmed')
|
||||
|
||||
# Test invalid transition
|
||||
with self.assertRaises(UserError) as context:
|
||||
record.action_confirm() # Already confirmed
|
||||
|
||||
self.assertIn('Cannot confirm', str(context.exception))
|
||||
```
|
||||
|
||||
**F. Inheritance/Extension Tests**
|
||||
|
||||
For modules that inherit existing models:
|
||||
|
||||
```python
|
||||
def test_07_inherited_method_override(self):
|
||||
"""Test overridden method applies custom logic."""
|
||||
location = self.Location.create({
|
||||
'name': 'Test Location',
|
||||
'usage': 'internal',
|
||||
'location_id': self.parent_location.id,
|
||||
})
|
||||
|
||||
# Create stock move using this location
|
||||
self.StockMove.create({
|
||||
'name': 'Test Move',
|
||||
'product_id': self.product.id,
|
||||
'product_uom_qty': 10,
|
||||
'product_uom': self.product.uom_id.id,
|
||||
'location_id': location.id,
|
||||
'location_dest_id': self.parent_location.id,
|
||||
})
|
||||
|
||||
# Test that custom validation prevents usage change
|
||||
with self.assertRaises(UserError) as context:
|
||||
location.write({'usage': 'inventory'})
|
||||
|
||||
self.assertIn('Cannot change the usage type', str(context.exception))
|
||||
```
|
||||
|
||||
### Step 5: Handle Common Pitfalls
|
||||
|
||||
Apply fixes for known issues in the Siafa codebase:
|
||||
|
||||
#### Pitfall 1: Database Constraints
|
||||
|
||||
**Problem:** Creating products fails with "null value in column 'sale_line_warn' violates not-null constraint"
|
||||
|
||||
**Solution:** Use existing products:
|
||||
```python
|
||||
self.product = self.env['product.product'].search([('type', '=', 'product')], limit=1)
|
||||
```
|
||||
|
||||
#### Pitfall 2: HTML Field Comparisons
|
||||
|
||||
**Problem:** HTML fields return `Markup` objects: `Markup('<p>Text</p>') != 'Text'`
|
||||
|
||||
**Solution:** Use non-HTML fields or convert to string:
|
||||
```python
|
||||
# Instead of comment field
|
||||
self.assertEqual(record.barcode, 'TEST001')
|
||||
|
||||
# Or convert to string
|
||||
self.assertIn('expected text', str(record.html_field))
|
||||
```
|
||||
|
||||
#### Pitfall 3: Permission Errors
|
||||
|
||||
**Problem:** Tests fail with access rights errors.
|
||||
|
||||
**Solution:** Use `.sudo()` when creating test data:
|
||||
```python
|
||||
self.partner = self.env['res.partner'].sudo().create({...})
|
||||
```
|
||||
|
||||
#### Pitfall 4: Incorrect Super() Call
|
||||
|
||||
**Problem:** Using old-style `super(ClassName, self).setUp()`
|
||||
|
||||
**Solution:** Use modern syntax:
|
||||
```python
|
||||
super().setUp() # ✅ Correct
|
||||
```
|
||||
|
||||
### Step 6: Run and Validate Tests
|
||||
|
||||
Execute tests and verify results:
|
||||
|
||||
```bash
|
||||
# Run tests during module update
|
||||
python3 src/odoo-bin -c src/odoo.conf -d DATABASE_NAME \
|
||||
--test-enable --stop-after-init \
|
||||
-u MODULE_NAME
|
||||
|
||||
# Run with verbose output
|
||||
python3 src/odoo-bin -c src/odoo.conf -d DATABASE_NAME \
|
||||
--test-enable --stop-after-init \
|
||||
--log-level=test \
|
||||
-u MODULE_NAME
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
INFO MODULE_NAME: 0 failed, 0 error(s) of N tests when loading database 'DATABASE_NAME'
|
||||
```
|
||||
|
||||
**If tests fail:**
|
||||
1. Read the full traceback carefully
|
||||
2. Check for database constraint violations
|
||||
3. Verify test data setup is correct
|
||||
4. Ensure imports are correct
|
||||
5. Review field types (especially HTML fields)
|
||||
|
||||
### Step 7: Document Tests
|
||||
|
||||
Add comprehensive docstrings to each test method:
|
||||
|
||||
```python
|
||||
def test_prevent_usage_change_with_moves(self):
|
||||
"""
|
||||
Test that location usage cannot be changed when moves exist.
|
||||
|
||||
This test verifies that the module prevents changing a location's
|
||||
usage type after it has been used in stock movements, protecting
|
||||
data integrity.
|
||||
"""
|
||||
# Test implementation
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
### references/test_patterns.md
|
||||
|
||||
Comprehensive documentation of:
|
||||
- Test infrastructure patterns
|
||||
- Common setup patterns for different scenarios
|
||||
- Database constraint handling strategies
|
||||
- Test organization best practices
|
||||
- Assertion patterns
|
||||
- Complete list of common pitfalls and solutions
|
||||
- Running tests with various options
|
||||
|
||||
Load this reference when:
|
||||
- Creating complex test scenarios
|
||||
- Handling database constraints
|
||||
- Troubleshooting test failures
|
||||
- Learning Siafa-specific testing patterns
|
||||
|
||||
### assets/test_model_basic.py
|
||||
|
||||
Template for testing basic model operations:
|
||||
- CRUD operations (Create, Read, Update, Delete)
|
||||
- Computed field testing
|
||||
- Onchange method testing
|
||||
- Constraint validation
|
||||
- State transitions
|
||||
- Search operations
|
||||
|
||||
Use as starting point for new model tests.
|
||||
|
||||
### assets/test_model_constraints.py
|
||||
|
||||
Template for testing constraints:
|
||||
- Python constraints (@api.constrains)
|
||||
- SQL constraints (_sql_constraints)
|
||||
- Required field validation
|
||||
- Domain constraints
|
||||
- Dependent field constraints
|
||||
- Conditional constraints
|
||||
- Cascading constraints
|
||||
|
||||
Use when module has complex validation logic.
|
||||
|
||||
### assets/test_model_inheritance.py
|
||||
|
||||
Template for testing model inheritance and extensions:
|
||||
- New field validation
|
||||
- Overridden method testing
|
||||
- Super() call behavior
|
||||
- Added constraints
|
||||
- Computed field extensions
|
||||
- Onchange extensions
|
||||
- Backward compatibility
|
||||
|
||||
Use when module extends existing Odoo models.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use existing records when possible** to avoid database constraints
|
||||
2. **Test both success and failure cases** for comprehensive coverage
|
||||
3. **Verify error messages** when testing exceptions
|
||||
4. **Use .sudo() for test data creation** to bypass permission issues
|
||||
5. **Add descriptive docstrings** to every test method
|
||||
6. **Number test methods** for predictable execution order
|
||||
7. **Keep tests isolated** - each test should work independently
|
||||
8. **Test edge cases** - empty data, maximum values, invalid combinations
|
||||
9. **Follow naming conventions** - clear, descriptive test names
|
||||
10. **Run tests frequently** during development to catch issues early
|
||||
|
||||
## Example: Complete Test File
|
||||
|
||||
For reference, see `/Users/jamshid/PycharmProjects/Siafa/odoo16e_simc/addons-stock/stock_location_usage_restriction/tests/test_stock_location_usage_restriction.py`
|
||||
|
||||
This file demonstrates:
|
||||
- Proper imports (`from odoo.tests.common import TransactionCase`)
|
||||
- Using existing records (`self.product = self.Product.search(...)`)
|
||||
- Comprehensive test coverage (7 test methods)
|
||||
- Exception testing with message validation
|
||||
- Proper super() call (`super().setUp()`)
|
||||
- Avoiding HTML field comparison issues
|
||||
146
skills/odoo-test-creator/assets/test_model_basic.py
Normal file
146
skills/odoo-test-creator/assets/test_model_basic.py
Normal file
@@ -0,0 +1,146 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
|
||||
class TestModelName(TransactionCase):
|
||||
"""Test cases for model.name functionality."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
super().setUp()
|
||||
|
||||
self.Model = self.env['model.name']
|
||||
|
||||
# Use existing records when possible
|
||||
self.partner = self.env['res.partner'].search([], limit=1)
|
||||
if not self.partner:
|
||||
self.skipTest("No partner available for testing")
|
||||
|
||||
# Or create with .sudo()
|
||||
self.test_partner = self.env['res.partner'].sudo().create({
|
||||
'name': 'Test Partner',
|
||||
'is_company': True,
|
||||
})
|
||||
|
||||
def test_01_create_record(self):
|
||||
"""Test creating a new record with valid data."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
# Add other required fields
|
||||
})
|
||||
|
||||
self.assertTrue(record)
|
||||
self.assertEqual(record.name, 'Test Record')
|
||||
self.assertEqual(record.state, 'draft') # Adjust as needed
|
||||
|
||||
def test_02_update_record(self):
|
||||
"""Test updating an existing record."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
# Update record
|
||||
record.write({
|
||||
'name': 'Updated Record',
|
||||
})
|
||||
|
||||
self.assertEqual(record.name, 'Updated Record')
|
||||
|
||||
def test_03_search_records(self):
|
||||
"""Test searching for records."""
|
||||
# Create test records
|
||||
self.Model.create({
|
||||
'name': 'Record A',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
self.Model.create({
|
||||
'name': 'Record B',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
# Search for records
|
||||
records = self.Model.search([('partner_id', '=', self.partner.id)])
|
||||
|
||||
self.assertGreaterEqual(len(records), 2)
|
||||
|
||||
def test_04_computed_field(self):
|
||||
"""Test computed field calculation."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
'quantity': 10,
|
||||
'unit_price': 5.0,
|
||||
})
|
||||
|
||||
# Test computed total
|
||||
self.assertEqual(record.total_amount, 50.0)
|
||||
|
||||
# Update dependency and verify recomputation
|
||||
record.write({'quantity': 20})
|
||||
self.assertEqual(record.total_amount, 100.0)
|
||||
|
||||
def test_05_onchange_method(self):
|
||||
"""Test onchange method behavior."""
|
||||
record = self.Model.new({
|
||||
'name': 'Test Record',
|
||||
})
|
||||
|
||||
# Trigger onchange
|
||||
record.partner_id = self.partner
|
||||
record._onchange_partner_id()
|
||||
|
||||
# Verify onchange updated fields
|
||||
# self.assertEqual(record.some_field, expected_value)
|
||||
|
||||
def test_06_constraint_validation(self):
|
||||
"""Test constraint validation."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
# Test that invalid value raises ValidationError
|
||||
with self.assertRaises(ValidationError) as context:
|
||||
record.write({'invalid_field': 'invalid_value'})
|
||||
|
||||
# Verify error message
|
||||
self.assertIn('expected error message', str(context.exception))
|
||||
|
||||
def test_07_state_transition(self):
|
||||
"""Test state transition workflow."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
# Initially in draft state
|
||||
self.assertEqual(record.state, 'draft')
|
||||
|
||||
# Confirm record
|
||||
record.action_confirm()
|
||||
self.assertEqual(record.state, 'confirmed')
|
||||
|
||||
# Test invalid transition
|
||||
with self.assertRaises(UserError) as context:
|
||||
record.action_confirm() # Already confirmed
|
||||
|
||||
self.assertIn('Cannot confirm', str(context.exception))
|
||||
|
||||
def test_08_delete_record(self):
|
||||
"""Test deleting a record (if applicable)."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
record_id = record.id
|
||||
|
||||
# Delete record
|
||||
record.unlink()
|
||||
|
||||
# Verify record no longer exists
|
||||
self.assertFalse(self.Model.browse(record_id).exists())
|
||||
144
skills/odoo-test-creator/assets/test_model_constraints.py
Normal file
144
skills/odoo-test-creator/assets/test_model_constraints.py
Normal file
@@ -0,0 +1,144 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import ValidationError
|
||||
from psycopg2 import IntegrityError
|
||||
|
||||
|
||||
class TestModelConstraints(TransactionCase):
|
||||
"""Test cases for model constraints and validation."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
super().setUp()
|
||||
|
||||
self.Model = self.env['model.name']
|
||||
|
||||
# Set up minimal test data
|
||||
self.partner = self.env['res.partner'].search([], limit=1)
|
||||
if not self.partner:
|
||||
self.skipTest("No partner available for testing")
|
||||
|
||||
def test_01_python_constraint_positive_value(self):
|
||||
"""Test Python constraint for positive values."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
'amount': 100.0,
|
||||
})
|
||||
|
||||
# Test valid positive value
|
||||
record.write({'amount': 50.0})
|
||||
self.assertEqual(record.amount, 50.0)
|
||||
|
||||
# Test that negative value raises ValidationError
|
||||
with self.assertRaises(ValidationError) as context:
|
||||
record.write({'amount': -10.0})
|
||||
|
||||
self.assertIn('must be positive', str(context.exception).lower())
|
||||
|
||||
def test_02_sql_constraint_unique(self):
|
||||
"""Test SQL constraint for unique values."""
|
||||
# Create first record
|
||||
self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'code': 'UNIQUE001',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
# Try to create duplicate
|
||||
with self.assertRaises(IntegrityError):
|
||||
with self.cr.savepoint():
|
||||
self.Model.create({
|
||||
'name': 'Test Record 2',
|
||||
'code': 'UNIQUE001', # Duplicate code
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
def test_03_required_field_validation(self):
|
||||
"""Test that required fields are enforced."""
|
||||
# Test missing required field raises ValidationError
|
||||
with self.assertRaises(ValidationError):
|
||||
self.Model.create({
|
||||
'name': 'Test Record',
|
||||
# Missing required 'partner_id'
|
||||
})
|
||||
|
||||
def test_04_field_domain_constraint(self):
|
||||
"""Test field domain constraints."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
'state': 'draft',
|
||||
})
|
||||
|
||||
# Test valid state
|
||||
record.write({'state': 'confirmed'})
|
||||
self.assertEqual(record.state, 'confirmed')
|
||||
|
||||
# Test invalid state raises ValidationError
|
||||
with self.assertRaises(ValidationError):
|
||||
record.write({'state': 'invalid_state'})
|
||||
|
||||
def test_05_dependent_field_constraint(self):
|
||||
"""Test constraints that depend on multiple fields."""
|
||||
# Test that start_date must be before end_date
|
||||
with self.assertRaises(ValidationError) as context:
|
||||
self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
'start_date': '2024-12-31',
|
||||
'end_date': '2024-01-01', # End before start
|
||||
})
|
||||
|
||||
self.assertIn('end date', str(context.exception).lower())
|
||||
self.assertIn('start date', str(context.exception).lower())
|
||||
|
||||
def test_06_conditional_constraint(self):
|
||||
"""Test constraints that apply conditionally."""
|
||||
# Create record in state where constraint doesn't apply
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
'state': 'draft',
|
||||
'approval_required': False,
|
||||
})
|
||||
|
||||
# Confirm - now constraint should apply
|
||||
record.write({'state': 'confirmed', 'approval_required': True})
|
||||
|
||||
# Test that missing approval raises error
|
||||
with self.assertRaises(ValidationError) as context:
|
||||
record.write({'approved_by': False}) # Clear approval
|
||||
|
||||
self.assertIn('approval', str(context.exception).lower())
|
||||
|
||||
def test_07_cascading_constraint(self):
|
||||
"""Test constraints that cascade to related records."""
|
||||
parent = self.Model.create({
|
||||
'name': 'Parent Record',
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
child = self.Model.create({
|
||||
'name': 'Child Record',
|
||||
'parent_id': parent.id,
|
||||
'partner_id': self.partner.id,
|
||||
})
|
||||
|
||||
# Test that deleting parent with children raises error
|
||||
with self.assertRaises(ValidationError) as context:
|
||||
parent.unlink()
|
||||
|
||||
self.assertIn('child', str(context.exception).lower())
|
||||
|
||||
def test_08_constraint_bypass_with_context(self):
|
||||
"""Test bypassing constraints with context (if applicable)."""
|
||||
# Some constraints can be bypassed with special context
|
||||
record = self.Model.with_context(skip_validation=True).create({
|
||||
'name': 'Test Record',
|
||||
'partner_id': self.partner.id,
|
||||
'amount': -100.0, # Normally not allowed
|
||||
})
|
||||
|
||||
self.assertEqual(record.amount, -100.0)
|
||||
153
skills/odoo-test-creator/assets/test_model_inheritance.py
Normal file
153
skills/odoo-test-creator/assets/test_model_inheritance.py
Normal file
@@ -0,0 +1,153 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class TestModelInheritance(TransactionCase):
|
||||
"""Test cases for model inheritance and extensions."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
super().setUp()
|
||||
|
||||
self.Model = self.env['base.model.name'] # The model being extended
|
||||
|
||||
# Set up test data
|
||||
self.partner = self.env['res.partner'].search([], limit=1)
|
||||
if not self.partner:
|
||||
self.skipTest("No partner available for testing")
|
||||
|
||||
def test_01_new_fields_exist(self):
|
||||
"""Test that new fields added by inheritance exist."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
# Base fields
|
||||
})
|
||||
|
||||
# Test that new fields exist and have default values
|
||||
self.assertTrue(hasattr(record, 'new_field'))
|
||||
self.assertEqual(record.new_field, False) # Or expected default
|
||||
|
||||
def test_02_inherited_method_override(self):
|
||||
"""Test that overridden methods work correctly."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
})
|
||||
|
||||
# Call overridden method
|
||||
result = record.action_confirm()
|
||||
|
||||
# Verify custom behavior was applied
|
||||
# self.assertEqual(record.state, 'custom_state')
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_03_super_call_behavior(self):
|
||||
"""Test that super() calls preserve base functionality."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
})
|
||||
|
||||
initial_state = record.state
|
||||
|
||||
# Call overridden method that should call super()
|
||||
record.write({'name': 'Updated Record'})
|
||||
|
||||
# Verify both base and custom behavior applied
|
||||
self.assertEqual(record.name, 'Updated Record') # Base behavior
|
||||
# self.assertEqual(record.custom_field, 'value') # Custom behavior
|
||||
|
||||
def test_04_added_constraint(self):
|
||||
"""Test new constraints added by inheritance."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'new_field': 'valid_value',
|
||||
})
|
||||
|
||||
# Test new constraint
|
||||
with self.assertRaises(UserError) as context:
|
||||
record.write({'new_field': 'invalid_value'})
|
||||
|
||||
self.assertIn('expected error', str(context.exception))
|
||||
|
||||
def test_05_computed_field_extension(self):
|
||||
"""Test computed fields added by inheritance."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'quantity': 10,
|
||||
'unit_price': 5.0,
|
||||
})
|
||||
|
||||
# Test new computed field
|
||||
self.assertEqual(record.total_with_tax, 52.5) # Example with 5% tax
|
||||
|
||||
def test_06_onchange_extension(self):
|
||||
"""Test onchange methods added by inheritance."""
|
||||
record = self.Model.new({
|
||||
'name': 'Test Record',
|
||||
})
|
||||
|
||||
# Trigger new onchange
|
||||
record.partner_id = self.partner
|
||||
record._onchange_partner_id_custom()
|
||||
|
||||
# Verify custom onchange behavior
|
||||
# self.assertEqual(record.custom_field, expected_value)
|
||||
|
||||
def test_07_api_depends_extension(self):
|
||||
"""Test that @api.depends works correctly on inherited fields."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'line_ids': [(0, 0, {
|
||||
'product_id': self.env['product.product'].search([], limit=1).id,
|
||||
'quantity': 5,
|
||||
'price_unit': 10.0,
|
||||
})],
|
||||
})
|
||||
|
||||
# Initial computed value
|
||||
initial_total = record.total_amount
|
||||
|
||||
# Add more lines
|
||||
record.write({
|
||||
'line_ids': [(0, 0, {
|
||||
'product_id': self.env['product.product'].search([], limit=1).id,
|
||||
'quantity': 3,
|
||||
'price_unit': 20.0,
|
||||
})],
|
||||
})
|
||||
|
||||
# Verify recomputation
|
||||
self.assertGreater(record.total_amount, initial_total)
|
||||
|
||||
def test_08_prevent_base_operation(self):
|
||||
"""Test blocking base operations with custom validation."""
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
'state': 'draft',
|
||||
})
|
||||
|
||||
# Transition to state that prevents deletion
|
||||
record.write({'state': 'confirmed'})
|
||||
|
||||
# Test that deletion is now blocked
|
||||
with self.assertRaises(UserError) as context:
|
||||
record.unlink()
|
||||
|
||||
self.assertIn('cannot delete', str(context.exception).lower())
|
||||
|
||||
def test_09_backward_compatibility(self):
|
||||
"""Test that base functionality still works after inheritance."""
|
||||
# Test base model functionality isn't broken
|
||||
record = self.Model.create({
|
||||
'name': 'Test Record',
|
||||
})
|
||||
|
||||
# Base operations should still work
|
||||
record.write({'name': 'Updated Name'})
|
||||
self.assertEqual(record.name, 'Updated Name')
|
||||
|
||||
# Base methods should still be callable
|
||||
if hasattr(record, 'base_method'):
|
||||
result = record.base_method()
|
||||
self.assertTrue(result)
|
||||
369
skills/odoo-test-creator/references/test_patterns.md
Normal file
369
skills/odoo-test-creator/references/test_patterns.md
Normal file
@@ -0,0 +1,369 @@
|
||||
# Odoo 16.0 Test Patterns and Best Practices
|
||||
|
||||
This document provides comprehensive patterns and best practices for writing tests in Odoo 16.0 Enterprise modules, based on the Siafa project standards.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Test Infrastructure](#test-infrastructure)
|
||||
2. [Common Setup Patterns](#common-setup-patterns)
|
||||
3. [Database Constraints](#database-constraints)
|
||||
4. [Test Organization](#test-organization)
|
||||
5. [Assertion Patterns](#assertion-patterns)
|
||||
6. [Common Pitfalls](#common-pitfalls)
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
### Import Statement
|
||||
|
||||
Always use the correct import for `TransactionCase`:
|
||||
|
||||
```python
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
```
|
||||
|
||||
**Note:** Use `from odoo.tests.common import TransactionCase`, NOT `from odoo.tests import TransactionCase`.
|
||||
|
||||
### Base Test Class
|
||||
|
||||
```python
|
||||
class TestModuleName(TransactionCase):
|
||||
"""Test cases for module functionality."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test data."""
|
||||
super().setUp() # Use super().setUp() not super(ClassName, self).setUp()
|
||||
|
||||
# Initialize models
|
||||
self.Model = self.env['model.name']
|
||||
|
||||
# Set up test data
|
||||
# ...
|
||||
```
|
||||
|
||||
## Common Setup Patterns
|
||||
|
||||
### Using Existing Records
|
||||
|
||||
**Preferred Pattern:** Use existing database records when possible to avoid database constraint issues:
|
||||
|
||||
```python
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Use existing warehouse
|
||||
self.warehouse = self.env['stock.warehouse'].search([], limit=1)
|
||||
if not self.warehouse:
|
||||
self.skipTest("No warehouse available for testing")
|
||||
|
||||
# Use existing product
|
||||
self.product = self.env['product.product'].search([('type', '=', 'product')], limit=1)
|
||||
if not self.product:
|
||||
self.skipTest("No storable product available for testing")
|
||||
```
|
||||
|
||||
### Creating New Records with .sudo()
|
||||
|
||||
When creating new records is necessary, use `.sudo()` to bypass access rights and permission issues:
|
||||
|
||||
```python
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Create vendor
|
||||
self.vendor = self.env['res.partner'].sudo().create({
|
||||
'name': 'Test Vendor',
|
||||
'is_company': True,
|
||||
'supplier_rank': 1,
|
||||
})
|
||||
|
||||
# Create product with minimal required fields
|
||||
self.product = self.env['product.product'].sudo().create({
|
||||
'name': 'Test Product',
|
||||
'type': 'product',
|
||||
'purchase_method': 'receive',
|
||||
'list_price': 100.0,
|
||||
'standard_price': 80.0,
|
||||
})
|
||||
```
|
||||
|
||||
### Class-Level Setup
|
||||
|
||||
For test data shared across all test methods, use `setUpClass`:
|
||||
|
||||
```python
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Set up test data shared across all test methods."""
|
||||
super().setUpClass()
|
||||
|
||||
# Create shared test data
|
||||
cls.vendor = cls.env['res.partner'].sudo().create({
|
||||
'name': 'Test Vendor',
|
||||
'is_company': True,
|
||||
})
|
||||
```
|
||||
|
||||
## Database Constraints
|
||||
|
||||
### Handling NOT NULL Constraints
|
||||
|
||||
Many databases have custom NOT NULL constraints. Avoid creating records that trigger these constraints:
|
||||
|
||||
**Problem:** Creating a product might fail due to missing `sale_line_warn` field:
|
||||
```python
|
||||
# ❌ May fail with "null value in column 'sale_line_warn' violates not-null constraint"
|
||||
self.product = self.env['product.product'].create({
|
||||
'name': 'Test Product',
|
||||
'type': 'product',
|
||||
})
|
||||
```
|
||||
|
||||
**Solution 1:** Use existing records:
|
||||
```python
|
||||
# ✅ Use existing product from database
|
||||
self.product = self.env['product.product'].search([('type', '=', 'product')], limit=1)
|
||||
```
|
||||
|
||||
**Solution 2:** Provide all required fields (if known):
|
||||
```python
|
||||
# ✅ Provide the required field
|
||||
self.product = self.env['product.product'].sudo().create({
|
||||
'name': 'Test Product',
|
||||
'type': 'product',
|
||||
'sale_line_warn': 'no-message', # Required in some databases
|
||||
})
|
||||
```
|
||||
|
||||
### HTML/Markup Fields
|
||||
|
||||
HTML fields return `Markup` objects, not plain strings:
|
||||
|
||||
```python
|
||||
# ❌ Fails: Markup('<p>Text</p>') != 'Text'
|
||||
self.assertEqual(record.comment, 'Updated comment')
|
||||
|
||||
# ✅ Use a different field for testing
|
||||
self.assertEqual(record.barcode, 'TEST002')
|
||||
|
||||
# ✅ Or convert to string
|
||||
self.assertIn('Updated comment', str(record.comment))
|
||||
```
|
||||
|
||||
## Test Organization
|
||||
|
||||
### Test Method Naming
|
||||
|
||||
Use descriptive names with numbering for execution order:
|
||||
|
||||
```python
|
||||
def test_01_create_record(self):
|
||||
"""Test creating a new record."""
|
||||
pass
|
||||
|
||||
def test_02_update_record(self):
|
||||
"""Test updating an existing record."""
|
||||
pass
|
||||
|
||||
def test_03_validation_error(self):
|
||||
"""Test that validation error is raised for invalid data."""
|
||||
pass
|
||||
```
|
||||
|
||||
### Test Coverage Areas
|
||||
|
||||
Comprehensive tests should cover:
|
||||
|
||||
1. **CRUD Operations**
|
||||
- Create records with valid data
|
||||
- Update records
|
||||
- Read/search records
|
||||
- Delete records (if applicable)
|
||||
|
||||
2. **Validation Logic**
|
||||
- Test constraints
|
||||
- Test computed fields
|
||||
- Test onchange methods
|
||||
|
||||
3. **Business Logic**
|
||||
- Test state transitions
|
||||
- Test workflow methods
|
||||
- Test custom methods
|
||||
|
||||
4. **Edge Cases**
|
||||
- Test with empty data
|
||||
- Test with maximum values
|
||||
- Test with invalid combinations
|
||||
|
||||
5. **Error Handling**
|
||||
- Test that appropriate errors are raised
|
||||
- Test error messages are clear
|
||||
|
||||
## Assertion Patterns
|
||||
|
||||
### Basic Assertions
|
||||
|
||||
```python
|
||||
# Equality
|
||||
self.assertEqual(record.field, expected_value)
|
||||
self.assertNotEqual(record.field, unexpected_value)
|
||||
|
||||
# Boolean
|
||||
self.assertTrue(record.active)
|
||||
self.assertFalse(record.archived)
|
||||
|
||||
# Containment
|
||||
self.assertIn(item, collection)
|
||||
self.assertNotIn(item, collection)
|
||||
|
||||
# String matching
|
||||
self.assertIn('substring', record.message)
|
||||
```
|
||||
|
||||
### Exception Testing
|
||||
|
||||
```python
|
||||
# Test that exception is raised
|
||||
with self.assertRaises(UserError) as context:
|
||||
record.forbidden_action()
|
||||
|
||||
# Check exception message
|
||||
self.assertIn('Cannot perform action', str(context.exception))
|
||||
```
|
||||
|
||||
### Recordset Assertions
|
||||
|
||||
```python
|
||||
# Check recordset size
|
||||
self.assertEqual(len(records), 3)
|
||||
|
||||
# Check recordset is empty
|
||||
self.assertFalse(records)
|
||||
|
||||
# Check record exists
|
||||
self.assertTrue(record)
|
||||
self.assertTrue(record.exists())
|
||||
```
|
||||
|
||||
### Computed Field Testing
|
||||
|
||||
```python
|
||||
# Test computed field
|
||||
self.assertEqual(record.total_amount, 100.0)
|
||||
|
||||
# Test compute dependency
|
||||
record.line_ids = [(5, 0, 0)] # Clear lines
|
||||
self.assertEqual(record.total_amount, 0.0)
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### 1. Product Creation Failures
|
||||
|
||||
**Issue:** Creating products fails due to database constraints.
|
||||
|
||||
**Solution:** Use existing products or create with `.sudo()` and minimal fields:
|
||||
```python
|
||||
self.product = self.env['product.product'].search([('type', '=', 'product')], limit=1)
|
||||
```
|
||||
|
||||
### 2. Incorrect Import Statement
|
||||
|
||||
**Issue:** `from odoo.tests import TransactionCase` causes import errors.
|
||||
|
||||
**Solution:** Use `from odoo.tests.common import TransactionCase`
|
||||
|
||||
### 3. Super() Call Format
|
||||
|
||||
**Issue:** Using old-style super() calls.
|
||||
|
||||
**Solution:** Use `super().setUp()` instead of `super(ClassName, self).setUp()`
|
||||
|
||||
### 4. Missing .sudo()
|
||||
|
||||
**Issue:** Tests fail due to access rights.
|
||||
|
||||
**Solution:** Use `.sudo()` when creating test data:
|
||||
```python
|
||||
self.partner = self.env['res.partner'].sudo().create({...})
|
||||
```
|
||||
|
||||
### 5. HTML Field Comparisons
|
||||
|
||||
**Issue:** Comparing HTML fields directly fails.
|
||||
|
||||
**Solution:** Use non-HTML fields or convert to string:
|
||||
```python
|
||||
# Instead of comment field, use barcode or another text field
|
||||
self.assertEqual(record.barcode, 'TEST001')
|
||||
```
|
||||
|
||||
### 6. Transaction Isolation
|
||||
|
||||
**Issue:** Tests affect each other due to shared data.
|
||||
|
||||
**Solution:** Each test method runs in its own transaction (automatic with `TransactionCase`)
|
||||
|
||||
### 7. Insufficient Error Checking
|
||||
|
||||
**Issue:** Not verifying error messages.
|
||||
|
||||
**Solution:** Always check exception messages:
|
||||
```python
|
||||
with self.assertRaises(UserError) as context:
|
||||
record.action()
|
||||
self.assertIn('expected error message', str(context.exception))
|
||||
```
|
||||
|
||||
### 8. Missing Test Documentation
|
||||
|
||||
**Issue:** Tests without docstrings are hard to understand.
|
||||
|
||||
**Solution:** Always add descriptive docstrings:
|
||||
```python
|
||||
def test_prevent_usage_change_with_moves(self):
|
||||
"""Test that usage cannot be changed when location has stock moves."""
|
||||
# ...
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Command Patterns
|
||||
|
||||
```bash
|
||||
# Run tests during module installation
|
||||
python3 src/odoo-bin -c src/odoo.conf -d DATABASE_NAME \
|
||||
--test-enable --stop-after-init \
|
||||
-i MODULE_NAME
|
||||
|
||||
# Run tests during module update
|
||||
python3 src/odoo-bin -c src/odoo.conf -d DATABASE_NAME \
|
||||
--test-enable --stop-after-init \
|
||||
-u MODULE_NAME
|
||||
|
||||
# Run with verbose output
|
||||
python3 src/odoo-bin -c src/odoo.conf -d DATABASE_NAME \
|
||||
--test-enable --stop-after-init \
|
||||
--log-level=test \
|
||||
-u MODULE_NAME
|
||||
```
|
||||
|
||||
### Test Tags
|
||||
|
||||
Tag tests for selective execution:
|
||||
|
||||
```python
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
|
||||
@tagged('post_install', '-at_install', 'module_name')
|
||||
class TestModuleName(TransactionCase):
|
||||
"""Test cases with tags."""
|
||||
pass
|
||||
```
|
||||
|
||||
Run tagged tests:
|
||||
```bash
|
||||
python3 src/odoo-bin -c src/odoo.conf -d DATABASE_NAME \
|
||||
--test-enable --stop-after-init \
|
||||
--test-tags module_name
|
||||
```
|
||||
Reference in New Issue
Block a user