diff --git a/docs/api/expect.md b/docs/api/expect.md index e577949c213b..069010257c4a 100644 --- a/docs/api/expect.md +++ b/docs/api/expect.md @@ -1688,6 +1688,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: 'john@example.com' } + + // 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` diff --git a/packages/expect/package.json b/packages/expect/package.json index 0625fdef1d19..643036c18483 100644 --- a/packages/expect/package.json +++ b/packages/expect/package.json @@ -33,6 +33,7 @@ "dev": "rollup -c --watch" }, "dependencies": { + "@standard-schema/spec": "^1.0.0", "@types/chai": "catalog:", "@vitest/spy": "workspace:*", "@vitest/utils": "workspace:*", diff --git a/packages/expect/src/index.ts b/packages/expect/src/index.ts index 074cf23e254d..19c70016ce4b 100644 --- a/packages/expect/src/index.ts +++ b/packages/expect/src/index.ts @@ -13,6 +13,7 @@ export { AsymmetricMatcher, JestAsymmetricMatchers, ObjectContaining, + SchemaMatching, StringContaining, StringMatching, } from './jest-asymmetric-matchers' diff --git a/packages/expect/src/jest-asymmetric-matchers.ts b/packages/expect/src/jest-asymmetric-matchers.ts index 38a539372c9b..9525ed22db78 100644 --- a/packages/expect/src/jest-asymmetric-matchers.ts +++ b/packages/expect/src/jest-asymmetric-matchers.ts @@ -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 { @@ -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 { @@ -395,6 +397,50 @@ class CloseTo extends AsymmetricMatcher { } } +export class SchemaMatching extends AsymmetricMatcher> { + private result: StandardSchemaV1.Result | undefined + + constructor(sample: StandardSchemaV1, 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()) @@ -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 @@ -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), } } diff --git a/packages/expect/src/jest-utils.ts b/packages/expect/src/jest-utils.ts index d6485ba62c97..69f9f628c8fb 100644 --- a/packages/expect/src/jest-utils.ts +++ b/packages/expect/src/jest-utils.ts @@ -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' @@ -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' + ) +} diff --git a/packages/expect/src/types.ts b/packages/expect/src/types.ts index 6c9e53a8c305..66206be70c6e 100644 --- a/packages/expect/src/types.ts +++ b/packages/expect/src/types.ts @@ -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 | AsymmetricMatcher diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52dd861deffe..57925746feaa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -666,6 +666,9 @@ importers: packages/expect: dependencies: + '@standard-schema/spec': + specifier: ^1.0.0 + version: 1.0.0 '@types/chai': specifier: 'catalog:' version: 5.2.2 @@ -1266,6 +1269,9 @@ importers: test/core: devDependencies: + '@standard-schema/spec': + specifier: ^1.0.0 + version: 1.0.0 '@test/vite-environment-external': specifier: link:./projects/vite-environment-external version: link:projects/vite-environment-external @@ -4021,6 +4027,9 @@ packages: '@sinonjs/fake-timers@14.0.0': resolution: {integrity: sha512-QfoXRaUTjMVVn/ZbnD4LS3TPtqOkOdKIYCKldIVPnuClcwRKat6LI2mRZ2s5qiBfO6Fy03An35dSls/2/FEc0Q==} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@stylistic/eslint-plugin@5.4.0': resolution: {integrity: sha512-UG8hdElzuBDzIbjG1QDwnYH0MQ73YLXDFHgZzB4Zh/YJfnw8XNsloVtytqzx0I2Qky9THSdpTmi8Vjn/pf/Lew==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -11927,6 +11936,8 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@standard-schema/spec@1.0.0': {} + '@stylistic/eslint-plugin@5.4.0(eslint@9.37.0(jiti@2.5.1))': dependencies: '@eslint-community/eslint-utils': 4.9.0(eslint@9.37.0(jiti@2.5.1)) diff --git a/test/core/package.json b/test/core/package.json index d171e4e2ae3f..d2ccb7251f21 100644 --- a/test/core/package.json +++ b/test/core/package.json @@ -13,6 +13,7 @@ "collect": "vitest list" }, "devDependencies": { + "@standard-schema/spec": "^1.0.0", "@test/vite-environment-external": "link:./projects/vite-environment-external", "@test/vite-external": "link:./projects/vite-external", "@types/debug": "catalog:", diff --git a/test/core/test/expect.test.ts b/test/core/test/expect.test.ts index 46b2c6251a38..22851dccdecd 100644 --- a/test/core/test/expect.test.ts +++ b/test/core/test/expect.test.ts @@ -1,5 +1,8 @@ +import type { StandardSchemaV1 } from '@standard-schema/spec' import type { Tester } from '@vitest/expect' +import { stripVTControlCharacters } from 'node:util' import { getCurrentTest } from '@vitest/runner' +import { processError } from '@vitest/utils/error' import { Temporal } from 'temporal-polyfill' import { describe, expect, expectTypeOf, test, vi } from 'vitest' @@ -477,3 +480,313 @@ describe('expect with custom message', () => { }) }) }) + +describe('Standard Schema', () => { + function createMockSchema(validate: StandardSchemaV1['~standard']['validate']): StandardSchemaV1 { + return { + '~standard': { + version: 1, + vendor: 'mock', + validate, + }, + } + } + + function createAsyncMockSchema(validate: StandardSchemaV1['~standard']['validate']): StandardSchemaV1 { + return { + '~standard': { + version: 1, + vendor: 'mock-async', + validate: value => Promise.resolve(validate(value)), + }, + } + } + + const stringSchema = createMockSchema(value => + typeof value === 'string' ? { issues: undefined, value } : { issues: [{ message: 'Expected string' }] }, + ) + const numberSchema = createMockSchema(value => + typeof value === 'number' ? { issues: undefined, value } : { issues: [{ message: 'Expected number' }] }, + ) + const emailSchema = createMockSchema(value => + typeof value === 'string' && /^[\w%+.-]+@[\d.A-Z-]+\.[A-Z]{2,}$/i.test(value) ? { issues: undefined, value } : { issues: [{ message: 'Expected email' }] }, + ) + const objectSchema = createMockSchema(value => + typeof value === 'object' && value !== null && 'name' in value && 'age' in value && typeof value.name === 'string' && typeof value.age === 'number' ? { issues: undefined, value } : { issues: [{ message: 'Expected object' }] }, + ) + const asyncStringSchema = createAsyncMockSchema(value => + typeof value === 'string' ? { issues: undefined, value } : { issues: [{ message: 'Expected string' }] }, + ) + + describe('schemaMatching()', () => { + test('should work with primitive values', () => { + expect('hello').toEqual(expect.schemaMatching(stringSchema)) + expect(42).toEqual(expect.schemaMatching(numberSchema)) + + expect(() => expect(123).toEqual(expect.schemaMatching(stringSchema))).toThrowErrorMatchingInlineSnapshot(`[AssertionError: expected 123 to deeply equal SchemaMatching{…}]`) + expect(() => expect('hello').toEqual(expect.schemaMatching(numberSchema))).toThrowErrorMatchingInlineSnapshot(`[AssertionError: expected 'hello' to deeply equal SchemaMatching{…}]`) + + try { + expect(123).toEqual(expect.schemaMatching(stringSchema)) + expect.unreachable() + } + catch (err) { + const error = processError(err) + const diff = stripVTControlCharacters(error.diff) + expect(diff).toMatchInlineSnapshot(` + "- Expected: + SchemaMatching { + "issues": [ + { + "message": "Expected string", + }, + ], + } + + + Received: + 123" + `) + } + }) + + test('should work with objects', () => { + expect({ + email: 'john@example.com', + }).toEqual({ + email: expect.schemaMatching(emailSchema), + }) + + expect(() => expect({ + email: 123, + }).toEqual({ + email: expect.schemaMatching(emailSchema), + })).toThrowErrorMatchingInlineSnapshot(`[AssertionError: expected { email: 123 } to deeply equal { email: SchemaMatching{…} }]`) + + try { + expect({ + email: 'not-an-email', + }).toEqual({ + email: expect.schemaMatching(emailSchema), + }) + expect.unreachable() + } + catch (err) { + const error = processError(err) + const diff = stripVTControlCharacters(error.diff) + expect(diff).toMatchInlineSnapshot(` + "- Expected + + Received + + { + - "email": SchemaMatching { + - "issues": [ + - { + - "message": "Expected email", + - }, + - ], + - }, + + "email": "not-an-email", + }" + `) + } + }) + + test('should work with objectContaining', () => { + expect({ + name: 'John', + age: 30, + }).toEqual(expect.objectContaining({ + age: expect.schemaMatching(numberSchema), + })) + + try { + expect({ + user: { + name: 'John', + age: 'thirty', + }, + }).toEqual({ + user: { + name: expect.schemaMatching(stringSchema), + age: expect.schemaMatching(numberSchema), + }, + }) + expect.unreachable() + } + catch (err) { + const error = processError(err) + const diff = stripVTControlCharacters(error.diff) + expect(diff).toMatchInlineSnapshot(` + "- Expected + + Received + + { + "user": { + - "age": SchemaMatching { + - "issues": [ + - { + - "message": "Expected number", + - }, + - ], + - }, + + "age": "thirty", + "name": SchemaMatching, + }, + }" + `) + } + }) + + test('should work with arrayContaining', () => { + expect([{ + name: 'John', + age: 30, + }]).toEqual(expect.arrayContaining([expect.schemaMatching(objectSchema)])) + + try { + expect([{ + name: 'John', + age: 'thirty', + }]).toEqual(expect.arrayContaining([expect.schemaMatching(objectSchema)])) + expect.unreachable() + } + catch (err) { + const error = processError(err) + const diff = stripVTControlCharacters(error.diff) + expect(diff).toContain('SchemaMatching') + expect(diff).toContain('ArrayContaining') + } + }) + + test('should work with negation', () => { + expect(123).not.toEqual(expect.schemaMatching(stringSchema)) + expect('hello').not.toEqual(expect.schemaMatching(numberSchema)) + + expect(() => expect('hello').not.toEqual(expect.schemaMatching(stringSchema))).toThrowErrorMatchingInlineSnapshot(`[AssertionError: expected 'hello' to not deeply equal SchemaMatching]`) + + try { + expect('hello').not.toEqual(expect.schemaMatching(stringSchema)) + expect.unreachable() + } + catch (err) { + const error = processError(err) + const diff = stripVTControlCharacters(error.diff) + expect(diff).toMatchInlineSnapshot(` + "- Expected: + SchemaMatching + + + Received: + "hello"" + `) + } + }) + + test('should throw error for async schemas', () => { + expect(() => expect('hello').toEqual(expect.schemaMatching(asyncStringSchema))).toThrowErrorMatchingInlineSnapshot(`[TypeError: Async schema validation is not supported in asymmetric matchers.]`) + }) + + test('should throw error for non-schema argument', () => { + expect(() => expect.schemaMatching('not-a-schema')).toThrowErrorMatchingInlineSnapshot(`[TypeError: SchemaMatching expected to receive a Standard Schema.]`) + }) + + test('should work with toMatchObject', () => { + const data = { + user: { + name: 'John', + age: 30, + }, + extra: 'data', + } + + expect(data).toMatchObject({ + user: { + name: expect.schemaMatching(stringSchema), + age: expect.schemaMatching(numberSchema), + }, + }) + + try { + expect({ + user: { + name: 123, + age: 30, + }, + }).toMatchObject({ + user: { + name: expect.schemaMatching(stringSchema), + }, + }) + expect.unreachable() + } + catch (err) { + const error = processError(err) + const diff = stripVTControlCharacters(error.diff) + expect(diff).toMatchInlineSnapshot(` + "- Expected + + Received + + { + "user": { + - "name": SchemaMatching { + - "issues": [ + - { + - "message": "Expected string", + - }, + - ], + - }, + + "name": 123, + }, + }" + `) + } + + try { + expect({ + name: 123, + email: 'invalid', + age: 'thirty', + }).toEqual({ + name: expect.schemaMatching(stringSchema), + email: expect.schemaMatching(emailSchema), + age: expect.schemaMatching(numberSchema), + }) + expect.unreachable() + } + catch (err) { + const error = processError(err) + const diff = stripVTControlCharacters(error.diff) + expect(diff).toMatchInlineSnapshot(` + "- Expected + + Received + + { + - "age": SchemaMatching { + - "issues": [ + - { + - "message": "Expected number", + - }, + - ], + - }, + - "email": SchemaMatching { + - "issues": [ + - { + - "message": "Expected email", + - }, + - ], + - }, + - "name": SchemaMatching { + - "issues": [ + - { + - "message": "Expected string", + - }, + - ], + - }, + + "age": "thirty", + + "email": "invalid", + + "name": 123, + }" + `) + } + }) + }) +})