- references
- What's Cooking in ZIO Test by Adam Fraser
- Using Aspects To Transform Your Code With ZIO Environment
- https://zio.dev/reference/observability/logging
- https://zio.dev/reference/test/aspects/
- https://zio.dev/reference/test/property-testing/
- https://www.zionomicon.com
- https://github.com/adamgfraser/0-to-100-with-zio-test
- https://docs.spring.io/spring-framework/docs/4.3.15.RELEASE/spring-framework-reference/html/aop.html
- https://dotty.epfl.ch/docs/reference/new-types/polymorphic-function-types.html
- goals of this workshop
- introduction to
- functional programming aspects
- property based testing
- understanding how to use test aspects in practice
- creating data generators
- introduction to
- workshops
- task1: implement generator of
Accounts- then derive it using
zio-test/magnolia - solution:
AccountGenerators
- then derive it using
- task2: derive generator of
Contributors- then switch it to generate
Contributorfrom filesrc/test/resources/contributors.txt - solution:
ContributorGenerators
- then switch it to generate
- task3: implement and plug aspect to set specific seed (
TestSeed.seed) before each test- solution:
MainSpec
- solution:
- task1: implement generator of
- lib: caliban
- example
val api = graphQL(???) @@ maxDepth(50) @@ timeout(3 seconds) @@ printSlowQueries(500 millis) @@ apolloTracing @@ apolloCaching - supports aspects (called wrappers) that allow modifying: query parsing, validation and execution
- example
- introduction
- in any domain there are cross-cutting concerns that are shared among different parts of our main program logic
- often these concerns are tangled with each part of our main program logic and scattered across different parts
- we want to increase the modularity of our programs by separating these concerns from our main program logic
- cross-cutting concerns are typically related to how we do something rather than what we are doing
- what level of authorization should this transfer require?
- how should this transfer be logged?
- how should this transfer be recorded to our database
- example: testing
- main program logic: tests
- concerns
- how many times should we run a test?
- what environments should we run the test on?
- what sample size should we use for property based tests?
- what degree of parallelism?
- what timeout to use?
- example: graphql
- main program logic: queries
- concerns
- what is the maximum depth of nested queries we should support
- what is the maximum number of fields we should support
- what timeout should we use?
- how should we handle slow queries?
- what kind of tracing and caching should we use?
- traditional approach: metaprogramming
- example: AspectJ
@Aspect public class BeforeExample { @Before("execution(* com.xyz.myapp.dao.*.*(..))") public void doAccessCheck() { // ... } } - relies on implementation details such as class and method names that may change
- no longer able to statically type check if code is dynamically generated
- example: AspectJ
- functional approach: polymorphic functions
- aspects are polymorphic functions
- polymorphic function: scala3
// A polymorphic method: def foo[A](xs: List[A]): List[A] = xs.reverse // A polymorphic function value: val bar: [A] => List[A] => List[A] // ^^^^^^^^^^^^^^^^^^^^^^^^^ // a polymorphic function type = [A] => (xs: List[A]) => foo[A](xs) - example: zio
trait Aspect[-R, +E] { def apply[R1 <: R, E1 >: E, A](zio: ZIO[R1, E1, A]): ZIO[R1, E1, A] }- potentially constraining the environment or widening the error type
- transforms the how but not the what
- composable
implicit final class AspectSyntax[-R, +E, +A)(private val zio: ZIO[R, E, A]) { def @@[R1 <: R, E1 >: E](aspect: Aspect[R1, E1]): ZIO[R1, E1, A] = aspect(zio) }
- example
test("concurrency test") { ??? } timeout(60.seconds) - seamlessly control how tests are executed
- example
- without aspects
test("foreachPar preserves ordering") { val zio = ZIO.foreach(1 to 100) { _ => ZIO.foreachPar(1 to 100)(ZIO.succeed(_)).map(_ == (1 to 100)) }.map(_.forall(identity)) assert(zio)(isTrue) } } - with aspects
test("foreachPar preserves ordering") { assert(ZIO.foreachPar(1 to 100)(ZIO.succeed(_)))(equalTo(1 to 100)) } } @@ nonFlaky
- without aspects
- example
- common test aspects
- diagnose - do a localized fiber dump if a test times out
- nonFlaky - run a test repeatedly to make sure it is stable
- timed - time a test to identify slow tests
- timeout - time out a test after specified duration
- tag - tag a test for reporting
- example: "this test is about database"
- composable
- test @@ nonFlaky @@ timeout(60.seconds)
- apply to tests, suites or entire specs
- order matters
- repeat(10) @@ timeout(60s)
- timeout(60s) @@ repeat(10)
- example: ZIO test
test("encode and decode is an identity") { // check operator, one or more generators, assertion check(genEvents) { event => assert(decode(encode(event)))(equalTo(event)) } } - is an approach where the framework generates test cases
- advantage: allows to quickly test a large number of test cases
- potentially: reveal not obvious counterexamples
- typically generate ~ 100-200 test cases
- Int ~2 billion values
- complex data types => number of possibilities increases exponentially
- complement property based testing with traditional tests (for particular degenerate cases)
- common mistake: generator is not general enough
- example: generating user input using ASCII
- what about: 普通话 ?
- example: generating user input using ASCII
- generator represents a distribution of potential values
- each time we run a property based test we sample values from that distribution
final case class Gen[-R, +A]( sample: ZStream[R, Nothing, Sample[R, A]] ) final case class Sample[-R, +A]( value: A, shrinks: ZStream[R, Nothing, Sample[R, A]] )
- each time we run a property based test we sample values from that distribution
- create generators
- construct generators for each field
- combine with operators
- example: flatMap, map, oneOf, zip
- example
val genAccount2: Gen[Any, Account] = (Gen.uuid <*> // symbolic alias for zip and zipWith; generate values in parallel Gen.fromIterable(AccountStatus.values) <*> Gen.string1(Gen.char) ).map { case (uuid, status, str) => Account(AccountId(uuid), status, NonEmptyString.unsafeFrom(str)) }
- auto-deriving generator
- example
val genAccount: Gen[Any, Account] = DeriveGen[Account] // implicit for each field - lib: https://zio.dev/api/zio/test/magnolia/index.html
- example
- don't use filter - transform instead
- filtering = "throw away" data that doesn’t satisfy our predicate
- example
val evens: Gen[Random, Int] = ints.map(n => if (n % 2 == 0) n else n + 1) // transformation
- shrinking
- counterexample will typically not be the "simplest"
- test framework tries to shrink failures to ones that
- are "simpler" (in some sense)
- example: smaller integers, smaller collections
- and still violate the property
- are "simpler" (in some sense)
- test framework tries to shrink failures to ones that
- ZIO Test uses "integrated shrinking"
- every generator already knows how to shrink itself
- all operators keep this property
- example: generator of even integers can’t shrink to 1
- every generator already knows how to shrink itself
- under the hood
- Sample contains a "tree" of possible "shrinkings" for the value
- root: original value
- invariants
- any given level: value earlier in the stream, must be "smaller" than later values
- all children must be "smaller" than their parents
- machinery
- generate the first Sample in the shrink stream
- test whether its value is also a counterexample to the property being tested
- counterexample => recurse on that sample
- not => repeat with the next Sample in shrink stream
- example: shrinking logic for int
- first tries to shrink to zero
- then to half the distance between counterexample and zero
- then to half that distance, and so on
- Sample contains a "tree" of possible "shrinkings" for the value
- counterexample will typically not be the "simplest"
- TestRandom service
- provides a testable implementation of the Random service
- serves as a purely functional random number generator
- implementation takes care of passing the updated seed
- we can set the seed and generate a value based on that seed
- default seed
/** * An arbitrary initial seed for the `TestRandom`. */ val DefaultData: Data = Data(1071905196, 1911589680) - we could set/get seed using: TestRandom.getSeed / TestRandom.setSeed
- default seed