Wasting $264K on Bugs? Write Test Cases in Odoo 18 ERP
By Braincuber Team
Published on December 23, 2025
Developer builds custom Odoo module: Inventory auto-reordering. Deploys to production (247 products tracked). Works perfectly. Two weeks later: Customer complains. Product X shows negative stock (-47 units). Impossible. Developer investigates. Bug: When product reserved in sale order but not invoiced, then stock transfer happens from different warehouse, quantity calculation breaks. Writes fix. Deploys. Different bug appears: Now products with variant attributes don't reorder at all. 87 products out of stock. Lost sales: $23,000 (3-day stockout). Root cause: No tests. Developer manually tested Product A (simple product, no variants). Didn't test Product B (with variants). Didn't test simultaneous warehouse transfers. Didn't test edge cases. Another company: Custom pricing module (discount rules based on customer tier). Works in demo. Deploys. VIP customer (Tier 1: 30% discount) places $100K order. Gets 0% discount. System applied Tier 3 discount logic. Developer checks code. Function check_customer_tier uses == instead of >= for tier comparison. VIP customers with tier exactly 1 get discount. But logic broke for tier checks. Manual testing: Developer only tested Tier 2 customers. Never tested Tier 1 or Tier 3. Annual cost: $127K bugs in production + $47K lost sales + $23K emergency fixes + $67K customer compensation = $264K untested code chaos.
Odoo 18 test framework fixes this: Write test cases (unit tests for business logic). Test edge cases (negative stock, variants, simultaneous operations). Auto-run tests before deployment (catch bugs pre-production). TransactionCase for models (create test data, assert expected behavior). HttpCase for controllers (test web routes, forms). Test access rights (verify security rules work). Test scheduled actions (cron jobs execute correctly). Performance tests (query count limits). Run all tests: ./odoo-bin --test-enable. CI/CD integration (auto-test on every commit). Here's how to write test cases in Odoo 18 so you stop losing $264K annually to untested code chaos.
You're Losing Money If:
Why Test Cases Matter
- Quality Assurance: Verify module behaves as expected (catch bugs before customers do)
- Regression Prevention: New code doesn't break old features
- Documentation: Tests show how code should work (living examples)
- Refactoring Confidence: Change code safely (tests catch breaks)
- Compliance: Business rules consistently enforced
- Edge Cases: Test scenarios manual testing misses (negative values, simultaneous operations, variants)
Step 1: Set Up Test Directory Structure
- Create
testsdirectory in your module folder - Create test files with
test_prefix (e.g.,test_inventory.py) - Import test files in
tests/__init__.py - Import tests package in module's
__init__.py
Module Structure
your_module/
├── __init__.py
├── __manifest__.py
├── models/
│ ├── __init__.py
│ └── inventory.py
├── tests/
│ ├── __init__.py
│ ├── test_inventory.py
│ └── test_pricing.py
└── views/
└── inventory_views.xml
tests/__init__.py
from . import test_inventory
from . import test_pricing
Module __init__.py
from . import models
from . import tests
Step 2: Write Your First Test Case (TransactionCase)
TransactionCase: Most common test class. Each test runs in separate transaction (auto-rolled back after test). Use for testing models, business logic.
Basic Test Structure
from odoo.tests.common import TransactionCase
class TestPartnerExtension(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Create test data available to all test methods
cls.partner = cls.env['res.partner'].create({
'name': 'Test Partner',
'email': 'partner@test.com',
'is_company': True,
'vat': 'BE0123456789'
})
def test_partner_creation(self):
"""Test that partner records are created correctly"""
self.assertEqual(self.partner.name, 'Test Partner')
self.assertTrue(self.partner.is_company)
self.assertEqual(self.partner.email, 'partner@test.com')
def test_email_validation(self):
"""Test email validation logic"""
# Test valid email
self.partner.write({'email': 'valid@example.com'})
self.assertEqual(self.partner.email, 'valid@example.com')
# Test invalid email raises error
with self.assertRaises(ValueError):
self.partner.write({'email': 'invalid-email'})
def test_vat_computation(self):
"""Test VAT number computation"""
# Assume custom logic that formats VAT
result = self.partner.compute_vat_display()
self.assertEqual(result, 'BE 0123 456 789')
Step 3: Common Assertion Methods
Basic Comparisons
# Equality checks
self.assertEqual(a, b) # Verify a equals b
self.assertNotEqual(a, b) # Verify a doesn't equal b
# Boolean checks
self.assertTrue(x) # Verify x is True
self.assertFalse(x) # Verify x is False
# Identity checks
self.assertIs(a, b) # Verify a is same object as b
self.assertIsNot(a, b) # Verify a is not same object as b
# None checks
self.assertIsNone(x) # Verify x is None
self.assertIsNotNone(x) # Verify x is not None
Collection Checks
# Membership checks
self.assertIn(a, b) # Verify a is in b
self.assertNotIn(a, b) # Verify a is not in b
# Count checks
self.assertCountEqual(a, b) # Verify a and b have same elements (order doesn't matter)
# Comparison checks
self.assertGreater(a, b) # Verify a > b
self.assertGreaterEqual(a, b) # Verify a >= b
self.assertLess(a, b) # Verify a < b
self.assertLessEqual(a, b) # Verify a <= b
Exception Handling
# Test that function raises specific exception
with self.assertRaises(ValueError):
self.partner.write({'email': 'invalid'})
# Test exception with specific message
with self.assertRaisesRegex(ValueError, 'Invalid email format'):
self.partner.write({'email': 'invalid'})
Step 4: Test Inventory Auto-Reordering (Real Example)
from odoo.tests.common import TransactionCase
class TestInventoryReordering(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Create warehouse
cls.warehouse = cls.env['stock.warehouse'].create({
'name': 'Test Warehouse',
'code': 'TEST'
})
# Create simple product
cls.product_simple = cls.env['product.product'].create({
'name': 'Simple Product',
'type': 'product',
'reordering_min_qty': 10,
'reordering_max_qty': 50
})
# Create product with variants
cls.product_template = cls.env['product.template'].create({
'name': 'Product with Variants',
'type': 'product'
})
# Add attribute (Size: S, M, L)
cls.attribute = cls.env['product.attribute'].create({
'name': 'Size'
})
cls.value_s = cls.env['product.attribute.value'].create({
'name': 'S',
'attribute_id': cls.attribute.id
})
cls.value_m = cls.env['product.attribute.value'].create({
'name': 'M',
'attribute_id': cls.attribute.id
})
# Create variants
cls.product_template.attribute_line_ids = [(0, 0, {
'attribute_id': cls.attribute.id,
'value_ids': [(6, 0, [cls.value_s.id, cls.value_m.id])]
})]
cls.product_variant_s = cls.product_template.product_variant_ids.filtered(
lambda p: 'S' in p.name
)
cls.product_variant_m = cls.product_template.product_variant_ids.filtered(
lambda p: 'M' in p.name
)
def test_simple_product_reordering(self):
"""Test reordering logic for simple product"""
# Set stock to below minimum
self._set_product_qty(self.product_simple, 5)
# Trigger reordering
self.product_simple.check_reordering_rules()
# Verify purchase order created
po = self.env['purchase.order'].search([
('state', '=', 'draft'),
('order_line.product_id', '=', self.product_simple.id)
])
self.assertEqual(len(po), 1, "Purchase order should be created")
self.assertEqual(po.order_line[0].product_qty, 45) # 50 max - 5 current
def test_variant_product_reordering(self):
"""Test reordering logic for product variants"""
# Set stock for variant S
self._set_product_qty(self.product_variant_s, 3)
# Trigger reordering
self.product_variant_s.check_reordering_rules()
# Verify purchase order created for correct variant
po = self.env['purchase.order'].search([
('state', '=', 'draft'),
('order_line.product_id', '=', self.product_variant_s.id)
])
self.assertTrue(po, "Purchase order should be created for variant S")
self.assertEqual(
po.order_line[0].product_id.id,
self.product_variant_s.id,
"Should order correct variant"
)
def test_negative_stock_scenario(self):
"""Test handling of negative stock"""
# Create sale order
so = self.env['sale.order'].create({
'partner_id': self.env.ref('base.res_partner_1').id,
'order_line': [(0, 0, {
'product_id': self.product_simple.id,
'product_uom_qty': 50
})]
})
so.action_confirm()
# Stock reserved but not invoiced
self.assertEqual(self.product_simple.qty_available, -50)
# Transfer from different warehouse
self._transfer_stock(self.product_simple, 100)
# Verify stock calculation correct
self.assertEqual(
self.product_simple.qty_available,
50, # 100 transferred - 50 reserved
"Stock calculation should handle reserved quantities correctly"
)
def _set_product_qty(self, product, qty):
"""Helper to set product quantity"""
inventory = self.env['stock.quant'].create({
'product_id': product.id,
'location_id': self.warehouse.lot_stock_id.id,
'quantity': qty
})
def _transfer_stock(self, product, qty):
"""Helper to transfer stock"""
picking = self.env['stock.picking'].create({
'picking_type_id': self.warehouse.in_type_id.id,
'location_id': self.env.ref('stock.stock_location_suppliers').id,
'location_dest_id': self.warehouse.lot_stock_id.id,
'move_ids': [(0, 0, {
'name': product.name,
'product_id': product.id,
'product_uom_qty': qty,
'product_uom': product.uom_id.id,
'location_id': self.env.ref('stock.stock_location_suppliers').id,
'location_dest_id': self.warehouse.lot_stock_id.id
})]
})
picking.action_confirm()
picking.button_validate()
Step 5: Test UI Components with HttpCase
HttpCase: Test web controllers, routes, website forms. Simulates HTTP requests.
from odoo.tests.common import HttpCase
class TestWebsiteController(HttpCase):
def test_contact_form_submission(self):
"""Test website contact form submission"""
# Start browser tour
self.start_tour('/contactus', 'website_contact_form_test')
# Verify record created
record = self.env['website.contact'].search([], limit=1, order='id desc')
self.assertTrue(record, "Form submission should create record")
self.assertEqual(record.email, 'test@example.com')
def test_controller_endpoint(self):
"""Test custom controller endpoint"""
response = self.url_open('/my_module/api/get_data')
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn('result', data)
self.assertEqual(data['status'], 'success')
Step 6: Test Access Rights & Security
from odoo.exceptions import AccessError
class TestAccessRights(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Create test users
cls.admin_user = cls.env.ref('base.user_admin')
cls.demo_user = cls.env.ref('base.user_demo')
# Create test partner
cls.partner = cls.env['res.partner'].create({
'name': 'Secure Partner',
'vat': 'BE0123456789'
})
def test_demo_user_cannot_edit_vat(self):
"""Test that demo user cannot edit VAT field"""
with self.assertRaises(AccessError):
self.partner.with_user(self.demo_user).write({
'vat': 'BE9876543210'
})
def test_admin_user_can_edit_vat(self):
"""Test that admin user can edit VAT field"""
self.partner.with_user(self.admin_user).write({
'vat': 'BE9876543210'
})
self.assertEqual(self.partner.vat, 'BE9876543210')
def test_record_rules(self):
"""Test record-level security rules"""
# Demo user should only see their own records
visible_partners = self.env['res.partner'].with_user(
self.demo_user
).search([])
# Verify demo user can't see admin's private partners
self.assertNotIn(
self.partner.id,
visible_partners.ids,
"Demo user should not see admin's partners"
)
Step 7: Test Scheduled Actions (Cron Jobs)
class TestScheduledActions(TransactionCase):
def test_invoice_generation_cron(self):
"""Test automated invoice generation"""
# Create subscription that should generate invoice
subscription = self.env['sale.subscription'].create({
'partner_id': self.env.ref('base.res_partner_1').id,
'recurring_amount': 100.0,
'next_invoice_date': fields.Date.today()
})
# Trigger cron manually
cron = self.env.ref('your_module.generate_invoices_cron')
cron.method_direct_trigger()
# Verify invoice created
invoices = self.env['account.move'].search([
('invoice_origin', '=', subscription.code),
('move_type', '=', 'out_invoice')
])
self.assertEqual(len(invoices), 1, "Cron should create invoice")
self.assertEqual(invoices[0].amount_total, 100.0)
def test_email_notification_cron(self):
"""Test scheduled email notifications"""
# Create overdue invoice
invoice = self.env['account.move'].create({
'partner_id': self.env.ref('base.res_partner_1').id,
'move_type': 'out_invoice',
'invoice_date': fields.Date.today() - timedelta(days=30),
'invoice_date_due': fields.Date.today() - timedelta(days=15)
})
invoice.action_post()
# Trigger reminder cron
cron = self.env.ref('account.ir_cron_send_payment_reminders')
cron.method_direct_trigger()
# Verify email sent
mail = self.env['mail.mail'].search([
('recipient_ids', 'in', invoice.partner_id.id)
], limit=1)
self.assertTrue(mail, "Reminder email should be sent")
Step 8: Performance Testing
class TestPerformance(TransactionCase):
def test_bulk_partner_creation_performance(self):
"""Test bulk partner creation doesn't exceed query threshold"""
with self.assertQueryCount(threshold=50):
partners = self.env['res.partner'].create([
{'name': f'Partner {i}', 'email': f'partner{i}@test.com'}
for i in range(100)
])
self.assertEqual(len(partners), 100, "Should create 100 partners")
def test_search_performance(self):
"""Test search performance on large dataset"""
# Create 1000 test records
self.env['res.partner'].create([
{'name': f'Test Partner {i}'}
for i in range(1000)
])
# Verify search is efficient
with self.assertQueryCount(threshold=10):
result = self.env['res.partner'].search([
('name', 'like', 'Test Partner')
], limit=10)
self.assertEqual(len(result), 10)
Step 9: Run Tests
Run All Tests
./odoo-bin -c /etc/odoo/odoo.conf -d test_db -i your_module --test-enable
Run Tests for Specific Module
./odoo-bin -c odoo.conf -d test_db --test-enable --test-tags=your_module
Run Tests with Specific Tags
# Tag test methods
class TestInventory(TransactionCase):
@tagged('post_install', 'slow')
def test_large_inventory_transfer(self):
"""Test transferring large inventory"""
pass
# Run only slow tests
./odoo-bin -c odoo.conf -d test_db --test-tags=slow
# Run security tests
./odoo-bin -c odoo.conf -d test_db --test-tags=security
# Exclude slow tests
./odoo-bin -c odoo.conf -d test_db --test-tags=-slow
Run Tests in CI/CD Pipeline
# .gitlab-ci.yml example
test:
stage: test
script:
- odoo-bin -c odoo.conf -d test_db -i your_module --test-enable --stop-after-init
only:
- merge_requests
- main
Best Practices
- Test Edge Cases: Negative values, zero quantities, simultaneous operations, product variants
- Isolate Tests: Each test independent (use setUpClass for shared data)
- Descriptive Names: test_negative_stock_after_transfer (not test_1)
- Document Tests: Docstrings explain what test verifies
- Fast Tests: Avoid sleep(). Use mock for external APIs
- Test One Thing: One assertion focus per test method
- Coverage Targets: Aim for 80%+ code coverage
- CI Integration: Auto-run tests on every commit
Real-World Impact
Company Built Custom Inventory Module:
Before Tests: Auto-reordering deployed. Worked for Product A (simple). Broke for Product X with variants (negative stock -47 units). Bug: Reserved + transfer = wrong calculation. Fixed bug. Different bug: Products with variants don't reorder (87 products out of stock, $23K lost sales). Pricing module: VIP customer (Tier 1, 30% discount) got 0% discount ($100K order). Bug: Used == instead of >= for tier check. Only tested Tier 2 customers manually. Never tested Tier 1 or 3. Total: $264K yearly.
After Writing Tests: Wrote test cases: test_simple_product_reordering, test_variant_product_reordering, test_negative_stock_scenario, test_tier_pricing (all tiers). Auto-run tests before deploy. Bug caught pre-production: "Test failed: variant reordering broken." Fixed before deploy. Pricing bug caught: "Test failed: Tier 1 customers get 0% discount." Fixed logic (== to >=). All tiers tested. Deployed confidently. CI/CD integration: Every commit auto-tests. Failed tests = blocked merge. Production bugs: Eliminated. Lost sales: $0 (caught in tests).
Total Year 1 impact: $264,000 saved
Pro Tip: Developer built inventory module. Deployed. Worked perfectly. Two weeks: Customer complained. Product X: -47 units (negative stock impossible). Bug: Reserved in sale order + transfer from different warehouse = quantity calculation broke. Fixed. Different bug appeared: Products with variants don't auto-reorder. 87 products out of stock = $23K lost sales (3 days). Root cause: No tests. Manually tested Product A (simple). Never tested Product B (with variants). Never tested simultaneous operations. Pricing module: VIP Tier 1 customer (30% discount) placed $100K order. Got 0% discount. Bug: check_customer_tier used == instead of >=. Only Tier 2 tested manually. Tier 1 and 3 never tested. Wrote tests: test_simple_product_reordering, test_variant_product_reordering, test_negative_stock_scenario (reserved + transfer), test_all_pricing_tiers. Ran tests before deploy. Caught variant bug: "Test failed." Fixed. Caught pricing bug: "Tier 1 gets 0%." Fixed (== to >=). Deployed. Zero bugs. Integrated CI/CD: Auto-test every commit. Failed test = blocked merge. Production bugs: Eliminated. Developer: "We deployed blind for 3 years when tests catch everything pre-production." ROI: $264K Year 1.
FAQs
Wasting $264K on Untested Code?
We write comprehensive Odoo 18 test suites: TransactionCase for models, HttpCase for controllers, access rights tests, performance tests, CI/CD integration. Turn production bugs into pre-deploy catches. Ship confidently.
