Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
97 changes: 97 additions & 0 deletions quickcheck/README.mbt.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,103 @@

MoonBit QuickCheck package provides property-based testing capabilities by generating random test inputs.

## Checking Properties

Use `check` for the common property shape `(A) -> Bool raise?`. The
input type must implement `Arbitrary`, `Shrink`, and `Debug`.

```mbt check
///|
test "adding zero is an identity" {
@quickcheck.check((x : Int) => x + 0 == x)
}
```

Returning `true` passes a case; returning `false` reports a logical
counterexample. A raised error is reported separately as an exceptional
counterexample. The first failure is greedily shrunk while preserving that
distinction: a `false` result cannot shrink into an error, and an error cannot
shrink into `false`.

Use the pure `filter` function for a precondition:

```mbt check
///|
test "division identity" {
@quickcheck.check((x : Int) => x / x == 1, filter=x => x != 0)
}
```

Filtered cases do not count toward `count`. The driver gives up after ten
discarded cases per requested test by default. During shrinking, a filtered candidate
consumes one shrink attempt, its subtree is skipped, and shrinking continues
with the next candidate.

The optional controls are deterministic:

```mbt check
///|
test "configured property run" {
@quickcheck.check(
(xs : Array[Int]) => xs.length() >= 0,
count=200,
max_size=50,
max_shrinks=100,
discard_ratio=10,
seed=2026,
)
}
```

`count`, `max_size`, `max_shrinks`, and `discard_ratio` are unsigned. A zero
`count` performs no tests. `discard_ratio` defaults to ten discarded cases per
requested test; zero gives up on the first discarded case. Generator size grows
from zero to `max_size`; consecutive discards temporarily increase the
requested size so filtering cannot leave the run stuck at size zero. Because
`Arbitrary` receives an `Int` size, larger values saturate at `Int::MAX_VALUE`.
`max_shrinks` counts every shrink candidate examined, including filtered
candidates, so zero disables shrinking and even an infinite or cyclic shrink
stream terminates at the limit. A failure report includes the final
counterexample, error when applicable, size, and shrink counts.

If a test needs to inspect an expected failure, use `report` instead of
catching the `Failure` raised by `check`:

```mbt check
///|
test "inspect a counterexample" {
let report = @quickcheck.report(
(_ : Int) => false,
count=1,
max_size=0,
max_shrinks=0,
seed=7,
)
debug_inspect(
report,
content=(
#|Falsified(
#| counterexample=0,
#| tests=1,
#| size=0,
#| shrinks=0,
#| shrink_attempts=0,
#|)
),
)
}
```

`report` returns an abstract `QuickCheckReport[A]` whose `Debug`
representation distinguishes `Passed`, `GaveUp`, `Falsified`, and `Raised`.
Every error from the property is represented by `Raised`; the driver does not
distinguish errors used by `inspect` or snapshot tests. Calling `report` itself
does not raise and does not require the input type to implement `Debug`.

Properties should be deterministic and should not mutate or consume their
input. In particular, an `Iter` is single-use; generate an `Array` and create a
fresh iterator inside the property when replayable sequence behavior matters.

## Basic Usage

Generate random values of any type that implements the `Arbitrary` trait:
Expand Down
293 changes: 293 additions & 0 deletions quickcheck/driver.mbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
// Copyright 2026 International Digital Economy Academy
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

///|
fn size_at(
index : UInt,
count : UInt,
max_size : UInt,
recent_discards : UInt,
) -> Int {
let base_size = if count <= 1 {
0UL
} else {
index.to_uint64() * max_size.to_uint64() / (count - 1).to_uint64()
}
let size = base_size + recent_discards.to_uint64()
let limit = if max_size.to_uint64() > @int.MAX_VALUE.to_uint64() {
@int.MAX_VALUE.to_uint64()
} else {
max_size.to_uint64()
}
if size > limit {
limit.to_int()
} else {
size.to_int()
}
}

