mirror of
				https://github.com/microsoft/playwright.git
				synced 2025-06-26 21:40:17 +00:00 
			
		
		
		
	chore: document chaining expect.extend (#27262)
Fixes https://github.com/microsoft/playwright/issues/15951
This commit is contained in:
		
							parent
							
								
									a6a0257c88
								
							
						
					
					
						commit
						d6ec1ae399
					
				| @ -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. | 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" | ```js title="fixtures.ts" | ||||||
| const { expect, defineConfig } = require('@playwright/test'); | import { expect as baseExpect } from '@playwright/test'; | ||||||
|  | import type { Page, Locator } from '@playwright/test'; | ||||||
| 
 | 
 | ||||||
| expect.extend({ | export { test } from '@playwright/test'; | ||||||
|   toBeWithinRange(received, floor, ceiling) { | 
 | ||||||
|     const pass = received >= floor && received <= ceiling; | export const expect = baseExpect.extend({ | ||||||
|     if (pass) { |   async toHaveAmount(locator: Locator, expected: number, options?: { timeout?: number }) { | ||||||
|       return { |     let pass: boolean; | ||||||
|         message: () => 'passed', |     let matcherResult: any; | ||||||
|         pass: true, |     try { | ||||||
|       }; |       await baseExpect(locator).toHaveAttribute('data-amount', String(expected), options); | ||||||
|     } else { |       pass = true; | ||||||
|       return { |     } catch (e: any) { | ||||||
|         message: () => 'failed', |       matcherResult = e.matcherResult; | ||||||
|         pass: false, |       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" | Now we can use `toHaveAmount` in the test. | ||||||
| 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. |  | ||||||
| 
 | 
 | ||||||
| ```js title="example.spec.ts" | ```js title="example.spec.ts" | ||||||
| import { test, expect } from '@playwright/test'; | import { test, expect } from './fixtures'; | ||||||
| 
 | 
 | ||||||
| test('numeric ranges', () => { | test('amount', async () => { | ||||||
|   expect(100).toBeWithinRange(90, 110); |   await expect(page.locator('.cart')).toHaveAmount(4); | ||||||
|   expect(101).not.toBeWithinRange(0, 100); |  | ||||||
| }); | }); | ||||||
| ``` | ``` | ||||||
| 
 | 
 | ||||||
| :::note | :::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`. | 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; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } |  | ||||||
| ``` |  | ||||||
|  | |||||||
| @ -48,7 +48,7 @@ import { | |||||||
| import { toMatchSnapshot, toHaveScreenshot, toHaveScreenshotStepTitle } from './toMatchSnapshot'; | import { toMatchSnapshot, toHaveScreenshot, toHaveScreenshotStepTitle } from './toMatchSnapshot'; | ||||||
| import type { Expect } from '../../types/test'; | import type { Expect } from '../../types/test'; | ||||||
| import { currentTestInfo, currentExpectTimeout, setCurrentExpectConfigureTimeout } from '../common/globals'; | import { currentTestInfo, currentExpectTimeout, setCurrentExpectConfigureTimeout } from '../common/globals'; | ||||||
| import { filteredStackTrace, stringifyStackFrames, trimLongString } from '../util'; | import { filteredStackTrace, trimLongString } from '../util'; | ||||||
| import { | import { | ||||||
|   expect as expectLibrary, |   expect as expectLibrary, | ||||||
|   INVERTED_COLOR, |   INVERTED_COLOR, | ||||||
| @ -58,6 +58,7 @@ import { | |||||||
| export type { ExpectMatcherContext } from '../common/expectBundle'; | export type { ExpectMatcherContext } from '../common/expectBundle'; | ||||||
| import { zones } from 'playwright-core/lib/utils'; | import { zones } from 'playwright-core/lib/utils'; | ||||||
| import { TestInfoImpl } from '../worker/testInfo'; | import { TestInfoImpl } from '../worker/testInfo'; | ||||||
|  | import { ExpectError } from './matcherHint'; | ||||||
| 
 | 
 | ||||||
| // #region
 | // #region
 | ||||||
| // Mirrored from https://github.com/facebook/jest/blob/f13abff8df9a0e1148baf3584bcde6d1b479edc7/packages/expect/src/print.ts
 | // 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, |         laxParent: true, | ||||||
|       }) : undefined; |       }) : undefined; | ||||||
| 
 | 
 | ||||||
|       const reportStepError = (jestError: Error) => { |       const reportStepError = (jestError: ExpectError) => { | ||||||
|         const message = jestError.message; |         const error = new ExpectError(jestError, customMessage, stackFrames); | ||||||
|         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 serializedError = { |         const serializedError = { | ||||||
|           message: jestError.message, |           message: error.message, | ||||||
|           stack: jestError.stack, |           stack: error.stack, | ||||||
|         }; |         }; | ||||||
| 
 |  | ||||||
|         step?.complete({ error: serializedError }); |         step?.complete({ error: serializedError }); | ||||||
|         if (this._info.isSoft) |         if (this._info.isSoft) | ||||||
|           testInfo._failWithError(serializedError, false /* isHardError */); |           testInfo._failWithError(serializedError, false /* isHardError */); | ||||||
|         else |         else | ||||||
|           throw jestError; |           throw error; | ||||||
|       }; |       }; | ||||||
| 
 | 
 | ||||||
|       const finalizer = () => { |       const finalizer = () => { | ||||||
|  | |||||||
| @ -17,6 +17,8 @@ | |||||||
| import { colors } from 'playwright-core/lib/utilsBundle'; | import { colors } from 'playwright-core/lib/utilsBundle'; | ||||||
| import type { ExpectMatcherContext } from './expect'; | import type { ExpectMatcherContext } from './expect'; | ||||||
| import type { Locator } from 'playwright-core'; | 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) { | 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'; |   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> = { | export type MatcherResult<E, A> = { | ||||||
|   locator?: Locator; |  | ||||||
|   name: string; |   name: string; | ||||||
|   expected: E; |   expected: E; | ||||||
|   message: () => string; |   message: () => string; | ||||||
|   pass: boolean; |   pass: boolean; | ||||||
|   actual?: A; |   actual?: A; | ||||||
|   log?: string[]; |   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'); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | |||||||
| @ -48,5 +48,13 @@ export async function toBeTruthy( | |||||||
|     return matches ? `${header}Expected: not ${expected}\nReceived: ${expected}${logText}` : |     return matches ? `${header}Expected: not ${expected}\nReceived: ${expected}${logText}` : | ||||||
|       `${header}Expected: ${expected}\nReceived: ${unexpected}${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, | ||||||
|  |   }; | ||||||
| } | } | ||||||
|  | |||||||
| @ -67,5 +67,12 @@ export async function toEqual<T>( | |||||||
|   // Passing the actual and expected objects so that a custom reporter
 |   // Passing the actual and expected objects so that a custom reporter
 | ||||||
|   // could access them, for example in order to display a custom visual diff,
 |   // could access them, for example in order to display a custom visual diff,
 | ||||||
|   // or create a different error message
 |   // 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, | ||||||
|  |   }; | ||||||
| } | } | ||||||
|  | |||||||
| @ -160,7 +160,6 @@ class SnapshotHelper<T extends ImageComparatorOptions> { | |||||||
|   createMatcherResult(message: string, pass: boolean, log?: string[]): ImageMatcherResult { |   createMatcherResult(message: string, pass: boolean, log?: string[]): ImageMatcherResult { | ||||||
|     const unfiltered: ImageMatcherResult = { |     const unfiltered: ImageMatcherResult = { | ||||||
|       name: this.matcherName, |       name: this.matcherName, | ||||||
|       locator: this.locator, |  | ||||||
|       expected: this.snapshotPath, |       expected: this.snapshotPath, | ||||||
|       actual: this.actualPath, |       actual: this.actualPath, | ||||||
|       diff: this.diffPath, |       diff: this.diffPath, | ||||||
|  | |||||||
| @ -105,6 +105,7 @@ export async function toMatchText( | |||||||
|     pass, |     pass, | ||||||
|     actual: received, |     actual: received, | ||||||
|     log, |     log, | ||||||
|  |     timeout: timedOut ? timeout : undefined, | ||||||
|   }; |   }; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -31,6 +31,7 @@ test('toMatchText-based assertions should have matcher result', async ({ page }) | |||||||
|       name: 'toHaveText', |       name: 'toHaveText', | ||||||
|       pass: false, |       pass: false, | ||||||
|       log: expect.any(Array), |       log: expect.any(Array), | ||||||
|  |       timeout: 1, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).toHaveText(expected)
 |     expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).toHaveText(expected)
 | ||||||
| @ -52,6 +53,7 @@ Call log`); | |||||||
|       name: 'toHaveText', |       name: 'toHaveText', | ||||||
|       pass: true, |       pass: true, | ||||||
|       log: expect.any(Array), |       log: expect.any(Array), | ||||||
|  |       timeout: 1, | ||||||
|     }); |     }); | ||||||
|     expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).not.toHaveText(expected)
 |     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', |       name: 'toBeVisible', | ||||||
|       pass: false, |       pass: false, | ||||||
|       log: expect.any(Array), |       log: expect.any(Array), | ||||||
|  |       timeout: 1, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).toBeVisible()
 |     expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).toBeVisible()
 | ||||||
| @ -98,6 +101,7 @@ Call log`); | |||||||
|       name: 'toBeVisible', |       name: 'toBeVisible', | ||||||
|       pass: true, |       pass: true, | ||||||
|       log: expect.any(Array), |       log: expect.any(Array), | ||||||
|  |       timeout: 1, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).not.toBeVisible()
 |     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', |       name: 'toHaveCount', | ||||||
|       pass: false, |       pass: false, | ||||||
|       log: expect.any(Array), |       log: expect.any(Array), | ||||||
|  |       timeout: 1, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).toHaveCount(expected)
 |     expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).toHaveCount(expected)
 | ||||||
| @ -143,6 +148,7 @@ Call log`); | |||||||
|       name: 'toHaveCount', |       name: 'toHaveCount', | ||||||
|       pass: true, |       pass: true, | ||||||
|       log: expect.any(Array), |       log: expect.any(Array), | ||||||
|  |       timeout: 1, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).not.toHaveCount(expected)
 |     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', |       name: 'toBeChecked', | ||||||
|       pass: false, |       pass: false, | ||||||
|       log: expect.any(Array), |       log: expect.any(Array), | ||||||
|  |       timeout: 1, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).toBeChecked()
 |     expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).toBeChecked()
 | ||||||
| @ -192,6 +199,7 @@ Call log`); | |||||||
|       name: 'toBeChecked', |       name: 'toBeChecked', | ||||||
|       pass: true, |       pass: true, | ||||||
|       log: expect.any(Array), |       log: expect.any(Array), | ||||||
|  |       timeout: 1, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).not.toBeChecked()
 |     expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).not.toBeChecked()
 | ||||||
| @ -213,6 +221,7 @@ Call log`); | |||||||
|       name: 'toBeChecked', |       name: 'toBeChecked', | ||||||
|       pass: false, |       pass: false, | ||||||
|       log: expect.any(Array), |       log: expect.any(Array), | ||||||
|  |       timeout: 1, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).toBeChecked({ checked: false })
 |     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', |       name: 'toBeChecked', | ||||||
|       pass: true, |       pass: true, | ||||||
|       log: expect.any(Array), |       log: expect.any(Array), | ||||||
|  |       timeout: 1, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|     expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).not.toBeChecked({ checked: false })
 |     expect.soft(stripAnsi(e.toString())).toContain(`Error: Timed out 1ms waiting for expect(locator).not.toBeChecked({ checked: false })
 | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 Pavel Feldman
						Pavel Feldman