Current section

Files

Jump to
stream_data lib stream_data.ex
Raw

lib/stream_data.ex

defmodule StreamData do
@moduledoc """
Functions to create and combine generators.
A generator is a `StreamData` struct. Generators can be created through the
functions exposed in this module, like `constant/1`, and by combining other
generators through functions like `bind/2`.
Generators are used for mainly two purposes: to generate test data, and as the
basis of property testing. When a generator is called internally, it will
generate a value that is not a directly usable value. Instead, it's a
"wrapper" around a normal value that is optimized for property testing and,
more specifically, for *shrinking* (see the section about shrinking below).
Note that values generated by generators are not unique.
## Generation size
Generators have access to a generation parameter called the **generation
size**, which is a non-negative integer. This parameter is meant to bind the
data generated by each generator in a way that is completely up to the
generator. For example, a generator that generates integer can use the `size`
parameter to generate integers inside the `-size..size` range. In a similar
way, a generator that generates lists could use this parameter to generate a
list with `0` to `size` elements. The generation size parameter is "global",
that is, complex generators that wrap other generators usually pass it down to
the inner generators when generating data.
When creating generators, they can access the generation size using the
`sized/1` function. Generators can be resized to a fixed generation size using
`resize/2`.
## Enumeration
Generators implement the `Enumerable` protocol. The implementation yields
simple values (not shrinkable wrappers as discussed above) when enumerating a
generator. The enumeration starts with a small generation size, which
increases when the enumeration continues (up to a fixed maximum size).
For example, to get an infinite stream of integers that starts with small
integers and progressively grows the boundaries, you can just use `int/0`:
Enum.take(StreamData.int(), 10)
#=> [-1, 0, -3, 4, -4, 5, -1, -3, 5, 8]
Since generators are proper streams, functions from the `Stream` module can be
used to stream values out of them. For example, to build an infinite stream of
positive even integers, you can do:
StreamData.int()
|> Stream.filter(&(&1 > 0))
|> Stream.map(&(&1 * 2))
|> Enum.take(10)
#=> [4, 6, 4, 10, 14, 16, 4, 16, 36, 16]
As we mentioned above, you can see that in general a generator does not
generate unique values.
Note that all generators are **infinite** streams that never terminate.
When using generators for test data, usually using them as enumerables is the
easiest choice.
## Shrinking
As we mentioned above, the wrapper values generated by generators internally
are optimized for **shrinking**. These wrapper values contain a generated
value (the one used when enumerating a generator) alongside a way to shrink
such value. For example, a value generated by the `list_of/1` generator will
contain the actual list to use alongside a way to shrink that list, which in
this specific case means taking elements out of that list until it comes to
the empty list (which is the "smallest" shrinkable value for lists). Each
generator shrinks generated values with its own logic, which is documented in
the generator's documentation.
Shrinking is used for property testing, where it's important to find the
minimal value for which a property fails (since generated values are often
bigger and full of garbage). See `PropertyTest` for more information.
Note that the generation size is not related in any way with shrinking: while
intuitively one may think that shrinking just means decreasing the generation
size, in reality the generation size is related to how generators generate
values, while shrinking is bound to each generated value and is a way to
shrink that particular value.
## Special generators
Some Elixir types are implicitly converted to `StreamData` generators when
used in property testing or composed. These types are:
* atoms - they generated themselves. For example, `:foo` is equivalent to
`StreamData.constant(:foo)`.
* tuples of generators - they generated tuples where each value is a value
generated by the corresponding generator, exactly like described in
`tuple/1`. For example, `{StreamData.int(), StreamData.boolean()}`
generates entries like `{10, false}`.
Note that *these terms are only implicitly converted to generators when
composing them*. This means that these terms are not full-fledged generators:
for example, atoms cannot be enumerated directly as they don't implement the
`Enumerable` protocol. However, `StreamData.map(:foo, &Atom.to_string/1)` can
be enumerated since `:foo` is implicitly converted to a generator when passed
to a `StreamData` function.
"""
alias StreamData.LazyTree
@typep seed :: :rand.state
@typep size :: non_neg_integer
@typep generator_fun(a) :: (seed, size -> LazyTree.t(a))
@typedoc """
An opaque type that represents a `StreamData` generator that generates values
of type `a`.
Note that while this type is opaque, a generator is still guaranteed to be a
`StreamData` struct (in case you want to pattern match on it).
"""
@opaque t(a) :: %__MODULE__{
generator: generator_fun(a),
}
@rand_algorithm :exs1024
defstruct [:generator]
defmodule FilterTooNarrowError do
defexception [:max_consecutive_failures]
def message(%{max_consecutive_failures: max_consecutive_failures}) do
"too many (#{max_consecutive_failures}) consecutive elements were filtered out. " <>
"Make sure to avoid filters that are too strict and filter out too many elements " <>
"and make sure a small generation size doesn't affect the filter too heavily (such as " <>
"when empty lists are filtered out but small generation size forces generation of many " <>
"empty lists)"
end
end
defmodule TooManyDuplicatesError do
defexception [:max_tries, :remaining_to_generate, :generated]
def message(%{max_tries: max_tries, remaining_to_generate: remaining, generated: generated}) do
"too many (#{max_tries}) non-unique elements were generated consecutively. " <>
"Make sure to avoid generating from a small space of data (such as only a " <>
"handful of terms) and make sure a small generation size doesn't affect " <>
"uniqueness too heavily. There were still #{remaining} elements left to " <>
"generate, while the generated elements were:\n\n#{inspect(generated)}"
end
end
### Minimal interface
## Helpers
defp new(generator) when is_function(generator, 2) do
%__MODULE__{generator: generator}
end
# We support multiple types of generators through call/3: this is basically a
# poor implementation of a protocol (which we don't want to add just for
# this).
defp call(%__MODULE__{generator: generator}, seed, size) do
%LazyTree{} = generator.(seed, size)
end
defp call(atom, _seed, _size) when is_atom(atom) do
LazyTree.constant(atom)
end
defp call(tuple, seed, size) when is_tuple(tuple) do
case tuple_size(tuple) do
0 ->
LazyTree.constant({})
size ->
{trees, _seed} =
Enum.map_reduce(0..size - 1, seed, fn index, acc ->
{seed1, seed2} = split_seed(acc)
data = elem(tuple, index)
{call(data, seed1, size), seed2}
end)
trees
|> LazyTree.zip()
|> LazyTree.map(&List.to_tuple/1)
end
end
## Generators
@doc """
A generator that always generates the given term.
## Examples
iex> Enum.take(StreamData.constant(:some_term), 3)
[:some_term, :some_term, :some_term]
## Shrinking
This generator doesn't shrink.
"""
@spec constant(a) :: t(a) when a: var
def constant(term) do
new(fn _seed, _size -> LazyTree.constant(term) end)
end
## Combinators
@doc """
Maps the given function `fun` over the given generator `data`.
Returns a new generator that returns elements from `data` after applying `fun`
to them.
## Examples
iex> data = StreamData.map(StreamData.int(), &Integer.to_string/1)
iex> Enum.take(data, 3)
["1", "0", "3"]
## Shrinking
This generator shrinks exactly like `data`, but with `fun` mapped over the
shrunk data.
"""
@spec map(t(a), (a -> b)) :: t(b) when a: term, b: term
def map(data, fun) when is_function(fun, 1) do
new(fn seed, size ->
data
|> call(seed, size)
|> LazyTree.map(fun)
end)
end
@doc """
Binds each element generated by `data` and to a new generator returned by
applying `fun` or filters the generated element.
Works similarly to `bind/2` but allows to filter out unwanted values. It takes
a generator `data` and invokes `fun` with each element generated by `data`.
`fun` must return one of:
* `{:cont, generator}` - `generator` is then used to generate the next
element
* `:skip` - the value generated by `data` is filtered out and a new element
is generated
Since this function acts as a filter as well, it behaves similarly to
`filter/3`: when more than `max_consecutive_failures` elements are filtered
out (that is, `fun` returns `:skip`), a `StreamData.FilterTooNarrowError` is
raised.
## Examples
Say we wanted to create a generator that generates two-element tuples where
the first element is a list of integers with an even number of members and the
second element is a member of that list. We can do that by generating a list
and, if it has even length, taking an element out of it, otherwise filtering
it out.
require Integer
list_data = StreamData.non_empty(StreamData.list_of(StreamData.int()))
data =
StreamData.bind_filter(list_data, fn
list when Integer.is_even(length(list)) ->
inner_data = StreamData.bind(StreamData.member_of(list), fn member ->
StreamData.constant({list, member})
end)
{:cont, inner_data}
_odd_list ->
:skip
end)
Enum.at(data, 0)
#=> {[-6, -7, -4, 5, -9, 8, 7, -9], 5}
## Shrinking
This generator shrinks like `bind/2` but values that are skipped are not used
for shrinking (similarly to how `filter/3` works).
"""
@spec bind_filter(t(a), (a -> {:cont, t(b)} | :skip), non_neg_integer) :: t(b) when a: term, b: term
def bind_filter(data, fun, max_consecutive_failures \\ 10)
when is_function(fun, 1) and is_integer(max_consecutive_failures) and max_consecutive_failures >= 0 do
new(fn seed, size ->
case bind_filter(seed, size, data, fun, max_consecutive_failures) do
{:ok, lazy_tree} ->
lazy_tree
:too_many_failures ->
raise FilterTooNarrowError, max_consecutive_failures: max_consecutive_failures
end
end)
end
defp bind_filter(_seed, _size, _data, _mapper, _tries_left = 0) do
:too_many_failures
end
defp bind_filter(seed, size, data, mapper, tries_left) do
{seed1, seed2} = split_seed(seed)
lazy_tree = call(data, seed1, size)
case LazyTree.filter_map(lazy_tree, mapper) do
{:ok, filter_mapped_tree} ->
tree =
filter_mapped_tree
|> LazyTree.map(&call(&1, seed2, size))
|> LazyTree.flatten()
{:ok, tree}
:error ->
bind_filter(seed2, size, data, mapper, tries_left - 1)
end
end
@doc """
Binds each element generated by `data` to a new generator returned by applying `fun`.
This function is the basic mechanism for composing generators. It takes a
generator `data` and invokes `fun` with each element in `data`. `fun` must
return a new *generator* that is effectively used to generate items from
now on.
## Examples
Say we wanted to create a generator that returns two-element tuples where the
first element is a list, and the second element is a random element from that
list. To do that, we can first generate a list and then bind a function to
that list; this function will return the list and a random element from it.
StreamData.bind(StreamData.list_of(StreamData.int()), fn list ->
StreamData.bind(StreamData.member_of(list), fn elem ->
StreamData.constant({list, elem})
end)
end)
## Shrinking
The generator returned by `bind/2` shrinks by first shrinking the value
generated by the inner generator and then by shrinking the outer generator
given as `data`. When `data` shrinks, `fun` is once more applied on the
shrunk value value and returns a whole new generator, which will most likely
emit new items.
"""
@spec bind(t(a), (a -> t(b))) :: t(b) when a: term, b: term
def bind(data, fun) when is_function(fun, 1) do
bind_filter(data, fn generated_term -> {:cont, fun.(generated_term)} end)
end
@doc """
Filters the given generator `data` according to the given `predicate` function.
Only elements generated by `data` that pass the filter are kept in the
resulting generator.
If the filter is too strict, it can happen that too few values generated by
`data` satisfy it. In case more than `max_consecutive_failures` consecutive
values don't satisfy the filter, a `StreamData.FilterTooNarrowError` will be
raised. Try to make sure that your filter takes out only a small subset of the
elements generated by `data`. When possible, a good way to go around the
limitations of `filter/3` is to instead transform the generated values in the
shape you want them instead of filtering out the ones you don't want.
For example, a generator of odd numbers could be implemented as:
require Integer
odd_ints = StreamData.filter(StreamData.int(), &Integer.is_odd/1)
Enum.take(odd_ints, 3)
#=> [1, 1, 3]
However, it will do more work and take more time to generate odd integers
because it will have to filter out all the even ones that it generates. In
this case, a better approach would be to generate integers and make sure they
are odd:
odd_ints = StreamData.map(StreamData.int(), &(&1 * 2 + 1))
Enum.take(odd_ints, 3)
#=> [1, 1, 3]
## Shrinking
All the values that each generated value shrinks to satisfy `predicate` as
well.
"""
@spec filter(t(a), (a -> as_boolean(term)), non_neg_integer) :: t(a) when a: term
def filter(data, predicate, max_consecutive_failures \\ 10)
when is_function(predicate, 1) and is_integer(max_consecutive_failures) and max_consecutive_failures >= 0 do
bind_filter(data, fn term ->
if predicate.(term) do
{:cont, constant(term)}
else
:skip
end
end)
end
### Rich API
@doc """
Generates an integer in the given `range`.
The generation size is ignored since the integer always lies inside `range`.
## Examples
Enum.take(StreamData.int(4..8), 3)
#=> [6, 7, 7]
## Shrinking
Shrinks towards with the smallest absolute value that still lie in `range`.
"""
@spec int(Range.t) :: t(integer)
def int(_lower.._upper = range) do
new(fn seed, _size ->
range
|> uniform_in_range(seed)
|> int_lazy_tree()
|> LazyTree.filter(&(&1 in range))
end)
end
defp int_lazy_tree(int) do
children =
int
|> Stream.iterate(&div(&1, 2))
|> Stream.take_while(&(&1 != 0))
|> Stream.map(&(int - &1))
|> Stream.map(&int_lazy_tree/1)
LazyTree.new(int, children)
end
## Generator modifiers
@doc """
Resize the given generated `data` to have fixed generation size `new_size`.
The new generator will ignore the generation size and always use `new_size`.
See the "Generation size" section in the documentation for `StreamData` for
more information about the generation size.
## Examples
data = StreamData.resize(StreamData.int(), 10)
Enum.take(data, 3)
#=> [4, -5, -9]
"""
@spec resize(t(a), size) :: t(a) when a: term
def resize(data, new_size) when is_integer(new_size) and new_size >= 0 do
new(fn seed, _size ->
call(data, seed, new_size)
end)
end
@doc """
Returns the generator returned by calling `fun` with the generation size.
`fun` takes the generation size and has to return a generator, that can use
that size to its advantage.
See the "Generation size" section in the documentation for `StreamData` for
more information about the generation size.
## Examples
Let's build a generator that generates integers in double the range `int/0`
does:
data = StreamData.sized(fn size ->
StreamData.resize(StreamData.int(), size * 2)
end)
Enum.take(data, 3)
#=> [0, -1, 5]
"""
@spec sized((size -> t(a))) :: t(a) when a: term
def sized(fun) when is_function(fun, 1) do
new(fn seed, size ->
new_data = fun.(size)
call(new_data, seed, size)
end)
end
@doc """
Scales the generation size of the given generator `data` according to
`size_changer`.
When generating data from `data`, the generation size will be the result of
calling `size_changer` with the generation size as its argument. This is
useful, for example, when a generator needs to grow faster or slower than
the default.
See the "Generation size" section in the documentation for `StreamData` for
more information about the generation size.
## Examples
Let's create a generator that generates much smaller integers than `int/0`
when size grows. We can do this by scaling the generation size to the
logarithm of the generation size.
data = StreamData.scale(StreamData.int(), fn size ->
trunc(:math.log(size))
end)
Enum.take(data, 3)
#=> [0, 0, -1]
Another interesting example is creating a generator with a fixed maximum
generation size. For example, say we want to generate binaries but we never
want them to be larger than 64 bytes:
small_binaries = StreamData.scale(StreamData.binary(), fn size ->
min(size, 64)
end)
"""
@spec scale(t(a), (size -> size)) :: t(a) when a: term
def scale(data, size_changer) when is_function(size_changer, 1) do
sized(fn size ->
resize(data, size_changer.(size))
end)
end
@doc """
Makes the values generated by `data` not shrink.
## Examples
Let's build a generator of bytes (integers in the `0..255`) range. We can
build this on top of `int/1`, but for our purposes, it doesn't make sense for
a byte to shrink towards `0`:
byte = StreamData.no_shrink(StreamData.int(0..255))
Enum.take(byte, 3)
#=> [190, 181, 178]
## Shrinking
The generator returned by `no_shrink/1` generates the same values as `data`,
but such values will not shrink.
"""
@spec no_shrink(t(a)) :: t(a) when a: term
def no_shrink(data) do
new(fn seed, size ->
%LazyTree{root: root} = call(data, seed, size)
LazyTree.constant(root)
end)
end
@doc """
Generates values from different generators with specified probability.
`frequencies` is a list of `{frequency, data}` where `frequency` is an integer
and `data` is a generator. The resulting generator will generate data from one
of the generators in `frequency`, with probability `frequency / vsum_of_frequencies`.
## Examples
Let's build a generator that returns a binary around 25% of times and a
integer around 75% of times. We'll use `int/0` first so that generated values
will shrink towards integers.
ints_and_some_bins = StreamData.frequency([
{3, StreamData.int()},
{1, StreamData.binary()},
])
Enum.take(ints_and_some_bins, 3)
#=> ["", -2, -1]
## Shrinking
Each generated value is shrunk, and then this generator shrinks towards
values generated by generators earlier in the list of `frequencies`.
"""
# Right now, it shrinks by first shrinking the generated value, and then
# shrinking towards earlier generators in "frequencies". Clojure shrinks
# towards earlier generators *first*, and then shrinks the generated value.
# An implementation that does this can be:
#
# new(fn seed, size ->
# {seed1, seed2} = split_seed(seed)
# frequency = uniform_in_range(0..sum - 1, seed1)
# index = pick_index(Enum.map(frequencies, &elem(&1, 0)), frequency)
# {_frequency, data} = Enum.fetch!(frequencies, index)
#
# tree = call(data, seed2, size)
#
# earlier_children =
# frequencies
# |> Stream.take(index)
# |> Stream.map(&call(elem(&1, 1), seed2, size))
# LazyTree.new(tree.root, Stream.concat(earlier_children, tree.children))
# end)
#
@spec frequency([{pos_integer, t(a)}]) :: t(a) when a: term
def frequency(frequencies) when is_list(frequencies) do
sum = Enum.reduce(frequencies, 0, fn {frequency, _data}, acc -> acc + frequency end)
bind(int(0..sum - 1), &pick_frequency(frequencies, &1))
end
defp pick_frequency([{frequency, data} | rest], int) do
if int < frequency do
data
else
pick_frequency(rest, int - frequency)
end
end
@doc """
Generates values out of one of the given `datas`.
`datas` must be a list of generators. The values generated by this generator
are values generated by generators in `datas`, chosen each time at random.
## Examples
data = StreamData.one_of([StreamData.int(), StreamData.binary()])
Enum.take(data, 3)
#=> [-1, <<28>>, ""]
## Shrinking
The generated value will be shrunk first according to the generator that
generated it, and then this generator will shrink towards earlier generators
in `datas`.
"""
@spec one_of([t(a)]) :: t(a) when a: term
def one_of([_ | _] = datas) do
bind(int(0..length(datas) - 1), fn index ->
Enum.fetch!(datas, index)
end)
end
@doc """
Generates elements taken randomly out of `enum`.
`enum` must be a non-empty and **finite** enumerable. If given an empty
enumerable, this function raises an error. If given an infinite enumerable,
this function will not terminate.
## Examples
Enum.take(StreamData.member_of([:ok, 4, "hello"]), 3)
#=> [4, 4, "hello"]
## Shrinking
This generator shrinks towards elements that appear earlier in `enum`.
"""
@spec member_of(Enumerable.t) :: t(term)
def member_of(enum) do
enum_length = Enum.count(enum)
if enum_length == 0 do
raise "cannot generate elements from an empty enumerable"
end
bind(int(0..enum_length - 1), fn index ->
constant(Enum.fetch!(enum, index))
end)
end
## Compound data types
@doc """
Generates lists where each values is generated by the given `data`.
Each generated list can contain duplicate elements. The length of the
generated list is bound by the generation size. If the generation size is `0`,
the empty list will always be generated.
## Examples
Enum.take(StreamData.list_of(StreamData.binary()), 3)
#=> [[""], [], ["", "w"]
## Shrinking
This generator shrinks by taking elements out of the generated list and also
by shrinking the elements of the generated list.
"""
# We could have an implementation that relies on fixed_list/1 and List.duplicate/2,
# it would look like this:
#
# new(fn seed, size ->
# {seed1, seed2} = split_seed(seed)
# length = uniform_in_range(0..size, seed1)
# data
# |> List.duplicate(length)
# |> fixed_list()
# |> call(seed2, size)
# |> LazyTree.map(&list_lazy_tree/1)
# |> LazyTree.flatten()
# end)
#
@spec list_of(t(a)) :: t([a]) when a: term
def list_of(data) do
new(fn seed, size ->
{seed1, seed2} = split_seed(seed)
length = uniform_in_range(0..size, seed1)
data
|> call_n_times(seed2, size, length, [])
|> LazyTree.zip()
|> LazyTree.map(&list_lazy_tree/1)
|> LazyTree.flatten()
end)
end
defp call_n_times(_data, _seed, _size, 0, acc) do
acc
end
defp call_n_times(data, seed, size, length, acc) do
{seed1, seed2} = split_seed(seed)
call_n_times(data, seed2, size, length - 1, [call(data, seed1, size) | acc])
end
defp list_lazy_tree([]) do
LazyTree.constant([])
end
defp list_lazy_tree(list) do
children =
(0..length(list) - 1)
|> Stream.map(&List.delete_at(list, &1))
|> Stream.map(&list_lazy_tree/1)
LazyTree.new(list, children)
end
@doc """
Generates a list of elements generated by `data` without duplicates according
to `uniq_fun`.
This generator will generate lists where each list is unique according to the
value returned by applying `uniq_fun` to each element (similarly to how
`Enum.uniq_by/2` works). If `max_tries` consecutive elements are generated
that are considered duplicates according to `uniq_fun`, a
`StreamData.TooManyDuplicatesError` error is raised. For this reason, try to
make sure to not make `uniq_fun` return values out of a small value space.
By default `uniq_fun` is the identity function so the behaviour is similar to
`Enum.uniq/1`.
## Examples
data = StreamData.uniq_list_of(StreamData.int())
Enum.take(data, 3)
#=> [[1], [], [2, 3, 1]]
## Shrinking
This generator shrinks like `list_of/1`, but the shrunk values are unique
according to `uniq_fun` as well.
"""
@spec uniq_list_of(t(a), (a -> term), non_neg_integer) :: t([a]) when a: term
def uniq_list_of(data, uniq_fun \\ &(&1), max_tries \\ 10) do
new(fn seed, size ->
{seed1, seed2} = split_seed(seed)
length = uniform_in_range(0..size, seed1)
data
|> uniq_list_of(uniq_fun, seed2, size, _seen = MapSet.new(), max_tries, max_tries, length, _acc = [])
|> LazyTree.zip()
|> LazyTree.map(&list_lazy_tree(Enum.uniq_by(&1, uniq_fun)))
|> LazyTree.flatten()
end)
end
defp uniq_list_of(_data, _uniq_fun, _seed, _size, seen, _tries_left = 0, max_tries, remaining, _acc) do
raise TooManyDuplicatesError, max_tries: max_tries, remaining_to_generate: remaining, generated: seen
end
defp uniq_list_of(_data, _uniq_fun, _seed, _size, _seen, _tries_left, _max_tries, _remaining = 0, acc) do
acc
end
defp uniq_list_of(data, uniq_fun, seed, size, seen, tries_left, max_tries, remaining, acc) do
{seed1, seed2} = split_seed(seed)
tree = call(data, seed1, size)
key = uniq_fun.(tree.root)
if MapSet.member?(seen, key) do
uniq_list_of(data, uniq_fun, seed2, size, seen, tries_left - 1, max_tries, remaining, acc)
else
uniq_list_of(data, uniq_fun, seed2, size, MapSet.put(seen, key), max_tries, max_tries, remaining - 1, [tree | acc])
end
end
@doc ~S"""
Generates non-empty improper lists where elements of the list are generated
out of `first` and the improper ending out of `improper`.
## Examples
data = StreamData.nonempty_improper_list_of(StreamData.byte(), StreamData.binary())
Enum.take(data, 3)
#=> [["\f"], [56 | <<140, 137>>], [226 | "j"]]
## Shrinking
Shrinks towards smaller lists (that are still non-empty, having the improper
ending) and towards shrunk elements of the list and a shrunk improper
ending.
"""
@spec nonempty_improper_list_of(t(a), t(b)) :: t(nonempty_improper_list(a, b)) when a: term, b: term
def nonempty_improper_list_of(first, improper) do
map({list_of(first), improper}, fn
{[], ending} ->
[ending]
{list, ending} ->
List.foldr(list, ending, &[&1 | &2])
end)
end
@doc """
Generates lists of elements out of `first` with a chance of them being
improper with the improper ending taken out of `improper`.
Behaves similarly to `nonempty_improper_list_of/2` but can generate empty
lists and proper lists as well.
## Examples
data = StreamData.maybe_improper_list_of(StreamData.byte(), StreamData.binary())
Enum.take(data, 3)
#=> [[60 | "."], [], [<<212>>]]
## Shrinking
Shrinks towards smaller lists and shrunk elements in those lists, and
ultimately towards proper lists.
"""
@spec maybe_improper_list_of(t(a), t(b)) :: t(maybe_improper_list(a, b)) when a: term, b: term
def maybe_improper_list_of(first, improper) do
frequency([
{2, list_of(first)},
{1, nonempty_improper_list_of(first, improper)},
])
end
@doc """
Generates a list of fixed length where each element is generated from the
corresponding generator in `data`.
## Examples
data = StreamData.fixed_list([StreamData.int(), StreamData.binary()])
Enum.take(data, 3)
#=> [[1, <<164>>], [2, ".T"], [1, ""]]
## Shrinking
Shrinks by shrinking each element in the generated list according to the
corresponding generator. Shrunk lists never lose elements.
"""
@spec fixed_list([t(a)]) :: t([a]) when a: term
def fixed_list(datas) when is_list(datas) do
new(fn seed, size ->
{trees, _seed} =
Enum.map_reduce(datas, seed, fn data, acc ->
{seed1, seed2} = split_seed(acc)
{call(data, seed1, size), seed2}
end)
LazyTree.zip(trees)
end)
end
@doc """
Generates tuples where each element is taken out of the corresponding
generator in the `tuple_datas` tuple.
## Examples
data = StreamData.tuple({StreamData.int(), StreamData.binary()})
Enum.take(data, 3)
#=> [{-1, <<170>>}, {1, "<"}, {1, ""}]
## Shrinking
Shrinks by shrinking each element in the generated tuple according to the
corresponding generator.
"""
@spec tuple(tuple) :: t(tuple)
def tuple(tuple_datas) when is_tuple(tuple_datas) do
new(fn seed, size -> call(tuple_datas, seed, size) end)
end
@doc """
Generates maps with keys from `key_data` and values from `value_data`.
Since maps require keys to be unique, this generator behaves similarly to
`uniq_list_of/3`: if more than `max_tries` duplicate keys are generated
consequently, it raises a `StreamData.TooManyDuplicatesError` exception.
## Examples
Enum.take(StreamData.map_of(StreamData.int(), StreamData.boolean()), 3)
#=> [%{}, %{1 => false}, %{-2 => true, -1 => false}]
## Shrinking
Shrinks towards smallest maps and towards shrinking keys and values according
to the respective generators.
"""
@spec map_of(t(key), t(value)) :: t(%{optional(key) => value}) when key: term, value: term
def map_of(key_data, value_data, max_tries \\ 10) do
{key_data, value_data}
|> uniq_list_of(fn {key, _value} -> key end, max_tries)
|> map(&Map.new/1)
end
@doc """
Generates maps with fixed keys and generated values.
`data_map` is a map of `fixed_key => data` pairs. Maps generated by this
generator will have the same keys as `data_map` and values corresponding to
values generated by the generator under those keys.
## Examples
data = StreamData.fixed_map(%{
int: StreamData.int(),
binary: StreamData.binary(),
})
Enum.take(data, 3)
#=> [%{binary: "", int: 1}, %{binary: "", int: -2}, %{binary: "R1^", int: -3}]
## Shrinking
This generator shrinks by shrinking the values of the generated map.
"""
@spec fixed_map(map) :: t(map)
def fixed_map(data_map) when is_map(data_map) do
data_map
|> Enum.map(fn {key, value_data} -> {constant(key), value_data} end)
|> fixed_list()
|> map(&Map.new/1)
end
@doc """
Generates keyword lists where values are generated by `value_data`.
Keys are always atoms.
## Examples
Enum.take(StreamData.keyword_of(StreamData.int()), 3)
#=> [[], [sY: 1], [t: -1]]
## Shrinking
This generator shrinks equivalently to a list of key-value tuples generated by
`list_of/1`, that is, by shrinking the values in each tuple and also reducing
the size of the generated keyword list.
"""
@spec keyword_of(t(a)) :: t(keyword(a)) when a: term
def keyword_of(value_data) do
list_of({unquoted_atom(), value_data})
end
@doc """
Constrains the given `enum_data` to be non-empty.
`enum_data` must be a generator that emits enumerables, such as lists
and maps. `non_empty/1` will filter out enumerables that are empty
(`Enum.empty?/1` returns `true`).
## Examples
Enum.take(StreamData.non_empty(StreamData.list_of(StreamData.int())), 3)
#=> [[1], [-1, 0], [2, 1, -2]]
"""
@spec non_empty(t(Enumerable.t)) :: t(Enumerable.t)
def non_empty(enum_data) do
filter(enum_data, &not(Enum.empty?(&1)))
end
@doc ~S"""
Generates trees of values generated by `leaf_data`.
`subtree_fun` is a function that takes a generator and returns a generator
that "combines" that generator. This generator will pass `leaf_data` to
`subtree_fun` when it needs to go "one level deeper" in the tree. Note that
raw values from `leaf_data` can sometimes be generated.
This is best explained with an example. Say that we want to generate binary
trees of integers, and that we represent binary trees as either an integer (a
leaf) a `%Branch{}` struct:
defmodule Branch do
defstruct [:left, :right]
end
We can start off by creating a generator that generates branches given the
generator that generates the content of each node (`int/0` in our case):
defmodule MyTree do
def branch_data(child_data) do
StreamData.map({child_data, child_data}, fn {left, right} ->
%Branch{left: left, right: right}
end)
end
end
Now, we can generate trees by simply using `branch_data` as the `subtree_fun`,
and `int/0` as `leaf_data`:
tree_data = StreamData.tree(StreamData.int(), &MyTree.branch_data/1)
Enum.at(StreamData.resize(tree_data, 10), 0)
#=> %Branch{left: %Branch{left: 4, right: -1}, right: -2}
## Examples
A common example is nested lists:
data = StreamData.tree(StreamData.int(), &StreamData.list_of/1)
Enum.at(StreamData.resize(data, 10), 0)
#=> [[], '\t', '\a', [1, 2], -3, [-7, [10]]]
## Shrinking
Shrinks values and shrinks towards less deep trees.
"""
@spec tree(t(a), (t(a) -> t(b))) :: t(a | b) when a: term, b: term
def tree(leaf_data, subtree_fun) do
new(fn seed, size ->
leaf_data = resize(leaf_data, size)
{seed1, seed2} = split_seed(seed)
nodes_on_each_level = random_pseudofactors(trunc(:math.pow(size, 1.1)), seed1)
data =
Enum.reduce(nodes_on_each_level, leaf_data, fn nodes_on_this_level, data_acc ->
frequency([
{1, data_acc},
{2, resize(subtree_fun.(data_acc), nodes_on_this_level)},
])
end)
call(data, seed2, size)
end)
end
defp random_pseudofactors(n, _seed) when n < 2 do
[n]
end
defp random_pseudofactors(n, seed) do
{seed1, seed2} = split_seed(seed)
{factor, _seed} = :rand.uniform_s(trunc(:math.log2(n)), seed1)
if factor == 1 do
[n]
else
[factor | random_pseudofactors(div(n, factor), seed2)]
end
end
## Data types
@doc """
Generates boolean values.
## Examples
Enum.take(StreamData.boolean(), 3)
#=> [true, true, false]
## Shrinking
Shrinks towards `false`.
"""
@spec boolean() :: t(boolean)
def boolean() do
member_of([false, true])
end
@doc """
Generates integers bound by the generation size.
## Examples
Enum.take(StreamData.int(), 3)
#=> [1, -1, -3]
## Shrinking
Generated values shrink towards `0`.
"""
@spec int() :: t(integer)
def int() do
sized(fn size -> int(-size..size) end)
end
@doc """
Generates uniformly distributed floats in the interval `0..1`.
Note that if you want to have more complex float values, such as negative
values or bigger values, you can transform this generator. For example, to
have floats in the interval `0..10`, you can use `map/2`:
StreamData.map(StreamData.uniform_float(), &(&1 * 10))
To have sometimes negative floats, you can for example use `bind/2`:
StreamData.bind(StreamData.boolean(), fn negative? ->
if negative? do
StreamData.map(StreamData.uniform_float(), &(-&1))
else
StreamData.uniform_float()
end
end)
## Examples
Enum.take(StreamData.uniform_float(), 3)
#=> [0.5122356680893687, 0.7387020706272481, 0.9613874981766901]
## Shrinking
Values generated by this generator do not shrink.
"""
@spec uniform_float() :: t(float)
def uniform_float() do
new(fn seed, _size ->
{float, _seed} = :rand.uniform_s(seed)
LazyTree.constant(float)
end)
end
@doc """
Generates bytes.
A byte is an integer between `0` and `255`.
## Examples
Enum.take(StreamData.byte(), 3)
#=> [102, 161, 13]
## Shrinking
Values generated by this generator do not shrink.
"""
@spec byte() :: t(byte)
def byte() do
no_shrink(int(0..255))
end
@doc """
Generates binaries.
The length of the generated binaries is limited by the generation size.
## Examples
Enum.take(StreamData.binary(), 3)
#=> [<<1>>, "", "@Q"]
## Shrinking
Values generated by this generator only shrink by getting smaller, but the
single bytes do not shrink (given `byte/0` does not shrink).
"""
@spec binary() :: t(binary)
def binary() do
map(list_of(byte()), &IO.iodata_to_binary/1)
end
@doc """
Generates a string from the given list of character ranges.
`char_ranges` has to be a list of enumerables where each enumerable has to be
an enumerable of characters (`t:char/0`).
## Examples
Enum.take(StreamData.string_from_chars([?a..?c, ?l..?o]), 3)
#=> ["c", "oa", "lb"]
## Shrinking
Shrinks towards smaller strings and towards choosing characters that appear
earlier in `char_ranges`.
"""
@spec string_from_chars([Enumerable.t]) :: t(String.t)
def string_from_chars(char_ranges) when is_list(char_ranges) do
char_ranges
|> Enum.concat()
|> member_of()
|> list_of()
|> map(&List.to_string/1)
end
@doc ~S"""
Generates strings with ascii characters in them.
Equivalent to `string_from_chars([?\s..?~])`.
"""
@spec ascii_string() :: t(String.t)
def ascii_string() do
string_from_chars([?\s..?~])
end
@doc ~S"""
Generates strings with ascii characters in them.
Equivalent to `string_from_chars([?a..?z, ?A..?Z, ?0..?9])`.
"""
@spec alphanumeric_string() :: t(String.t)
def alphanumeric_string() do
string_from_chars([?a..?z, ?A..?Z, ?0..?9])
end
@doc """
Generates atoms that don't need to be quoted when written as literals.
## Examples
Enum.take(StreamData.unquoted_atom(), 3)
#=> [:xF, :y, :B_]
## Shrinking
Shrinks towards smaller atoms in the `?a..?z` character set.
"""
@spec unquoted_atom() :: t(atom)
def unquoted_atom() do
starting_char =
frequency([
{4, member_of(?a..?z)},
{2, member_of(?A..?Z)},
{1, constant(?_)},
])
# We limit the size to 255 so that adding the first character doesn't
# break the system limit of 256 chars in an atom.
rest = scale(string_from_chars([?a..?z, ?A..?Z, ?0..?9, [?_, ?@]]), &min(&1, 255))
{starting_char, rest}
|> resize_atom_data()
|> map(fn {first, rest} -> String.to_atom(<<first>> <> rest) end)
end
defp resize_atom_data(data) do
scale(data, fn size ->
min(trunc(:math.pow(size, 0.5)), 256)
end)
end
@doc """
Generates iolists.
Iolists are values of the `t:iolist/0` type.
## Examples
Enum.take(StreamData.iolist(), 3)
#=> [[164 | ""], [225], ["" | ""]]
## Shrinking
Shrinks towards smaller and less nested lists and towards bytes instead of
binaries.
"""
@spec iolist() :: t(iolist)
def iolist() do
# We try to use binaries that scale slower otherwise we end up with iodata with
# big binaries at many levels deep.
scaled_binary = scale(binary(), &trunc(:math.pow(&1, 0.6)))
improper_ending = one_of([scaled_binary, constant([])])
tree = tree(one_of([byte(), scaled_binary]), &maybe_improper_list_of(&1, improper_ending))
map(tree, &List.wrap/1)
end
@doc """
Generates iodata.
Iodata are values of the `t:iodata/0` type.
## Examples
Enum.take(StreamData.iodata(), 3)
#=> [[""], <<198>>, [115, 172]]
## Shrinking
Shrinks towards less nested iodata and ultimately towards smaller binaries.
"""
@spec iodata() :: t(iodata)
def iodata() do
frequency([
{3, binary()},
{2, iolist()},
])
end
@doc """
Checks the behaviour of a given function on values generated by `data`.
This function takes a generator and a function `fun` and verifies that that
function "holds" for all generated data. `fun` is called with each generated
value and can return one of:
* {:ok, term} - means that the function "holds" for the given value. `term`
can be anything and will be used for internal purposes by `StreamData`.
* `{:error, term}` - means that the function doesn't hold for the given
value. `term` is the term that will be shrunk to find the minimal value
for which `fun` doesn't hold. See below for more information on shrinking.
When a value is found for which `fun` doesn't hold (returns `{:error, term}`),
`check_all/3` tries to shrink that value in order to find a minimal value that
still doesn't satisfy `fun`.
The return value of this function is one of:
* `{:ok, map}` - if all generated values satisfy `fun`. `map` is a map of
metadata that contains no keys for now.
* `{:error, map}` - if a generated value doesn't satisfy `fun`. `map` is a
map of metadata that contains the following keys:
* `:original_failure` - if `fun` returned `{:error, term}` for a generated
value, this key in the map will be `term`.
* `:shrunk_failure` - the value returned in `{:error, term}` by `fun`
when invoked with the smallest failing value that was generated.
* `:nodes_visited` - the number of nodes (a positive integer) visited in
the shrinking tree in order to find the smallest value. See also the
`:max_shrinking_steps` option.
## Options
This function takes the following options:
* `:initial_seed` - three-element tuple with three integers that is used as
the initial random seed that drives the random generation. This option is
required.
* `:initial_size` - (non-negative integer) the initial generation size used
to start generating values. The generation size is then incremented by `1`
on each iteration. See the "Generation size" section of the module
documentation for more information on generation size. Defaults to `1`.
* `:max_runs` - (non-negative integer) the total number of elements to
generate out of `data` and check through `fun`. Defaults to `100`.
* `:max_shrinking_steps` - (non-negative integer) the maximum numbers of
shrinking steps to perform in case `check_all/3` finds an element that
doesn't satisfy `fun`. Defaults to `100`.
## Examples
Let's try out a contrived example: we want to verify that the `int/0`
generator generates integers that are not `0` or multiples of `11`. This
verification is broken by design because `int/0` is likely to generate
multiples of `11` at some point, but it will show the capabilities of
`check_all/3`. For the sake of the example, let's say we want the values that
fail to be represented as strings instead of the original integers that
failed. We can implement what we described like this:
options = [initial_seed: :os.timestamp()]
{:error, metadata} = StreamData.check_all(StreamData.int(), options, fn int ->
if int == 0 or rem(int, 11) != 0 do
{:ok, nil}
else
{:error, Integer.to_string(int)}
end
end)
metadata.nodes_visited
#=> 7
metadata.original_failure
#=> 22
metadata.shrunk_failure
#=> 11
As we can see, the function we passed to `check_all/3` "failed" for `int =
22`, and `check_all/3` was able to shrink this value to the smallest failing
value, which in this case is `11`.
"""
@spec check_all(t(a), Keyword.t, (a -> {:ok, term} | {:error, b})) :: {:ok, map} | {:error, map}
when a: term, b: term
def check_all(data, options, fun) when is_list(options) and is_function(fun, 1) do
seed = new_seed(Keyword.fetch!(options, :initial_seed))
size = Keyword.get(options, :initial_size, 1)
max_shrinking_steps = Keyword.get(options, :max_shrinking_steps, 100)
max_runs = Keyword.get(options, :max_runs, 100)
config = %{
max_runs: max_runs,
max_shrinking_steps: max_shrinking_steps,
}
check_all(data, seed, size, fun, _runs = 0, config)
end
defp check_all(_data, _seed, _size, _fun, runs, %{max_runs: runs}) do
{:ok, %{}}
end
defp check_all(data, seed, size, fun, runs, config) do
{seed1, seed2} = split_seed(seed)
%LazyTree{root: root, children: children} = call(data, seed1, size)
case fun.(root) do
{:ok, _term} ->
check_all(data, seed2, size + 1, fun, runs + 1, config)
{:error, reason} ->
shrinking_result = shrink_failure(shrink_initial_cont(children), nil, reason, fun, 1, config)
shrinking_result = Map.put(shrinking_result, :original_failure, reason)
{:error, shrinking_result}
end
end
defp shrink_initial_cont(nodes) do
&Enumerable.reduce(nodes, &1, fn elem, acc -> {:suspend, [elem | acc]} end)
end
defp shrink_failure(_cont, _parent_cont, smallest, _fun, nodes_visited, %{max_shrinking_steps: nodes_visited}) do
%{shrunk_failure: smallest, nodes_visited: nodes_visited}
end
# We try to get the next element out of the current nodes. If the current
# nodes are finished, we check if this was the first check: if it was, it
# means we were visiting children of a node and this node has no children, so
# we recurse on the siblings of that node. Otherwise, we return the smallest
# failure. If we get the next nodes out of the current nodes, we check if it
# also fails: if it does, we "go down" and recurse on the children of that
# node but only if it has children, otherwise we move to the siblings. If it
# doesn't fail, we move to the siblings.
defp shrink_failure(cont, parent_cont, smallest, fun, nodes_visited, config) do
case cont.({:cont, []}) do
{state, _} when state in [:halted, :done] and is_function(parent_cont) ->
shrink_failure(parent_cont, nil, smallest, fun, nodes_visited, config)
{state, _} when state in [:halted, :done] ->
%{shrunk_failure: smallest, nodes_visited: nodes_visited}
{:suspended, [child], cont} ->
case fun.(child.root) do
{:ok, _term} ->
shrink_failure(cont, nil, smallest, fun, nodes_visited + 1, config)
{:error, reason} ->
shrink_failure(shrink_initial_cont(child.children), cont, reason, fun, nodes_visited + 1, config)
end
end
end
@doc """
Syntactic sugar to create generators.
This macro provides ad hoc syntax to write complex generators. Let's see a
quick example to get a feel of how it works. Say we have a `User` struct:
defmodule User do
defstruct [:name, :email]
end
We can create a generator of users like this:
email_generator = map({binary(), binary()}, fn left, right -> left <> "@" <> right end)
gen all name <- binary(),
email <- email_generator do
%User{name: name, email: email}
end
Everything between `gen all` and `do` is referred to as **clauses**. Clauses
are used to specify the values to generate to be used in the body. The newly
created generator will generated values that are the return value of the
`do` body for the generated values from the clauses.
### Clauses
As seen in the example above, clauses can be of three types:
* value generation - they have the form `pattern <- generator` where
`generator` must be a generator. These clauses take a value out of
`generator` on each run and match it against `pattern`. Variables bound in
`pattern` can be then used throughout subsequent clauses and in the `do`
body.
* binding - they have the form `pattern = expression`. They are exactly like
assignment through the `=` operator: if `pattern` doesn't match
`expression`, an error is raised. They can be used to bind values for use
in subsequent clauses and in the `do` body.
* filtering - they have the form `expression`. If a filtering clause returns
a truthy value, then the set of generated values that appear before the
filtering clause is considered valid and generation continues. If the
filtering clause returns a falsey value, then the current value is
considered invalid and a new value is generated. Note that filtering
clauses should not filter out too many times; in case they do, a
`StreamData.FilterTooNarrowError` error is raised (same as `filter/3`).
### Body
The return value of the body passed in the `do` block is what is ultimately
generated by the generator return by this macro.
## Shrinking
See the module documentation for more information on shrinking. Clauses affect
shrinking in the following way:
* binding clauses don't affect shrinking
* filtering clauses affect shrinking like `filter/3`
* value generation clauses affect shrinking similarly to `bind/2`
"""
defmacro gen({:all, _meta, clauses} = _generation_clauses, [do: body] = _block) do
compile(clauses, body)
end
defp compile(clauses, body) do
quote do
var!(generated_values, unquote(__MODULE__)) = []
{:cont, data} = unquote(compile_clauses(clauses, body))
data
end
end
defp compile_clauses([], body) do
quote do
var!(generated_values, unquote(__MODULE__)) = Enum.reverse(var!(generated_values, unquote(__MODULE__)))
{:cont, StreamData.constant(unquote(body))}
end
end
defp compile_clauses([{:<-, _meta, [pattern, generator]} = clause | rest], body) do
quote do
data =
StreamData.bind_filter(unquote(generator), fn unquote(pattern) = generated_value ->
var!(generated_values, unquote(__MODULE__)) =
[{unquote(Macro.to_string(clause)), generated_value} | var!(generated_values, unquote(__MODULE__))]
unquote(compile_clauses(rest, body))
end)
{:cont, data}
end
end
defp compile_clauses([{:=, _meta, [_left, _right]} = assignment | rest], body) do
quote do
unquote(assignment)
unquote(compile_clauses(rest, body))
end
end
defp compile_clauses([clause | rest], body) do
quote do
if unquote(clause) do
unquote(compile_clauses(rest, body))
else
:skip
end
end
end
defp new_seed({int1, int2, int3} = tuple)
when is_integer(int1) and is_integer(int2) and is_integer(int3) do
:rand.seed_s(@rand_algorithm, tuple)
end
defp split_seed(seed) do
{int1, seed} = :rand.uniform_s(1_000_000_000, seed)
{int2, seed} = :rand.uniform_s(1_000_000_000, seed)
{int3, seed} = :rand.uniform_s(1_000_000_000, seed)
new_seed = :rand.seed_s(@rand_algorithm, {int1, int2, int3})
{new_seed, seed}
end
defp uniform_in_range(left..right, seed) when left > right do
uniform_in_range(right..left, seed)
end
defp uniform_in_range(left..right, seed) do
width = right - left
{random_int, _seed} = :rand.uniform_s(width + 1, seed)
random_int - 1 + left
end
# This is the implementation of Enumerable.reduce/3. It's here because it
# needs split_seed/1 and call/3 which are private.
def __reduce__(data, acc, fun) do
reduce(data, acc, fun, new_seed(:os.timestamp()), _initial_size = 1, _max_size = 100)
end
defp reduce(_data, {:halt, acc}, _fun, _seed, _size, _max_size) do
{:halted, acc}
end
defp reduce(data, {:suspend, acc}, fun, seed, size, max_size) do
{:suspended, acc, &reduce(data, &1, fun, seed, size, max_size)}
end
defp reduce(data, {:cont, acc}, fun, seed, size, max_size) do
{seed1, seed2} = split_seed(seed)
%LazyTree{root: next} = call(data, seed1, size)
reduce(data, fun.(next, acc), fun, seed2, min(max_size, size + 1), max_size)
end
## Enumerable
defimpl Enumerable do
def reduce(data, acc, fun), do: @for.__reduce__(data, acc, fun)
def count(_data), do: {:error, __MODULE__}
def member?(_data, _term), do: {:error, __MODULE__}
end
end