diff --git a/content/developer/reference/frontend.rst b/content/developer/reference/frontend.rst index e2b7fae34..698fd26dc 100644 --- a/content/developer/reference/frontend.rst +++ b/content/developer/reference/frontend.rst @@ -21,3 +21,4 @@ Web framework frontend/mobile frontend/qweb frontend/odoo_editor + frontend/unit_testing diff --git a/content/developer/reference/frontend/unit_testing.rst b/content/developer/reference/frontend/unit_testing.rst new file mode 100644 index 000000000..e39b9151d --- /dev/null +++ b/content/developer/reference/frontend/unit_testing.rst @@ -0,0 +1,31 @@ +======================= +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 allows 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. + +.. toctree:: + :titlesonly: + + unit_testing/hoot + unit_testing/web_helpers + unit_testing/mock_server diff --git a/content/developer/reference/frontend/unit_testing/hoot.rst b/content/developer/reference/frontend/unit_testing/hoot.rst new file mode 100644 index 000000000..a900533f6 --- /dev/null +++ b/content/developer/reference/frontend/unit_testing/hoot.rst @@ -0,0 +1,1678 @@ +==== +HOOT +==== + +Overview +======== + +:abbr:`HOOT (Hierarchically Organized Odoo Tests)` is a testing framework written with Owl whose +key features are: + +- to register and run tests and test suites; +- to display an intuitive interface to view and filter test results; +- to provide ways to interact with the DOM to simulate user actions; +- to provide low-level helpers allowing to mock various global objects. + +As such, it has been integrated as a :file:`lib/` in the Odoo codebase and exports 3 main modules: + +- :file:`@odoo/hoot`: main building blocks of the framwork, such as: + + - `test`, `describe` and `expect` + - test hooks like `after` and `afterEach` + - fixture handling with `getFixture` + +- :file:`@odoo/hoot-dom`: helpers to: + + - **interact** with the DOM, such as :ref:`click ` and :ref:`press `; + - **query** elements from the DOM, such as :ref:`queryAll ` + and :ref:`waitFor `; + +- :file:`@odoo/hoot-mock`: helpers to mock default behaviours and objects, such as: + + - date and time handling like `mockDate` or `advanceTime` + - mocking network responses through :ref:`mockFetch ` or :ref:`mockWebSocket ` + + +Running tests +============= + +To setup the test runner is quite straightforward: you just need to have all the lib files loaded +in the assets bundle along with Owl, and then call the main `start` method entrypoint once all +tests and suites have been registered. The tests will then be run sequentially and the results +will be displayed in the **console** and in the **GUI** (if not running in `headless` mode). + + +Runner options +-------------- + +The runner can be configured either: + +- through the interface (with the configuration dropdown and the search bar); +- or through the URL query parameters (e.g. `?headless` to run in headless mode). + +Here is the list of available options for the runner: + +- `bail` + Amount of failed tests after which the test runner will be stopped. A falsy value + (including 0) means that the runner should never be aborted. (default: `0`) + +- `debugTest` + Same as the `FILTER_SCHEMA.test` filter, while also putting the test runner in + "debug" mode. See `TestRunner.debug` for more info. (default: `false`) + +- `fps` + Sets the value of frames per seconds (this will be transformed to milliseconds and used in + `advanceFrame`) + +- `filter` + Search string that will filter matching tests/suites, based on their full name (including + their parent suite(s)) and their tags. (default: `""`) + +- `frameRate` + *Estimated* amount of frames rendered per second, used when mocking animation frames. (default: + `60` fps) + +- `fun` + Lightens the mood. (default: `false`) + +- `headless` + Whether to render the test runner user interface. (default: `false`) + +- `loglevel` + Log level used by the test runner. The higher the level, the more logs will be displayed: + + - `0`: only runner logs are displayed (default) + - `1`: all suite results are also logged + - `2`: all test results are also logged + - `3`: debug information for each tests is also logged + +- `manual` + Whether the test runner must be manually started after page load (defaults to starting + automatically). (default: `false`) + +- `notrycatch` + Removes the safety of `try .. catch` statements around each test's run function to let errors + bubble to the browser. (default: `false`) + +- `order` + Determines the order of the tests execution: + + - `"fifo"`: tests will be run sequentially as declared in the file system; + - `"lifo"`: tests will be run sequentially in the reverse order; + - `"random"`: shuffles tests and suites within their parent suite. + +- `preset` + Environment in which the test runner is running. This parameter is used to + determine the default value of other features, namely: + + - the user agent; + - touch support; + - expected size of the viewport. + +- `showdetail` + Determines how the failed tests must be unfolded in the UI. (default: `"first-fail"`) + +- `suite` + **IDs** of the suites to run exclusively. The ID of a suite is an 8-character hash generated + deterministically based on its full name. (default: emtpy) + +- `tag` + Tag **names** of tests and suites to run exclusively (case insensitive). (default: empty) + +- `test` + **IDs** of the tests to run exclusively. The ID of a test is an 8-character hash generated + deterministically based on its full name. (default: empty) + +- `timeout` + Duration (in **milliseconds**) at the end of which a test will automatically fail. (default: `5` + seconds) + +.. note:: + When selecting tests and suites to run, an implicit `OR` is applied between the *including* + filters. This means that adding more inclusive filters will result in more tests being run. + This applies to the `filter`, `suite`, `tag` and `test` filters (*excluding* filters however + will remove matching tests from the list of tests to run). + + +Writing tests +============= + +Test +---- + +Writing a test can be very straightforward, as it is just a matter of calling the `test` function +with a **name** and a **function** that will contain the test logic. + +Here is a simple example: + +.. code-block:: javascript + + import { expect, test } from "@odoo/hoot"; + + test("My first test", () => { + expect(2 + 2).toBe(4); + }); + + +Describe +-------- + +Most of the time, tests are not that simple. They often require some setup and teardown, +and sometimes they need to be grouped together in a suite. This is where the `describe` +function comes into play. + +Here is how you would declare a suite and a test within it: + +.. code-block:: javascript + + import { describe, expect, test } from "@odoo/hoot"; + + describe("My first suite", () => { + test("My first test", () => { + expect(2 + 2).toBe(4); + }); + }); + +.. important:: + In Odoo, all test files are run in an isolated environment and are wrapped within a global + `describe` block (with the name of the suite being the *path* of the test file). + + With that in + mind you should not need to declare a suite in your test files, although you can still declare + sub-suites in the same file if you still want to split the file's suite, for organisation + or tagging purpose. + + +Expect +====== + +The `expect` function is the main assertion function of the framework. It is used to assert that +a value or an object is what it is expected to be or in the state it supposed to be. To do so, it +provides a few **modifiers** and a wide range of **matchers**. + + +Modifiers +--------- + +An `expect` modifier is a getter that returns another set of *altered* matchers that will behave in +a specific way. + +- `not` + Inverts the result of the following matcher: it will succeed if the matcher fails. + + .. code-block:: javascript + + expect(true).not.toBe(false); + +- `resolves` + Waits for the value (promise) to be **resolved** before running the following matcher + with the resolved **value**. + + .. code-block:: javascript + + await expect(Promise.resolve(42)).resolves.toBe(42); + +- `rejects` + Waits for the value (promise) to be **rejected** before running the following matcher + with the rejected **reason**. + + .. code-block:: javascript + + await expect(Promise.reject("error")).rejects.toBe("error"); + +.. note:: + The `resolves` and `rejects` modifiers are only available when the value is a promise, and will + return a **promise** that will resolve once the assertion is done. + + +Regular matchers +---------------- + +The matchers dictate what to do on the value being tested. Some will take that value as-is, while +others will *tranform* that value before performing the assertion on it (i.e. **DOM matchers**). + +Note that the last argument parameter of all matchers is an optional dictionary with additional +options, in which a custom assertion **message** can be given for added context/specificity. + +The first list of matchers are primitive or object based and are the most common ones: + +#. `toBe` + + Expects the received value to be **strictly equal** to the `expected` value. + + - Parameters + + * `expected`: `any` + * `options`: `{ message?: string }` + + - Examples + + .. code-block:: javascript + + expect("foo").toBe("foo"); + expect({ foo: 1 }).not.toBe({ foo: 1 }); + +#. `toBeCloseTo` + + Expects the received value to be **close to** the `expected` value up to a given + amount of digits (default is 2). + + - Parameters + + * `expected`: `any` + * `options`: `{ message?: string, digits?: number }` + + - Examples + + .. code-block:: javascript + + expect(0.2 + 0.1).toBeCloseTo(0.3); + expect(3.51).toBeCloseTo(3.5, { digits: 1 }); + +#. `toBeEmpty` + + Expects the received value to be **empty**: + + - `iterable`: no items + - `object`: no keys + - `node`: no content (i.e. no value or text) + - anything else: falsy value (`false`, `0`, `""`, `null`, `undefined`) + + - Parameters + + * `options`: `{ message?: string }` + + - Examples + + .. code-block:: javascript + + expect({}).toBeEmpty(); + expect(["a", "b"]).not.toBeEmpty(); + expect(queryOne("input")).toBeEmpty(); + +#. `toBeGreaterThan` + + Expects the received value to be **strictly greater** than `min`. + + - Parameters + + * `min`: `number` + * `options`: `{ message?: string }` + + - Examples + + .. code-block:: javascript + + expect(5).toBeGreaterThan(-1); + expect(4 + 2).toBeGreaterThan(5); + +#. `toBeInstanceOf` + + Expects the received value to be an instance of the given `cls`. + + - Parameters + + * `cls`: `Function` + * `options`: `{ message?: string }` + + - Examples + + .. code-block:: javascript + + expect({ foo: 1 }).not.toBeInstanceOf(Object); + expect(document.createElement("div")).toBeInstanceOf(HTMLElement); + +#. `toBeLessThan` + + Expects the received value to be **strictly less** than `max`. + + - Parameters + + * `max`: `number` + * `options`: `{ message?: string }` + + - Examples + + .. code-block:: javascript + + expect(5).toBeLessThan(10); + expect(8 - 6).toBeLessThan(3); + +#. `toBeOfType` + + Expects the received value to be of the given `type`. + + - Parameters + + * `type`: `string` + * `options`: `{ message?: string }` + + - Examples + + .. code-block:: javascript + + expect("foo").toBeOfType("string"); + expect({ foo: 1 }).toBeOfType("object"); + +#. `toBeWithin` + + Expects the received value to be **strictly between** `min` and `max` (both **inclusive**). + + - Parameters + + * `min`: `number` + * `max`: `number` + * `options`: `{ message?: string }` + + - Examples + + .. code-block:: javascript + + expect(3).toBeWithin(3, 9); + expect(-8.5).toBeWithin(-20, 0); + expect(100).toBeWithin(50, 100); + +#. `toEqual` + + Expects the received value to be **deeply equal** to the `expected` value. + + - Parameters + + * `expected`: `any` + * `options`: `{ message?: string }` + + - Examples + + .. code-block:: javascript + + expect(["foo"]).toEqual(["foo"]); + expect({ foo: 1 }).toEqual({ foo: 1 }); + +#. `toHaveLength` + + Expects the received value to have a length of the given `length`. + Received value can be any **iterable** or **object**. + + - Parameters + + * `length`: `number` + * `options`: `{ message?: string }` + + - Examples + + .. code-block:: javascript + + expect("foo").toHaveLength(3); + expect([1, 2, 3]).toHaveLength(3); + expect({ foo: 1, bar: 2 }).toHaveLength(2); + expect(new Set([1, 2])).toHaveLength(2); + +#. `toInclude` + + Expects the received value to include an `item` of a given shape. + + Received value can be an iterable or an object (in case it is an object, + the `item` should be a key or a tuple representing an entry in that object). + + Note that it is NOT a strict comparison: the item will be matched for deep + equality against each item of the iterable. + + - Parameters + + * `item`: `any` + * `options`: `{ message?: string }` + + - Examples + + .. code-block:: javascript + + expect([1, 2, 3]).toInclude(2); + expect({ foo: 1, bar: 2 }).toInclude("foo"); + expect({ foo: 1, bar: 2 }).toInclude(["foo", 1]); + expect(new Set([{ foo: 1 }, { bar: 2 }])).toInclude({ bar: 2 }); + +#. `toMatch` + + Expects the received value to match the given `matcher`. + + - Parameters + + * `matcher`: `string | number | RegExp` + * `options`: `{ message?: string }` + + - Examples + + .. code-block:: javascript + + expect(new Error("foo")).toMatch("foo"); + expect("a foo value").toMatch(/fo.*ue/); + +#. `toThrow` + + Expects the received `Function` to throw an error after being called. + + - Parameters + + * `matcher`: `string | number | RegExp` + * `options`: `{ message?: string }` + + - Examples + + .. code-block:: javascript + + expect(() => { throw new Error("Woops!") }).toThrow(/woops/i); + await expect(Promise.reject("foo")).rejects.toThrow("foo"); + + +DOM matchers +------------ + +This next list of matchers are node-based and are used to assert the state of a +node or a list of nodes. They generally take a :ref:`custom selector ` +as the argument of the `expect` function (although a `Node` or an iterable of `Node` +is also accepted). + +#. `toBeChecked` + + Expects the received `Target` to be **checked**, or to be **indeterminate** + if the homonymous option is set to `true`. + + - Parameters + + * `options`: `{ message?: string, indeterminate?: boolean }` + + - Examples + + .. code-block:: javascript + + expect("input[type=checkbox]").toBeChecked(); + +#. `toBeDisplayed` + + Expects the received `Target` to be **displayed**, meaning that: + + - it has a bounding box; + - it is contained in the root document. + + - Parameters + + * `options`: `{ message?: string }` + + - Examples + + .. code-block:: javascript + + expect(document.body).toBeDisplayed(); + expect(document.createElement("div")).not.toBeDisplayed(); + +#. `toBeEnabled` + + Expects the received `Target` to be **enabled**, meaning that it + matches the `:enabled` pseudo-selector. + + - Parameters + + * `options`: `{ message?: string }` + + - Examples + + .. code-block:: javascript + + expect("button").toBeEnabled(); + expect("input[type=radio]").not.toBeEnabled(); + +#. `toBeFocused` + + Expects the received `Target` to be **focused** in its owner document. + + - Parameters + + * `options`: `{ message?: string }` + +#. `toBeVisible` + + Expects the received `Target` to be **visible**, meaning that: + + - it has a bounding box; + - it is contained in the root document; + - it is not hidden by CSS properties. + + - Parameters + + * `options`: `{ message?: string }` + + - Examples + + .. code-block:: javascript + + expect(document.body).toBeVisible(); + expect("[style='opacity: 0']").not.toBeVisible(); + +#. `toHaveAttribute` + + Expects the received `Target` to have the given **attribute** set, and for that + attribute value to match the given `value` if any. + + - Parameters + + * `attribute`: `string` + * `value`: `string | number | RegExp` + * `options`: `{ message?: string }` + + - Examples + + .. code-block:: javascript + + expect("a").toHaveAttribute("href"); + expect("script").toHaveAttribute("src", "./index.js"); + +#. `toHaveClass` + + Expects the received `Target` to have the given **class name(s)**. + + - Parameters + + * `className`: `string | string[]` + * `options`: `{ message?: string }` + + - Examples + + .. code-block:: javascript + + expect("button").toHaveClass("btn btn-primary"); + expect("body").toHaveClass(["o_webclient", "o_dark"]); + +#. `toHaveCount` + + Expects the received `Target` to contain exactly `amount` element(s). + Note that the `amount` parameter can be omitted, in which case the function + will expect *at least* one element. + + - Parameters + + * `amount`: `number` + * `options`: `{ message?: string }` + + - Examples + + .. code-block:: javascript + + expect(".o_webclient").toHaveCount(1); + expect(".o_form_view .o_field_widget").toHaveCount(); + expect("ul > li").toHaveCount(4); + +#. `toHaveInnerHTML` + + Expects the `innerHTML` of the received `Target` to match the `expected` + value (upon formatting). + + - Parameters + + * `expected`: `string | RegExp` + * `options`: `{ message?: string, type?: "html" | "xml", tabSize?: number, keepInlineTextNodes?: boolean }` + + - Examples + + .. code-block:: javascript + + expect(".my_element").toHaveInnerHTML(` + Some text + `); + +#. `toHaveOuterHTML` + + Expects the `outerHTML` of the received `Target` to match the `expected` + value (upon formatting). + + - Parameters + + * `expected`: `string | RegExp` + * `options`: `{ message?: string, type?: "html" | "xml", tabSize?: number, keepInlineTextNodes?: boolean }` + + - Examples + + .. code-block:: javascript + + expect(".my_element").toHaveOuterHTML(` +
+ Some text +
+ `); + +#. `toHaveProperty` + + Expects the received `Target` to have its given **property** value match + the given `value`. If no value is given: the matcher will instead check that + the given property exists on the target. + + - Parameters + + * `property`: `string` + * `value`: `any` + * `options`: `{ message?: string }` + + - Examples + + .. code-block:: javascript + + expect("button").toHaveProperty("tabIndex", 0); + expect("input").toHaveProperty("ontouchstart"); + expect("script").toHaveProperty("src", "./index.js"); + +#. `toHaveRect` + + Expects the `DOMRect` of the received `Target` to match the given `rect` object. + The `rect` object can either be: + + - a `DOMRect` object; + - a CSS selector string (to get the rect of the *only* matching element); + - a node. + + If the resulting `rect` value is a node, then both nodes' rects will be compared. + + - Parameters + + * `rect`: `Partial | Target` + * `options`: `{ message?: string, trimPadding?: boolean }` + + - Examples + + .. code-block:: javascript + + expect("button").toHaveRect({ x: 20, width: 100, height: 50 }); + expect("button").toHaveRect(".container"); + +#. `toHaveStyle` + + Expects the received `Target` to match the given **style** properties. + + - Parameters + + * `style`: `string | Record` + * `options`: `{ message?: string }` + + - Examples + + .. code-block:: javascript + + expect("button").toHaveStyle({ color: "red" }); + expect("p").toHaveStyle("text-align: center"); + +#. `toHaveText` + + Expects the **text** content of the received `Target` to either: + + - be strictly equal to a given string; + - match a given regular expression. + + Note: `innerHTML` is used to retrieve the text content to take CSS visibility + into account. This also means that text values from child elements will be + joined using a **line-break** as separator. + + - Parameters + + * `text`: `string | RegExp` + * `options`: `{ message?: string, raw?: boolean }` + + - Examples + + .. code-block:: javascript + + expect("p").toHaveText("lorem ipsum dolor sit amet"); + expect("header h1").toHaveText(/odoo/i); + +#. `toHaveValue` + + Expects the **value** of the received `Target` to either: + + - be strictly equal to a given string or number; + - match a given regular expression; + - contain file objects matching the given `files` list. + + - Parameters + + * `value`: `any` + * `options`: `{ message?: string }` + + - Examples + + .. code-block:: javascript + + expect("input[type=email]").toHaveValue("john@doe.com"); + expect("input[type=file]").toHaveValue(new File(["foo"], "foo.txt")); + expect("select[multiple]").toHaveValue(["foo", "bar"]); + + +DOM +=== + +.. _hoot/custom-dom-selectors: + +Custom DOM selectors +-------------------- + +Here's a brief section on DOM selectors in Hoot, as they support additional **pseudo-classes** +that can be used to target elements based on non-standard features, such as their text content +or their global position in the document. + +- `:contains(text)` + matches nodes whose **text content** matches the given **text** + + - given *text* supports regular expression syntax (e.g. `:contains(/^foo.+/)`) and is + case-insensitive (unless using the **i** flag at the end of the regex) + +- `:displayed` + matches nodes that are **displayed** (see `isDisplayed`) + +- `:empty` + matches nodes that have an **empty content** (**value** or **inner text**) + +- `:eq(n)` + returns the **nth** node based on its global position (**0**-based index); + +- `:first` + returns the **first** node matching the selector (in the whole document) + +- `:focusable` + matches nodes that can be **focused** (see `isFocusable`) + +- `:hidden` + matches nodes that are **not** visible (see `isVisible`) + +- `:iframe` + matches nodes that are `