New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
ZIO Test: Add Gen Combinators #1356
Conversation
final val boolean: Gen[Random, Boolean] = | ||
choose(false, true) | ||
|
||
final def char(range: Range): Gen[Random, Char] = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would be useful to add identifierChar
, printableChar
, unicodeChar
, etc.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes.
final def constSample[R, A](sample: => Sample[R, A]): Gen[R, A] = | ||
fromEffectSample(ZIO.succeedLazy(sample)) | ||
|
||
final def double(min: Double, max: Double): Gen[Random, Double] = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Useful to add float
, short
, long
, etc. Maybe the bounded variants, but also: anyDouble
, anyChar
, anyShort
, anyLong
, anyFloat
, etc.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. One potential issue with the bounded variants for types like Long
and Double
is that they are "bigger" than the Int
that underlies range. I wonder if we should roll our own simple range for this so we can be consistent across numeric types or just use something like min
, max
, step
.
|
||
final def oneOf[R, A](a: Gen[R, A], as: Gen[R, A]*): Gen[R with Random, A] = | ||
int(0 to as.length).flatMap(n => if (n == 0) a else as(n - 1)) | ||
|
||
/** | ||
* A sized generator, whose size falls within the specified bounds. | ||
*/ | ||
final def sized[R <: Random, A](min: Int, max: Int)(f: Int => Gen[R, A]): Gen[R, A] = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably you can refactor the min: Int, max: Int
to be a Range
like the other combinators you introduced.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes.
gen1.zipAll(gen2).flatMap { | ||
case (a, a2) => | ||
Gen { | ||
a.fold[Gen[R, A]](empty)(const(_)).sample ++ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe interleave
would be more appropriate than ++
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes as soon as we get that merged we can simplify this a lot and implement some other combinators like weighted
. 😃
final def flatMap[R1 <: R, B](f: A => Gen[R1, B]): Gen[R1, B] = Gen { | ||
sample.flatMap { a => | ||
f(a.value).sample.map { b => | ||
val rest = a.shrink.flatMap(a => f(a).sample).flatMap(sample => ZStream(sample.value) ++ sample.shrink) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder what the most useful way to define this is...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is one of the key issues. I think, though I could be wrong, that this is the only lawful way to define it for potentially finite streams. The challenge with the stream based flatMap (at least when the stream is not just a single element that generates a random effect) is that we have to essentially traverse all the children of the first possibility before we can explore other possibilities. So like:
for {
x <- Stream(1, 2, 3)
y <- Stream(4, 5, 6)
} yield (x, y)
// Stream((1, 4), (1, 5), (1, 6), (2, 4), ...)
We get all the 1
possibilities before we see other possibilities whereas a lot of the time we want a more balanced distribution which is where combinators like union
come in to get a more balanced distribution.
The other thing we could explore is the infinite stream instance where unit
is repeating the same value and flatMap
is taking the diagonal but I'm not sure that is where we want to go.
I am open to this. The common libraries just use a flat list. In a PureScript library ( The tree you suggest basically gives you 2 dimensions: the horizontal one can be used for "alternates", the vertical one for "sizes". Note that we can actually reconstruct such a tree from the flat shrink list and a |
@jdegoes Hedgehog actually uses a shrink tree. So does Hypothesis in Python and test.check in Clojure. I think your concept about perturbing the shrink list is really interesting. Even in Hedgehog with the shrink tree special logic is needed when building structures like lists to get them to shrink efficiency versus being able to just do something like |
Ah, I didn't know that. I'm fine either way on this. I do agree the extra dimension (the tree) gives you ability to find better minimized examples. Won't have a major impact on end-user API. Let me know when you're ready for a review! |
Will do. The other thing to keep in mind is that a lot of shrinking approaches that seem to use shrink lists actually use trees. Like ScalaCheck and QuickCheck look like they use a shrink list with a type that is roughly Thinking about it more, the key idea is that we need to be able to express an idea of conditionality ("if this shrink still fails the test keep shrinking from there, otherwise backtrack and try shrinking some other way"). With just a list we can't do that. The shrink list has to be monotonically decreasing in some concept of "size" because when you do the shrink search in a method like |
Excellent point. This agues for a tree IMO. Then we can keep compositional shrinking with "backtracking". |
I agree. I am working on it. |
@jdegoes This is ready for another review. |
.map(Sample.shrinkIntegral(min)) | ||
} | ||
|
||
final def listOf[R, A](min: Int, max: Int)(g: Gen[R, A]): Gen[R with Random, List[A]] = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was thinking it could be more compositional to make the "sized" variants like this:
final def listOf(g: Gen[R, A]): Int => Gen[R with Random, List[A]]`
Then you use them like this:
import Gen.{ listOf, sized }
sized(0, 10)(listOf(boolean))
What do you think?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
By the way, a new pattern that infers better is using R <: Random
on the type declaration, rather than R with Random
on the output.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like that.
It potentially takes us in the direction of being able to have test modes where the size parameter is controlled by the runner versus the test, which a lot of other frameworks have but we don't yet. You could imagine having an alias for Int => Gen[R, A]
, maybe SGen[R, A]
for a Gen
that takes a size parameter. Then we could have a checkSized
method that took an SGen
, feed it a series of sizes, and checked each one.
The one issue I see here is that if you go down this road the next step is that you want to be able to compose sized generators, which works but would require writing a ton more code to make methods available on sized generators in addition to normal ones. One other way we could approach it is to use the environment type:
final case class Size(n: Int)
final def listOf[R <: Random with Size, A](g: Gen[R, A]): Gen[R, List[A]] =
Gen.fromEffect(ZIO.access[Size](_.n)).flatMap(listOfN(_)(g))
Then sizes naturally compose together unless you want to provide a different size parameter locally.
Depending on where we go with this the listOfN
, StringN
and vectorOfN
methods may be redundant with the new "sized" variants (they would be literally identical if the function versions if we just curried the parameters in the other order) so I think we take them out unless we go in a different direction here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree this makes sense. Although for namespacing reasons, it would probably be:
trait Size {
def size: Int
}
Or maybe even better:
trait TestConfig {
def testConfig: TestConfig.Service
}
...since we could stick other settings in there, as well.
I think we need to do this at some point, question is: 1.0 or after 1.0?
Global size parameters were kinda abused in QuickCheck (et al). What size do you need for trees? What size do you need for 2D arrays? Is one global default really suitable for all cases?
Basically people use sized
for convenience even when it didn't make sense, yielding some suboptimal tests. At least being explicit now, burden is on the user for choosing correct settings.
What I would really like to see in v1.0 and which should be easy is "small", "medium", and "large" combinators, e.g. small(listOf(...))
, where the bigger ones spend more time on smaller sizes, but bump up to bigger ones once in a while (exponential or quadratic distribution).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree with you about the dangers of relying too much on a global "size" parameter. My initial hypothesis would be that only collection types should have specific sized combinators (e.g. listOf
) and in other cases users can always use the generic sized
combinator.
On the naming, the downside of using something like TestConfig
is that it hides what the actual dependencies of a generator are. I really like how in the current API you can see right away whether a method generates random values by the Random
environment parameter. I think it could be nice to do the same thing with Size
so you can see that methods like listOf
take a size parameter and other ones don't. Of course the downside is that if we end up having a lot more dependencies the type signatures get very verbose, but I'm not sure there are any other dependencies that generic test combinators should have to rely on.
My vote would be for getting this in now if we think it makes sense. The code base is still small and we can iterate quite quickly so might as well lay a strong foundation now so we are building in the right direction.
Shall I submit a PR on this so we can work through it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@adamgfraser All right, let's do it!
I'd propose:
trait SizedGen {
def sizedGen: FiberRef[Int]
}
then we can augment the managed mock environment with SizedGen
using some suitable default.
The sized
combinator now can simply require R <: SizedGen
, and we can use that in the list*
etc. methods.
Sound reasonable?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes!
/** | ||
* A generator of optional values. Shrinks toward `None`. | ||
*/ | ||
final def option[R, A](gen: Gen[R, A]): Gen[R with Random, Option[A]] = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
R <: Random
on the type declaration to infer better, here and elsewhere.
final def some[R, A](gen: Gen[R, A]): Gen[R, Option[A]] = | ||
gen.map(Some(_)) | ||
|
||
final def string[R](min: Int, max: Int)(char: Gen[R, Char]): Gen[R with Random, String] = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
string[R <: Random](char: Gen[R, Char]): Int => Gen[R, String]
etc.
*/ | ||
final case class Sample[-R, +A](value: A, shrink: ZStream[R, Nothing, A]) { self => | ||
final def <*>[R1 <: R, B](that: Sample[R1, B]): Sample[R1, (A, B)] = self zip that | ||
final case class Sample[-R, +A](value: A, shrink: ZStream[R, Nothing, Sample[R, A]]) { self => |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Superb!
} | ||
} | ||
|
||
final def unfold[R, A, S](s: S)(f: S => (A, ZStream[R, Nothing, S])): Sample[R, A] = { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice handy functions!
.map(_.map(checkValue)) | ||
.dropWhile(!_.value.failure) // Drop until we get to a failure | ||
.take(1) // Get the first failure | ||
.flatMap(_.shrinkSearch(_.failure).take(maxShrinks)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Beautiful simplification. ❤️
def showTree[R, A](sample: Sample[R, A], offset: Int = 0): ZIO[R, Nothing, String] = { | ||
val head = " " * offset + sample.value + "\n" | ||
val tail = sample.shrink.mapM(showTree(_, offset + 2)).runCollect.map(_.mkString("\n")) | ||
tail.map(head + _) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Awesome work on all these tests!
@jdegoes I addressed all your review comments. For now I went with the approach to the "sized" combinators that you originally proposed. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Excellent work on this PR. Some really wonderful combinators and the tree-based shrinking is a massive improvement.
Looks like the test |
@ghostdogpr I don’t think I have permission to look at the link. Not sure what is going on. That test has been there through several iterations and all of the CI builds passed before merging, right? |
@adamgfraser oops, the link shows that 12 builds out of 12 failed with this line:
The build after your last change indeed passed, maybe there was another change applied on master in between that broke it? |
This link is public: https://circleci.com/gh/zio/zio/16472 |
Let me investigate. |
@adamgfraser |
I can reproduce locally. Trying to diagnose now. @iravid @vasilmkd We're running into an issue with a failing monad laws associativity test for our |
We only ever merged changes that passed all checks in the CI. Looking now. |
Okay. Yeah, we just merged this so none of these checks would have been in the CI before. |
Hmmm, it may be a regression when we converted |
Yes, I see the same thing. I wonder if it has something to do with the ordering of effects? Are you okay with reverting that change? |
Yes, please. Feel free to. I can't at the moment. |
Okay, will do now. |
Adds Gen combinators. There are a lot of directions to go here and we're still fleshing out the API so wanted to share and get feedback.
A couple of observations from my end:
union
operation that switches pulling values from two generators. It works as you would expect for two finite generators or two random generators, but the result can be unintuitive when combining a finite and random generator because a random generator is conceptually just a stream of one effectual value. Maybe a different namespace for some of the different constructors?With regard to the second point maybe we could do something like:
That way the shrinks are a recursive tree instead of a flat list.
@jdegoes Definitely open to feedback on this. I may just not be getting the right way to implement some of these combinators within our structure.