diff --git a/README.md b/README.md index 0077008..d37ed1a 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ This import enhances the standard `it` function from `@rstest/core` with several | `it.effect` | Runs a scoped test with test services such as `TestClock` and `TestConsole`. | | `it.live` | Runs a scoped test with the live Effect environment. | | `it.layer` | Shares a `Layer` between multiple tests. | -| `it.prop` | Runs property tests using Effect `Schema` values or FastCheck arbitraries. | +| `it.prop` | Runs property tests using Effect `Schema` values or `Arbitrary` inputs. | | `it.flakyTest` | Retries an Effect that might occasionally fail until it succeeds or reaches the configured timeout. | The package also re-exports everything from `@rstest/core` (`describe`, `expect`, `assert`, hooks, `rs`, ...), so a single import usually suffices, and provides the same assertion helpers as `@effect/vitest/utils` under `effect-rstest/utils`. @@ -390,33 +390,38 @@ it.effect("retrying until success or timeout", () => it.flakyTest(flaky, "5 seco ## Property Testing with `it.prop` -`it.prop`, `it.effect.prop` and `it.live.prop` run property tests using FastCheck arbitraries (from `effect/testing/FastCheck`) or Effect `Schema` values: +`it.prop`, `it.effect.prop` and `it.live.prop` run property tests using Effect `Schema` values or arbitraries from `effect/unstable/arbitrary`: ```ts import { assert, it } from "effect-rstest" import { Effect, Schema } from "effect" -import { FastCheck } from "effect/testing" +import { Arbitrary } from "effect/unstable/arbitrary" -const realNumber = FastCheck.float({ noNaN: true, noDefaultInfinity: true }) +const realNumber = Schema.Finite +const letter = Arbitrary.schema(Schema.Literals(["a", "b"])) // synchronous properties -it.prop("symmetry", [realNumber, FastCheck.integer()], ([a, b]) => a + b === b + a) +it.prop("symmetry", [realNumber, Schema.Int], ([a, b]) => a + b === b + a) -// named arbitraries +// named inputs, mixing schemas and arbitraries it.prop( - "symmetry with object", - { a: realNumber, b: FastCheck.integer() }, - ({ a, b }) => a + b === b + a + "letters", + { count: Schema.Int, text: letter }, + ({ count, text }) => Number.isInteger(count) && ["a", "b"].includes(text) ) -// effectful properties, with Schema-derived arbitraries +// effectful properties it.effect.prop("schema with object", { value: Schema.Int }, ({ value }) => Effect.sync(() => assert.isTrue(Number.isInteger(value)))) ``` -All three helpers accept both tuple and record inputs, mixing schemas and FastCheck arbitraries. Schemas are converted with `Schema.toArbitrary(schema)(FastCheck)`; FastCheck arbitraries are used directly. For example, a synchronous property can use `[Schema.Literal("schema"), FastCheck.integer()]` or `{ label: Schema.Literal("schema"), count: FastCheck.integer() }`. A schema must support arbitrary generation; this does not make every possible schema generatable. +All three helpers accept tuple and record inputs. Schemas are converted with `Arbitrary.schema(schema)`; `Arbitrary` values are used directly. A schema must support arbitrary generation. -FastCheck parameters can be passed through the options argument: `{ fastCheck: { numRuns: 200 } }`. +Returning `false`, throwing, or failing the Effect (including failed assertions) falsifies the property and shrinks the input; interruption still interrupts the test. The test timeout interrupts generation, evaluation, and shrinking, and Effect finalizers run. A timeout cannot preempt a synchronous callback that never returns. + +Check options are passed as `arbitrary` in the options argument: `{ arbitrary: { runs: 200, seed: "repro" } }` (see `Arbitrary.CheckOptions`). + +Requires `effect` `4.0.0-rc.113` or later. Earlier releases of `effect-rstest` used `effect/testing/FastCheck`, which Effect removed in rc.113; migrate `FastCheck.*` inputs to schemas or `Arbitrary`, and `{ fastCheck: { numRuns } }` to `{ arbitrary: { runs } }`. ## Differences from `@effect/vitest` diff --git a/package.json b/package.json index e04360b..af7c2d2 100644 --- a/package.json +++ b/package.json @@ -45,12 +45,12 @@ }, "peerDependencies": { "@rstest/core": "^0.11.11", - "effect": "^4.0.0-rc.108" + "effect": "^4.0.0-rc.113" }, "devDependencies": { "@rstest/core": "^0.11.11", "@types/node": "^26.4.0", - "effect": "4.0.0-rc.112", + "effect": "4.0.0-rc.117", "pkg-pr-new": "0.0.88", "publint": "^0.3.24", "tsdown": "^0.22.14", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed1f6c2..a0e0cd8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^26.4.0 version: 26.4.0 effect: - specifier: 4.0.0-rc.112 - version: 4.0.0-rc.112 + specifier: 4.0.0-rc.117 + version: 4.0.0-rc.117 pkg-pr-new: specifier: 0.0.88 version: 0.0.88 @@ -44,36 +44,6 @@ packages: '@emnapi/wasi-threads@1.2.3': resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': - resolution: {integrity: sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==} - cpu: [arm64] - os: [darwin] - - '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': - resolution: {integrity: sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==} - cpu: [x64] - os: [darwin] - - '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': - resolution: {integrity: sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==} - cpu: [arm64] - os: [linux] - - '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': - resolution: {integrity: sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==} - cpu: [arm] - os: [linux] - - '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': - resolution: {integrity: sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==} - cpu: [x64] - os: [linux] - - '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': - resolution: {integrity: sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==} - cpu: [x64] - os: [win32] - '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -469,10 +439,6 @@ packages: defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - dts-resolver@3.0.0: resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} engines: {node: ^22.18.0 || >=24.0.0} @@ -482,17 +448,13 @@ packages: oxc-resolver: optional: true - effect@4.0.0-rc.112: - resolution: {integrity: sha512-wXxwuh1Ywnv4cPRM3Wfa0vDwuOHnZ1TsTgHJkG9XgzND6inhBH9n1vBxhg3iIXOia/OrpmvVmd3lrD4vq6bF3A==} + effect@4.0.0-rc.117: + resolution: {integrity: sha512-UUyi9QiOW4nySFIBM3KnYbNMd9EZliOdKfBcyLD0mPMPheO2fkuXS5y0opjWc9oqzlJxPQICEGm8gGw3kh9j/w==} empathic@2.0.1: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} - fast-check@4.9.0: - resolution: {integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==} - engines: {node: '>=12.17.0'} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -517,17 +479,6 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} - msgpackr-extract@3.0.4: - resolution: {integrity: sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==} - hasBin: true - - msgpackr@2.1.0: - resolution: {integrity: sha512-p/pBCVO63CsvvpkomUnNNag6+n38rULuDA6HHe70o2gtC8ODI52foF/4ko2qQcp6OiErJXTmrZeXmsGGHsIQNQ==} - - node-gyp-build-optional-packages@5.2.2: - resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} - hasBin: true - obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} @@ -551,9 +502,6 @@ packages: engines: {node: '>=18'} hasBin: true - pure-rand@8.4.2: - resolution: {integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==} - quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} @@ -689,24 +637,6 @@ snapshots: tslib: 2.8.1 optional: true - '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4': - optional: true - - '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4': - optional: true - - '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4': - optional: true - - '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4': - optional: true - - '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4': - optional: true - - '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4': - optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': dependencies: '@emnapi/core': 1.11.3 @@ -957,22 +887,12 @@ snapshots: defu@6.1.7: {} - detect-libc@2.1.2: - optional: true - dts-resolver@3.0.0: {} - effect@4.0.0-rc.112: - dependencies: - fast-check: 4.9.0 - msgpackr: 2.1.0 + effect@4.0.0-rc.117: {} empathic@2.0.1: {} - fast-check@4.9.0: - dependencies: - pure-rand: 8.4.2 - fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -987,27 +907,6 @@ snapshots: mri@1.2.0: {} - msgpackr-extract@3.0.4: - dependencies: - node-gyp-build-optional-packages: 5.2.2 - optionalDependencies: - '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.4 - '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.4 - '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.4 - '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.4 - '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.4 - '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.4 - optional: true - - msgpackr@2.1.0: - optionalDependencies: - msgpackr-extract: 3.0.4 - - node-gyp-build-optional-packages@5.2.2: - dependencies: - detect-libc: 2.1.2 - optional: true - obug@2.1.4: {} package-manager-detector@1.8.0: {} @@ -1025,8 +924,6 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 - pure-rand@8.4.2: {} - quansync@1.0.0: {} resolve-pkg-maps@1.0.0: {} diff --git a/src/index.ts b/src/index.ts index 6da7d90..9a9483d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,7 @@ 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 FC from "effect/testing/FastCheck" +import type * as Arbitrary from "effect/unstable/arbitrary/Arbitrary" import * as Rs from "@rstest/core" import * as internal from "./internal/internal.js" @@ -58,8 +58,12 @@ export namespace Vitest { * @since 0.1.0 */ export type Arbitraries = - | Array | FC.Arbitrary> - | { [K in string]: Schema.Schema | FC.Arbitrary } + | Array | Arbitrary.Arbitrary> + | { [K in string]: Schema.Schema | Arbitrary.Arbitrary } + + type ArbitraryValue = A extends Schema.Schema ? T + : A extends Arbitrary.Arbitrary ? T + : never /** * @since 0.1.0 @@ -75,6 +79,15 @@ export namespace Vitest { fails: Vitest.Test /** + * Runs an Effectful property test using Schema or Arbitrary inputs. + * + * Returning `false` or completing with any non-interruption failure falsifies the property and triggers + * shrinking. This includes typed Effect failures, thrown exceptions, and defects such as failed assertions. + * Effect interruption continues to interrupt the test. + * + * The test timeout interrupts the Effect fiber running generation, property evaluation, and shrinking. Effect + * finalizers run during that interruption. A timeout cannot preempt a synchronous callback that never returns. + * * @since 0.1.0 */ prop: ( @@ -86,9 +99,7 @@ export namespace Vitest { R, [ { - [K in keyof Arbs]: Arbs[K] extends FC.Arbitrary ? T - : Arbs[K] extends Schema.Schema ? T - : never + [K in keyof Arbs]: ArbitraryValue }, Rs.TestContext ] @@ -96,12 +107,7 @@ export namespace Vitest { timeout?: | number | Rs.TestOptions & { - fastCheck?: FC.Parameters< - { - [K in keyof Arbs]: Arbs[K] extends FC.Arbitrary ? T : Arbs[K] extends Schema.Schema ? T - : never - } - > + arbitrary?: Arbitrary.CheckOptions } ) => void } @@ -127,6 +133,14 @@ export namespace Vitest { } /** + * Runs a synchronous property test using Schema or Arbitrary inputs. + * + * Returning `false` or throwing falsifies the property and triggers shrinking. A callback that returns + * normally without returning `false` passes for that generated input. + * + * The test timeout interrupts the Effect fiber running generation and shrinking. A timeout cannot preempt a + * synchronous callback that never returns. + * * @since 0.1.0 */ readonly prop: ( @@ -134,20 +148,14 @@ export namespace Vitest { arbitraries: Arbs, self: ( properties: { - [K in keyof Arbs]: Arbs[K] extends FC.Arbitrary ? T : Arbs[K] extends Schema.Schema ? T - : never + [K in keyof Arbs]: ArbitraryValue }, ctx: Rs.TestContext ) => void, timeout?: | number | Rs.TestOptions & { - fastCheck?: FC.Parameters< - { - [K in keyof Arbs]: Arbs[K] extends FC.Arbitrary ? T : Arbs[K] extends Schema.Schema ? T - : never - } - > + arbitrary?: Arbitrary.CheckOptions } ) => void } diff --git a/src/internal/internal.ts b/src/internal/internal.ts index 874f291..a3a85d3 100644 --- a/src/internal/internal.ts +++ b/src/internal/internal.ts @@ -10,14 +10,12 @@ import * as Exit from "effect/Exit" import * as Fiber from "effect/Fiber" import { flow, pipe } from "effect/Function" import * as Layer from "effect/Layer" -import { isObject } from "effect/Predicate" -import * as Rec from "effect/Record" import * as Schedule from "effect/Schedule" -import * as Schema from "effect/Schema" +import type * as Schema from "effect/Schema" import * as Scope from "effect/Scope" -import * as fc from "effect/testing/FastCheck" import * as TestClock from "effect/testing/TestClock" import * as TestConsole from "effect/testing/TestConsole" +import * as Arbitrary from "effect/unstable/arbitrary/Arbitrary" import * as Rs from "@rstest/core" import type * as EffectRstest from "../index.js" @@ -67,6 +65,58 @@ const testOptions = (timeout?: number | Rs.TestOptions): Rs.TestOptions => const hookTimeout = (timeout?: Duration.Input) => timeout === undefined ? undefined : Duration.toMillis(Duration.fromInputUnsafe(timeout)) +type PropertyTimeout = + | number + | Rs.TestOptions & { + readonly arbitrary?: Arbitrary.CheckOptions | undefined + } + +type ArbitraryInput = Schema.Schema | Arbitrary.Arbitrary + +type Arbitraries = Array | { [K in string]: ArbitraryInput } + +const checkOptions = (timeout: PropertyTimeout | undefined): Arbitrary.CheckOptions | undefined => + typeof timeout === "object" ? timeout.arbitrary : undefined + +const compileArbitraryInput = (input: ArbitraryInput): Arbitrary.Arbitrary => + Arbitrary.isArbitrary(input) ? input : Arbitrary.schema(input) + +const makeArbitrary = (arbitraries: Arbitraries): Arbitrary.Arbitrary => + Arbitrary.all( + Array.isArray(arbitraries) + ? arbitraries.map(compileArbitraryInput) + : Object.fromEntries(Object.entries(arbitraries).map(([key, input]) => [key, compileArbitraryInput(input)])) + ) + +const normalizeProperty = ( + property: (value: A) => boolean | Effect.Effect, + value: A +): Effect.Effect, R> => + Effect.catchCause( + Effect.suspend(() => { + const output = property(value) + return Effect.isEffect(output) ? output : Effect.succeed(output) + }), + (cause): Effect.Effect> => + Cause.hasInterrupts(cause) ? Effect.failCause(cause) : Effect.fail(cause) + ) + +const runCheck = ( + ctx: Rs.TestContext, + arbitrary: Arbitrary.Arbitrary, + property: (value: A) => boolean | Effect.Effect, + options: Arbitrary.CheckOptions | undefined +): Promise => + runTest(ctx)( + Effect.flatMapEager( + Arbitrary.checkEffect(arbitrary, (value) => normalizeProperty(property, value), options), + (result) => { + const failure = Arbitrary.formatCheckFailure(result) + return failure === undefined ? Effect.void : Effect.die(new Error(failure)) + } + ) + ) + const makeItProxy = ( it: Rs.TestAPIs, overrides: Methods @@ -123,46 +173,20 @@ const makeTester = ( it.fails(name, testOptions(timeout), (ctx) => run(ctx, [ctx], self)) const prop: EffectRstest.Vitest.Tester["prop"] = (name, arbitraries, self, timeout) => { - if (Array.isArray(arbitraries)) { - const arbs = arbitraries.map((arbitrary) => { - if (Schema.isSchema(arbitrary)) { - return Schema.toArbitrary(arbitrary)(fc) - } - return arbitrary as fc.Arbitrary - }) - return it( - name, - testOptions(timeout), - (ctx) => - // @ts-ignore - fc.assert( - // @ts-ignore - fc.asyncProperty(...arbs, (...as) => run(ctx, [as as any, ctx], self)), - // @ts-ignore - isObject(timeout) ? timeout?.fastCheck : {} - ) - ) - } - - const arbs = fc.record( - Object.keys(arbitraries).reduce(function(result, key) { - const arb: any = arbitraries[key] - Rec.assignProperty(result, key, Schema.isSchema(arb) ? Schema.toArbitrary(arb)(fc) : arb) - return result - }, {} as Record>) - ) - + const arbitrary = makeArbitrary(arbitraries) return it( name, testOptions(timeout), (ctx) => - // @ts-ignore - fc.assert( - fc.asyncProperty(arbs, (...as) => - // @ts-ignore - run(ctx, [as[0] as any, ctx], self)), - // @ts-ignore - isObject(timeout) ? timeout?.fastCheck : {} + runCheck( + ctx, + arbitrary, + (values) => + Effect.mapEager( + mapEffect(Effect.suspend(() => self(values as any, ctx))), + (value) => (value as unknown) !== false + ), + checkOptions(timeout) ) ) } @@ -172,34 +196,17 @@ const makeTester = ( /** @internal */ export const prop: EffectRstest.Vitest.Methods["prop"] = (name, arbitraries, self, timeout) => { - if (Array.isArray(arbitraries)) { - const arbs = arbitraries.map((arbitrary) => { - if (Schema.isSchema(arbitrary)) { - return Schema.toArbitrary(arbitrary)(fc) - } - return arbitrary - }) - return Rs.it( - name, - testOptions(timeout), - // @ts-ignore - (ctx) => fc.assert(fc.property(...arbs, (...as) => self(as, ctx)), isObject(timeout) ? timeout?.fastCheck : {}) - ) - } - - const arbs = fc.record( - Object.keys(arbitraries).reduce(function(result, key) { - const arb: any = arbitraries[key] - Rec.assignProperty(result, key, Schema.isSchema(arb) ? Schema.toArbitrary(arb)(fc) : arb) - return result - }, {} as Record>) - ) - + const arbitrary = makeArbitrary(arbitraries) return Rs.it( name, testOptions(timeout), - // @ts-ignore - (ctx) => fc.assert(fc.property(arbs, (as) => self(as, ctx)), isObject(timeout) ? timeout?.fastCheck : {}) + (ctx) => + runCheck( + ctx, + arbitrary, + (values) => (self(values as any, ctx) as unknown) !== false, + checkOptions(timeout) + ) ) } diff --git a/test/fixtures/prop-failure.fixture.ts b/test/fixtures/prop-failure.fixture.ts new file mode 100644 index 0000000..b348aa7 --- /dev/null +++ b/test/fixtures/prop-failure.fixture.ts @@ -0,0 +1,56 @@ +import { afterAll, assert, it } from "effect-rstest" +import { Effect, Schema } from "effect" + +// Run only in the child runner: every property here is falsified on purpose. +const Input = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 1_000 })) +const pureDefectValues: Array = [] +const effectDefectValues: Array = [] +let timeoutPropertyStarted = false +let timeoutPropertyReleased = false + +afterAll(() => { + assert.deepStrictEqual(pureDefectValues, [8, 1]) + assert.deepStrictEqual(effectDefectValues, [8, 1]) + assert.isTrue(timeoutPropertyStarted) + assert.isTrue(timeoutPropertyReleased) +}) + +it.prop("returns false", [Input], () => false, { arbitrary: { runs: 1, seed: "assertion-shrink" } }) + +it.prop( + "shrinks synchronous defects", + [Input], + ([value]) => { + pureDefectValues.push(value) + throw new Error("property defect") + }, + { arbitrary: { runs: 1, seed: "assertion-shrink" } } +) + +it.effect.prop( + "shrinks Effect defects", + [Input], + ([value]) => + Effect.sync(() => { + effectDefectValues.push(value) + assert.strictEqual(value, 0) + }), + { arbitrary: { runs: 1, seed: "assertion-shrink" } } +) + +it.effect.prop( + "interrupts property checking on timeout", + [Schema.Literal("value")], + () => + Effect.acquireUseRelease( + Effect.sync(() => { + timeoutPropertyStarted = true + }), + () => Effect.never, + () => + Effect.sync(() => { + timeoutPropertyReleased = true + }) + ), + { timeout: 10, arbitrary: { runs: 1, maxDiscards: 0, seed: "property-timeout" } } +) diff --git a/test/index.test.ts b/test/index.test.ts index c73dcf3..da436c5 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -1,7 +1,8 @@ import { afterAll, assert, describe, describeWrapped, expect, it, layer } from "effect-rstest" import * as testAssert from "effect-rstest/utils" import { Clock, Context, Duration, Effect, Fiber, Layer, Schema } from "effect" -import { FastCheck, TestClock } from "effect/testing" +import { TestClock } from "effect/testing" +import { Arbitrary } from "effect/unstable/arbitrary" it.effect( "effect", @@ -171,7 +172,7 @@ describe("layer", () => { expect(foo).toEqual("foo") return num === num }), - { fastCheck: { numRuns: 200 } } + { arbitrary: { runs: 200 } } ) }) }) @@ -213,13 +214,44 @@ describe("layer", () => { // property testing -const realNumber = FastCheck.float({ noNaN: true, noDefaultInfinity: true }) +const realNumber = Schema.Finite +const textArbitrary = Arbitrary.schema(Schema.Literals(["a", "b"])) -it.prop("symmetry", [realNumber, FastCheck.integer()], ([a, b]) => a + b === b + a) +let mixedTupleRuns = 0 +let mixedRecordRuns = 0 +afterAll(() => { + assert.strictEqual(mixedTupleRuns, 5) + assert.strictEqual(mixedRecordRuns, 5) +}) + +it.prop( + "Schema and Arbitrary with array", + [Schema.Int, textArbitrary], + ([count, text]) => { + mixedTupleRuns++ + assert.isTrue(Number.isInteger(count)) + assert.include(["a", "b"], text) + }, + { arbitrary: { runs: 5, maxDiscards: 0, seed: "rstest-mixed-tuple" } } +) + +it.effect.prop( + "Schema and Arbitrary with object", + { count: Schema.Int, text: textArbitrary }, + ({ count, text }) => + Effect.sync(() => { + mixedRecordRuns++ + assert.isTrue(Number.isInteger(count)) + assert.include(["a", "b"], text) + }), + { arbitrary: { runs: 5, maxDiscards: 0, seed: "rstest-mixed-record" } } +) + +it.prop("symmetry", [realNumber, Schema.Int], ([a, b]) => a + b === b + a) it.prop( "symmetry with object", - { a: realNumber, b: FastCheck.integer() }, + { a: realNumber, b: Schema.Int }, ({ a, b }) => a + b === b + a ) @@ -229,13 +261,13 @@ it.live.prop( ({ value }) => Effect.sync(() => assert.isTrue(Number.isInteger(value))) ) -it.effect.prop("symmetry", [realNumber, FastCheck.integer()], ([a, b]) => +it.effect.prop("symmetry", [realNumber, Schema.Int], ([a, b]) => Effect.gen(function*() { yield* Effect.void assert.isTrue(a + b === b + a) })) -it.effect.prop("symmetry with object", { a: realNumber, b: FastCheck.integer() }, ({ a, b }) => +it.effect.prop("symmetry with object", { a: realNumber, b: Schema.Int }, ({ a, b }) => Effect.gen(function*() { yield* Effect.void assert.strictEqual(a + b, b + a) @@ -243,7 +275,7 @@ it.effect.prop("symmetry with object", { a: realNumber, b: FastCheck.integer() } it.effect.prop( "should detect the substring", - { a: FastCheck.string(), b: FastCheck.string(), c: FastCheck.string() }, + { a: Schema.String, b: Schema.String, c: Schema.String }, ({ a, b, c }) => Effect.gen(function*() { yield* Effect.scope diff --git a/test/prop-failure.test.ts b/test/prop-failure.test.ts new file mode 100644 index 0000000..1b7be69 --- /dev/null +++ b/test/prop-failure.test.ts @@ -0,0 +1,16 @@ +import { expect, it } from "effect-rstest" +import { runFixture } from "./fixtures/run-fixture" + +it("falsified properties fail, shrink, and release resources on timeout", async () => { + const { status, stdout, stderr, report } = await runFixture("prop-failure", 1_000) + expect(status, `${stdout}\n${stderr}`).toBe(1) + expect(report.summary).toEqual({ failedTests: 4, passedTests: 0, skippedTests: 0, tests: 4 }) + expect(report.unhandledErrors).toEqual([]) + expect(report.files.flatMap((file) => file.errors)).toEqual([]) + const message = (name: string) => report.tests.find((test) => test.name === name)?.errors?.[0]?.message ?? "" + expect(message("returns false")).toContain("Failure: returned false") + for (const name of ["shrinks synchronous defects", "shrinks Effect defects"]) { + expect(message(name)).toContain("Property falsified after 1 run(s) and 1 shrink(s)\nShrunk input: [1]") + } + expect(message("interrupts property checking on timeout")).toContain("test timed out in 10ms") +}, 30_000) diff --git a/test/prop-schema-record.test.ts b/test/prop-schema-record.test.ts index b6366e0..b40dcac 100644 --- a/test/prop-schema-record.test.ts +++ b/test/prop-schema-record.test.ts @@ -1,22 +1,22 @@ import { expect, it } from "effect-rstest" import { Schema } from "effect" -import { FastCheck } from "effect/testing" +import { Arbitrary } from "effect/unstable/arbitrary" it.prop( - "plain record properties generate schemas alongside arbitraries", - { count: FastCheck.integer({ max: 10, min: 1 }), label: Schema.Literal("schema") }, + "plain record properties mix schemas and arbitraries", + { count: Arbitrary.schema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 10 }))), label: Schema.Literal("schema") }, ({ count, label }) => { expect(label).toBe("schema") expect(Number.isInteger(count)).toBe(true) expect(count).toBeGreaterThanOrEqual(1) expect(count).toBeLessThanOrEqual(10) }, - { fastCheck: { numRuns: 20 } } + { arbitrary: { runs: 20 } } ) it.prop( - "plain record properties retain FastCheck-only support", - { value: FastCheck.constant(7) }, + "plain record properties accept Arbitrary-only inputs", + { value: Arbitrary.Constant(7) }, ({ value }) => { expect(value).toBe(7) } diff --git a/test/prop-schema-tuple.test.ts b/test/prop-schema-tuple.test.ts index 796630b..2724718 100644 --- a/test/prop-schema-tuple.test.ts +++ b/test/prop-schema-tuple.test.ts @@ -1,22 +1,22 @@ import { expect, it } from "effect-rstest" import { Schema } from "effect" -import { FastCheck } from "effect/testing" +import { Arbitrary } from "effect/unstable/arbitrary" it.prop( - "plain tuple properties generate schemas alongside arbitraries", - [Schema.Literal("schema"), FastCheck.integer({ max: 10, min: 1 })], + "plain tuple properties mix schemas and arbitraries", + [Schema.Literal("schema"), Arbitrary.schema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 10 })))], ([label, count]) => { expect(label).toBe("schema") expect(Number.isInteger(count)).toBe(true) expect(count).toBeGreaterThanOrEqual(1) expect(count).toBeLessThanOrEqual(10) }, - { fastCheck: { numRuns: 20 } } + { arbitrary: { runs: 20 } } ) it.prop( - "plain tuple properties retain FastCheck-only support", - [FastCheck.constant(7)], + "plain tuple properties accept Arbitrary-only inputs", + [Arbitrary.Constant(7)], ([value]) => { expect(value).toBe(7) } diff --git a/typetest/prop-schema.tst.ts b/typetest/prop-schema.tst.ts index aecba17..f3ed1f3 100644 --- a/typetest/prop-schema.tst.ts +++ b/typetest/prop-schema.tst.ts @@ -1,18 +1,18 @@ import { it } from "effect-rstest" import { Effect, Schema } from "effect" -import { FastCheck } from "effect/testing" +import { Arbitrary } from "effect/unstable/arbitrary" import { describe, expect, test } from "tstyche" describe("schema properties", () => { test("plain tuple inputs infer schema and arbitrary values", () => { - it.prop("tuple", [Schema.Literal("schema"), FastCheck.integer()], ([label, count]) => { + it.prop("tuple", [Schema.Literal("schema"), Arbitrary.schema(Schema.Int)], ([label, count]) => { expect(label).type.toBe<"schema">() expect(count).type.toBe() }) }) test("plain record inputs infer schema and arbitrary values", () => { - it.prop("record", { label: Schema.Literal("schema"), count: FastCheck.integer() }, ({ label, count }) => { + it.prop("record", { label: Schema.Literal("schema"), count: Arbitrary.schema(Schema.Int) }, ({ label, count }) => { expect(label).type.toBe<"schema">() expect(count).type.toBe() })