Skip to content

Commit

Permalink
feat: introduce toMatchFileSnapshot
Browse files Browse the repository at this point in the history
  • Loading branch information
antfu committed Apr 1, 2023
1 parent 326b242 commit 49d87c3
Show file tree
Hide file tree
Showing 17 changed files with 227 additions and 12 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
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
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)
}))
}
44 changes: 36 additions & 8 deletions packages/snapshot/src/port/state.ts
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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) {
Expand All @@ -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
}
Expand All @@ -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,
Expand All @@ -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
}
Expand Down Expand Up @@ -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))
Expand All @@ -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
Expand Down Expand Up @@ -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++
}

Expand Down
18 changes: 18 additions & 0 deletions packages/snapshot/src/port/utils.ts
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/snapshot/src/types/environment.ts
Expand Up @@ -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>
Expand Down
2 changes: 2 additions & 0 deletions packages/snapshot/src/types/index.ts
@@ -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 }
Expand All @@ -21,6 +22,7 @@ export interface SnapshotMatchOptions {
inlineSnapshot?: string
isInline: boolean
error?: Error
rawSnapshot?: RawSnapshotInfo
}

export interface SnapshotResult {
Expand Down
22 changes: 22 additions & 0 deletions packages/vitest/src/integrations/snapshot/chai.ts
Expand Up @@ -72,6 +72,28 @@ export const SnapshotPlugin: ChaiPlugin = (chai, utils) => {
},
)
}

utils.addMethod(
chai.Assertion.prototype,
'toMatchFileSnapshot',
async function (this: Record<string, unknown>, file: string, message?: string) {
const expected = utils.flag(this, 'object')
const test = utils.flag(this, 'vitest-test')
const errorMessage = utils.flag(this, 'message')

await getSnapshotClient().assertRaw({
received: expected,
message,
isInline: false,
rawSnapshot: {
file,
},
errorMessage,
...getTestNames(test),
})
},
)

utils.addMethod(
chai.Assertion.prototype,
'toMatchInlineSnapshot',
Expand Down
5 changes: 3 additions & 2 deletions packages/vitest/src/types/global.ts
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions test/snapshots/test/fixtures/basic/input.json
@@ -0,0 +1,8 @@
[
[
".name",
{
"color": "red"
}
]
]
3 changes: 3 additions & 0 deletions test/snapshots/test/fixtures/basic/output.css
@@ -0,0 +1,3 @@
.name {
color: red;
}

0 comments on commit 49d87c3

Please sign in to comment.