diff --git a/.changeset/young-boxes-reflect.md b/.changeset/young-boxes-reflect.md new file mode 100644 index 0000000000..2c86f04656 --- /dev/null +++ b/.changeset/young-boxes-reflect.md @@ -0,0 +1,31 @@ +--- +"@effect/vitest": patch +--- + +Adds property testing to @effect/vitest + +```ts +import { Schema } from "effect" +import { it } from "@effect/vitest" + +const realNumber = Schema.Finite.pipe(Schema.nonNaN()) + +it.prop("symmetry", [realNumber, realNumber], ([a, b]) => a + b === b + a) + +it.effect.prop("symmetry", [realNumber, realNumber], ([a, b]) => + Effect.gen(function* () { + yield* Effect.void + return a + b === b + a + }) +) + +it.scoped.prop( + "should detect the substring", + { a: Schema.String, b: Schema.String, c: Schema.String }, + ({ a, b, c }) => + Effect.gen(function* () { + yield* Effect.scope + return (a + b + c).includes(b) + }) +) +``` diff --git a/packages/vitest/src/index.ts b/packages/vitest/src/index.ts index fb3df71a59..dbed927fdd 100644 --- a/packages/vitest/src/index.ts +++ b/packages/vitest/src/index.ts @@ -4,6 +4,7 @@ import type * as Duration from "effect/Duration" import type * as Effect from "effect/Effect" import type * as Layer from "effect/Layer" +import type * as Schema from "effect/Schema" import type * as Scope from "effect/Scope" import type * as TestServices from "effect/TestServices" import * as V from "vitest" @@ -36,6 +37,11 @@ export namespace Vitest { ): void } + /** + * @since 1.0.0 + */ + export type SchemaObj = Array | { [K in string]: Schema.Schema.Any } + /** * @since 1.0.0 */ @@ -47,6 +53,21 @@ export namespace Vitest { each: ( cases: ReadonlyArray ) => (name: string, self: TestFunction>, timeout?: number | V.TestOptions) => void + + /** + * @since 1.0.0 + */ + prop: ( + name: string, + schemas: S, + self: TestFunction< + A, + E, + R, + [{ [K in keyof S]: Schema.Schema.Type }, V.TaskContext> & V.TestContext] + >, + timeout?: number | V.TestOptions + ) => void } /** @@ -67,6 +88,19 @@ export namespace Vitest { (f: (it: Vitest.Methods) => void): void (name: string, f: (it: Vitest.Methods) => void): void } + + /** + * @since 1.0.0 + */ + readonly prop: ( + name: string, + schemas: S, + self: ( + schemas: { [K in keyof S]: Schema.Schema.Type }, + ctx: V.TaskContext> & V.TestContext + ) => void, + timeout?: number | V.TestOptions + ) => void } } @@ -151,8 +185,25 @@ export const flakyTest: ( timeout?: Duration.DurationInput ) => Effect.Effect = internal.flakyTest +/** + * @since 1.0.0 + */ +export const prop: ( + name: string, + schemas: S, + self: ( + schemas: { [K in keyof S]: Schema.Schema.Type }, + ctx: V.TaskContext> & V.TestContext + ) => void, + timeout?: number | V.TestOptions +) => void = internal.prop + +/** + * @since 1.0.0 + */ + /** @ignored */ -const methods = { effect, live, flakyTest, scoped, scopedLive, layer } as const +const methods = { effect, live, flakyTest, scoped, scopedLive, layer, prop } as const /** * @since 1.0.0 diff --git a/packages/vitest/src/internal.ts b/packages/vitest/src/internal.ts index 6285ac9150..c12b4b657b 100644 --- a/packages/vitest/src/internal.ts +++ b/packages/vitest/src/internal.ts @@ -2,6 +2,7 @@ * @since 1.0.0 */ import type { Tester, TesterContext } from "@vitest/expect" +import * as Arbitrary from "effect/Arbitrary" import * as Cause from "effect/Cause" import * as Duration from "effect/Duration" import * as Effect from "effect/Effect" @@ -16,6 +17,7 @@ import * as Scope from "effect/Scope" import * as TestEnvironment from "effect/TestContext" import type * as TestServices from "effect/TestServices" import * as Utils from "effect/Utils" +import fc from "fast-check" import * as V from "vitest" import type * as Vitest from "./index.js" @@ -45,8 +47,7 @@ const runPromise = (ctx?: Vitest.TaskContext) => (effect: Effect.Effect f()) /** @internal */ -const runTest = (ctx?: Vitest.TaskContext) => (effect: Effect.Effect) => - runPromise(ctx)(Effect.asVoid(effect)) +const runTest = (ctx?: Vitest.TaskContext) => (effect: Effect.Effect) => runPromise(ctx)(effect) /** @internal */ const TestEnv = TestEnvironment.TestContext.pipe( @@ -93,10 +94,62 @@ const makeTester = ( V.it.for(cases)( name, typeof timeout === "number" ? { timeout } : timeout ?? {}, - (args, ctx) => run(ctx, [args], self) + (args, ctx) => run(ctx, [args], self) as any ) - return Object.assign(f, { skip, skipIf, runIf, only, each }) + const prop: Vitest.Vitest.Tester["prop"] = (name, schemaObj, self, timeout) => { + if (Array.isArray(schemaObj)) { + const arbs = schemaObj.map((schema) => Arbitrary.make(schema)) + return V.it( + name, + // @ts-ignore + (ctx) => fc.assert(fc.asyncProperty(...arbs, (...as) => run(ctx, [as as any, ctx], self))), + timeout + ) + } + + const arbs = fc.record( + Object.keys(schemaObj).reduce(function(result, key) { + result[key] = Arbitrary.make(schemaObj[key]) + return result + }, {} as Record>) + ) + + return V.it( + name, + // @ts-ignore + (ctx) => fc.assert(fc.asyncProperty(arbs, (...as) => run(ctx, [as[0] as any, ctx], self))), + timeout + ) + } + + return Object.assign(f, { skip, skipIf, runIf, only, each, prop }) +} + +export const prop: Vitest.Vitest.Methods["prop"] = (name, schemaObj, self, timeout) => { + if (Array.isArray(schemaObj)) { + const arbs = schemaObj.map((schema) => Arbitrary.make(schema)) + return V.it( + name, + // @ts-ignore + (ctx) => fc.assert(fc.property(...arbs, (...as) => self(as, ctx))), + timeout + ) + } + + const arbs = fc.record( + Object.keys(schemaObj).reduce(function(result, key) { + result[key] = Arbitrary.make(schemaObj[key]) + return result + }, {} as Record>) + ) + + return V.it( + name, + // @ts-ignore + (ctx) => fc.assert(fc.property(arbs, (...as) => self(as[0], ctx))), + timeout + ) } /** @internal */ @@ -127,6 +180,9 @@ export const layer = (layer_: Layer.Layer, options?: { Effect.provide(TestEnv) )) ), + + prop, + scoped: makeTester((effect) => Effect.flatMap(runtimeEffect, (runtime) => effect.pipe( diff --git a/packages/vitest/test/index.test.ts b/packages/vitest/test/index.test.ts index 4670862365..15efbbde13 100644 --- a/packages/vitest/test/index.test.ts +++ b/packages/vitest/test/index.test.ts @@ -1,5 +1,5 @@ import { afterAll, describe, expect, it, layer } from "@effect/vitest" -import { Context, Effect, Layer } from "effect" +import { Context, Effect, Layer, Schema } from "effect" it.live( "live %s", @@ -146,5 +146,34 @@ layer(Foo.Live)("layer", (it) => { expect(scoped).toEqual("scoped") })) }) + + it.effect.prop("adds context", [Schema.Number], ([num]) => + Effect.gen(function*() { + const foo = yield* Foo + expect(foo).toEqual("foo") + return num === num + })) }) }) + +// property testing + +const realNumber = Schema.Finite.pipe(Schema.nonNaN()) + +it.prop("symmetry", [realNumber, realNumber], ([a, b]) => a + b === b + a) + +it.effect.prop("symmetry", [realNumber, realNumber], ([a, b]) => + Effect.gen(function*() { + yield* Effect.void + return a + b === b + a + })) + +it.scoped.prop( + "should detect the substring", + { a: Schema.String, b: Schema.String, c: Schema.String }, + ({ a, b, c }) => + Effect.gen(function*() { + yield* Effect.scope + return (a + b + c).includes(b) + }) +)