qcheck
QuickCheck-inspired property-based testing with integrated shrinking for Gleam.
Rather than specifying test cases manually, you describe the invariants that values of a given type must satisfy (“properties”). Then, generators generate lots of values (test cases) on which the properties are checked. Finally, if a value is found for which a given property does not hold, that value is “shrunk” in order to find an nice, informative counter-example that is presented to you.
While there are a ton of great articles introducing quickcheck or property-based testing, here are a couple general resources that you may enjoy:
You might also be interested in checking out this project that uses qcheck to test Gleam’s stdlib.
Usage & Examples
- See the API docs for detailed usage,
- See qcheck_viewer to visualize the distributions of some of the qcheck generators.
Basic example
Here is a short example to get you started. It assumes you are using gleeunit to run the tests, but any test runner that reasonably handles panics will do.
import qcheck
pub fn int_addition_commutativity__test() {
use n <- qcheck.given(qcheck.small_non_negative_int())
should.equal(n + 1, 1 + n)
}
pub fn int_addition_commutativity__failures_shrink_to_zero__test() {
use n <- qcheck.given(qcheck.small_non_negative_int())
should.not_equal(n + 1, 1 + n)
}
Let’s go through the code:
qcheck.givensets up the test- If a property holds for all generated values, then
qcheck.givenreturnsNil. - If a property does not hold for all generated values, then
qcheck.givenwill panic.
- If a property holds for all generated values, then
qcheck.small_non_negative_int()generates small integers greater than or equal to zero.assert n + 1 == 1 + nis the property being tested in the first test.- It should be true for all generated values.
- The return value of
qcheck.givenwill beNil, because the property does hold for all generated values. - You can use Gleam’s
assertkeyword to get nicer error messages
assert n + 1 != 1 + nis the property being tested in the second test.- It should be false for any generated values.
qcheck.givenwill be panic, because the property does not hold for any generated values.
If you run it, that second example will fail with an error that may look something like this if you are targeting Erlang.
panic src/qcheck.gleam:2833
test: examples@basic_example_test.int_addition_commutativity__failures_shrink_to_zero__test
info: a property was falsified!
qcheck assert test/examples/basic_example_test.gleam:12
code: assert n + 1 != 1 + n
left: 4
right: 4
info: Assertion failed.
qcheck shrinks
orig: 3
shrnk: 0
steps: 1
The error message gives some info to help you diagnose the issue. Since we used assert, we get some info about the code that was run, it’s location in the test file, the values on the left and right side.
Additionally, there is some info about the “shrinking”. You will see:
orig, which lists the original generated value that triggered the errorshrnk, which shows the “shrunk” value.- I.e., a value that is usually easiear to interpret.
- In this example
3and0aren’t too bad, but you will often see shrunk values that look like this:#(Some(0), None, None, None, None)which is much easier to figure out what the issue might be than something like this:#(Some(1371457222), Some(369023.1034975077), Some(True), Some("2x"), None). - (That is a real result from a property test I intionally broke for illustration from the bsql3 repository.)
steps, which shows the number of “shrink steps” it took to get to the shrunk value.- If this is really high, you may need to adjust your generation process.
In-depth example
Here is a more in-depth example. We will create a simple Point type, write some serialization functions, and then check that the serializing round-trips.
First, here is some code to define a Point, and a to_string function that makes a string representation like this: (x y).
type Point {
Point(Int, Int)
}
fn point_to_string(point: Point) -> String {
let Point(x, y) = point
"(" <> int.to_string(x) <> " " <> int.to_string(y) <> ")"
}
Next, let’s write a function that parses the string representation into a Point. The string representation is pretty simple, Point(1, 2) would be represented by the following string: (1 2).
Here is one possible way to parse that string representation into a Point. (Note that this implementation is intentionally broken for illustration.)
fn point_from_string(string: String) -> Result(Point, String) {
// Create the regex.
use re <- result.try(
regex.from_string("\\((\\d+) (\\d+)\\)")
|> result.map_error(string.inspect),
)
// Ensure there is a single match.
use submatches <- result.try(case regex.scan(re, string) {
[Match(_content, submatches)] -> Ok(submatches)
_ -> Error("expected a single match")
})
// Ensure both submatches are present.
use xy <- result.try(case submatches {
[Some(x), Some(y)] -> Ok(#(x, y))
_ -> Error("expected two submatches")
})
// Try to parse both x and y values as integers.
use xy <- result.try(case int.parse(xy.0), int.parse(xy.1) {
Ok(x), Ok(y) -> Ok(#(x, y))
Error(Nil), Ok(_) -> Error("failed to parse x value")
Ok(_), Error(Nil) -> Error("failed to parse y value")
Error(Nil), Error(Nil) -> Error("failed to parse x and y values")
})
Ok(Point(xy.0, xy.1))
}
Now we would like to test our implementation. Of course, we could make some examples and test it like so:
pub fn roundtrip_test() {
let point = Point(1, 2)
let parsed_point = point |> point_to_string |> point_of_string
assert point == parsed_point
}
That’s good, and you can imagine taking some corner cases like putting in 0 or -1 or the max and min values for integers on your selected target. I think you should still test some interesting cases manually to “anchor” your test suite. But, let’s think of a property to test.
I mention round-tripping, but how can you write a property to test it. Something like, “given a valid point, when serializing it to a string, and then deserializing that string into another point, both points should always be equal”.
Okay, first we need to write a generator of valid points. In this case, it isn’t too tricky as any integer can be used for both x and y values of the point. So we can use generator.map2 like so:
fn point_generator() {
qcheck.map2(qcheck.uniform_int(), qcheck.uniform_int(), Point)
}
For illustration, you could also utilize the use syntax. You could write:
fn point_generator() {
use x, y <- qcheck.map2(qcheck.uniform_int(), qcheck.uniform_int())
Point(x, y)
}
Now that we have the point generator, we can write a property test.
pub fn point_serialization_roundtripping__test() {
use generated_point <- qcheck.given(point_generator())
let assert Ok(parsed_point) =
generated_point |> point_to_string |> point_from_string
assert generated_point == parsed_point
}
Let’s try and run the test. You should see an error that looks something like this:
panic src/qcheck.gleam:2833
test: examples@parsing_example_test.point_serialization_roundtripping__test
info: a property was falsified!
qcheck let assert test/examples/parsing_example_test.gleam:62
code: let assert Ok(parsed_point) =
generated_point |> point_to_string |> point_from_string
value: Error("expected a single match")
info: Pattern match failed, no pattern matched the value.
qcheck shrinks
orig: Point(-1827478708, -274074946)
shrnk: Point(0, -1)
steps: 29
Here are the important parts to highlight.
- original value:
Point(-1827478708, -274074946)- This is the original counter-example that causes the test to fail.
- shrunk value:
Point(0, -1)- Because
qcheckgenerators have integrated shrinking, that counter-example “shrinks” to this simpler example. - The “shrunk” examples can help you better identify what the problem may be.
- Because
Error("expected a single match")- Here is the error message that actually caused the failure.
Let me point out that you could write this test in a slightly different way and get what I think is a bit better output:
pub fn point_serialization_roundtripping__test() {
use generated_point <- qcheck.given(point_generator())
let parsed_point = generated_point |> point_to_string |> point_from_string
assert Ok(generated_point) == parsed_point
}
Which would output:
panic src/qcheck.gleam:2833
test: examples@parsing_example_test.point_serialization_roundtripping__assert__test
info: a property was falsified!
qcheck assert test/examples/parsing_example_test.gleam:56
code: assert Ok(generated_point) == parsed_point
left: Ok(Point(-694965642, -939456627))
right: Error("expected a single match")
info: Assertion failed.
qcheck shrinks
orig: Point(-694965642, -939456627)
shrnk: Point(0, -1)
steps: 30
Either way is fine, but I think the second way gives a bit more clarity.
Anyway, back to interpreting the failure. So we see a failure with Point(0, -1), which means it probably has something to do with the negative number. Also, we see that the Error("expected a single match") is what triggered the failure. That error comes about when regex.scan fails in the point_from_string function.
Given those two pieces of information, we can infer that the issue is probably in our regular expression definition: regex.from_string("\\((\\d+) (\\d+)\\)"). And now we may notice that we are not allowing for negative numbers in the regular expression. To fix it, change that line to the following:
regex.from_string("\\((-?\\d+) (-?\\d+)\\)")
That is allowing an optional - sign in front of the integers. Now when you rerun the gleam test, everything passes.
You could imagine combining a property test like the one above, with a few well chosen examples to anchor everything, into a nice little test suite that exercises the serialization of points in a small amount of test code.
(The full code for this example can be found in test/examples/parsing_example_test.gleam.)
Applicative style
The applicative style provides a nice interface for creating generators for custom types. When you have independent generators, this way can often given better shrinking then using bind when you don’t need it.
import qcheck
/// A simple Box type with position (x, y) and dimensions (width, height).
type Box {
Box(x: Int, y: Int, w: Int, h: Int)
}
fn box_generator() {
// Lift the Box creating function into the Generator structure.
qcheck.return({
use x <- qcheck.parameter
use y <- qcheck.parameter
use w <- qcheck.parameter
use h <- qcheck.parameter
Box(x:, y:, w:, h:)
})
// Set the `x` generator.
|> qcheck.apply(qcheck.bounded_int(-100, 100))
// Set the `y` generator.
|> qcheck.apply(qcheck.bounded_int(-100, 100))
// Set the `width` generator.
|> qcheck.apply(qcheck.bounded_int(1, 100))
// Set the `height` generator.
|> qcheck.apply(qcheck.bounded_int(1, 100))
}
You could also write this example using map4:
fn x_gen() {
qcheck.bounded_int(-100, 100)
}
fn y_gen() {
qcheck.bounded_int(-100, 100)
}
fn w_gen() {
qcheck.bounded_int(1, 100)
}
fn h_gen() {
qcheck.bounded_int(1, 100)
}
fn box_generator_with_map4() {
use x, y, w, h <- qcheck.map4(x_gen(), y_gen(), w_gen(), h_gen())
Box(x:, y:, w:, h:)
}
For more info about this, see this issue.
Integrating with testing frameworks
You don’t have to do anything special to integrate qcheck with a testing framework like gleeunit. The only thing required is that your testing framework of choice be able to handle panics/exceptions.
Note: startest should be fine. (I last checked it on Jan 6, 2026.)
You may also be interested in qcheck_gleeunit_utils for running your tests in parallel and controlling test timeouts when using gleeunit and targeting Erlang.
Acknowledgements
Very heavily inspired by the qcheck and base_quickcheck OCaml packages.
Check out the licenses directory to view their licenses.
Contributing
Thank you for your interest in the project!
- Bug reports, feature requests, suggestions and ideas are welcomed. Please open an issue to start a discussion.
- External contributions will generally not be accepted without prior discussion.
- If you have an idea for a new feature, please open an issue for discussion prior to working on a pull request.
- Small pull requests for bug fixes, typos, or other changes with limited scope may be accepted. If in doubt, please open an issue for discussion first.
License
Copyright (c) 2024 - 2026 Ryan M. Moore
Licensed under the Apache License, Version 2.0 or the MIT license, at your option. This program may not be copied, modified, or distributed except according to those terms.