///|
priv enum CaseOutcome {
Passed
Discarded
Falsified
Raised(Error)
}

///|
/// Structured result of a property check.
///
/// `tests` includes the failing test case. `shrinks` counts accepted shrink
/// steps, while `shrink_attempts` counts every shrink candidate examined,
/// including candidates rejected by `filter`.
enum QuickCheckReport[A] {
Passed(tests~ : UInt)
/// The configured discard budget was exhausted before `count` tests completed.
GaveUp(tests~ : UInt, discarded~ : UInt)
Falsified(
counterexample~ : A,
tests~ : UInt,
size~ : Int,
shrinks~ : UInt,
shrink_attempts~ : UInt
)
Raised(
counterexample~ : A,
error~ : Error,
tests~ : UInt,
size~ : Int,
shrinks~ : UInt,
shrink_attempts~ : UInt
)
} derive(@debug.Debug)

///|
#deprecated("Use `@debug.Debug::to_repr` instead")
#doc(hidden)
pub extend QuickCheckReport with @debug.Debug::{to_repr}

///|
fn[A] evaluate_case(
property : (A) -> Bool raise?,
filter : (A) -> Bool,
input : A,
) -> CaseOutcome {
guard filter(input) else { Discarded }
let invoke : (A) -> Bool raise = a => property(a)
try invoke(input) catch {
err => Raised(err)
} noraise {
true => Passed
false => Falsified
}
}

///|
impl Eq for CaseOutcome with fn equal(expected, actual) {
match (expected, actual) {
(Falsified, Falsified) | (Raised(_), Raised(_)) => true
_ => false
}
}

///|
fn[A : @shrink.Shrink] shrink_failure(
property : (A) -> Bool raise?,
filter : (A) -> Bool,
input : A,
initial : CaseOutcome,
max_shrinks : UInt,
) -> (A, CaseOutcome, UInt, UInt) {
let mut current = input
let mut current_outcome = initial
let mut successful = 0U
let mut attempted = 0U
let mut found = true
while attempted < max_shrinks && found {
found = false
let candidates = @shrink.Shrink::shrink(current)
while attempted < max_shrinks && candidates.next() is Some(candidate) {
attempted = attempted + 1
let outcome = evaluate_case(property, filter, candidate)
if initial == outcome {
current = candidate
current_outcome = outcome
successful = successful + 1
found = true
break
}
}
}
(current, current_outcome, successful, attempted)
}

///|
fn[A : @debug.Debug] failure_report(report : QuickCheckReport[A]) -> String {
match report {
Passed(..) => abort("quickcheck internal error: a passed property reported")
GaveUp(tests~, discarded~) =>
(
$|QuickCheck gave up after \{tests} test(s)
$|discarded: \{discarded}
)
Falsified(counterexample~, tests~, size~, shrinks~, shrink_attempts~) =>
(
$|QuickCheck falsified after \{tests} test(s)
$|counterexample: \{@debug.to_string(counterexample)}
$|size: \{size}
$|shrinks: \{shrinks} successful, \{shrink_attempts} attempted
)
Raised(counterexample~, error~, tests~, size~, shrinks~, shrink_attempts~) =>
(
$|QuickCheck property raised after \{tests} test(s)
$|counterexample: \{@debug.to_string(counterexample)}
$|error: \{@debug.to_string(error)}
$|size: \{size}
$|shrinks: \{shrinks} successful, \{shrink_attempts} attempted
)
}
}

