diff --git a/src/testing.py b/src/testing.py index 70b108a21..db81e99e6 100644 --- a/src/testing.py +++ b/src/testing.py @@ -1,3 +1,112 @@ +r""" +There are two main classes used testing during the upgrade. + +* :class:`~odoo.upgrade.testing.UpgradeCase` for testing upgrade scripts, +* :class:`~odoo.upgrade.testing.UpgradeCase` for testing invariants across versions. + +Subclasses must implement: + +* For ``UpgradeCase``: + + - ``prepare`` method: prepare data before upgrade, + - ``check`` method: check data was correctly upgraded. + +* For ``IntegrityCase``: + + - ``invariant`` method: compute an invariant to check. + +Put your test classes in a ``tests`` Python module (folder) in any of the folders +containing the upgrade scripts of your modules. The script containing your tests must +have a `test_` prefix. The ``tests`` module must contain an ``__init__.py`` file to be +detected by Odoo. + +Example directory structure:: + + myupgrades/ + └── mymodule1/ + ├── 18.0.1.1.2/ + │ └── pre-myupgrade.py + └── tests/ + ├── __init__.py + └── test_myupgrade.py + +.. note:: + + The tests in the example above will be loaded only if ``mymodule1`` is being + **upgraded.** + +Running Upgrade Tests +--------------------- + +After receiving an upgraded database with all standard Odoo modules already upgraded to +their target version, you can test the upgrade of custom modules by following a three-step +process: + +1. Prepare the test data + + .. code-block:: bash + + $ ~/odoo/$version/odoo-bin -d DB --test-tags=$prepare_test_tag \ + --upgrade-path=~/upgrade-util/src,~/myupgrades \ + --addons=~/odoo/$version/addons,~/enterprise/$version,~/mymodules --stop + +2. Upgrade the modules + + .. code-block:: bash + + $ ~/odoo/$version/odoo-bin -d DB -u mymodule1,mymodule2 \ + --upgrade-path=~/upgrade-util/src,~/myupgrades \ + --addons=~/odoo/$version/addons,~/enterprise/$version,~/mymodules --stop + +3. Check the upgraded data + + .. code-block:: bash + + $ ~/odoo/$version/odoo-bin -d DB --test-tags=$check_test_tag \ + --upgrade-path=~/upgrade-util/src,~/myupgrades \ + --addons=~/odoo/$version/addons,~/enterprise/$version,~/mymodules --stop + +The example above assumes that ``$version`` is the target version of your upgrade (e.g. +``18.0``), ``DB`` is the name of your database, and ``mymodule1,mymodule2`` are the +modules you want to upgrade. The directory structure assumes that ``~/odoo/$version`` and +``~/enterprise/$version`` contain the Community and Enterprise source code for the target +Odoo version, respectively. ``~/mymodules`` contains the code of your custom modules +(``mymodule1``, ...), ``~/myupgrades`` contains your custom upgrade scripts, and +``~/upgrade-util`` contains the `upgrade utils `_ +repo. + +The variables ``$prepare_test_tag`` and ``$check_test_tag`` must be set according to: + +.. list-table:: + :header-rows: 1 + :stub-columns: 1 + + * - Variable + - ``UpgradeCase`` + - ``IntegrityCase`` + * - ``$prepare_test_tag`` + - ``upgrade.test_prepare`` + - ``integrity_case.test_prepare`` + * - ``$check_test_tag`` + - ``upgrade.test_check`` + - ``integrity_test.test_check`` + +.. note:: + + `upgrade.test_prepare` also runs ``IntegrityCase`` tests, so you can prepare data + for both ``UpgradeCase`` and ``IntegrityCase`` tests with only this tag. + +.. warning:: + + Do **not** run any ``prepare`` method of an ``UpgradeCase`` before sending your + database for a **production** upgrade to `upgrade.odoo.com + `_. Doing so may risk your upgrade being blocked and marked + as failed. + +API documentation +----------------- +""" + import functools import inspect import logging @@ -40,22 +149,17 @@ def parametrize(argvalues): """ Parametrize a test function. - Decorator for UnitTestCase test functions to parametrize the decorated test. - - Usage: - ```python - @parametrize([ - (1, 2), - (2, 4), - (-1, -2), - (0, 0), - ]) - def test_double(self, input, expected): - self.assertEqual(input * 2, expected) - ``` - - It works by injecting test functions in the containing class. - Idea taken from the `parameterized` package (https://pypi.org/project/parameterized/). + Decorator for upgrade test functions to parametrize and generate multiple tests from + it. + + Usage:: + + @parametrize([(1, 2), (2, 4), (-1, -2), (0, 0)]) + def test_double(self, input, expected): + self.assertEqual(input * 2, expected) + + Works by injecting test functions in the containing class. + Inspired by the `parameterized `_ package. """ def make_func(func, name, args): @@ -116,6 +220,8 @@ def __init_subclass__(cls): class UnitTestCase(TransactionCase, _create_meta(10, "upgrade_unit")): + """:meta private: exclude from online docs.""" + @classmethod def setUpClass(cls): super().setUpClass() @@ -211,6 +317,8 @@ def assertUpdated(self, table, ids=None, msg=None): class UpgradeCommon(BaseCase): + """:meta private: exclude from online docs.""" + __initialized = False change_version = (None, None) @@ -356,6 +464,25 @@ def convert_check(self, value): def change_version(version_str): + """ + Class decorator to specify the version on which a test is relevant. + + Using ``@change_version(version)`` indicates: + + * ``test_prepare`` will only run if the current Odoo version is in the range + ``[next_major_version-1, version)``. + * ``test_check`` will only run if the current Odoo version is in the range ``[version, + next_major_version)``. + + ``next_major_version`` is the next major version after ``version``, e.g. for + ``saas~17.2`` it is ``18.0``. + + .. note:: + + Do not use this decorator if your upgrade is in the same major version. Otherwise, + your tests will not run. + """ + def version_decorator(obj): match = VERSION_RE.match(version_str) if not match: @@ -394,24 +521,42 @@ def get_previous_major(major, minor): # pylint: disable=inherit-non-class class UpgradeCase(UpgradeCommon, _create_meta(10, "upgrade_case")): """ - Test case to modify data in origin version, and assert in target version. + Test case to verify that the upgrade scripts correctly upgrade data. - User must define a "prepare" and a "check" method. - - prepare method can write in database, return value will be stored in a dedicated table and - passed as argument to check. - - check method can assert that the received argument is the one expected, - executing any code to retrieve equivalent information in migrated database. - Note: check argument is a loaded json dump, meaning that tuple are converted to list. - convert_check can be used to normalise the right part of the comparison. + Override: - check method is only called if corresponding prepared was run in previous version + * ``prepare`` to set up data, + * ``check`` to assert expectations after the upgrade. - prepare and check implementation may contains version conditional code to match api changes. + The ORM can be used in these methods to perform the functional flow under test. The + return value of ``prepare`` is persisted and passed as an argument to ``check``. It + must be JSON-serializable. - using @change_version class decorator can indicate with script version is tested here if any: - Example: to test a saas~12.3 script, using @change_version('saas-12,3') will only run prepare if - version in [12.0, 12.3[ and run check if version is in [12.3, 13] + .. note:: + Since ``prepare`` injects or modifies data, this type of test is intended **only + for development**. Use it to test upgrade scripts while developing them. **Do not** + run these tests for a production upgrade. To verify that upgrades preserved + important invariants in production, use ``IntegrityCase`` tests instead. + + .. example:: + + .. code-block:: python + + from odoo.upgrade.testing import UpgradeCase, change_version + + + class DeactivateBobUsers(UpgradeCase): + + def prepare(self): + u = self.env["res.users"].create({"login": "bob", "name": "Bob"}) + return u.id # will be passed to check + + def check(self, uid): # uid is the value returned by prepare + self.env.cr.execute( + "SELECT * FROM res_users WHERE id=%s AND NOT active", [uid] + ) + self.assertEqual(self.env.cr.rowcount, 1) """ def __init_subclass__(cls, abstract=False): @@ -427,12 +572,27 @@ def test_prepare(self): # pylint: disable=inherit-non-class class IntegrityCase(UpgradeCommon, _create_meta(20, "integrity_case")): """ - Test case to check invariant through any version. + Test case for validating invariants across upgrades. + + Override: + + * ``invariant`` to return a JSON-serializable value representing + the invariant to check. + + The ``invariant`` method is called both before and after the upgrade, + and the results are compared. + + + .. example:: + + .. code-block:: python + + from odoo.upgrade.testing import IntegrityCase - User must define a "invariant" method. - invariant return value will be compared between the two version. - invariant implementation may contains version conditional code to match api changes. + class NoNewUsers(IntegrityCase): + def invariant(self): + return self.env["res.users"].search_count([]) """ message = "Invariant check fail" @@ -442,7 +602,7 @@ def __init_subclass__(cls, abstract=False): if not abstract and not hasattr(cls, "invariant"): _logger.error("%s (IntegrityCase) must define an invariant method", cls.__name__) - # IntegrityCase should not alterate database: + # IntegrityCase should not alter the database: # TODO give a test cursor, don't commit after prepare, use a protected cursor to set_value def prepare(self): @@ -462,6 +622,7 @@ def _setup_registry(self): self.addCleanup(self.registry.leave_test_mode) def setUp(self): + """:meta private: exclude from online docs.""" super(IntegrityCase, self).setUp() def commit(self):