The Testing Crisis
Your D2C brand deploys a custom order confirmation module. It auto-calculates discounts, updates inventory, sends emails, and records accounting entries.
Scenario A (No Tests)
Deploy to production
Discount calculation is wrong (off by 1 cent)
Customer data corrupts in accounting
Email goes to wrong address
You spend $8,000 fixing production data
Staff trust in system is gone
Scenario B (With Tests)
50 automated tests catch every corner case
Deploy with confidence
Bugs found in 5 minutes, fixed before production
Customer data stays clean
Emails go to right place
Zero production incidents
The Difference: $8,000 in disaster recovery
vs. $200 in test writing upfront.
We've implemented 150+ Odoo systems. The ones with comprehensive tests? Near-zero production bugs, deployments are painless, refactoring is safe. The ones without tests? Developers are terrified to change code, bugs appear weeks after release, patches cost $40,000+. That's $60,000-$150,000 in accumulated technical debt.
The Testing Pyramid (What to Test)
E2E Tests (5%)
Test complete workflows
Integration Tests (15%)
Test model interactions, database
Unit Tests (80%)
Test individual methods
Focus: 80% of effort on unit tests (fast, reliable). 15% on integration tests (slower, comprehensive). 5% on E2E tests (very slow, catch regressions).
Unit Tests (Test Individual Methods)
What it is: Test a single method in isolation. Fast, reliable, catches bugs early.
Setup
custom_module/
├── tests/
│ ├── __init__.py
│ └── test_sale_order.py
└── __manifest__.py
Basic Unit Test
from odoo.tests.common import TransactionCase
from odoo.exceptions import ValidationError
class TestSaleOrder(TransactionCase):
"""Test cases for sale.order model."""
@classmethod
def setUpClass(cls):
"""Set up test data once for all tests."""
super().setUpClass()
# Create test customer
cls.customer = cls.env['res.partner'].create({
'name': 'Test Customer',
'email': 'test@example.com',
'country_id': cls.env.ref('base.us').id,
})
# Create test product
cls.product = cls.env['product.product'].create({
'name': 'Test Product',
'list_price': 100,
'cost': 50,
})
def setUp(self):
"""Run before each test method."""
super().setUp()
# Create a fresh order for each test
self.order = self.env['sale.order'].create({
'partner_id': self.customer.id,
'order_line': [
(0, 0, {
'product_id': self.product.id,
'product_qty': 2,
'price_unit': 100,
})
],
})
# TEST METHODS
def test_order_creation(self):
"""Test that order is created with correct values."""
self.assertEqual(self.order.partner_id, self.customer)
self.assertEqual(len(self.order.order_line), 1)
self.assertEqual(self.order.state, 'draft')
def test_order_total_calculation(self):
"""Test that order total is calculated correctly."""
# 2 items × $100 = $200
self.assertEqual(self.order.amount_subtotal, 200)
# Tax: $200 × 8% = $16
self.assertEqual(self.order.amount_tax, 16)
# Total: $200 + $16 = $216
self.assertEqual(self.order.amount_total, 216)
def test_discount_application(self):
"""Test that discount reduces total correctly."""
# Apply 10% discount
self.order.discount_percent = 10
# Subtotal unchanged
self.assertEqual(self.order.amount_subtotal, 200)
# Tax on discounted amount
expected_tax = (200 - 20) * 0.08 # $14.40
self.assertAlmostEqual(self.order.amount_tax, expected_tax, places=2)
# Total: $180 + $14.40 = $194.40
self.assertAlmostEqual(self.order.amount_total, 194.40, places=2)
def test_validation_error_on_negative_quantity(self):
"""Test that negative quantity raises ValidationError."""
with self.assertRaises(ValidationError):
self.order.order_line[0].product_qty = -1
Run Tests
# Run all tests in module
python -m odoo -c /path/to/odoo.conf -d test_database -m custom_module --test-enable
# Run specific test file
python -m odoo -c /path/to/odoo.conf -d test_database -m custom_module --test-enable --test-file=tests/test_sale_order.py
# Run specific test class
python -m odoo -c /path/to/odoo.conf -d test_database -m custom_module --test-enable -u custom_module --test-tags="test_sale_order"
Integration Tests (Test Model Interactions)
What it is: Test multiple models working together. Catches bugs in interactions.
from odoo.tests.common import TransactionCase
class TestSaleOrderIntegration(TransactionCase):
"""Integration tests for order → inventory → accounting."""
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.customer = cls.env['res.partner'].create({'name': 'Customer'})
cls.product = cls.env['product.product'].create({
'name': 'Product',
'list_price': 100,
'cost': 50,
})
def test_order_confirmation_updates_inventory(self):
"""When order confirms, inventory should be reserved."""
# Create order
order = self.env['sale.order'].create({
'partner_id': self.customer.id,
'order_line': [(0, 0, {
'product_id': self.product.id,
'product_qty': 10,
})],
})
# Confirm order
order.action_confirm()
# Check inventory was reserved
stock_moves = self.env['stock.move'].search([
('sale_line_id.order_id', '=', order.id),
('state', '=', 'confirmed'),
])
self.assertEqual(len(stock_moves), 1)
self.assertEqual(stock_moves.product_qty, 10)
def test_order_creates_accounting_entries(self):
"""When order confirms, accounting entries should be created."""
order = self.env['sale.order'].create({
'partner_id': self.customer.id,
'order_line': [(0, 0, {
'product_id': self.product.id,
'product_qty': 5,
'price_unit': 100,
})],
})
order.action_confirm()
# Create invoice from order
invoice = order._create_invoices()
# Check invoice exists and has correct total
self.assertTrue(invoice)
self.assertEqual(invoice.amount_total, 500) # 5 × $100
def test_order_sends_confirmation_email(self):
"""When order confirms, email should be sent."""
order = self.env['sale.order'].create({
'partner_id': self.customer.id,
'order_line': [(0, 0, {
'product_id': self.product.id,
'product_qty': 1,
})],
})
# Mock email sending
with self.assertLogs(level='INFO'):
order.action_confirm()
# Check email was queued
mail = self.env['mail.mail'].search([
('email_to', '=', self.customer.email),
])
self.assertTrue(mail)
Using Fixtures (Reusable Test Data)
Problem: Creating test data is repetitive. Solution: Fixtures.
import pytest
from odoo import fields
@pytest.fixture
def test_customer(env):
"""Create a test customer."""
return env['res.partner'].create({
'name': 'Test Customer',
'email': 'test@example.com',
'country_id': env.ref('base.us').id,
})
@pytest.fixture
def test_product(env):
"""Create a test product."""
return env['product.product'].create({
'name': 'Test Product',
'list_price': 100,
'cost': 50,
})
@pytest.fixture
def test_order(env, test_customer, test_product):
"""Create a test order."""
return env['sale.order'].create({
'partner_id': test_customer.id,
'order_line': [(0, 0, {
'product_id': test_product.id,
'product_qty': 2,
'price_unit': 100,
})],
})
# Usage in tests:
def test_order_total(test_order):
"""Test order total using fixture."""
assert test_order.amount_total == 216 # 2×100 + tax
Mocking (Isolate Dependencies)
Problem: You need to test email sending, but don't want to actually send emails. Solution: Mock.
from unittest.mock import patch, MagicMock
def test_order_sends_email(self):
"""Test email sending without actually sending."""
order = self.env['sale.order'].create({
'partner_id': self.customer.id,
'order_line': [(0, 0, {'product_id': self.product.id})],
})
# Mock the email sending method
with patch.object(order, 'send_confirmation_email') as mock_send:
order.action_confirm()
# Verify email method was called
mock_send.assert_called_once()
def test_external_api_call(self):
"""Test code that calls external API without hitting real API."""
with patch('custom_module.models.requests.post') as mock_post:
# Mock the API response
mock_post.return_value = MagicMock(
status_code=200,
json=lambda: {'success': True, 'order_id': 12345}
)
# Call code that uses API
result = self.env['sale.order'].call_external_api()
# Verify API was called with correct parameters
mock_post.assert_called_once()
self.assertTrue(result['success'])
Assertions (Verify Test Results)
Common Assertions
# Equality
self.assertEqual(order.state, 'sale')
self.assertNotEqual(order.state, 'draft')
# Truthiness
self.assertTrue(order.confirmed)
self.assertFalse(order.cancelled)
# Numbers
self.assertAlmostEqual(order.total, 100.00, places=2)
self.assertGreater(order.total, 50)
self.assertLess(order.total, 200)
# Collections
self.assertIn('tax', order._fields) # Field exists
self.assertNotIn('deleted_field', order._fields)
# Exceptions
with self.assertRaises(ValidationError):
order.invalid_operation()
# Records
self.assertEqual(len(order.order_line), 3)
self.assertTrue(order.exists())
self.assertRecordValues(order, [{'state': 'sale'}])
Your Action Items
Write Tests
❏ Create tests/ folder
❏ Write unit tests for each method
❏ Write integration tests for workflows
❏ Aim for 80%+ code coverage
Test Data
❏ Use setUpClass for fixtures
❏ Use setUp for fresh data per test
❏ Use pytest fixtures for reusable fixtures
Run Tests
❏ Run locally before commit
❏ Run in CI/CD pipeline
❏ Track coverage metrics
❏ Add tests for every bug found
Free Testing Strategy Workshop
Stop shipping untested code. Most D2C brands deploy with zero tests. Adding tests prevents $40,000-$100,000 in production bugs. Building tests from the start costs 10% more development, saves 10x in support. We'll measure current test coverage, identify untested code, write unit tests for critical paths, set up CI/CD testing, and establish testing best practices.