///|
/// Checks `property` and returns a structured report.
///
/// Returning `false` records a falsification; every error raised by the
/// property is recorded separately. Unlike `check`, this function does not
/// turn either result into a test failure, does not raise property errors, and
/// does not require the input type to implement `Debug`. Cases rejected by the
/// pure `filter` do not count as tests. The run gives up after `discard_ratio`
/// discarded cases per requested test.
pub fn[A : Arbitrary + @shrink.Shrink] report(
property : (A) -> Bool raise?,
filter? : (A) -> Bool = _ => true,
count? : UInt = 100,
max_size? : UInt = 100,
max_shrinks? : UInt = 100,
discard_ratio? : UInt = 10,
seed? : UInt64 = 37,
) -> QuickCheckReport[A] {
let state = @splitmix.new(seed~)
for tests = 0U, discarded = 0U, recent_discards = 0U; tests < count; {
let size = size_at(tests, count, max_size, recent_discards)
let input : A = Arbitrary::arbitrary(size, state.split())
let outcome = evaluate_case(property, filter, input)
match outcome {
Passed => continue tests + 1, discarded, 0U
Discarded => {
let next_discarded = discarded + 1
if discard_ratio == 0 || next_discarded / discard_ratio >= count {
return GaveUp(tests~, discarded=next_discarded)
}
continue tests, next_discarded, recent_discards + 1
}
_ => {
let completed_tests = tests + 1
let (counterexample, outcome, shrinks, shrink_attempts) = shrink_failure(
property, filter, input, outcome, max_shrinks,
)
return match outcome {
Falsified =>
Falsified(
counterexample~,
tests=completed_tests,
size~,
shrinks~,
shrink_attempts~,
)
Raised(error) =>
Raised(
counterexample~,
error~,
tests=completed_tests,
size~,
shrinks~,
shrink_attempts~,
)
Passed | Discarded =>
abort(
"quickcheck internal error: unexpected non-failure after shrinking",
)
}
}
}
} nobreak {
Passed(tests=count)
}
}

///|
/// Checks `property` against generated values and shrinks the first failure.
///
/// Returning `false` falsifies the property. Raising an error records an
/// exceptional counterexample instead; these two failure classes are shrunk
/// independently. `filter` is evaluated first; returning `false` discards the
/// case without evaluating the property.
///
/// The generator size follows a linear schedule from zero to `max_size`.
/// Consecutive discarded cases temporarily increase the requested size, up to
/// `max_size`. Each generated case receives an independent random stream
/// derived from `seed`. If a case fails, the driver greedily keeps the first
/// smaller candidate with the same failure class until there are no such
/// candidates or `max_shrinks` candidates have been examined. A filtered
/// shrink candidate consumes an attempt, skips that candidate's subtree, and
/// does not count toward the run's discard budget.
///
/// Parameters:
///
/// * `property`: A deterministic function returning `true` on success.
/// * `filter`: A pure precondition returning `true` for cases to test.
/// * `count`: Number of non-discarded cases to test. Zero performs no tests.
/// * `max_size`: Largest requested generator size. Values above the `Int`
/// range accepted by `Arbitrary` are saturated at `Int::MAX_VALUE`.
/// * `max_shrinks`: Maximum number of shrink candidates examined. Zero
/// disables shrinking.
/// * `discard_ratio`: Maximum discarded cases per requested test. Zero gives
/// up on the first discarded case.
/// * `seed`: Seed used to derive each test case's random stream.
///
/// On falsification or a raised error, this function raises a
/// `Failure` containing the smallest counterexample found, its corresponding
/// error when applicable, and shrink information.
///
/// ```mbt check
/// test "adding zero is an identity" {
/// @quickcheck.check((x : Int) => x + 0 == x)
/// }
/// ```
#callsite(autofill(loc))
pub fn[A : Arbitrary + @shrink.Shrink + @debug.Debug] check(
property : (A) -> Bool raise?,
filter? : (A) -> Bool = _ => true,
count? : UInt = 100,
max_size? : UInt = 100,
max_shrinks? : UInt = 100,
discard_ratio? : UInt = 10,
seed? : UInt64 = 37,
loc~ : SourceLoc,
) -> Unit raise {
let result = report(
property,
filter~,
count~,
max_size~,
max_shrinks~,
discard_ratio~,
seed~,
)
match result {
Passed(..) => ()
GaveUp(..) | Falsified(..) | Raised(..) =>
fail(failure_report(result), loc~)
}
}
Loading
Loading