chore: document chaining expect.extend (#27262)

Fixes https://github.com/microsoft/playwright/issues/15951
This commit is contained in:
Pavel Feldman 2023-09-22 13:56:59 -07:00 committed by GitHub
parent a6a0257c88
commit d6ec1ae399
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 99 additions and 86 deletions

View File

@ -153,81 +153,57 @@ export default defineConfig({
You can extend Playwright assertions by providing custom matchers. These matchers will be available on the `expect` object.
In this example we add a custom `toBeWithinRange` function in the configuration file. Custom matcher should return a `message` callback and a `pass` flag indicating whether the assertion passed.
In this example we add a custom `toHaveAmount` function. Custom matcher should return a `message` callback and a `pass` flag indicating whether the assertion passed.
```js tab=js-js title="playwright.config.ts"
const { expect, defineConfig } = require('@playwright/test');
```js title="fixtures.ts"
import { expect as baseExpect } from '@playwright/test';
import type { Page, Locator } from '@playwright/test';
expect.extend({
toBeWithinRange(received, floor, ceiling) {
const pass = received >= floor && received <= ceiling;
if (pass) {
return {
message: () => 'passed',
pass: true,
};
} else {
return {
message: () => 'failed',
pass: false,
};
export { test } from '@playwright/test';
export const expect = baseExpect.extend({
async toHaveAmount(locator: Locator, expected: number, options?: { timeout?: number }) {
let pass: boolean;
let matcherResult: any;
try {
await baseExpect(locator).toHaveAttribute('data-amount', String(expected), options);
pass = true;
} catch (e: any) {
matcherResult = e.matcherResult;
pass = false;
}
const message = pass
? () => this.utils.matcherHint('toHaveAmount', locator, expected, { isNot: this.isNot }) +
'\n\n' +
`Expected: \${this.isNot ? 'not' : ''}\${this.utils.printExpected(expected)}\n` +
(matcherResult ? `Received: ${this.utils.printReceived(matcherResult.actual)}` : '')
: () => this.utils.matcherHint('toHaveAmount', locator, expected, expectOptions) +
'\n\n' +
`Expected: ${this.utils.printExpected(expected)}\n` +
(matcherResult ? `Received: ${this.utils.printReceived(matcherResult.actual)}` : '');
return {
message,
pass,
name: 'toHaveAmount',
expected,
actual: matcherResult?.actual,
};
},
});
module.exports = defineConfig({});
```
```js tab=js-ts title="playwright.config.ts"
import { expect, defineConfig } from '@playwright/test';
expect.extend({
toBeWithinRange(received: number, floor: number, ceiling: number) {
const pass = received >= floor && received <= ceiling;
if (pass) {
return {
message: () => 'passed',
pass: true,
};
} else {
return {
message: () => 'failed',
pass: false,
};
}
},
});
export default defineConfig({});
```
Now we can use `toBeWithinRange` in the test.
Now we can use `toHaveAmount` in the test.
```js title="example.spec.ts"
import { test, expect } from '@playwright/test';
import { test, expect } from './fixtures';
test('numeric ranges', () => {
expect(100).toBeWithinRange(90, 110);
expect(101).not.toBeWithinRange(0, 100);
test('amount', async () => {
await expect(page.locator('.cart')).toHaveAmount(4);
});
```
:::note
Do not confuse Playwright's `expect` with the [`expect` library](https://jestjs.io/docs/expect). The latter is not fully integrated with Playwright test runner, so make sure to use Playwright's own `expect`.
:::
For TypeScript, also add the following to your [`global.d.ts`](https://www.typescriptlang.org/docs/handbook/declaration-files/templates/global-d-ts.html). If it does not exist, you need to create it inside your repository. Make sure that your `global.d.ts` gets included inside your `tsconfig.json` via the `files`, `include` or `compilerOptions.typeRoots` option so that your IDE will pick it up.
You don't need it for JavaScript.
```js title="global.d.ts"
export {};
declare global {
namespace PlaywrightTest {
interface Matchers<R, T> {
toBeWithinRange(a: number, b: number): R;
}
}
}
```

View File

@ -48,7 +48,7 @@ import {
import { toMatchSnapshot, toHaveScreenshot, toHaveScreenshotStepTitle } from './toMatchSnapshot';
import type { Expect } from '../../types/test';
import { currentTestInfo, currentExpectTimeout, setCurrentExpectConfigureTimeout } from '../common/globals';
import { filteredStackTrace, stringifyStackFrames, trimLongString } from '../util';
import { filteredStackTrace, trimLongString } from '../util';
import {
expect as expectLibrary,
INVERTED_COLOR,
@ -58,6 +58,7 @@ import {
export type { ExpectMatcherContext } from '../common/expectBundle';
import { zones } from 'playwright-core/lib/utils';
import { TestInfoImpl } from '../worker/testInfo';
import { ExpectError } from './matcherHint';
// #region
// Mirrored from https://github.com/facebook/jest/blob/f13abff8df9a0e1148baf3584bcde6d1b479edc7/packages/expect/src/print.ts
@ -263,31 +264,17 @@ class ExpectMetaInfoProxyHandler implements ProxyHandler<any> {
laxParent: true,
}) : undefined;
const reportStepError = (jestError: Error) => {
const message = jestError.message;
if (customMessage) {
const messageLines = message.split('\n');
const newMessage = [
customMessage,
'',
...messageLines,
].join('\n');
jestError.message = newMessage;
jestError.stack = jestError.name + ': ' + newMessage + '\n' + stringifyStackFrames(stackFrames).join('\n');
}
// Use the exact stack that we entered the matcher with.
jestError.stack = jestError.name + ': ' + jestError.message + '\n' + stringifyStackFrames(stackFrames).join('\n');
const reportStepError = (jestError: ExpectError) => {
const error = new ExpectError(jestError, customMessage, stackFrames);
const serializedError = {
message: jestError.message,
stack: jestError.stack,
message: error.message,
stack: error.stack,
};
step?.complete({ error: serializedError });
if (this._info.isSoft)
testInfo._failWithError(serializedError, false /* isHardError */);
else
throw jestError;
throw error;
};
const finalizer = () => {

View File

@ -17,6 +17,8 @@
import { colors } from 'playwright-core/lib/utilsBundle';
import type { ExpectMatcherContext } from './expect';
import type { Locator } from 'playwright-core';
import { stringifyStackFrames } from '../util';
import type { StackFrame } from '@protocol/channels';
export function matcherHint(state: ExpectMatcherContext, locator: Locator | undefined, matcherName: string, expression: any, actual: any, matcherOptions: any, timeout?: number) {
let header = state.utils.matcherHint(matcherName, expression, actual, matcherOptions).replace(/ \/\/ deep equality/, '') + '\n\n';
@ -28,11 +30,34 @@ export function matcherHint(state: ExpectMatcherContext, locator: Locator | unde
}
export type MatcherResult<E, A> = {
locator?: Locator;
name: string;
expected: E;
message: () => string;
pass: boolean;
actual?: A;
log?: string[];
timeout?: number;
};
export class ExpectError extends Error {
matcherResult: {
message: string;
pass: boolean;
name?: string;
expected?: any;
actual?: any;
log?: string[];
timeout?: number;
};
constructor(jestError: ExpectError, customMessage: string, stackFrames: StackFrame[]) {
super('');
// Copy to erase the JestMatcherError constructor name from the console.log(error).
this.name = jestError.name;
this.message = jestError.message;
this.matcherResult = jestError.matcherResult;
if (customMessage)
this.message = customMessage + '\n\n' + this.message;
this.stack = this.name + ': ' + this.message + '\n' + stringifyStackFrames(stackFrames).join('\n');
}
}

View File

@ -48,5 +48,13 @@ export async function toBeTruthy(
return matches ? `${header}Expected: not ${expected}\nReceived: ${expected}${logText}` :
`${header}Expected: ${expected}\nReceived: ${unexpected}${logText}`;
};
return { message, pass: matches, actual, name: matcherName, expected, log };
return {
message,
pass: matches,
actual,
name: matcherName,
expected,
log,
timeout: timedOut ? timeout : undefined,
};
}

View File

@ -67,5 +67,12 @@ export async function toEqual<T>(
// Passing the actual and expected objects so that a custom reporter
// could access them, for example in order to display a custom visual diff,
// or create a different error message
return { actual: received, expected, message, name: matcherName, pass, log };
return {
actual: received,
expected, message,
name: matcherName,
pass,
log,
timeout: timedOut ? timeout : undefined,
};
}

View File

@ -160,7 +160,6 @@ class SnapshotHelper<T extends ImageComparatorOptions> {
createMatcherResult(message: string, pass: boolean, log?: string[]): ImageMatcherResult {
const unfiltered: ImageMatcherResult = {
name: this.matcherName,
locator: this.locator,
expected: this.snapshotPath,
actual: this.actualPath,
diff: this.diffPath,

View File

@ -105,6 +105,7 @@ export async function toMatchText(
pass,
actual: received,
log,
timeout: timedOut ? timeout : undefined,
};
}

View File

@ -31,6 +31,7 @@ test('toMatchText-based assertions should have matcher result', async ({ page })
name: 'toHaveText',
pass: false,
log: expect.any(Array),
timeout: 1,
});
expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).toHaveText(expected)
@ -52,6 +53,7 @@ Call log`);
name: 'toHaveText',
pass: true,
log: expect.any(Array),
timeout: 1,
});
expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).not.toHaveText(expected)
@ -77,6 +79,7 @@ test('toBeTruthy-based assertions should have matcher result', async ({ page })
name: 'toBeVisible',
pass: false,
log: expect.any(Array),
timeout: 1,
});
expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).toBeVisible()
@ -98,6 +101,7 @@ Call log`);
name: 'toBeVisible',
pass: true,
log: expect.any(Array),
timeout: 1,
});
expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).not.toBeVisible()
@ -123,6 +127,7 @@ test('toEqual-based assertions should have matcher result', async ({ page }) =>
name: 'toHaveCount',
pass: false,
log: expect.any(Array),
timeout: 1,
});
expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).toHaveCount(expected)
@ -143,6 +148,7 @@ Call log`);
name: 'toHaveCount',
pass: true,
log: expect.any(Array),
timeout: 1,
});
expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).not.toHaveCount(expected)
@ -171,6 +177,7 @@ test('toBeChecked({ checked: false }) should have expected: false', async ({ pag
name: 'toBeChecked',
pass: false,
log: expect.any(Array),
timeout: 1,
});
expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).toBeChecked()
@ -192,6 +199,7 @@ Call log`);
name: 'toBeChecked',
pass: true,
log: expect.any(Array),
timeout: 1,
});
expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).not.toBeChecked()
@ -213,6 +221,7 @@ Call log`);
name: 'toBeChecked',
pass: false,
log: expect.any(Array),
timeout: 1,
});
expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).toBeChecked({ checked: false })
@ -234,6 +243,7 @@ Call log`);
name: 'toBeChecked',
pass: true,
log: expect.any(Array),
timeout: 1,
});
expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).not.toBeChecked({ checked: false })