GuideOdoo DevelopmentMarch 13, 2026

Testing Framework in Odoo 19:
Unit, Integration & Tour Tests

INTRODUCTION

Your Custom Module Works Today. Will It Survive the Next Upgrade?

We audit Odoo custom modules for a living. The pattern we see most often: a module with 3,000 lines of Python, zero tests, and an owner who is terrified to touch it. Every minor Odoo upgrade is a coin flip. Every new developer who inherits it spends a week reading code before they dare change a line. And when something breaks in production, the debugging starts with "well, it worked on my machine."

Odoo 19 ships with a mature, batteries-included testing framework that most developers never use beyond copy-pasting the demo test from the scaffold command. The framework supports unit tests (isolated ORM operations), integration tests (multi-model workflows with rollback), HTTP tests (controller endpoints and authentication), and tour tests (full browser automation via JavaScript). It integrates with Python's unittest module, supports mock.patch for external dependencies, and can be wired into any CI pipeline.

This guide covers every test class available in Odoo 19, when to use each one, how to structure test files for real modules, and how to run them in development and CI. Every code sample is production-tested across our client portfolio.

01

Odoo 19 Test Classes Explained: TransactionCase, HttpCase, and SavepointCase

Odoo provides three primary test base classes. Choosing the wrong one is the #1 reason tests are slow, flaky, or don't catch real bugs. Here's when to use each:

Base ClassTransaction BehaviorUse WhenPerformance
TransactionCaseRolls back after each test methodTesting individual ORM operations, computed fields, constraintsModerate — fresh state per test, but setUp runs every time
SavepointCaseRolls back after each test class (uses savepoints between methods)Testing multi-step workflows where tests share setup dataFast — setUpClass runs once, methods share state via savepoints
HttpCaseCommits to database, spawns a real HTTP serverTesting controllers, JSON-RPC, tour tests, portal pagesSlow — real HTTP requests, real browser for tours

The critical distinction: TransactionCase and SavepointCase run inside a transaction that gets rolled back — your test data never touches the real database. HttpCase commits data to the database because the HTTP server runs in a separate thread that needs to read the test data. This means HttpCase tests can leave residual data if they fail mid-execution, and they cannot run in parallel safely.

Python — Test file structure: my_module/tests/__init__.py
# my_module/tests/__init__.py
# Import all test modules here — Odoo's test runner discovers them
# through this __init__.py file.

from . import test_sale_logic
from . import test_invoice_workflow
from . import test_controllers
from . import test_tour_checkout
Naming Convention Matters

Odoo's test runner only discovers test methods that start with test_ and test classes that inherit from one of the base classes. A method named check_invoice_total will never run. A class that inherits from unittest.TestCase directly will run but won't have access to self.env — the Odoo environment, registry, and cursor are only injected by Odoo's base classes.

02

Writing Unit and Integration Tests for Odoo 19 Custom Modules

Let's build real tests for a custom module that adds a discount approval workflow to sale orders. The module adds a discount_requires_approval boolean, a discount_approved field, and a constraint that blocks confirmation of orders with unapproved discounts above 15%.

Python — tests/test_sale_discount.py (TransactionCase)
from odoo.tests.common import TransactionCase, tagged
from odoo.exceptions import ValidationError


