feat(snapshot): introduce toMatchFileSnapshot and auto queuing expect promise (#3116)

This commit is contained in:
Anthony Fu 2023-04-03 16:03:46 +02:00 committed by GitHub
parent 035230b497
commit bdc06dcbc4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 325 additions and 23 deletions

View File

@ -678,6 +678,22 @@ type Awaitable<T> = T | PromiseLike<T>
})
```
## toMatchFileSnapshot
- **Type:** `<T>(filepath: string, message?: string) => Promise<void>`
Compare or update the snapshot with the content of a file explicitly specified (instead of the `.snap` file).
```ts
import { expect, it } from 'vitest'
it('render basic', async () => {
const result = renderHTML(h('div', { class: 'foo' }))
await expect(result).toMatchFileSnapshot('./test/basic.output.html')
})
```
Note that since file system operation is async, you need to use `await` with `toMatchFileSnapshot()`.
## toThrowErrorMatchingSnapshot

View File

@ -79,6 +79,23 @@ Or you can use the `--update` or `-u` flag in the CLI to make Vitest update snap
vitest -u
```
## File Snapshots
When calling `toMatchSnapshot()`, we store all snapshots in a formatted snap file. That means we need to escaping some characters (namely the double-quote `"` and backtick `\``) in the snapshot string. Meanwhile, you might lose the syntax highlighting for the snapshot content (if they are in some language).
To improve this case, we introduce [`toMatchFileSnapshot()`](/api/expect#tomatchfilesnapshot) to explicitly snapshot in a file. This allows you to assign any file extension to the snapshot file, and making them more readable.
```ts
import { expect, it } from 'vitest'
it('render basic', async () => {
const result = renderHTML(h('div', { class: 'foo' }))
await expect(result).toMatchFileSnapshot('./test/basic.output.html')
})
```
It will compare with the content of `./test/basic.output.html`. And can be written back with the `--update` flag.
## Image Snapshots
It's also possible to snapshot images using [`jest-image-snapshot`](https://github.com/americanexpress/jest-image-snapshot).

View File

@ -22,6 +22,10 @@ export class BrowserSnapshotEnvironment implements SnapshotEnvironment {
return rpc().resolveSnapshotPath(filepath)
}
resolveRawPath(testPath: string, rawPath: string): Promise<string> {
return rpc().resolveSnapshotRawPath(testPath, rawPath)
}
removeSnapshotFile(filepath: string): Promise<void> {
return rpc().removeFile(filepath)
}

View File

@ -8,6 +8,7 @@ import { arrayBufferEquality, generateToBeMessage, iterableEquality, equals as j
import type { AsymmetricMatcher } from './jest-asymmetric-matchers'
import { diff, stringify } from './jest-matcher-utils'
import { JEST_MATCHERS_OBJECT } from './constants'
import { recordAsyncExpect } from './utils'
// Jest Expect Compact
export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
@ -633,6 +634,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
utils.addProperty(chai.Assertion.prototype, 'resolves', function __VITEST_RESOLVES__(this: any) {
utils.flag(this, 'promise', 'resolves')
utils.flag(this, 'error', new Error('resolves'))
const test = utils.flag(this, 'vitest-test')
const obj = utils.flag(this, 'object')
if (typeof obj?.then !== 'function')
@ -646,7 +648,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
return result instanceof chai.Assertion ? proxy : result
return async (...args: any[]) => {
return obj.then(
const promise = obj.then(
(value: any) => {
utils.flag(this, 'object', value)
return result.call(this, ...args)
@ -655,6 +657,8 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
throw new Error(`promise rejected "${String(err)}" instead of resolving`)
},
)
return recordAsyncExpect(test, promise)
}
},
})
@ -665,6 +669,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
utils.addProperty(chai.Assertion.prototype, 'rejects', function __VITEST_REJECTS__(this: any) {
utils.flag(this, 'promise', 'rejects')
utils.flag(this, 'error', new Error('rejects'))
const test = utils.flag(this, 'vitest-test')
const obj = utils.flag(this, 'object')
const wrapper = typeof obj === 'function' ? obj() : obj // for jest compat
@ -679,7 +684,7 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
return result instanceof chai.Assertion ? proxy : result
return async (...args: any[]) => {
return wrapper.then(
const promise = wrapper.then(
(value: any) => {
throw new Error(`promise resolved "${String(value)}" instead of rejecting`)
},
@ -688,6 +693,8 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
return result.call(this, ...args)
},
)
return recordAsyncExpect(test, promise)
}
},
})

View File

@ -0,0 +1,18 @@
export function recordAsyncExpect(test: any, promise: Promise<any>) {
// record promise for test, that resolves before test ends
if (test) {
// if promise is explicitly awaited, remove it from the list
promise = promise.finally(() => {
const index = test.promises.indexOf(promise)
if (index !== -1)
test.promises.splice(index, 1)
})
// record promise
if (!test.promises)
test.promises = []
test.promises.push(promise)
}
return promise
}

View File

@ -2,4 +2,5 @@ export { startTests, updateTask } from './run'
export { test, it, describe, suite, getCurrentSuite } from './suite'
export { beforeAll, beforeEach, afterAll, afterEach, onTestFailed } from './hooks'
export { setFn, getFn } from './map'
export { getCurrentTest } from './test-state'
export * from './types'

View File

@ -145,6 +145,14 @@ export async function runTest(test: Test, runner: VitestRunner) {
await fn()
}
// some async expect will be added to this array, in case user forget to await theme
if (test.promises) {
const result = await Promise.allSettled(test.promises)
const errors = result.map(r => r.status === 'rejected' ? r.reason : undefined).filter(Boolean)
if (errors.length)
throw errors
}
await runner.onAfterTryTest?.(test, retryCount)
test.result.state = 'pass'
@ -197,10 +205,15 @@ export async function runTest(test: Test, runner: VitestRunner) {
function failTask(result: TaskResult, err: unknown, runner: VitestRunner) {
result.state = 'fail'
const error = processError(err, runner.config)
result.error = error
result.errors ??= []
result.errors.push(error)
const errors = Array.isArray(err)
? err
: [err]
for (const e of errors) {
const error = processError(e, runner.config)
result.error ??= error
result.errors ??= []
result.errors.push(error)
}
}
function markTasksAsSkipped(suite: Suite, runner: VitestRunner) {

View File

@ -59,6 +59,10 @@ export interface Test<ExtraContext = {}> extends TaskBase {
fails?: boolean
context: TestContext & ExtraContext
onFailed?: OnTestFailedHandler[]
/**
* Store promises (from async expects) to wait for them before finishing the test
*/
promises?: Promise<any>[]
}
export type Task = Test | Suite | TaskCustom | File

View File

@ -1,6 +1,7 @@
import { deepMergeSnapshot } from './port/utils'
import SnapshotState from './port/state'
import type { SnapshotStateOptions } from './types'
import type { RawSnapshotInfo } from './port/rawSnapshot'
const createMismatchError = (message: string, actual: unknown, expected: unknown) => {
const error = new Error(message)
@ -35,6 +36,7 @@ interface AssertOptions {
inlineSnapshot?: string
error?: Error
errorMessage?: string
rawSnapshot?: RawSnapshotInfo
}
export class SnapshotClient {
@ -79,7 +81,7 @@ export class SnapshotClient {
}
/**
* Should be overriden by the consumer.
* Should be overridden by the consumer.
*
* Vitest checks equality with @vitest/expect.
*/
@ -97,6 +99,7 @@ export class SnapshotClient {
inlineSnapshot,
error,
errorMessage,
rawSnapshot,
} = options
let { received } = options
@ -134,12 +137,38 @@ export class SnapshotClient {
isInline,
error,
inlineSnapshot,
rawSnapshot,
})
if (!pass)
throw createMismatchError(`Snapshot \`${key || 'unknown'}\` mismatched`, actual?.trim(), expected?.trim())
}
async assertRaw(options: AssertOptions): Promise<void> {
if (!options.rawSnapshot)
throw new Error('Raw snapshot is required')
const {
filepath = this.filepath,
rawSnapshot,
} = options
if (rawSnapshot.content == null) {
if (!filepath)
throw new Error('Snapshot cannot be used outside of test')
const snapshotState = this.getSnapshotState(filepath)
// save the filepath, so it don't lose even if the await make it out-of-context
options.filepath ||= filepath
// resolve and read the raw snapshot file
rawSnapshot.file = await snapshotState.environment.resolveRawPath(filepath, rawSnapshot.file)
rawSnapshot.content = await snapshotState.environment.readSnapshotFile(rawSnapshot.file) || undefined
}
return this.assert(options)
}
async resetCurrent() {
if (!this.snapshotState)
return null

View File

@ -1,5 +1,5 @@
import { existsSync, promises as fs } from 'node:fs'
import { basename, dirname, join } from 'pathe'
import { basename, dirname, isAbsolute, join, resolve } from 'pathe'
import type { SnapshotEnvironment } from '../types'
export class NodeSnapshotEnvironment implements SnapshotEnvironment {
@ -11,6 +11,12 @@ export class NodeSnapshotEnvironment implements SnapshotEnvironment {
return `// Snapshot v${this.getVersion()}`
}
async resolveRawPath(testPath: string, rawPath: string) {
return isAbsolute(rawPath)
? rawPath
: resolve(dirname(testPath), rawPath)
}
async resolvePath(filepath: string): Promise<string> {
return join(
join(

View File

@ -1,4 +1,4 @@
import { basename, dirname, join } from 'pathe'
import { basename, dirname, isAbsolute, join, resolve } from 'pathe'
import type { SnapshotResult, SnapshotStateOptions, SnapshotSummary } from './types'
export class SnapshotManager {
@ -28,6 +28,12 @@ export class SnapshotManager {
return resolver(testPath, this.extension)
}
resolveRawPath(testPath: string, rawPath: string) {
return isAbsolute(rawPath)
? rawPath
: resolve(dirname(testPath), rawPath)
}
}
export function emptySummary(options: Omit<SnapshotStateOptions, 'snapshotEnvironment'>): SnapshotSummary {

View File

@ -0,0 +1,22 @@
import type { SnapshotEnvironment } from '../types'
export interface RawSnapshotInfo {
file: string
readonly?: boolean
content?: string
}
export interface RawSnapshot extends RawSnapshotInfo {
snapshot: string
file: string
}
export async function saveRawSnapshots(
environment: SnapshotEnvironment,
snapshots: Array<RawSnapshot>,
) {
await Promise.all(snapshots.map(async (snap) => {
if (!snap.readonly)
await environment.saveSnapshotFile(snap.file, snap.snapshot)
}))
}

View File

@ -11,6 +11,8 @@ import type { OptionsReceived as PrettyFormatOptions } from 'pretty-format'
import type { SnapshotData, SnapshotEnvironment, SnapshotMatchOptions, SnapshotResult, SnapshotStateOptions, SnapshotUpdateState } from '../types'
import type { InlineSnapshot } from './inlineSnapshot'
import { saveInlineSnapshots } from './inlineSnapshot'
import type { RawSnapshot, RawSnapshotInfo } from './rawSnapshot'
import { saveRawSnapshots } from './rawSnapshot'
import {
addExtraLineBreaks,
@ -43,6 +45,7 @@ export default class SnapshotState {
private _snapshotData: SnapshotData
private _initialData: SnapshotData
private _inlineSnapshots: Array<InlineSnapshot>
private _rawSnapshots: Array<RawSnapshot>
private _uncheckedKeys: Set<string>
private _snapshotFormat: PrettyFormatOptions
private _environment: SnapshotEnvironment
@ -69,6 +72,7 @@ export default class SnapshotState {
this._snapshotData = data
this._dirty = dirty
this._inlineSnapshots = []
this._rawSnapshots = []
this._uncheckedKeys = new Set(Object.keys(this._snapshotData))
this._counters = new Map()
this.expand = options.expand || false
@ -93,6 +97,10 @@ export default class SnapshotState {
return new SnapshotState(testFilePath, snapshotPath, content, options)
}
get environment() {
return this._environment
}
markSnapshotsAsCheckedForTest(testName: string): void {
this._uncheckedKeys.forEach((uncheckedKey) => {
if (keyToTestName(uncheckedKey) === testName)
@ -115,7 +123,7 @@ export default class SnapshotState {
private _addSnapshot(
key: string,
receivedSerialized: string,
options: { isInline: boolean; error?: Error },
options: { isInline: boolean; rawSnapshot?: RawSnapshotInfo; error?: Error },
): void {
this._dirty = true
if (options.isInline) {
@ -135,6 +143,12 @@ export default class SnapshotState {
...stack,
})
}
else if (options.rawSnapshot) {
this._rawSnapshots.push({
...options.rawSnapshot,
snapshot: receivedSerialized,
})
}
else {
this._snapshotData[key] = receivedSerialized
}
@ -154,7 +168,8 @@ export default class SnapshotState {
async save(): Promise<SaveStatus> {
const hasExternalSnapshots = Object.keys(this._snapshotData).length
const hasInlineSnapshots = this._inlineSnapshots.length
const isEmpty = !hasExternalSnapshots && !hasInlineSnapshots
const hasRawSnapshots = this._rawSnapshots.length
const isEmpty = !hasExternalSnapshots && !hasInlineSnapshots && !hasRawSnapshots
const status: SaveStatus = {
deleted: false,
@ -168,6 +183,8 @@ export default class SnapshotState {
}
if (hasInlineSnapshots)
await saveInlineSnapshots(this._environment, this._inlineSnapshots)
if (hasRawSnapshots)
await saveRawSnapshots(this._environment, this._rawSnapshots)
status.saved = true
}
@ -206,6 +223,7 @@ export default class SnapshotState {
inlineSnapshot,
isInline,
error,
rawSnapshot,
}: SnapshotMatchOptions): SnapshotReturnOptions {
this._counters.set(testName, (this._counters.get(testName) || 0) + 1)
const count = Number(this._counters.get(testName))
@ -219,14 +237,24 @@ export default class SnapshotState {
if (!(isInline && this._snapshotData[key] !== undefined))
this._uncheckedKeys.delete(key)
const receivedSerialized = addExtraLineBreaks(serialize(received, undefined, this._snapshotFormat))
const expected = isInline ? inlineSnapshot : this._snapshotData[key]
let receivedSerialized = rawSnapshot && typeof received === 'string'
? received as string
: serialize(received, undefined, this._snapshotFormat)
if (!rawSnapshot)
receivedSerialized = addExtraLineBreaks(receivedSerialized)
const expected = isInline
? inlineSnapshot
: rawSnapshot
? rawSnapshot.content
: this._snapshotData[key]
const expectedTrimmed = prepareExpected(expected)
const pass = expectedTrimmed === prepareExpected(receivedSerialized)
const hasSnapshot = expected !== undefined
const snapshotIsPersisted = isInline || this._fileExists
const snapshotIsPersisted = isInline || this._fileExists || (rawSnapshot && rawSnapshot.content != null)
if (pass && !isInline) {
if (pass && !isInline && !rawSnapshot) {
// Executing a snapshot file as JavaScript and writing the strings back
// when other snapshots have changed loses the proper escaping for some
// characters. Since we check every snapshot in every test, use the newly
@ -255,14 +283,14 @@ export default class SnapshotState {
else
this.added++
this._addSnapshot(key, receivedSerialized, { error, isInline })
this._addSnapshot(key, receivedSerialized, { error, isInline, rawSnapshot })
}
else {
this.matched++
}
}
else {
this._addSnapshot(key, receivedSerialized, { error, isInline })
this._addSnapshot(key, receivedSerialized, { error, isInline, rawSnapshot })
this.added++
}

View File

@ -163,6 +163,24 @@ export async function saveSnapshotFile(
)
}
export async function saveSnapshotFileRaw(
environment: SnapshotEnvironment,
content: string,
snapshotPath: string,
) {
const oldContent = await environment.readSnapshotFile(snapshotPath)
const skipWriting = oldContent && oldContent === content
if (skipWriting)
return
await ensureDirectoryExists(environment, snapshotPath)
await environment.saveSnapshotFile(
snapshotPath,
content,
)
}
export function prepareExpected(expected?: string) {
function findStartIndent() {
// Attempts to find indentation for objects.

View File

@ -2,6 +2,7 @@ export interface SnapshotEnvironment {
getVersion(): string
getHeader(): string
resolvePath(filepath: string): Promise<string>
resolveRawPath(testPath: string, rawPath: string): Promise<string>
prepareDirectory(filepath: string): Promise<void>
saveSnapshotFile(filepath: string, snapshot: string): Promise<void>
readSnapshotFile(filepath: string): Promise<string | null>

View File

@ -1,4 +1,5 @@
import type { OptionsReceived as PrettyFormatOptions } from 'pretty-format'
import type { RawSnapshotInfo } from '../port/rawSnapshot'
import type { SnapshotEnvironment } from './environment'
export type { SnapshotEnvironment }
@ -21,6 +22,7 @@ export interface SnapshotMatchOptions {
inlineSnapshot?: string
isInline: boolean
error?: Error
rawSnapshot?: RawSnapshotInfo
}
export interface SnapshotResult {

View File

@ -61,6 +61,9 @@ export function setup(ctx: Vitest, server?: ViteDevServer) {
resolveSnapshotPath(testPath) {
return ctx.snapshot.resolvePath(testPath)
},
resolveSnapshotRawPath(testPath, rawPath) {
return ctx.snapshot.resolveRawPath(testPath, rawPath)
},
removeFile(id) {
return fs.unlink(id)
},

View File

@ -15,6 +15,7 @@ export interface WebSocketHandlers {
getPaths(): string[]
getConfig(): ResolvedConfig
resolveSnapshotPath(testPath: string): string
resolveSnapshotRawPath(testPath: string, rawPath: string): string
getModuleGraph(id: string): Promise<ModuleGraphData>
getTransformResult(id: string): Promise<TransformResultWithSource | undefined>
readFile(id: string): Promise<string | null>

View File

@ -1,6 +1,7 @@
import * as chai from 'chai'
import './setup'
import type { Test } from '@vitest/runner'
import { getCurrentTest } from '@vitest/runner'
import { GLOBAL_EXPECT, getState, setState } from '@vitest/expect'
import type { MatcherState } from '../../types/chai'
import { getCurrentEnvironment, getFullName } from '../../utils'
@ -10,9 +11,10 @@ export function createExpect(test?: Test) {
const { assertionCalls } = getState(expect)
setState({ assertionCalls: assertionCalls + 1 }, expect)
const assert = chai.expect(value, message) as unknown as Vi.Assertion
if (test)
const _test = test || getCurrentTest()
if (_test)
// @ts-expect-error internal
return assert.withTest(test) as Vi.Assertion
return assert.withTest(_test) as Vi.Assertion
else
return assert
}) as Vi.ExpectStatic

View File

@ -3,6 +3,7 @@ import type { Test } from '@vitest/runner'
import { getNames } from '@vitest/runner/utils'
import type { SnapshotClient } from '@vitest/snapshot'
import { addSerializer, stripSnapshotIndentation } from '@vitest/snapshot'
import { recordAsyncExpect } from '../../../../expect/src/utils'
import { VitestSnapshotClient } from './client'
let _client: SnapshotClient
@ -72,6 +73,30 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => {
},
)
}
utils.addMethod(
chai.Assertion.prototype,
'toMatchFileSnapshot',
function (this: Record<string, unknown>, file: string, message?: string) {
const expected = utils.flag(this, 'object')
const test = utils.flag(this, 'vitest-test') as Test
const errorMessage = utils.flag(this, 'message')
const promise = getSnapshotClient().assertRaw({
received: expected,
message,
isInline: false,
rawSnapshot: {
file,
},
errorMessage,
...getTestNames(test),
})
return recordAsyncExpect(test, promise)
},
)
utils.addMethod(
chai.Assertion.prototype,
'toMatchInlineSnapshot',

View File

@ -66,12 +66,13 @@ declare global {
interface JestAssertion<T = any> extends jest.Matchers<void, T> {
// Snapshot
toMatchSnapshot<U extends { [P in keyof T]: any }>(snapshot: Partial<U>, message?: string): void
toMatchSnapshot(message?: string): void
matchSnapshot<U extends { [P in keyof T]: any }>(snapshot: Partial<U>, message?: string): void
matchSnapshot(message?: string): void
toMatchSnapshot<U extends { [P in keyof T]: any }>(snapshot: Partial<U>, message?: string): void
toMatchSnapshot(message?: string): void
toMatchInlineSnapshot<U extends { [P in keyof T]: any }>(properties: Partial<U>, snapshot?: string, message?: string): void
toMatchInlineSnapshot(snapshot?: string, message?: string): void
toMatchFileSnapshot(filepath: string, message?: string): Promise<void>
toThrowErrorMatchingSnapshot(message?: string): void
toThrowErrorMatchingInlineSnapshot(snapshot?: string, message?: string): void

View File

@ -544,7 +544,7 @@ describe('async expect', () => {
})()).resolves.not.toThrow(Error)
})
it('resolves trows chai', async () => {
it('resolves throws chai', async () => {
const assertion = async () => {
await expect((async () => new Error('msg'))()).resolves.toThrow()
}
@ -552,7 +552,7 @@ describe('async expect', () => {
await expect(assertion).rejects.toThrowError('expected promise to throw an error, but it didn\'t')
})
it('resolves trows jest', async () => {
it('resolves throws jest', async () => {
const assertion = async () => {
await expect((async () => new Error('msg'))()).resolves.toThrow(Error)
}
@ -679,6 +679,31 @@ describe('async expect', () => {
expect(error).toEqual(toEqualError2)
}
})
describe('promise auto queuing', () => {
it.fails('fails', () => {
expect(() => new Promise((resolve, reject) => setTimeout(reject, 500)))
.resolves
.toBe('true')
})
let value = 0
it('pass first', () => {
expect((async () => {
await new Promise(resolve => setTimeout(resolve, 500))
value += 1
return value
})())
.resolves
.toBe(1)
})
it('pass second', () => {
// even if 'pass first' is sync, we will still wait the expect to resolve
expect(value).toBe(1)
})
})
})
it('compatible with jest', () => {

View File

@ -0,0 +1,8 @@
[
[
".name",
{
"color": "red"
}
]
]

View File

@ -0,0 +1,3 @@
.name {
color: red;
}

View File

@ -0,0 +1,15 @@
[
[
".text-red",
{
"color": "red"
}
],
[
".text-lg",
{
"font-size": "1.25rem",
"line-height": "1.75rem"
}
]
]

View File

@ -0,0 +1,7 @@
.text-red {
color: red;
}
.text-lg {
font-size: 1.25rem;
line-height: 1.75rem;
}

View File

@ -0,0 +1,20 @@
import { expect, test } from 'vitest'
function objectToCSS(selector: string, obj: Record<string, string>) {
const body = Object.entries(obj)
.map(([key, value]) => ` ${key}: ${value};`)
.join('\n')
return `${selector} {\n${body}\n}`
}
describe('snapshots', () => {
const files = import.meta.glob('./fixtures/**/input.json', { as: 'raw' })
for (const [path, file] of Object.entries(files)) {
test(path, async () => {
const entries = JSON.parse(await file()) as any[]
expect(entries.map(i => objectToCSS(i[0], i[1])).join('\n'))
.toMatchFileSnapshot(path.replace('input.json', 'output.css'))
})
}
})