@assertrx/dino 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +293 -0
- package/extensions/dino.ts +70 -0
- package/extensions/mabl-mcp.ts +306 -0
- package/extensions/playwright-mcp.ts +142 -0
- package/package.json +47 -0
- package/prompts/create-pr.md +110 -0
- package/prompts/generate-bdd-from-url.md +65 -0
- package/prompts/generate-pom-from-url-cli.md +48 -0
- package/prompts/generate-pom-from-url.md +45 -0
- package/prompts/generate-spec-from-feature.md +64 -0
- package/prompts/heal-playwright-failures.md +106 -0
- package/prompts/setup-playwright.md +81 -0
- package/scripts/start-dino.cmd +8 -0
- package/scripts/start-dino.mjs +226 -0
- package/scripts/start-dino.ps1 +9 -0
- package/scripts/start-dino.sh +10 -0
- package/skills/acli/SKILL.md +292 -0
- package/skills/bdd-gherkin/SKILL.md +552 -0
- package/skills/build-review-interface/SKILL.md +96 -0
- package/skills/error-analysis/SKILL.md +164 -0
- package/skills/eval-audit/SKILL.md +183 -0
- package/skills/evaluate-rag/SKILL.md +177 -0
- package/skills/generate-synthetic-data/SKILL.md +131 -0
- package/skills/mabl-mcp/SKILL.md +79 -0
- package/skills/playwright-cli/SKILL.md +246 -0
- package/skills/playwright-cli/advanced-workflows.md +679 -0
- package/skills/playwright-cli/core-commands.md +375 -0
- package/skills/playwright-cli/device-emulation.md +475 -0
- package/skills/playwright-cli/request-mocking.md +373 -0
- package/skills/playwright-cli/running-custom-code.md +553 -0
- package/skills/playwright-cli/screenshots-and-media.md +457 -0
- package/skills/playwright-cli/session-management.md +425 -0
- package/skills/playwright-cli/storage-and-auth.md +487 -0
- package/skills/playwright-cli/test-generation.md +336 -0
- package/skills/playwright-cli/tracing-and-debugging.md +501 -0
- package/skills/playwright-core/SKILL.md +101 -0
- package/skills/playwright-core/accessibility.md +1517 -0
- package/skills/playwright-core/angular.md +1086 -0
- package/skills/playwright-core/api-testing.md +1623 -0
- package/skills/playwright-core/assertions-and-waiting.md +703 -0
- package/skills/playwright-core/auth-flows.md +1081 -0
- package/skills/playwright-core/authentication.md +1409 -0
- package/skills/playwright-core/browser-apis.md +673 -0
- package/skills/playwright-core/browser-extensions.md +319 -0
- package/skills/playwright-core/canvas-and-webgl.md +494 -0
- package/skills/playwright-core/clock-and-time-mocking.md +427 -0
- package/skills/playwright-core/common-pitfalls.md +1318 -0
- package/skills/playwright-core/component-testing.md +1179 -0
- package/skills/playwright-core/configuration.md +731 -0
- package/skills/playwright-core/crud-testing.md +945 -0
- package/skills/playwright-core/debugging.md +827 -0
- package/skills/playwright-core/drag-and-drop.md +992 -0
- package/skills/playwright-core/electron-testing.md +622 -0
- package/skills/playwright-core/error-and-edge-cases.md +1181 -0
- package/skills/playwright-core/error-index.md +1903 -0
- package/skills/playwright-core/file-operations.md +749 -0
- package/skills/playwright-core/file-upload-download.md +1012 -0
- package/skills/playwright-core/fixtures-and-hooks.md +1072 -0
- package/skills/playwright-core/flaky-tests.md +883 -0
- package/skills/playwright-core/forms-and-validation.md +1056 -0
- package/skills/playwright-core/i18n-and-localization.md +622 -0
- package/skills/playwright-core/iframes-and-shadow-dom.md +488 -0
- package/skills/playwright-core/locator-strategy.md +588 -0
- package/skills/playwright-core/locators.md +743 -0
- package/skills/playwright-core/mobile-and-responsive.md +1669 -0
- package/skills/playwright-core/multi-context-and-popups.md +581 -0
- package/skills/playwright-core/multi-user-and-collaboration.md +477 -0
- package/skills/playwright-core/network-mocking.md +1395 -0
- package/skills/playwright-core/nextjs.md +1013 -0
- package/skills/playwright-core/performance-testing.md +608 -0
- package/skills/playwright-core/react.md +1082 -0
- package/skills/playwright-core/search-and-filter.md +1366 -0
- package/skills/playwright-core/security-testing.md +624 -0
- package/skills/playwright-core/service-workers-and-pwa.md +524 -0
- package/skills/playwright-core/test-architecture.md +569 -0
- package/skills/playwright-core/test-data-management.md +1209 -0
- package/skills/playwright-core/test-organization.md +947 -0
- package/skills/playwright-core/third-party-integrations.md +754 -0
- package/skills/playwright-core/trace-analysis.md +161 -0
- package/skills/playwright-core/visual-regression.md +1027 -0
- package/skills/playwright-core/vue.md +1162 -0
- package/skills/playwright-core/websockets-and-realtime.md +623 -0
- package/skills/playwright-core/when-to-mock.md +827 -0
- package/skills/playwright-pom/SKILL.md +94 -0
- package/skills/playwright-pom/page-object-model.md +921 -0
- package/skills/playwright-pom/pom-vs-fixtures-vs-helpers.md +937 -0
- package/skills/validate-evaluator/SKILL.md +215 -0
- package/skills/write-judge-prompt/SKILL.md +144 -0
- package/themes/dino.json +66 -0
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
# Page Object Model
|
|
2
|
+
|
|
3
|
+
> **When to use**: Any page or component is touched by more than one test file, or a single test file interacts with more than two distinct UI regions.
|
|
4
|
+
|
|
5
|
+
Page objects encapsulate **actions**, not locators. A test should read like a user story: `await loginPage.login('admin', 'secret')`, never `await loginPage.usernameInput.fill('admin')`. The POM is a boundary between "what the user does" and "how the UI is structured." Assertions stay in tests, never inside page objects.
|
|
6
|
+
|
|
7
|
+
## Quick Reference
|
|
8
|
+
|
|
9
|
+
| Concept | Rule |
|
|
10
|
+
|---|---|
|
|
11
|
+
| Locators | Defined as `readonly` properties in the constructor or as getters. Never exposed raw to tests. |
|
|
12
|
+
| Actions | Public methods that perform a user-visible behavior. Return `Promise<void>` or the next page object. |
|
|
13
|
+
| Assertions | **Never** inside page objects. Tests own all `expect()` calls. |
|
|
14
|
+
| Navigation | Methods that navigate return the destination page object, not `void`. |
|
|
15
|
+
| State | Page objects are stateless. No caching locator text, no tracking "current step." |
|
|
16
|
+
| Constructor | Takes `Page` (or `Locator` for components). Nothing else. No URLs, no test data. |
|
|
17
|
+
| Naming | `LoginPage`, `DashboardPage`, `NavbarComponent`. File: `login.page.ts`, `navbar.component.ts`. |
|
|
18
|
+
|
|
19
|
+
## Patterns
|
|
20
|
+
|
|
21
|
+
### 1. Basic POM Class
|
|
22
|
+
|
|
23
|
+
**Use when**: A page has 3+ interactions across multiple tests.
|
|
24
|
+
**Avoid when**: A page is used in a single test file with trivial interactions -- use a helper function instead.
|
|
25
|
+
|
|
26
|
+
**TypeScript**
|
|
27
|
+
|
|
28
|
+
```typescript
|
|
29
|
+
// tests/pages/login.page.ts
|
|
30
|
+
import { type Page, type Locator } from '@playwright/test';
|
|
31
|
+
|
|
32
|
+
export class LoginPage {
|
|
33
|
+
readonly usernameInput: Locator;
|
|
34
|
+
readonly passwordInput: Locator;
|
|
35
|
+
readonly submitButton: Locator;
|
|
36
|
+
readonly errorMessage: Locator;
|
|
37
|
+
|
|
38
|
+
constructor(private readonly page: Page) {
|
|
39
|
+
this.usernameInput = page.getByLabel('Username');
|
|
40
|
+
this.passwordInput = page.getByLabel('Password');
|
|
41
|
+
this.submitButton = page.getByRole('button', { name: 'Sign in' });
|
|
42
|
+
this.errorMessage = page.getByRole('alert');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async goto() {
|
|
46
|
+
await this.page.goto('/login');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async login(username: string, password: string) {
|
|
50
|
+
await this.usernameInput.fill(username);
|
|
51
|
+
await this.passwordInput.fill(password);
|
|
52
|
+
await this.submitButton.click();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**JavaScript**
|
|
58
|
+
|
|
59
|
+
```javascript
|
|
60
|
+
// tests/pages/login.page.js
|
|
61
|
+
// @ts-check
|
|
62
|
+
|
|
63
|
+
export class LoginPage {
|
|
64
|
+
/** @param {import('@playwright/test').Page} page */
|
|
65
|
+
constructor(page) {
|
|
66
|
+
this.page = page;
|
|
67
|
+
this.usernameInput = page.getByLabel('Username');
|
|
68
|
+
this.passwordInput = page.getByLabel('Password');
|
|
69
|
+
this.submitButton = page.getByRole('button', { name: 'Sign in' });
|
|
70
|
+
this.errorMessage = page.getByRole('alert');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async goto() {
|
|
74
|
+
await this.page.goto('/login');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async login(username, password) {
|
|
78
|
+
await this.usernameInput.fill(username);
|
|
79
|
+
await this.passwordInput.fill(password);
|
|
80
|
+
await this.submitButton.click();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**Test usage**
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
// tests/auth.spec.ts
|
|
89
|
+
import { test, expect } from '@playwright/test';
|
|
90
|
+
import { LoginPage } from './pages/login.page';
|
|
91
|
+
|
|
92
|
+
test('successful login redirects to dashboard', async ({ page }) => {
|
|
93
|
+
const loginPage = new LoginPage(page);
|
|
94
|
+
await loginPage.goto();
|
|
95
|
+
await loginPage.login('admin', 'password123');
|
|
96
|
+
|
|
97
|
+
await expect(page).toHaveURL('/dashboard');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('invalid credentials show error', async ({ page }) => {
|
|
101
|
+
const loginPage = new LoginPage(page);
|
|
102
|
+
await loginPage.goto();
|
|
103
|
+
await loginPage.login('admin', 'wrong');
|
|
104
|
+
|
|
105
|
+
await expect(loginPage.errorMessage).toBeVisible();
|
|
106
|
+
await expect(loginPage.errorMessage).toHaveText('Invalid credentials');
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### 2. Component Objects
|
|
111
|
+
|
|
112
|
+
**Use when**: A UI element (navbar, sidebar, modal, form, table) appears on multiple pages.
|
|
113
|
+
**Avoid when**: The component is page-specific and never reused.
|
|
114
|
+
|
|
115
|
+
Components take a `Locator` (their root container), not a `Page`. This scopes all queries to the component's DOM subtree and allows composing components into page objects.
|
|
116
|
+
|
|
117
|
+
**TypeScript**
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
// tests/components/navbar.component.ts
|
|
121
|
+
import { type Locator } from '@playwright/test';
|
|
122
|
+
|
|
123
|
+
export class NavbarComponent {
|
|
124
|
+
readonly profileMenu: Locator;
|
|
125
|
+
readonly searchInput: Locator;
|
|
126
|
+
readonly notificationBell: Locator;
|
|
127
|
+
|
|
128
|
+
constructor(private readonly root: Locator) {
|
|
129
|
+
this.profileMenu = root.getByRole('button', { name: 'Profile' });
|
|
130
|
+
this.searchInput = root.getByRole('searchbox');
|
|
131
|
+
this.notificationBell = root.getByRole('button', { name: 'Notifications' });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async search(query: string) {
|
|
135
|
+
await this.searchInput.fill(query);
|
|
136
|
+
await this.searchInput.press('Enter');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async openProfile() {
|
|
140
|
+
await this.profileMenu.click();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async openNotifications() {
|
|
144
|
+
await this.notificationBell.click();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// tests/components/modal.component.ts
|
|
149
|
+
import { type Locator } from '@playwright/test';
|
|
150
|
+
|
|
151
|
+
export class ModalComponent {
|
|
152
|
+
readonly title: Locator;
|
|
153
|
+
readonly closeButton: Locator;
|
|
154
|
+
readonly confirmButton: Locator;
|
|
155
|
+
readonly cancelButton: Locator;
|
|
156
|
+
|
|
157
|
+
constructor(private readonly root: Locator) {
|
|
158
|
+
this.title = root.getByRole('heading');
|
|
159
|
+
this.closeButton = root.getByRole('button', { name: 'Close' });
|
|
160
|
+
this.confirmButton = root.getByRole('button', { name: 'Confirm' });
|
|
161
|
+
this.cancelButton = root.getByRole('button', { name: 'Cancel' });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async confirm() {
|
|
165
|
+
await this.confirmButton.click();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async cancel() {
|
|
169
|
+
await this.cancelButton.click();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async close() {
|
|
173
|
+
await this.closeButton.click();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// tests/pages/dashboard.page.ts
|
|
178
|
+
import { type Page } from '@playwright/test';
|
|
179
|
+
import { NavbarComponent } from '../components/navbar.component';
|
|
180
|
+
import { ModalComponent } from '../components/modal.component';
|
|
181
|
+
|
|
182
|
+
export class DashboardPage {
|
|
183
|
+
readonly navbar: NavbarComponent;
|
|
184
|
+
readonly deleteModal: ModalComponent;
|
|
185
|
+
|
|
186
|
+
constructor(private readonly page: Page) {
|
|
187
|
+
this.navbar = new NavbarComponent(page.getByRole('navigation'));
|
|
188
|
+
this.deleteModal = new ModalComponent(page.getByRole('dialog'));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async goto() {
|
|
192
|
+
await this.page.goto('/dashboard');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async deleteItem(name: string) {
|
|
196
|
+
await this.page.getByRole('row', { name }).getByRole('button', { name: 'Delete' }).click();
|
|
197
|
+
await this.deleteModal.confirm();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**JavaScript**
|
|
203
|
+
|
|
204
|
+
```javascript
|
|
205
|
+
// tests/components/navbar.component.js
|
|
206
|
+
// @ts-check
|
|
207
|
+
|
|
208
|
+
export class NavbarComponent {
|
|
209
|
+
/** @param {import('@playwright/test').Locator} root */
|
|
210
|
+
constructor(root) {
|
|
211
|
+
this.root = root;
|
|
212
|
+
this.profileMenu = root.getByRole('button', { name: 'Profile' });
|
|
213
|
+
this.searchInput = root.getByRole('searchbox');
|
|
214
|
+
this.notificationBell = root.getByRole('button', { name: 'Notifications' });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async search(query) {
|
|
218
|
+
await this.searchInput.fill(query);
|
|
219
|
+
await this.searchInput.press('Enter');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async openProfile() {
|
|
223
|
+
await this.profileMenu.click();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async openNotifications() {
|
|
227
|
+
await this.notificationBell.click();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// tests/pages/dashboard.page.js
|
|
232
|
+
// @ts-check
|
|
233
|
+
import { NavbarComponent } from '../components/navbar.component.js';
|
|
234
|
+
import { ModalComponent } from '../components/modal.component.js';
|
|
235
|
+
|
|
236
|
+
export class DashboardPage {
|
|
237
|
+
/** @param {import('@playwright/test').Page} page */
|
|
238
|
+
constructor(page) {
|
|
239
|
+
this.page = page;
|
|
240
|
+
this.navbar = new NavbarComponent(page.getByRole('navigation'));
|
|
241
|
+
this.deleteModal = new ModalComponent(page.getByRole('dialog'));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async goto() {
|
|
245
|
+
await this.page.goto('/dashboard');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async deleteItem(name) {
|
|
249
|
+
await this.page.getByRole('row', { name }).getByRole('button', { name: 'Delete' }).click();
|
|
250
|
+
await this.deleteModal.confirm();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### 3. Page Object with Navigation
|
|
256
|
+
|
|
257
|
+
**Use when**: An action on one page navigates to a different page (form submission, link click, redirect).
|
|
258
|
+
**Avoid when**: Navigation is uncertain (might fail validation and stay on the same page).
|
|
259
|
+
|
|
260
|
+
When a method causes navigation, return the destination page object. This creates a typed chain that mirrors the user flow and catches stale page object usage at the call site.
|
|
261
|
+
|
|
262
|
+
**TypeScript**
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
// tests/pages/login.page.ts
|
|
266
|
+
import { type Page } from '@playwright/test';
|
|
267
|
+
import { DashboardPage } from './dashboard.page';
|
|
268
|
+
|
|
269
|
+
export class LoginPage {
|
|
270
|
+
constructor(private readonly page: Page) {}
|
|
271
|
+
|
|
272
|
+
async goto() {
|
|
273
|
+
await this.page.goto('/login');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Returns DashboardPage on success. Call only when credentials are valid. */
|
|
277
|
+
async loginAs(username: string, password: string): Promise<DashboardPage> {
|
|
278
|
+
await this.page.getByLabel('Username').fill(username);
|
|
279
|
+
await this.page.getByLabel('Password').fill(password);
|
|
280
|
+
await this.page.getByRole('button', { name: 'Sign in' }).click();
|
|
281
|
+
await this.page.waitForURL('/dashboard');
|
|
282
|
+
return new DashboardPage(this.page);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Use for invalid credential tests -- stays on login page. */
|
|
286
|
+
async loginExpectingError(username: string, password: string) {
|
|
287
|
+
await this.page.getByLabel('Username').fill(username);
|
|
288
|
+
await this.page.getByLabel('Password').fill(password);
|
|
289
|
+
await this.page.getByRole('button', { name: 'Sign in' }).click();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// tests/pages/dashboard.page.ts
|
|
294
|
+
import { type Page } from '@playwright/test';
|
|
295
|
+
import { SettingsPage } from './settings.page';
|
|
296
|
+
|
|
297
|
+
export class DashboardPage {
|
|
298
|
+
constructor(private readonly page: Page) {}
|
|
299
|
+
|
|
300
|
+
async gotoSettings(): Promise<SettingsPage> {
|
|
301
|
+
await this.page.getByRole('link', { name: 'Settings' }).click();
|
|
302
|
+
await this.page.waitForURL('/settings');
|
|
303
|
+
return new SettingsPage(this.page);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
**JavaScript**
|
|
309
|
+
|
|
310
|
+
```javascript
|
|
311
|
+
// tests/pages/login.page.js
|
|
312
|
+
// @ts-check
|
|
313
|
+
import { DashboardPage } from './dashboard.page.js';
|
|
314
|
+
|
|
315
|
+
export class LoginPage {
|
|
316
|
+
/** @param {import('@playwright/test').Page} page */
|
|
317
|
+
constructor(page) {
|
|
318
|
+
this.page = page;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async goto() {
|
|
322
|
+
await this.page.goto('/login');
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** @returns {Promise<DashboardPage>} */
|
|
326
|
+
async loginAs(username, password) {
|
|
327
|
+
await this.page.getByLabel('Username').fill(username);
|
|
328
|
+
await this.page.getByLabel('Password').fill(password);
|
|
329
|
+
await this.page.getByRole('button', { name: 'Sign in' }).click();
|
|
330
|
+
await this.page.waitForURL('/dashboard');
|
|
331
|
+
return new DashboardPage(this.page);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async loginExpectingError(username, password) {
|
|
335
|
+
await this.page.getByLabel('Username').fill(username);
|
|
336
|
+
await this.page.getByLabel('Password').fill(password);
|
|
337
|
+
await this.page.getByRole('button', { name: 'Sign in' }).click();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
**Test usage showing the navigation chain**
|
|
343
|
+
|
|
344
|
+
```typescript
|
|
345
|
+
// tests/settings.spec.ts
|
|
346
|
+
import { test, expect } from '@playwright/test';
|
|
347
|
+
import { LoginPage } from './pages/login.page';
|
|
348
|
+
|
|
349
|
+
test('user can navigate from login to settings', async ({ page }) => {
|
|
350
|
+
const loginPage = new LoginPage(page);
|
|
351
|
+
await loginPage.goto();
|
|
352
|
+
|
|
353
|
+
const dashboard = await loginPage.loginAs('admin', 'password123');
|
|
354
|
+
const settings = await dashboard.gotoSettings();
|
|
355
|
+
|
|
356
|
+
await expect(page).toHaveURL('/settings');
|
|
357
|
+
});
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
### 4. Page Object as Fixture
|
|
361
|
+
|
|
362
|
+
**Use when**: Page objects are used in 3+ test files. This is the recommended approach for mature test suites.
|
|
363
|
+
**Avoid when**: Early prototyping with rapidly changing pages.
|
|
364
|
+
|
|
365
|
+
Fixtures eliminate boilerplate instantiation, provide automatic cleanup, and make POMs available via destructuring -- identical to built-in `page` and `request` fixtures.
|
|
366
|
+
|
|
367
|
+
**TypeScript**
|
|
368
|
+
|
|
369
|
+
```typescript
|
|
370
|
+
// tests/fixtures.ts
|
|
371
|
+
import { test as base } from '@playwright/test';
|
|
372
|
+
import { LoginPage } from './pages/login.page';
|
|
373
|
+
import { DashboardPage } from './pages/dashboard.page';
|
|
374
|
+
import { SettingsPage } from './pages/settings.page';
|
|
375
|
+
|
|
376
|
+
type PageObjects = {
|
|
377
|
+
loginPage: LoginPage;
|
|
378
|
+
dashboardPage: DashboardPage;
|
|
379
|
+
settingsPage: SettingsPage;
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
export const test = base.extend<PageObjects>({
|
|
383
|
+
loginPage: async ({ page }, use) => {
|
|
384
|
+
await use(new LoginPage(page));
|
|
385
|
+
},
|
|
386
|
+
dashboardPage: async ({ page }, use) => {
|
|
387
|
+
await use(new DashboardPage(page));
|
|
388
|
+
},
|
|
389
|
+
settingsPage: async ({ page }, use) => {
|
|
390
|
+
await use(new SettingsPage(page));
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
export { expect } from '@playwright/test';
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
**JavaScript**
|
|
398
|
+
|
|
399
|
+
```javascript
|
|
400
|
+
// tests/fixtures.js
|
|
401
|
+
// @ts-check
|
|
402
|
+
import { test as base } from '@playwright/test';
|
|
403
|
+
import { LoginPage } from './pages/login.page.js';
|
|
404
|
+
import { DashboardPage } from './pages/dashboard.page.js';
|
|
405
|
+
import { SettingsPage } from './pages/settings.page.js';
|
|
406
|
+
|
|
407
|
+
export const test = base.extend({
|
|
408
|
+
loginPage: async ({ page }, use) => {
|
|
409
|
+
await use(new LoginPage(page));
|
|
410
|
+
},
|
|
411
|
+
dashboardPage: async ({ page }, use) => {
|
|
412
|
+
await use(new DashboardPage(page));
|
|
413
|
+
},
|
|
414
|
+
settingsPage: async ({ page }, use) => {
|
|
415
|
+
await use(new SettingsPage(page));
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
export { expect } from '@playwright/test';
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
**Test usage**
|
|
423
|
+
|
|
424
|
+
```typescript
|
|
425
|
+
// tests/auth.spec.ts
|
|
426
|
+
import { test, expect } from './fixtures';
|
|
427
|
+
|
|
428
|
+
test('successful login redirects to dashboard', async ({ loginPage, page }) => {
|
|
429
|
+
await loginPage.goto();
|
|
430
|
+
await loginPage.login('admin', 'password123');
|
|
431
|
+
|
|
432
|
+
await expect(page).toHaveURL('/dashboard');
|
|
433
|
+
});
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
**Advanced: fixture that performs setup**
|
|
437
|
+
|
|
438
|
+
```typescript
|
|
439
|
+
// tests/fixtures.ts
|
|
440
|
+
import { test as base } from '@playwright/test';
|
|
441
|
+
import { DashboardPage } from './pages/dashboard.page';
|
|
442
|
+
|
|
443
|
+
export const test = base.extend<{ authenticatedDashboard: DashboardPage }>({
|
|
444
|
+
authenticatedDashboard: async ({ page }, use) => {
|
|
445
|
+
// Setup: navigate and authenticate
|
|
446
|
+
await page.goto('/login');
|
|
447
|
+
await page.getByLabel('Username').fill('admin');
|
|
448
|
+
await page.getByLabel('Password').fill('password123');
|
|
449
|
+
await page.getByRole('button', { name: 'Sign in' }).click();
|
|
450
|
+
await page.waitForURL('/dashboard');
|
|
451
|
+
|
|
452
|
+
await use(new DashboardPage(page));
|
|
453
|
+
|
|
454
|
+
// Teardown (optional): logout, clean up test data
|
|
455
|
+
await page.goto('/logout');
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
export { expect } from '@playwright/test';
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
### 5. Factory Functions
|
|
463
|
+
|
|
464
|
+
**Use when**: A page has few actions and no shared state. Simpler than a class, still encapsulates actions.
|
|
465
|
+
**Avoid when**: The page has 5+ methods or needs component composition.
|
|
466
|
+
|
|
467
|
+
Factory functions are a lightweight alternative for simple pages. They return an object with action methods, avoiding class ceremony.
|
|
468
|
+
|
|
469
|
+
**TypeScript**
|
|
470
|
+
|
|
471
|
+
```typescript
|
|
472
|
+
// tests/pages/error.page.ts
|
|
473
|
+
import { type Page } from '@playwright/test';
|
|
474
|
+
|
|
475
|
+
export function createErrorPage(page: Page) {
|
|
476
|
+
return {
|
|
477
|
+
errorHeading: page.getByRole('heading', { name: 'Something went wrong' }),
|
|
478
|
+
retryButton: page.getByRole('button', { name: 'Retry' }),
|
|
479
|
+
|
|
480
|
+
async goto() {
|
|
481
|
+
await page.goto('/error');
|
|
482
|
+
},
|
|
483
|
+
|
|
484
|
+
async retry() {
|
|
485
|
+
await page.getByRole('button', { name: 'Retry' }).click();
|
|
486
|
+
},
|
|
487
|
+
|
|
488
|
+
async goHome() {
|
|
489
|
+
await page.getByRole('link', { name: 'Go home' }).click();
|
|
490
|
+
await page.waitForURL('/');
|
|
491
|
+
},
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
**JavaScript**
|
|
497
|
+
|
|
498
|
+
```javascript
|
|
499
|
+
// tests/pages/error.page.js
|
|
500
|
+
// @ts-check
|
|
501
|
+
|
|
502
|
+
/** @param {import('@playwright/test').Page} page */
|
|
503
|
+
export function createErrorPage(page) {
|
|
504
|
+
return {
|
|
505
|
+
errorHeading: page.getByRole('heading', { name: 'Something went wrong' }),
|
|
506
|
+
retryButton: page.getByRole('button', { name: 'Retry' }),
|
|
507
|
+
|
|
508
|
+
async goto() {
|
|
509
|
+
await page.goto('/error');
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
async retry() {
|
|
513
|
+
await page.getByRole('button', { name: 'Retry' }).click();
|
|
514
|
+
},
|
|
515
|
+
|
|
516
|
+
async goHome() {
|
|
517
|
+
await page.getByRole('link', { name: 'Go home' }).click();
|
|
518
|
+
await page.waitForURL('/');
|
|
519
|
+
},
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
**Test usage**
|
|
525
|
+
|
|
526
|
+
```typescript
|
|
527
|
+
// tests/error.spec.ts
|
|
528
|
+
import { test, expect } from '@playwright/test';
|
|
529
|
+
import { createErrorPage } from './pages/error.page';
|
|
530
|
+
|
|
531
|
+
test('retry button reloads the page', async ({ page }) => {
|
|
532
|
+
const errorPage = createErrorPage(page);
|
|
533
|
+
await errorPage.goto();
|
|
534
|
+
await errorPage.retry();
|
|
535
|
+
|
|
536
|
+
await expect(page).not.toHaveURL('/error');
|
|
537
|
+
});
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
### 6. Getter Pattern for Dynamic Locators
|
|
541
|
+
|
|
542
|
+
**Use when**: Locators depend on dynamic content (parameterized rows, nth items, content that changes after page load).
|
|
543
|
+
**Avoid when**: All locators are static and known at construction time -- use constructor assignment instead.
|
|
544
|
+
|
|
545
|
+
Getters create the locator fresh on each access. This avoids stale references when the DOM changes after the page object is constructed.
|
|
546
|
+
|
|
547
|
+
**TypeScript**
|
|
548
|
+
|
|
549
|
+
```typescript
|
|
550
|
+
// tests/pages/users.page.ts
|
|
551
|
+
import { type Page, type Locator } from '@playwright/test';
|
|
552
|
+
|
|
553
|
+
export class UsersPage {
|
|
554
|
+
constructor(private readonly page: Page) {}
|
|
555
|
+
|
|
556
|
+
/** Static locators -- fine in constructor or as getters, either works. */
|
|
557
|
+
get addUserButton(): Locator {
|
|
558
|
+
return this.page.getByRole('button', { name: 'Add user' });
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
get userRows(): Locator {
|
|
562
|
+
return this.page.getByRole('row').filter({ hasNot: this.page.getByRole('columnheader') });
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** Dynamic locator -- must be a method since it takes a parameter. */
|
|
566
|
+
userRow(name: string): Locator {
|
|
567
|
+
return this.page.getByRole('row', { name });
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
userDeleteButton(name: string): Locator {
|
|
571
|
+
return this.userRow(name).getByRole('button', { name: 'Delete' });
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async goto() {
|
|
575
|
+
await this.page.goto('/admin/users');
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async deleteUser(name: string) {
|
|
579
|
+
await this.userDeleteButton(name).click();
|
|
580
|
+
// Wait for row to disappear, confirming deletion
|
|
581
|
+
await this.userRow(name).waitFor({ state: 'hidden' });
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async addUser() {
|
|
585
|
+
await this.addUserButton.click();
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
**JavaScript**
|
|
591
|
+
|
|
592
|
+
```javascript
|
|
593
|
+
// tests/pages/users.page.js
|
|
594
|
+
// @ts-check
|
|
595
|
+
|
|
596
|
+
export class UsersPage {
|
|
597
|
+
/** @param {import('@playwright/test').Page} page */
|
|
598
|
+
constructor(page) {
|
|
599
|
+
this.page = page;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
get addUserButton() {
|
|
603
|
+
return this.page.getByRole('button', { name: 'Add user' });
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
get userRows() {
|
|
607
|
+
return this.page.getByRole('row').filter({ hasNot: this.page.getByRole('columnheader') });
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
userRow(name) {
|
|
611
|
+
return this.page.getByRole('row', { name });
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
userDeleteButton(name) {
|
|
615
|
+
return this.userRow(name).getByRole('button', { name: 'Delete' });
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async goto() {
|
|
619
|
+
await this.page.goto('/admin/users');
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
async deleteUser(name) {
|
|
623
|
+
await this.userDeleteButton(name).click();
|
|
624
|
+
await this.userRow(name).waitFor({ state: 'hidden' });
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
async addUser() {
|
|
628
|
+
await this.addUserButton.click();
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
### 7. Directory Structure
|
|
634
|
+
|
|
635
|
+
Use this layout. Adapt folder names to your convention, but keep the separation between pages, components, and fixtures.
|
|
636
|
+
|
|
637
|
+
```
|
|
638
|
+
tests/
|
|
639
|
+
fixtures.ts # Custom test with POM fixtures
|
|
640
|
+
auth.spec.ts # Test files at top level or in feature folders
|
|
641
|
+
dashboard.spec.ts
|
|
642
|
+
pages/ # One file per page
|
|
643
|
+
login.page.ts
|
|
644
|
+
dashboard.page.ts
|
|
645
|
+
settings.page.ts
|
|
646
|
+
users.page.ts
|
|
647
|
+
components/ # Reusable UI components
|
|
648
|
+
navbar.component.ts
|
|
649
|
+
modal.component.ts
|
|
650
|
+
sidebar.component.ts
|
|
651
|
+
data-table.component.ts
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
Rules:
|
|
655
|
+
- **One class per file.** File name matches the class: `LoginPage` lives in `login.page.ts`.
|
|
656
|
+
- **Pages vs components**: Pages own a full route (`/login`, `/dashboard`). Components are embedded in pages (`NavbarComponent`, `ModalComponent`).
|
|
657
|
+
- **No barrel files** (`index.ts` re-exporting everything). Import directly from the file. Barrel files create circular dependency traps and slow down IDE tooling.
|
|
658
|
+
- **`fixtures.ts` at `tests/` root.** Every spec imports `test` and `expect` from `./fixtures` instead of `@playwright/test`.
|
|
659
|
+
|
|
660
|
+
### 8. Handling Async Initialization
|
|
661
|
+
|
|
662
|
+
**Use when**: A page object needs async setup before it is usable (waiting for API data to load, waiting for animations to settle, waiting for a specific element to appear).
|
|
663
|
+
**Avoid when**: The page is ready immediately after `goto()`.
|
|
664
|
+
|
|
665
|
+
Never put `await` in a constructor. Use a static factory method instead.
|
|
666
|
+
|
|
667
|
+
**TypeScript**
|
|
668
|
+
|
|
669
|
+
```typescript
|
|
670
|
+
// tests/pages/analytics.page.ts
|
|
671
|
+
import { type Page, type Locator } from '@playwright/test';
|
|
672
|
+
|
|
673
|
+
export class AnalyticsPage {
|
|
674
|
+
readonly chart: Locator;
|
|
675
|
+
readonly dateRange: Locator;
|
|
676
|
+
|
|
677
|
+
private constructor(private readonly page: Page) {
|
|
678
|
+
this.chart = page.locator('[data-testid="analytics-chart"]');
|
|
679
|
+
this.dateRange = page.getByLabel('Date range');
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Use this instead of `new AnalyticsPage()`. Waits for chart data to load. */
|
|
683
|
+
static async create(page: Page): Promise<AnalyticsPage> {
|
|
684
|
+
const analyticsPage = new AnalyticsPage(page);
|
|
685
|
+
await page.goto('/analytics');
|
|
686
|
+
// Wait for the async content that makes this page "ready"
|
|
687
|
+
await analyticsPage.chart.waitFor({ state: 'visible' });
|
|
688
|
+
return analyticsPage;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
async selectDateRange(range: string) {
|
|
692
|
+
await this.dateRange.click();
|
|
693
|
+
await this.page.getByRole('option', { name: range }).click();
|
|
694
|
+
// Wait for chart to update after date change
|
|
695
|
+
await this.chart.waitFor({ state: 'visible' });
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
**JavaScript**
|
|
701
|
+
|
|
702
|
+
```javascript
|
|
703
|
+
// tests/pages/analytics.page.js
|
|
704
|
+
// @ts-check
|
|
705
|
+
|
|
706
|
+
export class AnalyticsPage {
|
|
707
|
+
/** @param {import('@playwright/test').Page} page */
|
|
708
|
+
constructor(page) {
|
|
709
|
+
this.page = page;
|
|
710
|
+
this.chart = page.locator('[data-testid="analytics-chart"]');
|
|
711
|
+
this.dateRange = page.getByLabel('Date range');
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** @param {import('@playwright/test').Page} page */
|
|
715
|
+
static async create(page) {
|
|
716
|
+
const analyticsPage = new AnalyticsPage(page);
|
|
717
|
+
await page.goto('/analytics');
|
|
718
|
+
await analyticsPage.chart.waitFor({ state: 'visible' });
|
|
719
|
+
return analyticsPage;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async selectDateRange(range) {
|
|
723
|
+
await this.dateRange.click();
|
|
724
|
+
await this.page.getByRole('option', { name: range }).click();
|
|
725
|
+
await this.chart.waitFor({ state: 'visible' });
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
**As a fixture**
|
|
731
|
+
|
|
732
|
+
```typescript
|
|
733
|
+
// tests/fixtures.ts
|
|
734
|
+
import { test as base } from '@playwright/test';
|
|
735
|
+
import { AnalyticsPage } from './pages/analytics.page';
|
|
736
|
+
|
|
737
|
+
export const test = base.extend<{ analyticsPage: AnalyticsPage }>({
|
|
738
|
+
analyticsPage: async ({ page }, use) => {
|
|
739
|
+
const analyticsPage = await AnalyticsPage.create(page);
|
|
740
|
+
await use(analyticsPage);
|
|
741
|
+
},
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
export { expect } from '@playwright/test';
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
## Decision Guide
|
|
748
|
+
|
|
749
|
+
```
|
|
750
|
+
How complex is the page?
|
|
751
|
+
│
|
|
752
|
+
├── 1-2 interactions, single test file
|
|
753
|
+
│ └── No POM. Inline locators in the test are fine.
|
|
754
|
+
│
|
|
755
|
+
├── 3-5 interactions OR 2+ test files use it
|
|
756
|
+
│ ├── Few methods, no composition needed
|
|
757
|
+
│ │ └── Factory function (Pattern 5)
|
|
758
|
+
│ └── Multiple methods, needs component composition
|
|
759
|
+
│ └── Full POM class (Pattern 1 + 2)
|
|
760
|
+
│
|
|
761
|
+
├── Used across 3+ test files
|
|
762
|
+
│ └── POM class + fixture injection (Pattern 4)
|
|
763
|
+
│
|
|
764
|
+
└── Page requires async setup before usable
|
|
765
|
+
└── Static factory method (Pattern 8) + fixture
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
| Factor | Simple helper | Factory function | POM class | POM + fixture |
|
|
769
|
+
|---|---|---|---|---|
|
|
770
|
+
| Page complexity | 1-2 actions | 3-5 actions | 5+ actions | 5+ actions |
|
|
771
|
+
| Reuse across files | 1 file | 1-2 files | 2+ files | 3+ files |
|
|
772
|
+
| Component composition | No | No | Yes | Yes |
|
|
773
|
+
| Team size | Solo | Small | Any | Any |
|
|
774
|
+
| Setup ceremony | None | Low | Medium | Medium (once) |
|
|
775
|
+
| Recommended for | Quick prototypes | Simple pages | Standard choice | Mature suites |
|
|
776
|
+
|
|
777
|
+
## Anti-Patterns
|
|
778
|
+
|
|
779
|
+
### God Object
|
|
780
|
+
|
|
781
|
+
One page object for the entire application or a page object with 30+ methods covering unrelated features.
|
|
782
|
+
|
|
783
|
+
```typescript
|
|
784
|
+
// BAD: one class for everything
|
|
785
|
+
class AppPage {
|
|
786
|
+
async login() { /* ... */ }
|
|
787
|
+
async addToCart() { /* ... */ }
|
|
788
|
+
async checkout() { /* ... */ }
|
|
789
|
+
async changePassword() { /* ... */ }
|
|
790
|
+
async viewAnalytics() { /* ... */ }
|
|
791
|
+
// 40 more methods...
|
|
792
|
+
}
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
Fix: Split by page or feature. One class per logical page. Compose shared UI into component objects.
|
|
796
|
+
|
|
797
|
+
### Assertions Inside Page Objects
|
|
798
|
+
|
|
799
|
+
```typescript
|
|
800
|
+
// BAD: page object owns the assertion
|
|
801
|
+
class LoginPage {
|
|
802
|
+
async loginAndVerify(username: string, password: string) {
|
|
803
|
+
await this.usernameInput.fill(username);
|
|
804
|
+
await this.passwordInput.fill(password);
|
|
805
|
+
await this.submitButton.click();
|
|
806
|
+
// This assertion belongs in the test, not here
|
|
807
|
+
await expect(this.page).toHaveURL('/dashboard');
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// GOOD: page object performs action, test asserts
|
|
812
|
+
class LoginPage {
|
|
813
|
+
async login(username: string, password: string) {
|
|
814
|
+
await this.usernameInput.fill(username);
|
|
815
|
+
await this.passwordInput.fill(password);
|
|
816
|
+
await this.submitButton.click();
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// In test:
|
|
821
|
+
await loginPage.login('admin', 'pass');
|
|
822
|
+
await expect(page).toHaveURL('/dashboard');
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
Exception: `waitForURL()` or `waitFor()` calls inside POM methods that ensure navigation completed are fine -- those are synchronization, not assertion.
|
|
826
|
+
|
|
827
|
+
### Deeply Nested Inheritance
|
|
828
|
+
|
|
829
|
+
```typescript
|
|
830
|
+
// BAD: inheritance chain
|
|
831
|
+
class BasePage { /* ... */ }
|
|
832
|
+
class AuthenticatedPage extends BasePage { /* ... */ }
|
|
833
|
+
class AdminPage extends AuthenticatedPage { /* ... */ }
|
|
834
|
+
class SuperAdminPage extends AdminPage { /* ... */ }
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
Fix: Use composition. Create component objects for shared UI (navbar, sidebar) and compose them into page objects. Prefer `has-a` over `is-a`.
|
|
838
|
+
|
|
839
|
+
```typescript
|
|
840
|
+
// GOOD: composition
|
|
841
|
+
class AdminPage {
|
|
842
|
+
readonly navbar: NavbarComponent;
|
|
843
|
+
readonly sidebar: AdminSidebarComponent;
|
|
844
|
+
|
|
845
|
+
constructor(private readonly page: Page) {
|
|
846
|
+
this.navbar = new NavbarComponent(page.getByRole('navigation'));
|
|
847
|
+
this.sidebar = new AdminSidebarComponent(page.getByRole('complementary'));
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
### Storing State in Page Objects
|
|
853
|
+
|
|
854
|
+
```typescript
|
|
855
|
+
// BAD: tracking state
|
|
856
|
+
class CartPage {
|
|
857
|
+
private itemCount = 0;
|
|
858
|
+
|
|
859
|
+
async addItem(name: string) {
|
|
860
|
+
await this.page.getByRole('button', { name: `Add ${name}` }).click();
|
|
861
|
+
this.itemCount++; // State goes stale if the UI changes independently
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
getCount() {
|
|
865
|
+
return this.itemCount; // Lies if another tab or API call modified the cart
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
```
|
|
869
|
+
|
|
870
|
+
Fix: Read state from the DOM when needed. The page is the source of truth, not your JavaScript variable.
|
|
871
|
+
|
|
872
|
+
```typescript
|
|
873
|
+
// GOOD: read from DOM
|
|
874
|
+
class CartPage {
|
|
875
|
+
async addItem(name: string) {
|
|
876
|
+
await this.page.getByRole('button', { name: `Add ${name}` }).click();
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
get itemCount(): Locator {
|
|
880
|
+
return this.page.getByTestId('cart-count');
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// In test:
|
|
885
|
+
await cartPage.addItem('Widget');
|
|
886
|
+
await expect(cartPage.itemCount).toHaveText('1');
|
|
887
|
+
```
|
|
888
|
+
|
|
889
|
+
### Exposing Raw Locators as the Primary API
|
|
890
|
+
|
|
891
|
+
```typescript
|
|
892
|
+
// BAD: test manipulates locators directly
|
|
893
|
+
const loginPage = new LoginPage(page);
|
|
894
|
+
await loginPage.usernameInput.fill('admin');
|
|
895
|
+
await loginPage.passwordInput.fill('secret');
|
|
896
|
+
await loginPage.submitButton.click();
|
|
897
|
+
|
|
898
|
+
// GOOD: test calls an action
|
|
899
|
+
const loginPage = new LoginPage(page);
|
|
900
|
+
await loginPage.login('admin', 'secret');
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
Exposing locators as `readonly` properties for assertions is acceptable (`expect(loginPage.errorMessage).toBeVisible()`). The anti-pattern is requiring tests to orchestrate multi-step interactions through raw locators.
|
|
904
|
+
|
|
905
|
+
## Troubleshooting
|
|
906
|
+
|
|
907
|
+
| Problem | Cause | Fix |
|
|
908
|
+
|---|---|---|
|
|
909
|
+
| Locator times out but element exists | POM constructed before navigation; locator attached to wrong page state | Move `goto()` before POM construction, or use getter pattern (Pattern 6) |
|
|
910
|
+
| Circular import between page objects | `LoginPage` imports `DashboardPage` which imports `LoginPage` | Break the cycle: use lazy `import()` in the navigation method, or have the test create the destination POM |
|
|
911
|
+
| Fixture not available in test | Test imports `test` from `@playwright/test` instead of `./fixtures` | Always import `test` and `expect` from your custom fixtures file |
|
|
912
|
+
| TypeScript error: property does not exist on fixture | Fixture type not declared in `test.extend<>()` generic | Add the POM type to the generic parameter: `test.extend<{ loginPage: LoginPage }>()` |
|
|
913
|
+
| POM methods feel duplicated across similar pages | Multiple pages share the same form structure | Extract a component object for the shared form, compose into each page |
|
|
914
|
+
| `page.goto()` in constructor causes "cannot use await" | Constructors cannot be async | Use static factory method (Pattern 8) |
|
|
915
|
+
|
|
916
|
+
## Related
|
|
917
|
+
|
|
918
|
+
- [core/fixtures-and-hooks.md](../core/fixtures-and-hooks.md) -- how `test.extend()` works in depth, fixture scoping, teardown
|
|
919
|
+
- [core/locators.md](../core/locators.md) -- choosing the right locator strategy for POM properties
|
|
920
|
+
- [core/pom-vs-fixtures-vs-helpers.md](../core/pom-vs-fixtures-vs-helpers.md) -- expanded decision framework for when to use each approach
|
|
921
|
+
- [core/test-organization.md](../core/test-organization.md) -- file and folder conventions for the broader test suite
|