@tagged('post_install', '-at_install')
class TestSaleDiscountApproval(TransactionCase):
    """Unit tests for the discount approval workflow."""

    @classmethod
    def setUpClass(cls):
        """Create shared test data once for the entire class.

        In Odoo 19, setUpClass is the recommended way to create
        expensive test fixtures. Use setUp() only when you need
        fresh data for every single test method.
        """
        super().setUpClass()

        # Create a test customer
        cls.partner = cls.env['res.partner'].create({
            'name': 'Test Customer',
            'email': 'test@example.com',
        })

        # Create a test product
        cls.product = cls.env['product.product'].create({
            'name': 'Test Widget',
            'list_price': 100.0,
            'type': 'consu',
        })

        # Create a sale order with a 20% discount (above threshold)
        cls.order = cls.env['sale.order'].create({
            'partner_id': cls.partner.id,
            'order_line': [(0, 0, {
                'product_id': cls.product.id,
                'product_uom_qty': 10,
                'price_unit': 100.0,
                'discount': 20.0,  # Above 15% threshold
            })],
        })

    def test_discount_flag_set_above_threshold(self):
        """Orders with discount > 15% should require approval."""
        self.assertTrue(
            self.order.discount_requires_approval,
            "Order with 20% discount should require approval"
        )

    def test_discount_flag_clear_below_threshold(self):
        """Orders with discount <= 15% should not require approval."""
        order = self.env['sale.order'].create({
            'partner_id': self.partner.id,
            'order_line': [(0, 0, {
                'product_id': self.product.id,
                'product_uom_qty': 5,
                'price_unit': 100.0,
                'discount': 10.0,  # Below threshold
            })],
        })
        self.assertFalse(
            order.discount_requires_approval,
            "Order with 10% discount should NOT require approval"
        )

    def test_confirmation_blocked_without_approval(self):
        """Confirming an unapproved high-discount order should raise."""
        with self.assertRaises(ValidationError):
            self.order.action_confirm()

    def test_confirmation_allowed_after_approval(self):
        """Approved orders should confirm normally."""
        self.order.discount_approved = True
        self.order.action_confirm()
        self.assertEqual(
            self.order.state, 'sale',
            "Approved order should transition to 'sale' state"
        )

    def test_computed_discount_amount(self):
        """Verify the total discount amount computation."""
        # 10 units * $100 * 20% = $200 total discount
        self.assertAlmostEqual(
            self.order.total_discount_amount,
            200.0,
            places=2,
            msg="Total discount should be 200.00"
        )

Integration Tests with SavepointCase

When testing multi-step workflows — like creating a sale order, confirming it, generating an invoice, and validating the invoice — use SavepointCase. It runs setUpClass once and creates a database savepoint between each test method. This is significantly faster than TransactionCase for test classes that share expensive setup data.

Python — tests/test_invoice_workflow.py (SavepointCase)
from odoo.tests.common import SavepointCase, tagged


@tagged('post_install', '-at_install')
class TestInvoiceWorkflow(SavepointCase):
    """Integration tests for the full order-to-invoice flow.

    SavepointCase is ideal here because each test method builds
    on shared setup data, and the savepoint rollback between
    methods is 10-50x faster than full transaction rollback.
    """

    @classmethod
    def setUpClass(cls):
        super().setUpClass()

        cls.partner = cls.env['res.partner'].create({
            'name': 'Integration Customer',
            'email': 'integration@example.com',
        })
        cls.product = cls.env['product.product'].create({
            'name': 'Service Package',
            'list_price': 500.0,
            'type': 'service',
            'invoice_policy': 'order',
        })
        cls.pricelist = cls.env['product.pricelist'].create({
            'name': 'Test Pricelist',
            'currency_id': cls.env.ref('base.USD').id,
        })

    def test_01_create_and_confirm_order(self):
        """Create SO, approve discount, confirm."""
        self.order = self.env['sale.order'].create({
            'partner_id': self.partner.id,
            'pricelist_id': self.pricelist.id,
            'order_line': [(0, 0, {
                'product_id': self.product.id,
                'product_uom_qty': 2,
                'price_unit': 500.0,
                'discount': 20.0,
            })],
        })
        self.order.discount_approved = True
        self.order.action_confirm()
        self.assertEqual(self.order.state, 'sale')

    def test_02_generate_invoice(self):
        """Generate invoice from confirmed SO."""
        # Re-fetch order (savepoint may have rolled back)
        orders = self.env['sale.order'].search([
            ('partner_id', '=', self.partner.id),
            ('state', '=', 'sale'),
        ])
        if not orders:
            # Re-create if savepoint rolled back
            self.test_01_create_and_confirm_order()
            orders = self.env['sale.order'].search([
                ('partner_id', '=', self.partner.id),
                ('state', '=', 'sale'),
            ])
        order = orders[0]
        invoice_wizard = self.env['sale.advance.payment.inv'].with_context(
            active_ids=order.ids,
            active_model='sale.order',
        ).create({
            'advance_payment_method': 'delivered',
        })
        invoice_wizard.create_invoices()
        self.assertTrue(
            order.invoice_ids,
            "Invoice should be created from the sale order"
        )

    def test_03_invoice_discount_matches_order(self):
        """Invoice lines should carry the same discount as SO lines."""
        invoices = self.env['account.move'].search([
            ('partner_id', '=', self.partner.id),
            ('move_type', '=', 'out_invoice'),
        ])
        for invoice in invoices:
            for line in invoice.invoice_line_ids:
                self.assertEqual(
                    line.discount, 20.0,
                    "Invoice line discount should match SO line"
                )
