[IMP] web: HOOT - documentation

Part-of: odoo/documentation#14832
Signed-off-by: Julien Mougenot (jum) <jum@odoo.com>
This commit is contained in:
Julien Mougenot
2024-03-01 17:51:25 +01:00
parent 9336912a16
commit eb756332c9
5 changed files with 2323 additions and 0 deletions

View File

@@ -21,3 +21,4 @@ Web framework
frontend/mobile
frontend/qweb
frontend/odoo_editor
frontend/unit_testing

View File

@@ -0,0 +1,90 @@
:show-content:
:show-toc:
=======================
JavaScript Unit Testing
=======================
Writing unit tests is as important as writing the code itself: it helps to
ensure that the code is written according to a given specification and that it
remains correct as it evolves.
Testing Framework
=================
Testing the code starts with a testing framework. The framework provides a level
of abstraction that makes it possible to write tests in an easy and efficient way.
It also provides a set of tools to run the tests, make assertions and report the
results.
Odoo developers use a home-grown testing framework called :abbr:`HOOT (Hierarchically Organized
Odoo Tests)`. The main reason for using a custom framework is that it allows us to extend it based
on our needs (tags system, mocking of global objects, etc.).
On top of that framework we have built a set of tools to help us write tests for the web client
(`web_test_helpers`), and a mock server to simulate the server side (`mock_server`).
You can find links to the reference of each of these parts below, as well as a section filled with
examples and best practices for writing tests.
Setup
=====
Before learning how to write tests, it is good to start with the basics. The following steps
will ensure that your test files are properly picked up by the test runner.
Note that in existing addons, most of these steps can be skipped since the proper
folder structure and asset bundles are probably set up.
#. Writing files in the right **place**:
All JavaScript test files should be put under the `/static/tests` folder of the
related addon (e.g. :file:`/web/static/tests/env.test.js`).
#. Using the right **name**:
Test files must end with :file:`.test.js`. This is not only a convention, but a requirement
for test files to be picked up by the runner. All other JavaScript files will be
interpreted either as production code (i.e. the code to be tested), or as test
helper files (such as `web_test_helpers <{GITHUB_PATH}/addons/web/static/tests/web_test_helpers.js>`_).
.. note::
It is to be noted that there is an exception for :file:`.hoot.js` files, which are not
considered as test files, but as global modules for the whole test run, while other
JavaScript modules are re-created for each test suite. Since the same instance of
these modules will be running for the whole test run, they follow strict constraints,
such as restricted imports, or advanced memory management techniques to
ensure no side-effects are affecting tests.
#. Calling the files in the right **bundle**:
Test files, added in the right folder, must be included in the `web.assets_unit_tests`
bundle. For ease of use, this can be done with glob syntax to import all test
and test helper files:
.. code:: python
# Unit test files
'web.assets_unit_tests': [
'my_addon/static/tests/**/*',
],
#. Heading to the right **URL**:
To run tests, you can then go to the `/web/tests` URL.
.. tip::
This page can be accessed through :icon:`fa-bug` :menuselection:`Debug menu --> Run Unit Tests`.
Writing tests
=============
After creating and including test files, it is time to write tests. You may refer
to the following documentation sections to learn about the testing framework.
.. toctree::
:titlesonly:
unit_testing/hoot
unit_testing/web_helpers
unit_testing/mock_server

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,383 @@
===========
Mock server
===========
Rationale
=========
Test cases can range in complexity; from testing the return value of a simple helper
function, to rendering an entire webclient to simulate interactions across several
components/services.
In the latter cases, many interactions will trigger server requests, without
which the components or features will stop functioning properly. However, it is
important that these requests *do not* land on the actual server, as they could
affect the database, which is definitely not something a test should be doing.
To overcome this, each request should be intercepted and replaced by a function
emulating actual server responses with test (i.e. fake) data.
Since some of these requests are very common (e.g. ORM calls, such as `web_search_read`
or `web_save`, or other methods such as `get_views`), a mock server has been
implemented by default for every test that spawns an :abbr:`env (Odoo environment)` [#]_.
These mock servers act independently for each test, can be configured separately,
and provide out of the box helpers for the most used routes in Odoo.
.. [#] A mock server is needed as soon as an environment is spawned because some :doc:`services <../services>`
do send server requests as soon as they start.
Overview
========
A mock server is actually quite simple in itself: it is an object containing a *collection*
of all defined mock models, and a *mapping* between routes and callbacks returning
the test data.
The mock models themselves hold most of the CRUD logic, as well as the data used
to simulate server records.
Once a mock server starts, it hijacks *all* server requests, and for each of them
it will check in its *mapping* whether one of its registered routes matches
the requested URL. The most notable example of its pre-defined routes is
`/web/dataset/call_kw`, which is responsible for calling an ORM method on the
appropriate mock model.
.. note::
Like most test helpers not provided by Hoot, mock server-related helpers and
classes can be found in the `"@web/../tests/web_test_helpers"` module.
.. _mock-server/configuration:
Configuration
=============
By default, a mock server is *"empty"*, meaning that it has no defined mock model.
This does not mean that it is useless though, as it will already handle a few pre-defined
routes, such as the ones responsible for fetching `menus` and `translations`, which
are spawned by :doc:`services <../services>` as soon as an `env` is spawned.
But this means that ORM methods will fail, as the model that they target has not
been defined yet.
To create and define a mock model, you need 2 things:
- a `class` extending the `models.Model` class;
- special keys prefixed with a `_` act as metadata holders, like in Python
(e.g. `_name`, `_order`, `_description`, etc.) [#]_ [#]_;
- `_records` holds the list of objects representing fake record data;
- `_views` can be a mapping of view types and XML arches;
- other `public class fields <public-class-fields_>`_
will be interpreted as fields (by calling the appropriate method from `fields`);
- model-specific methods (such as `has_group` for `"res.users"`) can also be
defined here.
- calling `defineModels` with the class defined above.
.. [#] Only a subset of these special keys will have an actual effect. For example,
`_inherit` will not work as intended, prefer standard class extension.
.. [#] These can be altered by each test without thinking about cleaning up: any change
performed on a special key will be reverted at the end of a test.
Here is a basic example of a simple, fake, `"res.partner"` model:
.. code-block:: javascript
import { defineModels, fields, models } from "@web/../tests/web_test_helpers";
class ResPartner extends models.Model {
_name = "res.partner";
name = fields.Char({ required: true );
_records = [
{ name: "Mitchel Admin" },
];
_views = {
form: /* xml */`
<form>
<field name="name" />
</form>
`,
list: /* xml */`
<list>
<field name="display_name" />
</list>
`,
};
}
defineModels({ ResPartner });
This code will make these data available for *all* tests in the current test file.
Of course, defining a class and calling `defineModels` can also be done from *within*
a given test to limit the scope of that model to the current test.
Other methods such as `defineMenus`, `defineActions` or `defineParams` can also
be used to configure the current mock server. Most of their API is quite straightforward
(i.e. they receive JSON-like descriptions of menus, actions, etc.).
Mock models: requests
---------------------
Many test cases only require one or a few mock models to work. But sometimes,
it is either too bothersome to implement the mocking logic within a model, or a
*route* (i.e. server request URL) is simply not associated to a Python model at all.
In such cases, the `onRpc` method is to be called, to associate a route or an ORM
method to a callback.
.. note::
Multiple `onRpc` calls can be associated to the same route / ORM method;
in which case they will be called sequentially from last to first defined.
Returning a *non-null-or-undefined* value will interrupt the current chain,
and return that value as final result of the server request.
It can be used in 4 different ways:
`onRpc`: with a route (`"/"`)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
When the first argument is a `string` starting with a `"/"`, the callback
is expected to be a *route* callback, receiving a `Request_`
object:
.. code-block:: javascript
onRpc("/route/to/test", async (request) => {
const { ids } = await request.json();
expect.step(ids);
return {};
});
By default, the return value of these callbacks are wrapped within the `body`
of a mock `Response_` object.
This is fine for most use-cases, but sometimes the callback needs to respond with
a `Response_` object with custom `status` or `headers`.
In such cases, an *optional* dictionary can be passed as a 3rd argument to specify
whether the callback is to be considered *"pure"*, meaning that its return value
should be returned as-is to the server caller:
.. code-block:: javascript
onRpc(
"/not/found",
() => new Response("{}", { status: 404 }),
{ pure: true }
);
.. note::
Using *"pure"* request callbacks can also be used to return anything else than
a `Response_` object, in which case the returned value will still be wrapped
in the body of a mock `Response_` to comply with the `fetch_` / `XMLHttpRequest_`
APIs.
`onRpc`: with method name(s)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
When the first argument is a `string` *NOT* starting with a `"/"` or a list of `strings`,
the callback is expected to be an ORM callback, only called when the request's `method`
matches the one given as argument.
The callback will receive an object containing:
- the *spread* `params` value contained in the request body (typically: `args`,
`kwargs`, `model` and `method`);
- a `parent()` function, which when invoked will call the defined ORM callback *preceding*
this one;
- a `route` key, containing the `pathname` of the request (typically: `/web/dataset/call_kw`);
- the `request` object.
.. code-block:: javascript
onRpc("web_read", async ({ args, parent }) => {
const result = parent();
expect.step(args[0]); // Contains the list of IDs
result.some_meta_data = { foo: "bar" };
return result;
});
`onRpc`: with model name(s) AND method name(s)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
When:
- the first argument is a `string` *NOT* starting with a `"/"` or a list of `strings`;
- the second argument is also a `string` or a list of `strings`;
Then the callback is expected to be an ORM callback, only called when the request's
`method` *AND* `model` match the ones given in the arguments.
This works just the same as the above shape, with an added `model` filter:
.. code-block:: javascript
onRpc("web_read", "res.partner", ({ args }) => {
expect.step(args[0]);
});
`onRpc`: for *every* ORM method/model
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
When the *only* argument is a callback, it is expected to be an ORM callback to
be called for *every* ORM call:
.. code-block:: javascript
onRpc(({ method }) => {
expect.step(method); // Will step every ORM method call on every model
});
Mock models: fields
-------------------
Model fields can be declared in 2 ways:
- as `public class fields <public-class-fields_>`_;
- under the `_fields` special key. For example:
.. code-block:: javascript
test("test view with date fields", async () => {
// `_fields` can be assigned over, or extended directly.
ResPartner._fields.date = fields.Date({ string: "Registration date" });
});
Field constructors can take a parameters dictionary to dictate their behaviour.
It will be required for some of them, like relational fields, which need a `relation`
property to work correctly.
There are limits to what can be done with a mock field compared to an actual Python
server field, but expect the most basic properties to be supported:
`readonly`, `required`, `string`, etc.
`compute` and `related` do work for the most basic use-cases, but do not expect
them to function reliably as they would on the actual server.
.. note::
There are 4 default fields pre-defined for each created model: `id`, `display_name`,
`created_at` and `updated_at`. They match their server-side counterpart in their
behaviour (e.g. `id` is incremental and `display_name` has a `compute` function
similar to its server counterpart), and can be overridden if needed.
Mock models: records
--------------------
Model records are generated based on each object contained in the `_records`
special key *when the model is loaded*. They are validated based on the fields available
on the current models; if a property does not match a field defined on the model,
an error is thrown.
.. important::
`_records` *cannot* be altered *after* the model has been loaded, i.e. after
the mock server has started. This key is only used to generate initial records.
If records should be added *after* model creation, do it either form the available
components in the UI, or through direct ORM calls on the mock server instance.
Mock models: views
------------------
Since actual views need an `"ir.ui.view"` model to be declared, mock models
use a simplified *mapping* to provide view arches.
The `_view` special key is a dictionary, with its *keys* being view types, optionally
accompanied by a view ID, and its *values* being the XML arch string representation.
By default, view IDs are `false`, but can be specified explicitly with a comma-separated
key combining the view type and its ID:
.. code-block:: javascript
// Will simulate a list view with no ID (false).
ResPartner._views.list = /* xml */ `
<list>
<field name="display_name" />
</list>
`;
// Will simulate a form view with ID 418.
ResPartner._views["form,418"] = /* xml */ `
<form>
<field name="name" />
<field name="date" />
</form>
`;
.. _mock-server/spawning:
Spawning a mock server
======================
Just like in most cases, only one server can be active for a given test.
As mentioned above, creating an `env` will automatically deploy a mock server.
This means that all of these methods will *also* create a mock server, since
they do create an `env`:
- :ref:`makeMockEnv <web-test-helpers/environment>`;
- :ref:`mountWithCleanup <web-test-helpers/components>` (calling :ref:`makeMockEnv <web-test-helpers/environment>`);
- :ref:`mountView <web-test-helpers/views>` (calling :ref:`mountWithCleanup <web-test-helpers/components>`).
However, some low-level features may require to spawn a mock server *without* an
environment. For that purpose, a `makeMockServer` helper can be called separately
to initiate a mock server.
.. note::
`makeMockServer` should *only* be used by low-level features, such as testing
the `rpc` function without the environment. It is not meant to be used as a
means to retrieve the current mock server instance. For that purpose, refer to
:ref:`MockServer.current <mock-server/interacting>`.
.. note::
It is to be noted that subsequent calls to `makeMockServer` after a mock server
has been started are simply ignored.
.. _mock-server/interacting:
Interacting with the server
===========================
While most of the server interactions are expected to be done directly or indirectly
by production code spawned in the test case, it is sometimes meaningful to bypass
the UI and call the mock server directly (e.g. to simulate that another user,
somewhere else, somehow, has altered the database).
This can be done by retrieving the `MockServer.current` static property containing
the current mock server instance (only after initialization):
.. code-block:: javascript
// Most common ORM methods are provided out of the box by server models,
// and are synchronous. Although, be careful that this will NOT trigger a
// UI re-render, and will ONLY affect the (fake) database.
const ids = MockServer.env["res.partner"].create([
{ name: "foo" },
{ name: "bar" },
]);
.. tip::
`MockServer.env` is just a shortcut to `MockServer.current.env`.
.. _fetch: https://developer.mozilla.org/en-US/docs/Web/API/Window/fetch
.. _public-class-fields: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Public_class_fields
.. _Request: https://developer.mozilla.org/en-US/docs/Web/API/Request
.. _Response: https://developer.mozilla.org/en-US/docs/Web/API/Response
.. _XMLHttpRequest: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest

View File

@@ -0,0 +1,187 @@
================
Web test helpers
================
Overview
========
After the :doc:`"@odoo/hoot" <./hoot>` module, the second most-solicited module
in test files should be `"@web/../tests/web_test_helpers"`.
This module contains all the helpers that combine the low-level helpers provided
by Hoot, with all the most common features that are used in tests in Odoo.
These helpers are many, and this section of the documentation will only highlight
the most common ones, and the way they interact with one another.
For a full list of available helpers, you may refer to the `web_test_helpers file <{GITHUB_PATH}/addons/web/static/tests/web_test_helpers.js>`_.
.. _web-test-helpers/environment:
Mock environment
================
The `makeMockEnv` helper is the lowest helper that can spawn an `env`.
It will take care of
- creating the `env` object itself, pre-configured with all the required properties
for the proper functioning of web components, such as `getTemplate` or `translateFn`;
- spawning a `MockServer` (if one did not exist already for that test);
- starting all registered :doc:`services <../services>`, and awaiting until they are all ready;
- initiating other features that are not tied to a service, such as the web `router`;
- guaranteeing the teardown of all the features in its setup at the end of the test.
This method is great for testing low-level features, such as :doc:`services <../services>` that are
not tied to a Component_:
.. code-block:: javascript
// Can be further configured, but is already packed with all the necessary stuff
const env = await makeMockEnv();
expect(env.isSmall).toBe(false);
.. note::
Like :ref:`makeMockServer <mock-server/spawning>`, only one `env` can be active for a given test.
It is not necessary to call `makeMockEnv` manually to retrieve the current environment
instance; the `getMockEnv` helper can be called instead.
.. example::
- `DateTime input tests <{GITHUB_PATH}/addons/web/static/tests/core/components/datetime/datetime_input.test.js>`_
- `Name service tests <{GITHUB_PATH}/addons/web/static/tests/core/name_service.test.js>`_
.. _web-test-helpers/components:
Mounting components
===================
Instantiating and appending `components <Component_>`_ to the DOM is meant to be easy,
through the use of the `mountWithCleanup` helper. It will prepare an `env` internally
(if one does not exist yet), which in turn also makes sure that a `MockServer` is
running.
It takes a `Component` class as its first argument, and an *optional* parameters
second argument, used to specify `props` or a custom `target`:
.. code-block:: javascript
await mountWithCleanup(Checkbox, {
props: {
value: false
},
});
This helper will return the active `Component` instance.
.. important::
It is generally *ill-advised* to retrieve the `Component` instance to directly
interact with it or to perform assertions on its internal variables. The only
"accepted" use cases are when the `Component` is displaying hard-to-retrieve information
in the DOM, such as graphs in a `canvas <https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API>`_.
For most cases, it is highly preferred to query derived information in the DOM.
.. example::
- `Checkbox tests <{GITHUB_PATH}/addons/web/static/tests/core/checkbox.test.js>`_
- `Popover tests <{GITHUB_PATH}/addons/web/static/tests/core/popover/popover.test.js>`_
- `DateTimePicker tests <{GITHUB_PATH}/addons/web/static/tests/core/components/datetime/datetime_picker.test.js>`_
.. _web-test-helpers/views:
Mounting views
==============
Mounting a view is simply a matter of using :ref:`mountWithCleanup <web-test-helpers/components>`
with the View_ component and the correct properties.
For that purpose, web test helpers export a `mountView` helper, taking a parameters
object determining the view `type`, `resModel`, and other optional properties such
as an XML `arch`:
.. code-block:: javascript
// Resolves when the view is fully ready
await mountView({
type: "list",
resModel: "res.partner",
arch: /* xml */ `
<list>
<field name="display_name" />
</list>
`,
});
Like the previous helpers on top of which `mountView` is built, it will ensure that
both an `env` and a `MockServer` are running for the current test.
.. note::
Like :ref:`mountWithCleanup <web-test-helpers/components>`, it is *NOT*
recommended to retrieve the returned View_ component instance. It can however
be done, for cases like the `Graph view <{GITHUB_PATH}/addons/web/static/src/views/graph/graph_view.js>`_.
.. example::
- `Calendar view tests <{GITHUB_PATH}/addons/web/static/tests/views/calendar/calendar_view.test.js>`_
- `Graph view tests <{GITHUB_PATH}/addons/web/static/tests/views/graph/graph_view.test.js>`_
- `Kanban view tests <{GITHUB_PATH}/addons/web/static/tests/views/kanban/kanban_view.test.js>`_
Interacting with components
===========================
Hoot provides helpers to interact with the DOM (e.g. `click`, `press`, etc.). However,
these helpers present 2 issues when interacting with more complex components:
#. helpers try to interact instantly, while sometimes the element has yet to be
appended to the document (in an unknown amount of time);
#. helpers only wait a single micro-task tick per dispatched event, while most
Owl-based UIs take at least a full animation frame to update.
.. code-block:: javascript
// Edit record name
await click(".o_field_widget[name=name]");
await edit("Gaston Lagaffe");
// Potential error 1: button may not be in the DOM yet
await click(".btn:contains(Save)");
// Potential error 2: view is not yet updated
expect(".o_field_widget[name=name]").toHaveText("Gaston Lagaffe");
With these constraints in mind, web test helpers provide the `contains` helper:
.. code-block:: javascript
// Combines 'click' + 'edit' + 'animationFrame' calls
await contains(".o_field_widget[name=name]").edit("Gaston Lagaffe");
// Waits for (at least) a full animation frame after the click
await contains(".btn:contains(Save)").click();
expect(".o_field_widget[name=name]").toHaveText("Gaston Lagaffe");
This approach, while seemingly drifting a bit further away from the concept of "unit
testing", is still a nice and convenient way to test more complex units such as `views <View_>`_,
the `WebClient <{GITHUB_PATH}/addons/web/static/src/webclient/webclient.js>`_, or
interactions between couples of :doc:`services <../services>` and components.
It should however not become the default for all interactions, as some of them still
need to happen *precisely* within a given time frame, which is a concept completely
ignored by `contains`.
.. note::
Most helpers in Hoot are available as methods of a `contains` instance, with
(generally) the same shape and API.
.. _Component: https://github.com/odoo/owl/blob/master/doc/reference/component.md
.. _View: {GITHUB_PATH}/addons/web/static/src/views/view.js