Skip to content

Commit

Permalink
feat(snapshot): introduce toMatchFileSnapshot and auto queuing expe…
Browse files Browse the repository at this point in the history
…ct promise (#3116)
  • Loading branch information
antfu committed Apr 3, 2023
1 parent 035230b commit bdc06dc
Show file tree
Hide file tree
Showing 28 changed files with 325 additions and 23 deletions.
16 changes: 16 additions & 0 deletions docs/api/expect.md
Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions docs/guide/snapshot.md
Expand Up @@ -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).
Expand Down
4 changes: 4 additions & 0 deletions packages/browser/src/client/snapshot.ts
Expand Up @@ -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)
}
Expand Down
11 changes: 9 additions & 2 deletions packages/expect/src/jest-expect.ts
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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')
Expand All @@ -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)
Expand All @@ -655,6 +657,8 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
throw new Error(`promise rejected "${String(err)}" instead of resolving`)
},
)

return recordAsyncExpect(test, promise)
}
},
})
Expand All @@ -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

Expand All @@ -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`)
},
Expand All @@ -688,6 +693,8 @@ export const JestChaiExpect: ChaiPlugin = (chai, utils) => {
return result.call(this, ...args)
},
)

return recordAsyncExpect(test, promise)
}
},
})
Expand Down
18 changes: 18 additions & 0 deletions packages/expect/src/utils.ts
@@ -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
}
1 change: 1 addition & 0 deletions packages/runner/src/index.ts
Expand Up @@ -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'
21 changes: 17 additions & 4 deletions packages/runner/src/run.ts
Expand Up @@ -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'
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions packages/runner/src/types/tasks.ts
Expand Up @@ -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
Expand Down
31 changes: 30 additions & 1 deletion packages/snapshot/src/client.ts
@@ -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)
Expand Down Expand Up @@ -35,6 +36,7 @@ interface AssertOptions {
inlineSnapshot?: string
error?: Error
errorMessage?: string
rawSnapshot?: RawSnapshotInfo
}

export class SnapshotClient {
Expand Down Expand Up @@ -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.
*/
Expand All @@ -97,6 +99,7 @@ export class SnapshotClient {
inlineSnapshot,
error,
errorMessage,
rawSnapshot,
} = options
let { received } = options

Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion packages/snapshot/src/env/node.ts
@@ -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 {
Expand All @@ -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(
Expand Down
8 changes: 7 additions & 1 deletion packages/snapshot/src/manager.ts
@@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
22 changes: 22 additions & 0 deletions packages/snapshot/src/port/rawSnapshot.ts
@@ -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)
}))
}

0 comments on commit bdc06dc

Please sign in to comment.