post_install vs at_install

The @tagged('post_install', '-at_install') decorator tells Odoo to run these tests after all modules are installed, not during installation. Use post_install for tests that depend on other modules being fully loaded (sale, account, stock). Use at_install only for tests that validate your module's own installation — like checking that XML data records were created correctly.

03

JavaScript Tour Tests: Browser-Based Automation for Odoo 19 UI Flows

Tour tests are Odoo's answer to Selenium — but they run inside the Odoo JavaScript framework itself, which means they have access to the OWL component tree, the RPC layer, and the action manager. This makes them more reliable than external browser automation because they wait for Odoo's own rendering cycle instead of arbitrary timeouts.

A tour is a sequence of steps. Each step targets a DOM element (via CSS selector), performs an action (click, input text, drag), and optionally waits for a condition. The tour runner advances to the next step only when the current step's trigger element is visible and interactive.

JavaScript — static/tests/tour/test_discount_approval_tour.js
/** @odoo-module **/

import { registry } from "@web/core/registry";

registry.category("web_tour.tours").add("test_discount_approval", {
    test: true,
    url: "/odoo/sales",
    steps: () => [
        // Step 1: Create a new quotation
        {
            trigger: ".o_list_button_add",
            content: "Click 'New' to create a quotation",
            run: "click",
        },
        // Step 2: Select the customer
        {
            trigger: ".o_field_widget[name='partner_id'] input",
            content: "Search for the test customer",
            run: "edit Test Customer",
        },
        {
            trigger: ".ui-autocomplete .ui-menu-item a:contains('Test Customer')",
            content: "Select customer from dropdown",
            run: "click",
        },
        // Step 3: Add a product line with discount
        {
            trigger: ".o_field_x2many_list_row_add a",
            content: "Click 'Add a line'",
            run: "click",
        },
        {
            trigger: ".o_field_widget[name='product_id'] input",
            content: "Search for product",
            run: "edit Test Widget",
        },
        {
            trigger: ".ui-autocomplete .ui-menu-item a:contains('Test Widget')",
            content: "Select product",
            run: "click",
        },
        {
            trigger: ".o_field_widget[name='discount'] input",
            content: "Set discount to 20%",
            run: "edit 20",
        },
        // Step 4: Verify the approval banner appears
        {
            trigger: ".o_discount_approval_banner",
            content: "Discount approval banner should be visible",
            run: () => {
                const banner = document.querySelector(
                    ".o_discount_approval_banner"
                );
                if (!banner) {
                    throw new Error("Discount approval banner not found");
                }
            },
        },
        // Step 5: Approve the discount
        {
            trigger: ".btn_approve_discount",
            content: "Click approve discount button",
            run: "click",
        },
        // Step 6: Confirm the order
        {
            trigger: ".o_sale_order_confirm",
            content: "Confirm the sale order",
            run: "click",
        },
        {
            trigger: ".o_statusbar_status .o_arrow_button_current:contains('Sales Order')",
            content: "Verify order is confirmed",
            run: () => {},  // No action — just verify element exists
        },
    ],
});

To trigger this tour from a Python test, use HttpCase:

Python — tests/test_tour_checkout.py
from odoo.tests import HttpCase, tagged


