Skip to content

Commit 0c6606d

Browse files
committed
refactor: expect decorator
1 parent 57b7b1a commit 0c6606d

File tree

12 files changed

+524
-861
lines changed

12 files changed

+524
-861
lines changed
Lines changed: 103 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,110 @@
1-
import { type Locator } from '@playwright/test'
1+
import { test } from '@fixtures'
2+
import { expect as expectPw } from '@playwright/test'
3+
import { BaseComponent } from '@shared/components/base'
4+
import { handlePlaywrightError, quoteName } from '@services/utils'
25

3-
export abstract class BaseExpect {
4-
abstract not: BaseExpectNot
6+
/**
7+
* List of Playwright expect methods that can be called by the BaseExpect class
8+
*/
9+
type CallableExpectMethods =
10+
'toBeVisible'
11+
| 'toBeHidden'
12+
| 'toBeEnabled'
13+
| 'toBeDisabled'
14+
| 'toBeEmpty'
15+
| 'toHaveText'
16+
| 'toContainText'
17+
| 'toHaveValue'
18+
| 'toHaveCount'
19+
| 'toBeFocused'
20+
| 'toBeChecked'
21+
| 'toHaveClass'
22+
| 'toHaveAttribute'
23+
| 'toEqual'
24+
| 'toContain'
25+
| 'toMatch'
526

6-
protected constructor(readonly actual: Locator, readonly options: { soft: boolean }) {
7-
}
27+
/**
28+
* Type for accessing Playwright's expect methods
29+
*/
30+
type PlaywrightExpectation = {
31+
[key in CallableExpectMethods]: (...args: unknown[]) => Promise<void>;
832
}
933

10-
export abstract class BaseExpectNot {
34+
/**
35+
* Base abstract class for all expect decorators
36+
*
37+
* This class provides the foundation for all specialized expect classes,
38+
* handling common functionality like negation, soft assertions, and
39+
* execution of expectations with proper error handling and reporting.
40+
*
41+
* @template T The type of value being asserted on
42+
*/
43+
export abstract class BaseExpect<T> {
44+
/** Text indicator for negated assertions in step messages */
45+
protected readonly notIndicator: string = this.isNot ? 'not ' : ''
46+
47+
/**
48+
* Creates a new instance of BaseExpect
49+
*
50+
* @param actual The value to assert on
51+
* @param isNot Whether this is a negated assertion
52+
* @param isSoft Whether this is a soft assertion that doesn't stop test execution on failure
53+
* @param message Optional custom error message
54+
*/
55+
protected constructor(
56+
protected readonly actual: T,
57+
protected readonly isNot: boolean,
58+
protected readonly isSoft: boolean,
59+
protected readonly message?: string,
60+
) {}
61+
62+
/**
63+
* Returns a new instance of the expect class with negation toggled
64+
*/
65+
abstract get not(): BaseExpect<T>
66+
67+
/**
68+
* Executes an expectation with proper error handling and reporting
69+
*
70+
* @param assertionDescription Human-readable description of the assertion
71+
* @param method The Playwright expect method to call
72+
* @param params Parameters to pass to the Playwright expect method
73+
* @param customActual Optional custom value to assert on instead of this.actual
74+
*/
75+
protected async executeExpectation(
76+
assertionDescription: string,
77+
method: CallableExpectMethods,
78+
params: unknown[] = [],
79+
customActual?: unknown,
80+
): Promise<void> {
81+
try {
82+
await test.step(this.formatStepMessage(assertionDescription), async () => {
83+
const expectFn = this.isSoft ? expectPw.soft : expectPw
84+
const target = customActual || (this.actual instanceof BaseComponent ? this.actual.mainLocator : this.actual)
85+
const assertion = expectFn(target, this.message)
86+
if (this.isNot) {
87+
await ((assertion.not as unknown) as PlaywrightExpectation)[method](...params)
88+
} else {
89+
await ((assertion as unknown) as PlaywrightExpectation)[method](...params)
90+
}
91+
}, { box: true })
92+
} catch (error: unknown) {
93+
// it only works when not using .soft
94+
handlePlaywrightError(error, `Expectation failed: ${this.formatStepMessage(assertionDescription)}`)
95+
}
96+
}
1197

12-
protected constructor(readonly actual: Locator, readonly options: { soft: boolean }) {
98+
/**
99+
* Formats a human-readable step message for the assertion
100+
*
101+
* @param assertionDescription Human-readable description of the assertion
102+
* @returns Formatted step message
103+
*/
104+
protected formatStepMessage(assertionDescription: string): string {
105+
if (this.actual instanceof BaseComponent) {
106+
return `Expect ${quoteName(this.actual.componentName)} ${this.actual.componentType} ${this.notIndicator}${assertionDescription}`
107+
}
108+
return `Expect "${this.actual}" ${this.notIndicator}${assertionDescription}`
13109
}
14110
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import type { Locator } from '@playwright/test'
2+
import { BaseComponent } from '@shared/components/base'
3+
import { BaseExpect } from './BaseExpect'
4+
import type {
5+
BeCheckedOptions,
6+
BeEnabledOptions,
7+
BeVisibleOptions,
8+
HaveAttributeOptions,
9+
HaveContainTextOptions,
10+
TimeoutOption,
11+
} from './options'
12+
13+
export type ExpectInput = Locator | BaseComponent
14+
15+
export class CommonExpect<T extends ExpectInput> extends BaseExpect<T> {
16+
17+
constructor(
18+
actual: T,
19+
isNot = false,
20+
isSoft = false,
21+
message?: string,
22+
) {
23+
super(actual, isNot, isSoft, message)
24+
}
25+
26+
get not(): CommonExpect<T> {
27+
return new CommonExpect(this.actual, !this.isNot, this.isSoft, this.message)
28+
}
29+
30+
async toBeVisible(options?: BeVisibleOptions): Promise<void> {
31+
await this.executeExpectation('to be visible', 'toBeVisible', [options])
32+
}
33+
34+
async toBeHidden(options?: TimeoutOption): Promise<void> {
35+
await this.executeExpectation('to be hidden', 'toBeHidden', [options])
36+
}
37+
38+
async toBeEnabled(options?: BeEnabledOptions): Promise<void> {
39+
await this.executeExpectation('to be enabled', 'toBeEnabled', [options])
40+
}
41+
42+
async toBeDisabled(options?: TimeoutOption): Promise<void> {
43+
await this.executeExpectation('to be disabled', 'toBeDisabled', [options])
44+
}
45+
46+
async toBeEmpty(options?: TimeoutOption): Promise<void> {
47+
await this.executeExpectation('to be empty', 'toBeEmpty', [options])
48+
}
49+
50+
async toHaveText(expected: string | RegExp | Array<string | RegExp>, options?: HaveContainTextOptions): Promise<void> {
51+
await this.executeExpectation(`to have text "${expected}"`, 'toHaveText', [expected, options])
52+
}
53+
54+
async toContainText(expected: string | RegExp | Array<string | RegExp>, options?: HaveContainTextOptions): Promise<void> {
55+
await this.executeExpectation(`to contain text "${expected}"`, 'toContainText', [expected, options])
56+
}
57+
58+
async toHaveValue(expected: string | RegExp, options?: TimeoutOption): Promise<void> {
59+
await this.executeExpectation(`to have value "${expected}"`, 'toHaveValue', [expected, options])
60+
}
61+
62+
async toHaveCount(expected: number, options?: TimeoutOption): Promise<void> {
63+
await this.executeExpectation(`to have count "${expected}"`, 'toHaveCount', [expected, options])
64+
}
65+
66+
async toBePressed(options?: TimeoutOption): Promise<void> {
67+
await this.executeExpectation('to be pressed', 'toHaveAttribute', ['aria-pressed', 'true', options])
68+
}
69+
70+
async toBeFocused(options?: TimeoutOption): Promise<void> {
71+
await this.executeExpectation('to be focused', 'toBeFocused', [options])
72+
}
73+
74+
async toBeChecked(options?: BeCheckedOptions): Promise<void> {
75+
await this.executeExpectation('to be checked', 'toBeChecked', [options])
76+
}
77+
78+
async toHaveClass(expected: string | RegExp | Array<string | RegExp>, options?: TimeoutOption): Promise<void> {
79+
await this.executeExpectation(`to have class "${expected}"`, 'toHaveClass', [expected, options])
80+
}
81+
82+
async toHaveAttribute(name: string, value: string | RegExp, options?: HaveAttributeOptions): Promise<void> {
83+
await this.executeExpectation(`to have attribute "${name}" with value "${value}"`, 'toHaveAttribute', [name, value, options])
84+
}
85+
86+
async toHaveIcon(expected: string, options?: TimeoutOption): Promise<void> {
87+
const customLocator = this.actual instanceof BaseComponent
88+
? this.actual.mainLocator.locator('svg').first()
89+
: this.actual.locator('svg').first()
90+
await this.executeExpectation(
91+
`to have icon "${expected}"`,
92+
'toHaveAttribute',
93+
['data-testid', expected, options],
94+
customLocator,
95+
)
96+
}
97+
}
Lines changed: 26 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,44 @@
1-
import { expect as expectPw, test } from '@fixtures'
1+
import { test } from '@fixtures'
22
import { getAuthDataFromStorageStateFile } from '@services/auth'
33
import { SS_SYSADMIN_PATH } from '@services/storage-state'
44
import { createRest, rGetPackageById } from '@services/rest'
55
import { BASE_ORIGIN } from '@test-setup'
6+
import { BaseExpect } from './BaseExpect'
67

7-
class BaseExpectApiPackage {
8-
protected readonly notStr: string
8+
export type PackageInput = { packageId: string; name?: string }
9+
10+
export class ExpectApiPackage extends BaseExpect<PackageInput> {
911

1012
constructor(
11-
protected readonly actual: { packageId: string; name?: string },
12-
protected readonly isNot: boolean,
13-
protected readonly isSoft: boolean,
14-
protected readonly message?: string,
13+
actual: PackageInput,
14+
isNot = false,
15+
isSoft = false,
16+
message?: string,
1517
) {
16-
if (isNot) {
17-
this.notStr = 'not '
18-
} else {
19-
this.notStr = ''
20-
}
18+
super(actual, isNot, isSoft, message)
19+
}
20+
21+
get not(): ExpectApiPackage {
22+
return new ExpectApiPackage(this.actual, !this.isNot, this.isSoft, this.message)
23+
}
24+
25+
protected override formatStepMessage(assertionDescription: string): string {
26+
return `Expect "${this.actual.name || this.actual.packageId}" package ${this.notIndicator}${assertionDescription}`
2127
}
2228

2329
async toBeCreated(): Promise<void> {
24-
await test.step(`Expect Package "${this.actual.name || this.actual.packageId}" ${this.notStr}to be created `, async () => {
30+
await test.step(this.formatStepMessage('to be created'), async () => {
2531
const authData = await getAuthDataFromStorageStateFile(SS_SYSADMIN_PATH)
2632
const rest = await createRest(BASE_ORIGIN, authData.token)
2733
const response = await rest.send(rGetPackageById, [200, 404], this.actual)
34+
const expectedStatus = this.isNot ? 404 : 200
2835

29-
if (!this.isNot) {
30-
if (!this.isSoft) {
31-
expectPw(response.status(), this.message || undefined).toEqual(200)
32-
} else {
33-
expectPw.soft(response.status(), this.message || undefined).toEqual(200)
34-
}
35-
} else {
36-
if (!this.isSoft) {
37-
expectPw(response.status(), this.message || undefined).toEqual(404)
38-
} else {
39-
expectPw.soft(response.status(), this.message || undefined).toEqual(404)
40-
}
41-
}
36+
await this.executeExpectation(
37+
'to be created',
38+
'toEqual',
39+
[expectedStatus],
40+
response.status(),
41+
)
4242
}, { box: true })
4343
}
4444
}
45-
46-
export class ExpectApiPackage extends BaseExpectApiPackage {
47-
readonly not = new BaseExpectApiPackage(this.actual, true, this.isSoft, this.message)
48-
49-
constructor(
50-
protected readonly actual: { packageId: string; name?: string },
51-
protected readonly isNot: boolean,
52-
protected readonly isSoft: boolean,
53-
protected readonly message?: string,
54-
) {
55-
super(actual, isNot, isSoft, message)
56-
}
57-
}
Lines changed: 27 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,44 @@
1-
import { expect as expectPw, test } from '@fixtures'
1+
import { test } from '@fixtures'
22
import { getAuthDataFromStorageStateFile } from '@services/auth'
33
import { SS_SYSADMIN_PATH } from '@services/storage-state'
44
import { createRest, rGetPackageVersion } from '@services/rest'
55
import { BASE_ORIGIN } from '@test-setup'
6+
import { BaseExpect } from './BaseExpect'
67

7-
class BaseExpectApiVersion {
8-
protected readonly notStr: string
8+
export type VersionInput = { packageId: string; version: string }
9+
10+
export class ExpectApiVersion extends BaseExpect<VersionInput> {
911

1012
constructor(
11-
protected readonly actual: { packageId: string; version: string },
12-
protected readonly isNot: boolean,
13-
protected readonly isSoft: boolean,
14-
protected readonly message?: string,
13+
actual: VersionInput,
14+
isNot = false,
15+
isSoft = false,
16+
message?: string,
1517
) {
16-
if (isNot) {
17-
this.notStr = 'not '
18-
} else {
19-
this.notStr = ''
20-
}
18+
super(actual, isNot, isSoft, message)
19+
}
20+
21+
get not(): ExpectApiVersion {
22+
return new ExpectApiVersion(this.actual, !this.isNot, this.isSoft, this.message)
2123
}
2224

23-
async toBeCreated(): Promise<void> {
24-
await test.step(`Expect version "${this.actual.version}" ${this.notStr}to be created `, async () => {
25+
protected override formatStepMessage(assertionDescription: string): string {
26+
return `Expect version "${this.actual.version}" ${this.notIndicator}${assertionDescription}`
27+
}
28+
29+
async toBePublished(): Promise<void> {
30+
await test.step(this.formatStepMessage('to be created'), async () => {
2531
const authData = await getAuthDataFromStorageStateFile(SS_SYSADMIN_PATH)
2632
const rest = await createRest(BASE_ORIGIN, authData.token)
2733
const response = await rest.send(rGetPackageVersion, [200, 404], this.actual)
34+
const expectedStatus = this.isNot ? 404 : 200
2835

29-
if (!this.isNot) {
30-
if (!this.isSoft) {
31-
expectPw(response.status(), this.message || undefined).toEqual(200)
32-
} else {
33-
expectPw.soft(response.status(), this.message || undefined).toEqual(200)
34-
}
35-
} else {
36-
if (!this.isSoft) {
37-
expectPw(response.status(), this.message || undefined).toEqual(404)
38-
} else {
39-
expectPw.soft(response.status(), this.message || undefined).toEqual(404)
40-
}
41-
}
36+
await this.executeExpectation(
37+
'to be created',
38+
'toEqual',
39+
[expectedStatus],
40+
response.status(),
41+
)
4242
}, { box: true })
4343
}
4444
}
45-
46-
export class ExpectApiVersion extends BaseExpectApiVersion {
47-
readonly not = new BaseExpectApiVersion(this.actual, true, this.isSoft, this.message)
48-
49-
constructor(
50-
protected readonly actual: { packageId: string; version: string },
51-
protected readonly isNot: boolean,
52-
protected readonly isSoft: boolean,
53-
protected readonly message?: string,
54-
) {
55-
super(actual, isNot, isSoft, message)
56-
}
57-
}

0 commit comments

Comments
 (0)