Testing Odoo like software: pytest, fixtures and CI gates for modules
Custom modules without tests are upgrades waiting to fail. Our pytest-odoo setup, the fixtures worth writing, and the CI gate that blocks a merge when a workflow breaks.
We already said it once, in passing: tests before the first deploy — Odoo's test framework is right there, a module without tests is a liability with a version number. This is the follow-up nobody asks for until the upgrade breaks: what we actually write, how it runs, and why a broken test blocks the merge instead of just embarrassing someone in a code review.
pytest-odoo: the harness that makes it feel like a normal Python project
Odoo ships its own test runner, invoked through odoo-bin --test-enable, but it drags the whole ORM bootstrap along for every run and reports failures the way a log file reports things — in prose. pytest-odoo wraps the same TransactionCase machinery in a pytest plugin, so a module's tests get pytest's discovery, its assertion rewriting, its -k filtering, and its exit codes that CI actually understands:
[pytest]
addopts = --odoo-database=test_ci --odoo-log-level=warn
python_files = test_*.py
markers =
slow: full-database tests, run in CI onlyfrom odoo.tests import TransactionCase, tagged
@tagged("post_install", "-at_install")
class TestDeliveryWindow(TransactionCase):
def test_default_window_is_morning(self):
order = self.env["sale.order"].create({"partner_id": self.partner.id})
self.assertEqual(order.delivery_window, "morning")
def test_afternoon_shifts_commitment_date(self):
order = self.env["sale.order"].create(
{"partner_id": self.partner.id, "delivery_window": "afternoon"}
)
self.assertGreater(order.commitment_date, order.date_order)Same test class Odoo has always used — pytest tests/ just runs it, in parallel with pytest-xdist if the suite is big enough to need it.
Fixtures worth writing — and the ones to skip
The temptation is to reuse the module's demo data as test fixtures. Don't: demo data changes for marketing reasons (a nicer product name, a rounder price) and every change silently reaches into your assertions. Build a small, boring, dedicated fixture set instead:
import pytest
@pytest.fixture
def belgian_partner(env):
return env["res.partner"].create(
{"name": "ACME NV", "vat": "BE0123456789", "country_id": env.ref("base.be").id}
)
@pytest.fixture
def draft_order(env, belgian_partner):
return env["sale.order"].create({"partner_id": belgian_partner.id})Three rules keep this from turning into a second module to maintain:
- Fixtures create the minimum record graph the test needs — one partner, one order, not a re-creation of the demo dataset.
- No fixture depends on install order. If a test needs a specific
res.currencyorir.sequencepresent, the fixture creates it — it never assumes another module's demo data already did. - Slow fixtures get the
slowmarker, not a comment asking people to be patient.pytest -m "not slow"is what runs on every push; the full suite runs on the merge gate.
The CI gate: a broken workflow is a build failure, not a Slack message
This is the part that actually changes behaviour. Tests that only run when someone remembers to run them get skipped the week before a deadline — always the week a regression sneaks through. So the pytest-odoo run is a required step in the same Woodpecker pipeline that builds and deploys everything else we ship:
steps:
test:
image: registry.internal/odoo-ci:19.0
commands:
- odoo -d test_ci -i delivery --stop-after-init
- pytest --odoo-database=test_ci -m "not slow" -x
when:
event: [pull_request, push]-x stops on the first failure — on a merge gate you want the fast, unambiguous no, not a scroll through forty red lines. The pull request stays un-mergeable until it's green, the same preview-instance discipline applies to Odoo modules as it does to the rest of our stack, and a reviewer approving the diff is no longer also silently vouching for behaviour nobody actually exercised.
What we don't test, on purpose
Full coverage on a customisation layer is a trap — it tests Odoo core through your module and breaks on every upgrade for reasons that have nothing to do with your code. We scope tests to what the client is actually paying to protect:
- Computed fields and their edge cases — the delivery window shifting the commitment date, the discount that shouldn't stack twice.
- Access rules on custom models — the record rule that should hide another company's data in a multi-company setup is exactly the kind of bug that stays invisible until a client sees it.
- Anything a support ticket has ever been opened about. A regression test written the day a bug is fixed is the cheapest insurance in the codebase.
We don't write tests asserting that Odoo's own sale.order totals add up — that's Odoo's test suite to maintain, and duplicating it just means two suites to update on every version bump.
The upgrade stops being a leap of faith
None of this is exotic — it's the same discipline any custom web application gets from us, applied to Odoo development instead of a JavaScript codebase. The payoff shows up eighteen months later, on the day of the 19-to-20 upgrade: the merge gate that has been quietly failing bad diffs the whole time is the same gate that tells you, in thirty seconds, whether the upgrade broke delivery_window — instead of a client finding out at the worst possible moment.
Want us to publish something specific?
Tell us what you'd like to read and we'll add it to our writing queue.
Get the next one in your inbox
New articles, videos and the occasional engineering note — a short mail when there’s something worth reading, nothing else.