@tagged('post_install', '-at_install')
class TestDiscountApprovalTour(HttpCase):
    """Run the discount approval UI tour.

    HttpCase starts a real HTTP server and a headless Chrome
    browser. The tour JS file is loaded automatically because
    it's in static/tests/tour/.
    """

    def test_discount_approval_tour(self):
        # Create prerequisite data that the tour expects
        self.env['res.partner'].create({
            'name': 'Test Customer',
            'email': 'test@example.com',
        })
        self.env['product.product'].create({
            'name': 'Test Widget',
            'list_price': 100.0,
            'type': 'consu',
        })

        # Run the tour as the admin user
        self.start_tour(
            "/odoo/sales",
            "test_discount_approval",
            login="admin",
            timeout=120,
        )
Tour Debugging Tip

When a tour step fails, Odoo logs the step index and trigger selector, but the error is often cryptic. To debug interactively, add ?debug=assets to the URL and run the tour manually from the browser console: odoo.__DEBUG__.services["web_tour.tour_service"].startTour("test_discount_approval"). You'll see each step highlight the target element and can watch exactly where it fails.

04

Running Odoo 19 Tests: CLI Flags, Test Tags, and CI Integration

Odoo's test runner is controlled entirely through command-line flags passed to odoo-bin. There is no separate test command — tests run as part of module installation or update when the --test-enable flag is present.

Shell — Running tests from the command line
# ── Run ALL tests for a specific module ──
python odoo-bin \
    -d odoo_test \
    --addons-path=odoo/addons,custom_addons \
    -i my_module \
    --test-enable \
    --stop-after-init \
    --log-level=test

# ── Run only tests tagged 'post_install' ──
# (skip at_install tests that run during module installation)
python odoo-bin \
    -d odoo_test \
    --addons-path=odoo/addons,custom_addons \
    -u my_module \
    --test-enable \
    --test-tags=post_install \
    --stop-after-init

# ── Run a specific test class ──
python odoo-bin \
    -d odoo_test \
    --addons-path=odoo/addons,custom_addons \
    -u my_module \
    --test-enable \
    --test-tags=/my_module:TestSaleDiscountApproval \
    --stop-after-init

# ── Run tests for multiple modules ──
python odoo-bin \
    -d odoo_test \
    --addons-path=odoo/addons,custom_addons \
    -i my_module,my_other_module \
    --test-enable \
    --stop-after-init

# ── Run with coverage reporting ──
pip install coverage
coverage run odoo-bin \
    -d odoo_test \
    --addons-path=odoo/addons,custom_addons \
    -i my_module \
    --test-enable \
    --stop-after-init \
    --log-level=test
coverage report --include="custom_addons/my_module/*" --show-missing
coverage html --include="custom_addons/my_module/*"

Understanding --test-tags Syntax

Flag ValueWhat It RunsWhen to Use
--test-tags=post_installOnly @tagged('post_install') testsDefault for CI — tests that need all modules loaded
--test-tags=at_installOnly @tagged('at_install') testsTesting module installation hooks and XML data
--test-tags=/my_moduleAll tests in my_moduleRunning a single module's tests in development
--test-tags=/my_module:TestClassNameSpecific test classDebugging a single failing test class
--test-tags=-at_install,post_installpost_install but NOT at_installSkip installation tests, run only post-install

For CI integration, the critical detail is that Odoo's test runner always exits with code 0 — even when tests fail. You must parse the log output to detect failures. Here's the pattern we use in every CI pipeline:

Shell — CI-safe test runner with failure detection
#!/bin/bash
# scripts/run_tests.sh — CI-safe Odoo test runner
set -euo pipefail

DB_NAME="${1:-odoo_test}"
MODULES="${2:-my_module}"
LOG_FILE="/tmp/odoo_test_output.log"

echo "=== Running tests for: $MODULES ==="

python odoo-bin \
    -d "$DB_NAME" \
    --addons-path=odoo/addons,custom_addons \
    -u "$MODULES" \
    --test-enable \
    --test-tags=post_install \
    --stop-after-init \
    --log-level=test \
    2>&1 | tee "$LOG_FILE"

