diff --git a/.changeset/tame-bags-know.md b/.changeset/tame-bags-know.md new file mode 100644 index 0000000000..3775221444 --- /dev/null +++ b/.changeset/tame-bags-know.md @@ -0,0 +1,31 @@ +--- +"@effect/vitest": patch +--- + +Adds property testing to @effect/vitest + +```ts +import { Schema } from "@effect/schema" +import { prop } from "@effect/vitest/Schema" + +const realNumber = Schema.Finite.pipe(Schema.nonNaN()) + +prop("symmetry", [realNumber, realNumber], ([a, b]) => a + b === b + a) + +prop.effect("symmetry", [realNumber, realNumber], ([a, b]) => + Effect.gen(function* () { + yield* Effect.void + return a + b === b + a + }) +) + +prop.scoped( + "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/package.json b/packages/vitest/package.json index 1eec197909..4f3053c683 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -29,10 +29,12 @@ }, "peerDependencies": { "effect": "workspace:^", + "@effect/schema": "workspace:^", "vitest": "^2.0.5" }, "devDependencies": { "effect": "workspace:^", + "@effect/schema": "workspace:^", "vitest": "^2.0.5" } } diff --git a/packages/vitest/src/Schema.ts b/packages/vitest/src/Schema.ts new file mode 100644 index 0000000000..d78721f770 --- /dev/null +++ b/packages/vitest/src/Schema.ts @@ -0,0 +1,118 @@ +import * as Arbitrary from "@effect/schema/Arbitrary" +import type * as Schema from "@effect/schema/Schema" +import * as Effect from "effect/Effect" +import type * as Scope from "effect/Scope" +import type { TestServices } from "effect/TestServices" +import fc from "fast-check" +import * as V from "vitest" +import * as internal from "./internal.js" + +/** + * @since 1.0.0 + */ +type SchemaObj = Array> | { [K in string]: Schema.Schema } + +/** + * @since 1.0.0 + */ +export type TestFn, R> = ( + schemas: { [K in keyof S]: Schema.Schema.Type }, + ctx: V.TaskContext> & V.TestContext +) => R + +/** + * @internal + */ +const makePropTester = () => { + const f = >( + name: string, + schemaObj: S, + fn: TestFn>, + timeout?: number | V.TestOptions + ) => { + 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) => fn(as as any, 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.asyncProperty(arbs, (...as) => fn(as[0] as any, ctx))), + timeout + ) + } + + const effect = >( + name: string, + schemaObj: S, + fn: TestFn>, + timeout?: number | V.TestOptions + ) => + f( + name, + schemaObj, + (obj, ctx) => Effect.runPromise(fn(obj, ctx).pipe(Effect.provide(internal.TestEnv))), + timeout + ) + + const live = >( + name: string, + schemaObj: S, + fn: TestFn>, + timeout?: number | V.TestOptions + ) => + f( + name, + schemaObj, + (obj, ctx) => Effect.runPromise(fn(obj, ctx)), + timeout + ) + + const scoped = >( + name: string, + schemaObj: S, + fn: TestFn>, + timeout?: number | V.TestOptions + ) => + f( + name, + schemaObj, + (obj, ctx) => Effect.runPromise(fn(obj, ctx).pipe(Effect.scoped, Effect.provide(internal.TestEnv))), + timeout + ) + + const scopedLive = >( + name: string, + schemaObj: S, + fn: TestFn>, + timeout?: number | V.TestOptions + ) => + f( + name, + schemaObj, + (obj, ctx) => Effect.runPromise(fn(obj, ctx).pipe(Effect.scoped)), + timeout + ) + + return Object.assign(f, { + effect, + scoped, + live, + scopedLive + }) +} + +export const prop = makePropTester() diff --git a/packages/vitest/src/internal.ts b/packages/vitest/src/internal.ts index 6285ac9150..57b6c795ea 100644 --- a/packages/vitest/src/internal.ts +++ b/packages/vitest/src/internal.ts @@ -19,6 +19,7 @@ import * as Utils from "effect/Utils" import * as V from "vitest" import type * as Vitest from "./index.js" +/** @internal */ const runPromise = (ctx?: Vitest.TaskContext) => (effect: Effect.Effect) => Effect.gen(function*() { const exitFiber = yield* Effect.fork(Effect.exit(effect)) @@ -45,11 +46,10 @@ const runPromise = (ctx?: Vitest.TaskContext) => (effect: Effect.Effect f()) /** @internal */ -const runTest = (ctx?: Vitest.TaskContext) => (effect: Effect.Effect) => - runPromise(ctx)(Effect.asVoid(effect)) +export const runTest = (ctx?: Vitest.TaskContext) => (effect: Effect.Effect) => runPromise(ctx)(effect) /** @internal */ -const TestEnv = TestEnvironment.TestContext.pipe( +export const TestEnv = TestEnvironment.TestContext.pipe( Layer.provide(Logger.remove(Logger.defaultLogger)) ) @@ -93,7 +93,7 @@ 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 }) diff --git a/packages/vitest/test/Schema.test.ts b/packages/vitest/test/Schema.test.ts new file mode 100644 index 0000000000..c4e78385d5 --- /dev/null +++ b/packages/vitest/test/Schema.test.ts @@ -0,0 +1,25 @@ +import { Schema } from "@effect/schema" +import { Effect } from "effect" +import { prop } from "@effect/vitest/Schema" + +// property testing + +const realNumber = Schema.Finite.pipe(Schema.nonNaN()) + +prop("symmetry", [realNumber, realNumber], ([a, b]) => a + b === b + a) + +prop.effect("symmetry", [realNumber, realNumber], ([a, b]) => + Effect.gen(function*() { + yield* Effect.void + return a + b === b + a + })) + +prop.scoped( + "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/tsconfig.build.json b/packages/vitest/tsconfig.build.json index 76ee3883a1..dadbe3eb97 100644 --- a/packages/vitest/tsconfig.build.json +++ b/packages/vitest/tsconfig.build.json @@ -1,7 +1,8 @@ { "extends": "./tsconfig.src.json", "references": [ - { "path": "../effect/tsconfig.build.json" } + { "path": "../effect/tsconfig.build.json" }, + { "path": "../schema/tsconfig.build.json" } ], "compilerOptions": { "tsBuildInfoFile": ".tsbuildinfo/build.tsbuildinfo", diff --git a/packages/vitest/tsconfig.src.json b/packages/vitest/tsconfig.src.json index 8b0357d42d..bff9c5cb72 100644 --- a/packages/vitest/tsconfig.src.json +++ b/packages/vitest/tsconfig.src.json @@ -1,7 +1,7 @@ { "extends": "../../tsconfig.base.json", "include": ["src"], - "references": [{ "path": "../effect" }], + "references": [{ "path": "../effect/tsconfig.src.json" }, { "path": "../schema/tsconfig.build.json" }], "compilerOptions": { "outDir": "build/src", "tsBuildInfoFile": ".tsbuildinfo/src.tsbuildinfo", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 571ce9c715..696515d882 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -861,6 +861,9 @@ importers: packages/vitest: devDependencies: + '@effect/schema': + specifier: workspace:^ + version: link:../schema/dist effect: specifier: workspace:^ version: link:../effect/dist diff --git a/tsconfig.base.json b/tsconfig.base.json index fe004b5dd2..76f75d8f5e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -152,7 +152,8 @@ "@effect/typeclass": ["./packages/typeclass/src/index.js"], "@effect/typeclass/*": ["./packages/typeclass/src/*.js"], "@effect/typeclass/test/*": ["./packages/typeclass/test/*.js"], - "@effect/vitest": ["./packages/vitest/src/index.js"] + "@effect/vitest": ["./packages/vitest/src/index.js"], + "@effect/vitest/*": ["./packages/vitest/src/*.js"] } } }