Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/api/expect.md
Original file line number Diff line number Diff line change
Expand Up @@ -1658,6 +1658,42 @@ test('variety ends with "re"', () => {
You can use `expect.not` with this matcher to negate the expected value.
:::

## expect.schemaMatching

- **Type:** `(expected: StandardSchemaV1) => any`

When used with an equality check, this asymmetric matcher will return `true` if the value matches the provided schema. The schema must implement the [Standard Schema v1](https://standardschema.dev/) specification.

```ts
import { expect, test } from 'vitest'
import { z } from 'zod'
import * as v from 'valibot'
import { type } from 'arktype'

test('email validation', () => {
const user = { email: '[email protected]' }

// using Zod
expect(user).toEqual({
email: expect.schemaMatching(z.string().email()),
})

// using Valibot
expect(user).toEqual({
email: expect.schemaMatching(v.pipe(v.string(), v.email()))
})

// using ArkType
expect(user).toEqual({
email: expect.schemaMatching(type('string.email')),
})
})
```

:::tip
You can use `expect.not` with this matcher to negate the expected value.
:::

## expect.addSnapshotSerializer

- **Type:** `(plugin: PrettyFormatPlugin) => void`
Expand Down
1 change: 1 addition & 0 deletions packages/expect/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"dev": "rollup -c --watch"
},
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@types/chai": "catalog:",
"@vitest/spy": "workspace:*",
"@vitest/utils": "workspace:*",
Expand Down
1 change: 1 addition & 0 deletions packages/expect/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export {
AsymmetricMatcher,
JestAsymmetricMatchers,
ObjectContaining,
SchemaMatching,
StringContaining,
StringMatching,
} from './jest-asymmetric-matchers'
Expand Down
55 changes: 54 additions & 1 deletion packages/expect/src/jest-asymmetric-matchers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable unicorn/no-instanceof-builtins -- we check both */

import type { StandardSchemaV1 } from '@standard-schema/spec'
import type { ChaiPlugin, MatcherState, Tester } from './types'
import { GLOBAL_EXPECT } from './constants'
import {
Expand All @@ -8,14 +9,15 @@ import {
getMatcherUtils,
stringify,
} from './jest-matcher-utils'

import {
equals,
isA,
isStandardSchema,
iterableEquality,
pluralize,
subsetEquality,
} from './jest-utils'

import { getState } from './state'

export interface AsymmetricMatcherInterface {
Expand Down Expand Up @@ -395,6 +397,50 @@ class CloseTo extends AsymmetricMatcher<number> {
}
}

export class SchemaMatching extends AsymmetricMatcher<StandardSchemaV1<unknown, unknown>> {
private result: StandardSchemaV1.Result<unknown> | undefined

constructor(sample: StandardSchemaV1<unknown, unknown>, inverse = false) {
if (!isStandardSchema(sample)) {
throw new TypeError(
'SchemaMatching expected to receive a Standard Schema.',
)
}
super(sample, inverse)
}

asymmetricMatch(other: unknown): boolean {
const result = this.sample['~standard'].validate(other)

// Check if the result is a Promise (async validation)
if (result instanceof Promise) {
throw new TypeError('Async schema validation is not supported in asymmetric matchers.')
}

this.result = result
const pass = !this.result.issues || this.result.issues.length === 0

return this.inverse ? !pass : pass
}

toString() {
return `Schema${this.inverse ? 'Not' : ''}Matching`
}

getExpectedType() {
return 'object'
}

toAsymmetricMatcher(): string {
const { utils } = this.getMatcherContext()
const issues = this.result?.issues || []
if (issues.length > 0) {
return `${this.toString()} ${utils.stringify(this.result, undefined, { printBasicPrototype: false })}`
}
return this.toString()
}
}

export const JestAsymmetricMatchers: ChaiPlugin = (chai, utils) => {
utils.addMethod(chai.expect, 'anything', () => new Anything())

Expand Down Expand Up @@ -428,6 +474,12 @@ export const JestAsymmetricMatchers: ChaiPlugin = (chai, utils) => {
chai.expect,
'closeTo',
(expected: any, precision?: number) => new CloseTo(expected, precision),
)

utils.addMethod(
chai.expect,
'schemaMatching',
(expected: any) => new SchemaMatching(expected),
);

// defineProperty does not work
Expand All @@ -441,5 +493,6 @@ export const JestAsymmetricMatchers: ChaiPlugin = (chai, utils) => {
new StringMatching(expected, true),
closeTo: (expected: any, precision?: number) =>
new CloseTo(expected, precision, true),
schemaMatching: (expected: any) => new SchemaMatching(expected, true),
}
}
13 changes: 13 additions & 0 deletions packages/expect/src/jest-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

*/

import type { StandardSchemaV1 } from '@standard-schema/spec'
import type { AsymmetricMatcher } from './jest-asymmetric-matchers'
import type { Tester, TesterContext } from './types'
import { isObject } from '@vitest/utils/helpers'
Expand Down Expand Up @@ -799,3 +800,15 @@ export function getObjectSubset(

return { subset: getObjectSubsetWithContext()(object, subset), stripped }
}

/**
* Detects if an object is a Standard Schema V1 compatible schema
*/
export function isStandardSchema(obj: any): obj is StandardSchemaV1 {
return (
!!obj
&& typeof obj === 'object'
&& obj['~standard']
&& typeof obj['~standard'].validate === 'function'
)
}
11 changes: 11 additions & 0 deletions packages/expect/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,17 @@ export interface AsymmetricMatchersContaining extends CustomMatcher {
* expect(5.11).toEqual(expect.closeTo(5.12)); // with default precision
*/
closeTo: (expected: number, precision?: number) => any

/**
* Matches if the received value validates against a Standard Schema.
*
* @param schema - A Standard Schema V1 compatible schema object
*
* @example
* expect(user).toEqual(expect.schemaMatching(z.object({ name: z.string() })))
* expect(['hello', 'world']).toEqual([expect.schemaMatching(z.string()), expect.schemaMatching(z.string())])
*/
schemaMatching: (schema: unknown) => any
}

type WithAsymmetricMatcher<T> = T | AsymmetricMatcher<unknown>
Expand Down
Loading
Loading