Repository navigation
feat: add formFiller to declaratively fill forms in page objects
#49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@cronn/playwright-utils": minor | ||
| --- | ||
|
|
||
| Add `formFiller` to declaratively fill forms in page objects. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,173 @@ | ||
| # Forms | ||
|
|
||
| ## Form Filler | ||
|
|
||
| Filling a form in a page object usually turns into a long list of imperative calls, each guarded by a check whether the value was provided at all. `formFiller` replaces this with a declarative mapping: given a `fields` object that maps each data key to a form input, it returns a function that takes a data object and fills every input for which a value is provided. | ||
|
|
||
| ### Usage | ||
|
|
||
| ```ts | ||
| import { formFiller, roleLocators } from "@cronn/playwright-utils"; | ||
| import type { Page } from "@playwright/test"; | ||
|
|
||
| function createRegistrationForm(page: Page) { | ||
| const { button, textbox } = roleLocators(page); | ||
|
|
||
| return { | ||
| fill: formFiller({ | ||
| name: textbox("Name"), | ||
| email: textbox("Email"), | ||
| }), | ||
| submitButton: button("Register"), | ||
| }; | ||
| } | ||
| ``` | ||
|
|
||
| A test then only describes _what_ to enter, not _how_ to enter it: | ||
|
|
||
| ```ts | ||
| import { test } from "@playwright/test"; | ||
|
|
||
| test("registers a new user", async ({ page }) => { | ||
| const registrationForm = createRegistrationForm(page); | ||
|
|
||
| await registrationForm.fill({ name: "Jane Doe", email: "jane@example.com" }); | ||
| await registrationForm.submitButton.click(); | ||
| }); | ||
| ``` | ||
|
|
||
| ### Data Type | ||
|
|
||
| By default, the type of the data is inferred from the `fields` object, e.g. `{ name?: string; email?: string }` for the form above. Filler functions (see below) need an explicit parameter type, otherwise their value is `unknown`: | ||
|
|
||
| ```ts | ||
| formFiller({ | ||
| acceptTerms: (accepted: boolean) => | ||
| checkbox("Accept terms").setChecked(accepted), | ||
| }); | ||
| ``` | ||
|
|
||
| Alternatively, the type of the data can be set with the type argument, e.g. `formFiller<Registration>`. The `fields` object is then type-checked against it: every key of the data needs a field, including optional keys, and each field has to accept the type of its value. In return, the parameter types of filler functions are inferred. To declare the fields separately, use the `FormFields<TData>` type. | ||
|
|
||
| In both cases, all keys are optional when filling, so a test only has to pass the values that matter for it (see [Optional Values](#optional-values)). | ||
|
|
||
| ```ts | ||
| interface Registration { | ||
| name: string; | ||
| email?: string; | ||
| } | ||
|
|
||
| const fillRegistration = formFiller<Registration>({ | ||
| name: textbox("Name"), | ||
| email: textbox("Email"), | ||
| }); | ||
| ``` | ||
|
|
||
| ### Locators and Filler Functions | ||
|
|
||
| A field can be either: | ||
|
|
||
| - a **fillable**, i.e. any object with a `fill(value)` method, such as a Playwright `Locator`, or | ||
| - a **filler function** `(value) => Promise<void>` for inputs that aren't filled with `fill`, such as checkboxes, selects or custom widgets. | ||
|
|
||
| ```ts | ||
| import { formFiller, roleLocators } from "@cronn/playwright-utils"; | ||
| import type { Page } from "@playwright/test"; | ||
|
|
||
| interface Registration { | ||
| name: string; | ||
| country?: string; | ||
| acceptTerms?: boolean; | ||
| } | ||
|
|
||
| function createRegistrationForm(page: Page) { | ||
| const { checkbox, combobox, textbox } = roleLocators(page); | ||
|
|
||
| return { | ||
| fill: formFiller<Registration>({ | ||
| name: textbox("Name"), | ||
| country: (country) => combobox("Country").selectOption(country), | ||
| acceptTerms: (accepted) => checkbox("Accept terms").setChecked(accepted), | ||
| }), | ||
| }; | ||
| } | ||
| ``` | ||
|
|
||
| ### Optional Values | ||
|
|
||
| Fields whose value is `undefined` are skipped, so a test can pass only the values that matter for it and leave all other inputs untouched: | ||
|
|
||
| ```ts | ||
| await registrationForm.fill({ name: "Jane Doe" }); | ||
| ``` | ||
|
|
||
| Falsy values such as `""`, `0` or `false` are still filled. For example, `acceptTerms: false` unchecks the checkbox. | ||
|
|
||
| ### Fill Order | ||
|
|
||
| Inputs are filled one after another, in the order of the keys in the `fields` object. The order of the keys in the data object doesn't matter. This helps with dependent inputs, e.g. a state select that is only populated after a country was selected: | ||
|
|
||
| ```ts | ||
| formFiller<Address>({ | ||
| country: (country) => combobox("Country").selectOption(country), | ||
| state: (state) => combobox("State").selectOption(state), | ||
| }); | ||
| ``` | ||
|
|
||
| ### Nested Data | ||
|
|
||
| The function returned by `formFiller` is a filler function itself. When the data contains nested objects, a nested `formFiller` can therefore be used as a field. Within a form with explicit data type, the type of the nested data is taken from the enclosing form: | ||
|
|
||
| ```ts | ||
| interface Registration { | ||
| name: string; | ||
| address?: Address; | ||
| } | ||
|
|
||
| interface Address { | ||
| street?: string; | ||
| city?: string; | ||
| } | ||
|
|
||
| function createRegistrationForm(page: Page) { | ||
| const { textbox } = roleLocators(page); | ||
|
|
||
| return { | ||
| fill: formFiller<Registration>({ | ||
| name: textbox("Name"), | ||
| address: formFiller({ | ||
| street: textbox("Street"), | ||
| city: textbox("City"), | ||
| }), | ||
| }), | ||
| }; | ||
| } | ||
| ``` | ||
|
|
||
| If the nested part of the form is a page object on its own, it can be used as a field directly, because its `fill` method makes it fillable: | ||
|
|
||
| ```ts | ||
| import type { Locator } from "@playwright/test"; | ||
|
|
||
| function createAddressForm(container: Locator) { | ||
| const { textbox } = roleLocators(container); | ||
|
|
||
| return { | ||
| fill: formFiller<Address>({ | ||
| street: textbox("Street"), | ||
| city: textbox("City"), | ||
| }), | ||
| }; | ||
| } | ||
|
|
||
| function createRegistrationForm(page: Page) { | ||
| const { group, textbox } = roleLocators(page); | ||
|
|
||
| return { | ||
| fill: formFiller<Registration>({ | ||
| name: textbox("Name"), | ||
| address: createAddressForm(group("Address")), | ||
| }), | ||
| }; | ||
| } | ||
| ``` | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,186 @@ | ||
| import { expect, expectTypeOf, test, vi } from "vitest"; | ||
|
|
||
| import { formFiller } from "./form-filler"; | ||
|
|
||
| interface Registration { | ||
| name?: string; | ||
| age?: number; | ||
| newsletter?: boolean; | ||
| } | ||
|
|
||
| interface Address { | ||
| street?: string; | ||
| city?: string; | ||
| } | ||
|
|
||
| function createFiller<TValue>() { | ||
| return vi.fn<(value: TValue) => Promise<void>>().mockResolvedValue(); | ||
| } | ||
|
|
||
| function createRecordingFiller(calls: Array<string>, name: string) { | ||
| return vi.fn(() => { | ||
| calls.push(name); | ||
| return Promise.resolve(); | ||
| }); | ||
| } | ||
|
|
||
| test("calls fill on fillable fields", async () => { | ||
| const name = { fill: createFiller<string>() }; | ||
|
|
||
| await formFiller<Registration>({ | ||
| name, | ||
| age: createFiller(), | ||
| newsletter: createFiller(), | ||
| })({ | ||
| name: "Jane", | ||
| }); | ||
|
|
||
| expect(name.fill).toHaveBeenCalledExactlyOnceWith("Jane"); | ||
| }); | ||
|
|
||
| test("calls filler functions", async () => { | ||
| const name = createFiller<string>(); | ||
|
|
||
| await formFiller<{ name: string }>({ name })({ name: "Jane" }); | ||
|
|
||
| expect(name).toHaveBeenCalledExactlyOnceWith("Jane"); | ||
| }); | ||
|
|
||
| test("skips fields with undefined values", async () => { | ||
| const name = createFiller<string>(); | ||
| const age = createFiller<number>(); | ||
|
|
||
| await formFiller<Registration>({ name, age, newsletter: createFiller() })({ | ||
| age: 42, | ||
| }); | ||
|
|
||
| expect(name).not.toHaveBeenCalled(); | ||
| expect(age).toHaveBeenCalledExactlyOnceWith(42); | ||
| }); | ||
|
|
||
| test("fills falsy values", async () => { | ||
| const name = createFiller<string>(); | ||
| const age = createFiller<number>(); | ||
| const newsletter = createFiller<boolean>(); | ||
|
|
||
| await formFiller<Registration>({ name, age, newsletter })({ | ||
| name: "", | ||
| age: 0, | ||
| newsletter: false, | ||
| }); | ||
|
|
||
| expect(name).toHaveBeenCalledExactlyOnceWith(""); | ||
| expect(age).toHaveBeenCalledExactlyOnceWith(0); | ||
| expect(newsletter).toHaveBeenCalledExactlyOnceWith(false); | ||
| }); | ||
|
|
||
| test("fills fields in the order of the fields object", async () => { | ||
| const calls: Array<string> = []; | ||
|
|
||
| await formFiller<Registration>({ | ||
| newsletter: createRecordingFiller(calls, "newsletter"), | ||
| name: createRecordingFiller(calls, "name"), | ||
| age: createRecordingFiller(calls, "age"), | ||
| })({ name: "Jane", age: 42, newsletter: true }); | ||
|
|
||
| expect(calls).toEqual(["newsletter", "name", "age"]); | ||
| }); | ||
|
|
||
| test("waits for each field before filling the next one", async () => { | ||
| const calls: Array<string> = []; | ||
|
|
||
| await formFiller<Registration>({ | ||
| name: async () => { | ||
| await new Promise((resolve) => setTimeout(resolve, 10)); | ||
| calls.push("name"); | ||
| }, | ||
| age: createRecordingFiller(calls, "age"), | ||
| newsletter: createFiller(), | ||
| })({ name: "Jane", age: 42 }); | ||
|
|
||
| expect(calls).toEqual(["name", "age"]); | ||
| }); | ||
|
|
||
| test("can be called multiple times", async () => { | ||
| const name = createFiller<string>(); | ||
| const fill = formFiller<{ name: string }>({ name }); | ||
|
|
||
| await fill({ name: "Jane" }); | ||
| await fill({ name: "John" }); | ||
|
|
||
| expect(name).toHaveBeenNthCalledWith(1, "Jane"); | ||
| expect(name).toHaveBeenNthCalledWith(2, "John"); | ||
| }); | ||
|
|
||
| test("supports nested form fillers", async () => { | ||
| const street = createFiller<string>(); | ||
| const city = createFiller<string>(); | ||
|
|
||
| await formFiller<{ address: Address }>({ | ||
| address: formFiller({ street, city }), | ||
| })({ address: { street: "Main Street 1", city: "Springfield" } }); | ||
|
|
||
| expect(street).toHaveBeenCalledExactlyOnceWith("Main Street 1"); | ||
| expect(city).toHaveBeenCalledExactlyOnceWith("Springfield"); | ||
| }); | ||
|
|
||
| test("infers the data type from the fields", async () => { | ||
| const name = { fill: createFiller<string>() }; | ||
| const age = createFiller<number>(); | ||
| const street = createFiller<string>(); | ||
|
|
||
| const fill = formFiller({ | ||
| name, | ||
| age, | ||
| address: formFiller({ street }), | ||
| }); | ||
| await fill({ name: "Jane", address: { street: "Main Street 1" } }); | ||
|
|
||
| expectTypeOf(fill).parameter(0).toEqualTypeOf<{ | ||
| name?: string; | ||
| age?: number; | ||
| address?: { street?: string }; | ||
| }>(); | ||
| expect(name.fill).toHaveBeenCalledExactlyOnceWith("Jane"); | ||
| expect(age).not.toHaveBeenCalled(); | ||
| expect(street).toHaveBeenCalledExactlyOnceWith("Main Street 1"); | ||
| }); | ||
|
|
||
| test("rejects invalid fields and data on type level", () => { | ||
| const fill = formFiller({ name: createFiller<string>() }); | ||
| // @ts-expect-error -- value doesn't match the type of the field | ||
| void fill({ name: 42 }); | ||
|
|
||
| formFiller({ | ||
| // @ts-expect-error -- value of filler function without parameter type is unknown | ||
| name: (name) => createFiller<string>()(name), | ||
| }); | ||
|
|
||
| // @ts-expect-error -- field missing for a required key of the explicit data type | ||
| formFiller<{ name: string; age: number }>({ name: createFiller() }); | ||
|
|
||
| // @ts-expect-error -- field missing for an optional key of the explicit data type | ||
| formFiller<Registration>({ name: createFiller(), age: createFiller() }); | ||
| }); | ||
|
|
||
| test("accepts partial data for an explicit data type", async () => { | ||
| const name = createFiller<string>(); | ||
|
|
||
| const fill = formFiller<{ name: string }>({ name }); | ||
| await fill({}); | ||
|
|
||
| expectTypeOf(fill).parameter(0).toEqualTypeOf<{ name?: string }>(); | ||
| expect(name).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| test("infers filler function parameters of nested form fillers", async () => { | ||
| const newsletter = createFiller<boolean>(); | ||
|
|
||
| await formFiller<{ settings: { newsletter?: boolean } }>({ | ||
| settings: formFiller({ | ||
| newsletter: (subscribe) => newsletter(subscribe), | ||
| }), | ||
| })({ settings: { newsletter: true } }); | ||
|
|
||
| expect(newsletter).toHaveBeenCalledExactlyOnceWith(true); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.