# Count failures and errors
ERRORS=$(grep -c "ERROR.*odoo\.\(tests\|modules\)" "$LOG_FILE" || true)
FAILS=$(grep -c "FAIL:" "$LOG_FILE" || true)

echo "=== Results: $ERRORS errors, $FAILS failures ==="

if [ "$ERRORS" -gt 0 ] || [ "$FAILS" -gt 0 ]; then
    echo "::error::Tests failed: $ERRORS errors, $FAILS failures"
    grep -E "(ERROR|FAIL:)" "$LOG_FILE"
    exit 1
fi

echo "All tests passed."
exit 0
05

Mocking External Services and ORM Methods in Odoo 19 Tests

Custom modules often integrate with external APIs — payment gateways, shipping carriers, tax calculators, email services. You don't want your test suite making real API calls. Python's unittest.mock.patch works seamlessly with Odoo's test framework. The key is knowing where to patch.

In Odoo, you patch the method where it's looked up, not where it's defined. If your custom module imports a utility function from a library, you patch it on your module's namespace, not the library's.

Python — Mocking external API calls in tests
from unittest.mock import patch, MagicMock
from odoo.tests.common import TransactionCase, tagged


@tagged('post_install', '-at_install')
class TestShippingRateCalculation(TransactionCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        cls.partner = cls.env['res.partner'].create({
            'name': 'Shipping Customer',
            'street': '123 Test St',
            'city': 'New York',
            'zip': '10001',
            'country_id': cls.env.ref('base.us').id,
        })

    @patch('odoo.addons.my_module.models.sale_order.requests.post')
    def test_shipping_rate_api_called(self, mock_post):
        """Verify the shipping API is called with correct payload."""
        # Configure the mock to return a fake API response
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_response.json.return_value = {
            'rate': 12.50,
            'currency': 'USD',
            'carrier': 'UPS Ground',
            'estimated_days': 5,
        }
        mock_post.return_value = mock_response

        # Trigger the shipping rate calculation
        order = self.env['sale.order'].create({
            'partner_id': self.partner.id,
            'partner_shipping_id': self.partner.id,
        })
        order._compute_shipping_rate()

        # Verify the API was called
        mock_post.assert_called_once()
        call_args = mock_post.call_args
        payload = call_args[1]['json']
        self.assertEqual(payload['destination_zip'], '10001')

        # Verify the rate was stored
        self.assertAlmostEqual(
            order.shipping_rate, 12.50, places=2
        )

    @patch('odoo.addons.my_module.models.sale_order.requests.post')
    def test_shipping_api_failure_handled(self, mock_post):
        """API failure should not crash — should log and set rate to 0."""
        mock_post.side_effect = ConnectionError("API timeout")

        order = self.env['sale.order'].create({
            'partner_id': self.partner.id,
            'partner_shipping_id': self.partner.id,
        })
        # Should not raise
        order._compute_shipping_rate()
        self.assertEqual(
            order.shipping_rate, 0.0,
            "Shipping rate should default to 0 on API failure"
        )

    def test_override_env_context(self):
        """Test with a modified environment context.

        Odoo's with_context() and with_user() methods let you
        test behavior under different contexts without mocking.
        """
        # Test as a non-admin user
        sales_user = self.env['res.users'].create({
            'name': 'Sales Rep',
            'login': 'salesrep@test.com',
            'groups_id': [(6, 0, [
                self.env.ref('sales_team.group_sale_salesman').id,
            ])],
        })
        order_as_user = self.env['sale.order'].with_user(
            sales_user
        ).create({
            'partner_id': self.partner.id,
        })
        # Sales rep should not be able to approve discounts
        with self.assertRaises(Exception):
            order_as_user.action_approve_discount()
06

4 Odoo Testing Mistakes That Give False Confidence

1

Testing Against Demo Data That Doesn't Match Production

Odoo's demo data creates a handful of products, a few partners, and some sample orders. Your production database has 50,000 products with 12 pricelist rules, partners with complex fiscal positions, and orders spanning 3 years of history. A test that passes against demo data can fail spectacularly in production because the computed field assumed a single pricelist, the search domain didn't account for archived records, or the workflow breaks when there are more than 1,000 order lines.

Our Fix

Create your own test data in setUpClass — never rely on demo data for assertions. For performance-sensitive code, add a dedicated test that creates 1,000+ records and validates timing with self.assertLess(elapsed, 2.0).

2

Using at_install When You Need post_install

Tests tagged with at_install run during module installation, before dependent modules are fully loaded. If your test creates a sale order and expects the accounting module to generate journal entries, it will fail — because account might not be installed yet when your test runs. The error message is usually an unhelpful KeyError: 'account.move' that looks like a bug in your code but is actually a test ordering issue.

Our Fix

Default to @tagged('post_install', '-at_install') for all tests. Only use at_install when explicitly testing that your module's data files loaded correctly.

3

Forgetting to Invalidate the Cache After Direct SQL

When tests use self.env.cr.execute() to run raw SQL (common for performance-critical operations or data fixes), the ORM cache still holds the old values. Subsequent reads via record.field_name return stale data, and your assertion passes even though the database has the wrong value — or fails when the database is correct but the cache is stale. This creates tests that are nondeterministic depending on cache state.

Our Fix

Always call self.env.invalidate_all() after raw SQL in tests. Better yet, avoid raw SQL in tests entirely — use the ORM so your tests verify the same code path that production uses.

4

HttpCase Tests Leaking Data Between Runs

Unlike TransactionCase, HttpCase commits data to the database. If a tour test creates a sale order and then fails halfway through, that order persists in the test database. The next test run finds an unexpected existing order and fails with a different error. This cascading failure pattern makes HttpCase tests appear "flaky" when they're actually suffering from test pollution.

Our Fix

Use unique, timestamped names for test data in HttpCase tests (e.g., Test Customer 1710345600). In CI, always start with a fresh database — never reuse a test database between pipeline runs. Add a tearDown method that cleans up created records explicitly.

BUSINESS ROI

What a Tested Codebase Saves Your Business

Writing tests adds 20-30% to initial development time. Here's what that investment returns:

85%Fewer Production Regressions

Automated tests catch the "I changed one field and broke three workflows" class of bugs before they reach users. The remaining 15% are edge cases that tests inform you to write next.

3xFaster Developer Onboarding

Tests are executable documentation. A new developer reads test_sale_discount.py and understands the discount approval workflow in 10 minutes — no meetings, no stale wiki pages.

50%Faster Odoo Upgrades

When upgrading from Odoo 19 to 20, a test suite tells you exactly which custom modules break. Without tests, you discover breakage in production over the next 3 months.

The hidden ROI is refactoring confidence. Untested code becomes legacy code the moment it's written — nobody dares restructure it because there's no safety net. Tested code can be refactored, optimized, and extended with confidence. The test suite is the difference between "we can't touch that module" and "let's improve it."

SEO NOTES

Optimization Metadata

Meta Desc

Complete guide to Odoo 19 testing: TransactionCase, SavepointCase, HttpCase, tour tests, mock/patch, running tests with --test-enable, coverage, and CI integration.

H2 Keywords

1. "Odoo 19 Test Classes Explained: TransactionCase, HttpCase, and SavepointCase"
2. "Writing Unit and Integration Tests for Odoo 19 Custom Modules"
3. "JavaScript Tour Tests: Browser-Based Automation for Odoo 19 UI Flows"
4. "4 Odoo Testing Mistakes That Give False Confidence"

Untested Code Is a Liability on Your Balance Sheet

Every custom Odoo module without tests is technical debt accruing interest. The interest compounds with every upgrade, every new developer, and every "quick fix" that nobody dares to refactor. The cost of adding tests later is always higher than adding them during development — because later, you've forgotten the edge cases, the original developer has left, and the module has grown from 500 lines to 3,000.

If you have custom Odoo modules without test coverage, we can help. We audit existing modules, write retroactive test suites that cover critical business workflows, and set up CI pipelines that run those tests on every push. The result is a codebase you can upgrade, extend, and hand off without fear.

Book a Free Testing Audit