Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 5 commits into from Apr 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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)
}))
}