Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/brave-forms-fill.md
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.
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export default defineConfig({
text: "Locator Extensions",
link: "/page-object-model/locator-extensions",
},
{ text: "Forms", link: "/page-object-model/forms" },
],
},
{
Expand Down
173 changes: 173 additions & 0 deletions docs/src/page-object-model/forms.md
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:
Comment thread
PMudra marked this conversation as resolved.

```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")),
}),
};
}
```
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export {

export { resolveFromPackageRoot } from "./file";

export { formFiller, type FormFields } from "./page-object-model/form-filler";

export {
extendLocator,
type ExtendedLocator,
Expand Down
186 changes: 186 additions & 0 deletions src/page-object-model/form-filler.test.ts
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);
});
Loading
Loading