==== 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 `