Imagine that you are tasked to add one related field to point from a sale order line to the partner’s country. The field is read-only, the code is clean, the unit tests pass. After deploying to production, the sale order list page degrades from 3 queries to 47 because the ORM is now making a per-row database call to fetch the partner record, then the country record, for every line. Odoo’s ORM will not warn you and standard test suite will not catch it but your customer will.
Most Odoo Developers often ignore the performance testing layer built into the framework. This article covers assertQueryCount, @warmup, @users, @tagged, and a full real-world example from the OCA fs_image module.
assertQueryCount
assertQueryCount(n) is a context manager on odoo.tests.common.BaseCase. Wrap any block in it and the test fails if the SQL count exceeds n.
with self.assertQueryCount(42):
do_something()
Under the hood, it reads cr.sql_log_count before and after the block, with a flush on both sides to capture deferred ORM writes. The flush matters: Odoo batches some writes, and without it the count before the context manager exits could be understated. The number you assert against is the real number, not an optimistic one.
assertQueryCount also accepts per-login keyword arguments for use with the @users decorator:
with self.assertQueryCount(admin=3, demo=5):
do_something()
This lets you document and assert different counts for different user types, which is useful when access rights checks or ir.rule evaluation varies by user.
@warmup
The first time any ORM operation runs in a test, it populates caches: field definitions, access rights, ir.rule records. Those cache misses produce extra queries. The count on the first run is inflated and unstable.
@warmup solves this by running the test twice. The first pass (self.warm = False) runs the full test body, populates caches, then rolls back. The second pass (self.warm = True) runs for real with assertions active. assertQueryCount silently skips during the warm-up pass, so only the second run produces a failure or success.
@warmup
def test_my_operation(self):
with self.assertQueryCount(5):
self.env['sale.order'].browse(ids).action_confirm()
Without @warmup, a test may pass in development (where caches are already warm from previous test runs or UI navigation) and fail in CI (where each test class starts cold). Or vice versa. Either way, the assertion is measuring noise. @warmup is mandatory for any assertQueryCount that should be meaningful.
Putting It Together: OCA fs_image
A concrete example is The OCA storage module’s fs_image test.
@users("__system__")
@warmup
def test_generated_sql_commands(self):
with self.assertQueryCount(__system__=3):
instance = self.env["test.image.model"].create(
{"fs_image": FSImageValue(name=self.filename, value=self.image_w)}
)
instance.invalidate_recordset()
with self.assertQueryCount(__system__=1):
self.assertEqual(instance.fs_image.getvalue(), self.image_w)
self.env.flush_all()
Line by line:
@users("__system__") runs the test as the system user and passes __system__ as the login key in assertQueryCount. Use @users when the count depends on which user is active, and use the per-login form of the assertion to match.
@warmup ensures caches are populated before counts are measured. Always stack it inside @users (innermost decorator runs first).
assertQueryCount(__system__=3) asserts the create operation costs exactly 3 queries as the system user. This is the write path assertion.
instance.invalidate_recordset() resets the per-record ORM cache between the two assertions. Without this, the read in the second block might hit an in-memory cache rather than the database, understating the real query count.
assertQueryCount(__system__=1) asserts the read path costs 1 query. Two separate assertions, write and read, give a precise budget for each operation.
This pattern is directly copyable. Replace the model, the create values, and the threshold numbers with your own, and you have a performance regression gate for any Odoo module.
Going Further: Profiler
When assertQueryCount tells you that counts are too high but not why, use Profiler. It is a context manager that captures SQL queries, periodic stack traces, and memory snapshots in a single pass.
with self.profile():
self.env['sale.order'].browse(ids).action_confirm()
Results are saved to the database and viewable in Odoo’s performance UI. Use Profiler when you need to trace which method is generating the extra queries, not just that the total is over budget.
Conclusion
Performance testing in Odoo is often skipped during development and only discovered to be missing when real users interact with the module in production. By then, the cost is a support ticket, a rollback, and the customer’s trust.
assertQueryCount plus @warmup is the minimum viable safety net. Add @users when counts vary by user. Use Profiler when you need to trace the root cause.