Skip to content

Commit 2c0910b

Browse files
committed
refactor(e2e): implement Page Object Model pattern
Introduce POM abstraction layer for Playwright e2e tests. Create BasePage class and page objects for all tested pages, extend fixtures to provide them automatically, refactor all existing tests, and add smoke tests.
1 parent 325ee16 commit 2c0910b

16 files changed

+542
-392
lines changed

e2e/README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,55 @@ pnpm test:e2e:docker
141141
lsof -ti:8000 | xargs kill -9
142142
lsof -ti:18080 | xargs kill -9
143143
```
144+
145+
## Page Object Model
146+
147+
Tests use the Page Object Model (POM) pattern. Page objects live in `e2e/pages/` and encapsulate selectors and actions for each page.
148+
149+
### Directory structure
150+
151+
```
152+
e2e/
153+
├── fixtures/
154+
│ ├── nectar.fixture.ts — Playwright fixtures (provides page objects + helpers)
155+
│ └── helpers.ts — Cookie/response utility functions
156+
├── pages/
157+
│ ├── base.page.ts — Abstract base class (navigation, cookies, scenarios)
158+
│ ├── home.page.ts — Landing page
159+
│ ├── login.page.ts — Login form page
160+
│ ├── search.page.ts — Search results page
161+
│ ├── register.page.ts — Registration page
162+
│ ├── forgot-password.page.ts — Forgot password page
163+
│ ├── verify.page.ts — Email verification page
164+
│ ├── settings.page.ts — User settings page
165+
│ └── index.ts — Barrel export
166+
└── tests/
167+
├── middleware/ — Middleware integration tests
168+
└── smoke/ — Smoke/navigation tests
169+
```
170+
171+
### Adding a new page object
172+
173+
1. Create `e2e/pages/my-page.page.ts` extending `BasePage`
174+
2. Set the `path` property (e.g., `/my-page`)
175+
3. Add selectors as private readonly properties
176+
4. Add action methods (e.g., `fillForm`, `submit`)
177+
5. Export from `e2e/pages/index.ts`
178+
6. Add a fixture in `e2e/fixtures/nectar.fixture.ts`
179+
180+
### Using page objects in tests
181+
182+
```typescript
183+
import { test, expect } from '../../fixtures/nectar.fixture';
184+
185+
test('example', async ({ loginPage, searchPage }) => {
186+
await loginPage.addSessionCookie('anonymous-session');
187+
await loginPage.setScenarioHeader('bootstrap-anonymous');
188+
await loginPage.goto();
189+
190+
await loginPage.fillCredentials('user@example.com', 'pass');
191+
await loginPage.submit();
192+
});
193+
```
194+
195+
Page objects are provided automatically via Playwright fixtures — destructure them in the test signature.

e2e/fixtures/nectar.fixture.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,66 @@
1+
/* eslint-disable react-hooks/rules-of-hooks -- Playwright fixture `use` is not React */
12
import { test as base, expect } from '@playwright/test';
3+
import { HomePage } from '../pages/home.page';
4+
import { LoginPage } from '../pages/login.page';
5+
import { SearchPage } from '../pages/search.page';
6+
import { RegisterPage } from '../pages/register.page';
7+
import { ForgotPasswordPage } from '../pages/forgot-password.page';
8+
import { VerifyPage } from '../pages/verify.page';
9+
import { SettingsPage } from '../pages/settings.page';
210

311
export type NectarTestContext = {
412
nectarUrl: string;
13+
homePage: HomePage;
14+
loginPage: LoginPage;
15+
searchPage: SearchPage;
16+
registerPage: RegisterPage;
17+
forgotPasswordPage: ForgotPasswordPage;
18+
verifyPage: VerifyPage;
19+
settingsPage: SettingsPage;
520
setTestScenario: (scenario: string) => Promise<void>;
21+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- __NEXT_DATA__ is untyped
622
getNextData: () => Promise<any>;
723
assertCookieRewritten: (cookie: string | undefined, expectedValue: string) => void;
24+
resetStub: () => Promise<void>;
825
};
926

27+
const STUB_URL = process.env.STUB_URL || 'http://127.0.0.1:18080';
28+
1029
export const test = base.extend<NectarTestContext>({
1130
nectarUrl: async ({}, use) => {
1231
await use(process.env.NECTAR_URL || process.env.BASE_URL || 'http://127.0.0.1:8000');
1332
},
1433

15-
setTestScenario: async ({ context }, use) => {
16-
let currentScenario: string | null = null;
34+
homePage: async ({ page, context, nectarUrl }, use) => {
35+
await use(new HomePage(page, context, nectarUrl));
36+
},
37+
38+
loginPage: async ({ page, context, nectarUrl }, use) => {
39+
await use(new LoginPage(page, context, nectarUrl));
40+
},
41+
42+
searchPage: async ({ page, context, nectarUrl }, use) => {
43+
await use(new SearchPage(page, context, nectarUrl));
44+
},
45+
46+
registerPage: async ({ page, context, nectarUrl }, use) => {
47+
await use(new RegisterPage(page, context, nectarUrl));
48+
},
49+
50+
forgotPasswordPage: async ({ page, context, nectarUrl }, use) => {
51+
await use(new ForgotPasswordPage(page, context, nectarUrl));
52+
},
53+
54+
verifyPage: async ({ page, context, nectarUrl }, use) => {
55+
await use(new VerifyPage(page, context, nectarUrl));
56+
},
57+
58+
settingsPage: async ({ page, context, nectarUrl }, use) => {
59+
await use(new SettingsPage(page, context, nectarUrl));
60+
},
1761

62+
setTestScenario: async ({ context }, use) => {
1863
await use(async (scenario: string) => {
19-
currentScenario = scenario;
2064
await context.route('**/*', async (route) => {
2165
const headers = route.request().headers();
2266
if (scenario) {
@@ -45,6 +89,12 @@ export const test = base.extend<NectarTestContext>({
4589
expect(cookie).not.toContain('Domain=');
4690
});
4791
},
92+
93+
resetStub: async ({ request }, use) => {
94+
await use(async () => {
95+
await request.post(`${STUB_URL}/__test__/reset`);
96+
});
97+
},
4898
});
4999

50100
export { expect };

e2e/pages/base.page.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { Page, BrowserContext, Response, expect } from '@playwright/test';
2+
3+
export abstract class BasePage {
4+
constructor(protected readonly page: Page, protected readonly context: BrowserContext, readonly baseUrl: string) {}
5+
6+
protected abstract readonly path: string;
7+
8+
get url(): string {
9+
return this.page.url();
10+
}
11+
12+
buildUrl(params?: string): string {
13+
const base = `${this.baseUrl}${this.path}`;
14+
if (!params) {
15+
return base;
16+
}
17+
const url = new URL(base);
18+
const extra = new URLSearchParams(params.replace(/^\?/, ''));
19+
extra.forEach((value, key) => url.searchParams.set(key, value));
20+
return url.toString();
21+
}
22+
23+
async goto(options?: { waitUntil?: 'load' | 'commit' | 'networkidle' }): Promise<Response | null> {
24+
return this.page.goto(`${this.baseUrl}${this.path}`, options);
25+
}
26+
27+
async gotoWithParams(
28+
params: string,
29+
options?: { waitUntil?: 'load' | 'commit' | 'networkidle' },
30+
): Promise<Response | null> {
31+
return this.page.goto(this.buildUrl(params), options);
32+
}
33+
34+
async gotoUrl(url: string, options?: { waitUntil?: 'load' | 'commit' | 'networkidle' }): Promise<Response | null> {
35+
return this.page.goto(url, options);
36+
}
37+
38+
async waitForUrl(pattern: string | RegExp, options?: { timeout?: number }): Promise<void> {
39+
await this.page.waitForURL(pattern, options);
40+
}
41+
42+
async waitForLoadState(state: 'load' | 'domcontentloaded' | 'networkidle'): Promise<void> {
43+
await this.page.waitForLoadState(state);
44+
}
45+
46+
async clearCookies(): Promise<void> {
47+
await this.context.clearCookies();
48+
}
49+
50+
async addSessionCookie(value: string): Promise<void> {
51+
await this.context.addCookies([{ name: 'ads_session', value, url: this.baseUrl }]);
52+
}
53+
54+
async setScenarioHeader(scenario: string): Promise<void> {
55+
await this.page.setExtraHTTPHeaders({
56+
'x-test-scenario': scenario,
57+
});
58+
}
59+
60+
async setScenarioViaRoute(scenario: string): Promise<void> {
61+
await this.context.route('**/*', async (route) => {
62+
const headers = route.request().headers();
63+
headers['x-test-scenario'] = scenario;
64+
await route.continue({ headers });
65+
});
66+
}
67+
68+
async setExtraHeaders(headers: Record<string, string>): Promise<void> {
69+
await this.page.setExtraHTTPHeaders(headers);
70+
}
71+
72+
async interceptRoute(pattern: string, handler: Parameters<Page['route']>[1]): Promise<void> {
73+
await this.page.route(pattern, handler);
74+
}
75+
76+
async getCookies() {
77+
return this.context.cookies();
78+
}
79+
80+
urlContains(substring: string): void {
81+
expect(this.page.url()).toContain(substring);
82+
}
83+
84+
urlEquals(expected: string): void {
85+
expect(this.page.url()).toBe(expected);
86+
}
87+
88+
urlMatches(pattern: RegExp): void {
89+
expect(this.page.url()).toMatch(pattern);
90+
}
91+
}

e2e/pages/forgot-password.page.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Page, BrowserContext } from '@playwright/test';
2+
import { BasePage } from './base.page';
3+
4+
export class ForgotPasswordPage extends BasePage {
5+
protected readonly path = '/user/forgotpassword';
6+
7+
constructor(page: Page, context: BrowserContext, baseUrl: string) {
8+
super(page, context, baseUrl);
9+
}
10+
}

e2e/pages/home.page.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Page, BrowserContext } from '@playwright/test';
2+
import { BasePage } from './base.page';
3+
4+
export class HomePage extends BasePage {
5+
protected readonly path = '/';
6+
7+
private readonly searchInput = '[data-testid="search-input"]';
8+
private readonly searchSubmit = '[data-testid="search-submit"]';
9+
10+
constructor(page: Page, context: BrowserContext, baseUrl: string) {
11+
super(page, context, baseUrl);
12+
}
13+
14+
async search(query: string): Promise<void> {
15+
await this.page.fill(this.searchInput, query);
16+
await this.page.click(this.searchSubmit);
17+
}
18+
}

e2e/pages/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export { BasePage } from './base.page';
2+
export { HomePage } from './home.page';
3+
export { LoginPage } from './login.page';
4+
export { SearchPage } from './search.page';
5+
export { RegisterPage } from './register.page';
6+
export { ForgotPasswordPage } from './forgot-password.page';
7+
export { VerifyPage } from './verify.page';
8+
export { SettingsPage } from './settings.page';

e2e/pages/login.page.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { Page, BrowserContext } from '@playwright/test';
2+
import { BasePage } from './base.page';
3+
4+
export class LoginPage extends BasePage {
5+
protected readonly path = '/user/account/login';
6+
7+
private readonly emailInput = 'input[name="email"]';
8+
private readonly passwordInput = 'input[name="password"]';
9+
private readonly submitButton = 'button[type="submit"]';
10+
11+
constructor(page: Page, context: BrowserContext, baseUrl: string) {
12+
super(page, context, baseUrl);
13+
}
14+
15+
async gotoWithNext(nextUrl: string): Promise<void> {
16+
await this.gotoWithParams(`?next=${encodeURIComponent(nextUrl)}`);
17+
}
18+
19+
async fillCredentials(email: string, password: string): Promise<void> {
20+
await this.page.fill(this.emailInput, email);
21+
await this.page.fill(this.passwordInput, password);
22+
}
23+
24+
async submit(): Promise<void> {
25+
await this.page.click(this.submitButton);
26+
}
27+
28+
async mockLoginSuccess(): Promise<void> {
29+
await this.interceptRoute('**/api/auth/login', async (route) => {
30+
await route.fulfill({
31+
status: 200,
32+
contentType: 'application/json',
33+
body: JSON.stringify({ success: true }),
34+
});
35+
});
36+
}
37+
38+
async login(email: string, password: string): Promise<void> {
39+
await this.fillCredentials(email, password);
40+
await this.mockLoginSuccess();
41+
await this.submit();
42+
}
43+
}

e2e/pages/register.page.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Page, BrowserContext } from '@playwright/test';
2+
import { BasePage } from './base.page';
3+
4+
export class RegisterPage extends BasePage {
5+
protected readonly path = '/user/account/register';
6+
7+
constructor(page: Page, context: BrowserContext, baseUrl: string) {
8+
super(page, context, baseUrl);
9+
}
10+
}

e2e/pages/search.page.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Page, BrowserContext, Response, expect } from '@playwright/test';
2+
import { BasePage } from './base.page';
3+
4+
export class SearchPage extends BasePage {
5+
protected readonly path = '/search';
6+
7+
private readonly searchInput = '[data-testid="search-input"]';
8+
private readonly searchSubmit = '[data-testid="search-submit"]';
9+
10+
constructor(page: Page, context: BrowserContext, baseUrl: string) {
11+
super(page, context, baseUrl);
12+
}
13+
14+
async search(query: string): Promise<void> {
15+
await this.page.fill(this.searchInput, query);
16+
await this.page.click(this.searchSubmit);
17+
}
18+
19+
async gotoAndExpect(options?: { waitUntil?: 'load' | 'commit' | 'networkidle' }): Promise<Response> {
20+
const response = await this.goto(options);
21+
expect(response).not.toBeNull();
22+
return response!;
23+
}
24+
}

e2e/pages/settings.page.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Page, BrowserContext } from '@playwright/test';
2+
import { BasePage } from './base.page';
3+
4+
export class SettingsPage extends BasePage {
5+
protected readonly path = '/user/settings';
6+
7+
constructor(page: Page, context: BrowserContext, baseUrl: string) {
8+
super(page, context, baseUrl);
9+
}
10+
}

0 commit comments

Comments
 (0)