==== 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 2 main modules: - :file:`@odoo/hoot-dom`: (can be used in tours) helpers to: - **interact** with the DOM, such as :js:meth:`click` and :js:meth:`press`; - **query** elements from the DOM, such as :js:meth:`queryAll` and :js:meth:`waitFor`; - :file:`@odoo/hoot`: (only to be used in unit tests) all the test framework features: - `test`, `describe` and `expect` - test hooks like `after` and `afterEach` - fixture handling with `getFixture` - date and time handling like `mockDate` or `advanceTime` - mocking network responses through :js:meth:`mockFetch` or :js:meth:`mockWebSocket` - every helper exported by :file:`@odoo/hoot-dom` .. note:: This section of the documentation is not meant to list *all* helpers available in Hoot (the full list can be found in the `@odoo/hoot <{GITHUB_PATH}/addons/web/static/lib/hoot/hoot.js>`_ module itself). The goal here is to showcase the most used helpers and to justify some of the decisions that have led to the current shape of the testing framework. Running tests ============= In Odoo, frontend unit tests can be run by going to the `/web/tests` URL. Most of the setup for calling the test runner is already in place: - the `web.assets_unit_tests` bundle is already defined, and picks up all tests defined in most addons; - a :file:`start.hoot.js` file takes care of calling the test runner with its exported `start` entry point function. When going to the test page, tests will 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`) - `id` IDs of the suites OR tests to run exclusively. The ID of a job is generated deterministically based on its full name. - `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 test 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 test 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"`) - `tag` Tag names of tests and suites to run exclusively (case insensitive). (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`, `id` and `tag` 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 organization or tagging purposes. 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 is 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 *transform* 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: .. js:method:: toBe(expected[, options]) 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 }); .. js:method:: toBeCloseTo(expected[, options]) 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 }); .. js:method:: toBeEmpty([options]) 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(); .. js:method:: toBeGreaterThan(min[, options]) 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); .. js:method:: toBeInstanceOf(cls[, options]) 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); .. js:method:: toBeLessThan(max[, options]) 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); .. js:method:: toBeOfType(type[, options]) 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"); .. js:method:: toBeWithin(min, max[, options]) Expects the received value to be *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); .. js:method:: toEqual(expected[, options]) 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 }); .. js:method:: toHaveLength(length[, options]) 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); .. js:method:: toInclude(item[, options]) 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 }); .. js:method:: toMatch(matcher[, options]) 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/); .. js:method:: toThrow(matcher[, options]) 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). .. js:method:: toBeChecked([options]) 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(); .. js:method:: toBeDisplayed([options]) 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(); .. js:method:: toBeEnabled([options]) 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(); .. js:method:: toBeFocused([options]) Expects the received `Target` to be *"focused"* in its owner document. - Parameters * `options`: `{ message?: string }` .. js:method:: toBeVisible([options]) 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(); .. js:method:: toHaveAttribute(attribute, value[, options]) 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"); .. js:method:: toHaveClass(className[, options]) 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"]); .. js:method:: toHaveCount(amount[, options]) 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); .. js:method:: toHaveInnerHTML(expected[, options]) 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 `); .. js:method:: toHaveOuterHTML(expected[, options]) 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
`); .. js:method:: toHaveProperty(property, value[, options]) 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"); .. js:method:: toHaveRect(rect[, options]) 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"); .. js:method:: toHaveStyle(style[, options]) 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"); .. js:method:: toHaveText(text[, options]) 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); .. js:method:: toHaveValue(value[, options]) 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"]); Static methods -------------- The `expect` helper function also contains static methods that can be used to run through a detached testing flow that isn't bound to one specific value at a certain moment. These methods are mainly used to register steps or errors in the scope of the current test, and to evaluate them later on. .. js:function:: expect.assertions(expected) :param number expected: Expects the current test to have the `expected` amount of assertions. This number cannot be less than 1. .. note:: It is generally preferred to use :js:meth:`expect.step` and :js:meth:`expect.verifySteps` instead as it is more reliable and allows to test more extensively. .. js:function:: expect.errors(expected) :param number expected: Expects the current test to have the `expected` amount of errors. This also means that from the moment this function is called, the test will accept that amount of errors before being considered as failed. .. js:function:: expect.step(value) :param unknown value: Registers a step for the current test, that can be consumed by :js:meth:`expect.verifySteps`. Unconsumed steps will fail the test. .. js:function:: expect.verifyErrors(errors[, options]) :param unknown[] errors: :param { message?\: string } options: :returns: `boolean` Expects the received matchers to match the errors thrown since the start of the test or the last call to :js:meth:`expect.verifyErrors`. Calling this matcher will reset the list of current errors. .. code-block:: javascript expect.verifyErrors([/RPCError/, /Invalid domain AST/]); .. js:function:: expect.verifySteps(steps[, options]) :param unknown[] steps: :param { ignoreOrder?\: boolean, message?\: string, partial?\: boolean } options: :returns: `boolean` Expects the received steps to be equal to the steps emitted since the start of the test or the last call to :js:meth:`expect.verifySteps`. Calling this matcher will reset the list of current steps. .. code-block:: javascript expect.step("web_read_group"); expect.step([1, 2]); expect.verifySteps(["web_read_group", [1, 2]]); .. js:function:: expect.waitForErrors(errors[, options]) :param unknown[] errors: :param { message?\: string } options: :returns: `Promise` Same as :js:meth:`expect.verifyErrors`, but will not immediatly fail if errors are not caught yet, and will instead wait for a certain timeout (default: 2000ms) to allow errors to be caught later. Checks are performed initially, at the end of the timeout, and each time an error is detected. .. code-block:: javascript fetch("invalid/url"); await expect.waitForErrors([/RPCError/]); .. js:function:: expect.waitForSteps(steps[, options]) :param unknown[] steps: :param { ignoreOrder?\: boolean, message?\: string, partial?\: boolean } options: :returns: `Promise` Same as :js:meth:`expect.verifySteps`, but will not immediatly fail if steps have not been registered yet, and will instead wait for a certain timeout (default: 2000ms) to allow steps to be registered later. Checks are performed initially, at the end of the timeout, and each time a step is registered. .. code-block:: javascript // ... step on each 'web_read_group' call fetch(".../call_kw/web_read_group"); await expect.waitForSteps(["web_read_group"]); DOM: queries ============ .. _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 text content) - `: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 `