forked from zio/zio-json
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Feat/support golden testing (zio#636)
* Add golden testing functionality * Add tests for golden testing functionality * Add README
- Loading branch information
Showing
13 changed files
with
796 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
# zio-json-golden | ||
|
||
This module provides golden testing for your data types. | ||
|
||
## What is golden testing? | ||
|
||
Golden testing, also known as snapshot testing, is a way to ensure that the way your data type is encoded and decoded the way you expect. | ||
|
||
By golden testing your datatype, you save the expected serialization output into a separate file. That file is then used as a reference from which you ensure that your data type can be serialized and deserialized. | ||
|
||
|
||
[Further reading](https://ro-che.info/articles/2017-12-04-golden-tests) | ||
|
||
|
||
### Traditional way | ||
|
||
Without golden testing, it's usually done the following way: | ||
|
||
```scala | ||
import zio.json._ | ||
import zio.test._ | ||
object EncodeDecodeSpec extends ZIOSpecDefault { | ||
|
||
case class Banana(curvature: Double) | ||
object Banana { | ||
implicit val codec: JsonCodec[Banana] = DeriveJsonCodec.gen[Banana] | ||
} | ||
|
||
def spec = suite("EncodeDecodeSpec")( | ||
test("Encode/decode test for Banana") { | ||
val banana = Banana(0.5) | ||
val expected = """{"curvature":0.5}""" | ||
assertTrue( | ||
expected.fromJson[Banana].toOption.get == banana, | ||
banana.toJson == expected | ||
) | ||
} | ||
) | ||
} | ||
``` | ||
That's a lot of boilerplate for such a simple test. | ||
Let's show how we can do better using zio-json-golden. | ||
|
||
### Simple Example | ||
```scala | ||
import zio.json._ | ||
import zio.test._ | ||
import zio.json.golden._ | ||
object EncodeDecodeSpec extends ZIOSpecDefault { | ||
|
||
case class Banana(curvature: Double) | ||
object Banana { | ||
implicit val codec: JsonCodec[Banana] = DeriveJsonCodec.gen[Banana] | ||
} | ||
|
||
def spec = suite("EncodeDecodeSpec")( | ||
goldenTest(DeriveGen[Banana]) | ||
) | ||
} | ||
``` | ||
|
||
This test will generate a reference file under `src/test/resources/golden/`. | ||
|
||
The test will fail the first time it is run since there's no reference file. The file will have `_new` suffix appended to it. To fix the test, simply remove `_new` from the file name and run the test. | ||
|
||
If the `Banana` datatype is modified in an incompatible way in the future, then this will fail the test and generate a `_changed` file. | ||
If the change is intended, then simply copy the contents of the `_changed` file into the current reference file and re-run the test. If the change is not intended, then the test has served its purpose! | ||
|
||
### Configuration | ||
|
||
It's possible to override the default configuration of the relative path under the `golden` directory (in case of type name conflicts for instance) and change the default sample size (which is 20). | ||
|
||
This can be done by supplying a different `GoldenConfiguration` to the scope | ||
|
||
```scala | ||
{ | ||
implicit val config: GoldenConfiguration = ??? | ||
goldenTest(DeriveGen[SumType]) | ||
} | ||
|
||
``` | ||
|
||
|
13 changes: 13 additions & 0 deletions
13
zio-json-golden/src/main/scala/zio/json/golden/GoldenConfiguration.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
package zio.json.golden | ||
|
||
final case class GoldenConfiguration( | ||
relativePath: String, | ||
sampleSize: Int | ||
) | ||
|
||
object GoldenConfiguration { | ||
implicit val default: GoldenConfiguration = GoldenConfiguration( | ||
relativePath = "", | ||
sampleSize = 20 | ||
) | ||
} |
10 changes: 10 additions & 0 deletions
10
zio-json-golden/src/main/scala/zio/json/golden/GoldenSample.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
package zio.json.golden | ||
|
||
import zio.json._ | ||
import zio.json.ast.Json | ||
|
||
case class GoldenSample(samples: Json) | ||
|
||
object GoldenSample { | ||
implicit val jsonCodec: JsonCodec[GoldenSample] = DeriveJsonCodec.gen | ||
} |
38 changes: 38 additions & 0 deletions
38
zio-json-golden/src/main/scala/zio/json/golden/filehelpers.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,38 @@ | ||
package zio.json.golden | ||
|
||
import java.io.{ File => JFile } | ||
|
||
import zio.{ test => _, _ } | ||
import zio.json.golden.GoldenSample | ||
import zio.nio.file._ | ||
import zio.stacktracer.TracingImplicits.disableAutoTrace | ||
|
||
object filehelpers { | ||
|
||
def getRootDir(file: JFile)(implicit trace: Trace): Task[JFile] = | ||
if (file.getName == "target") ZIO.succeed(file) | ||
else ZIO.attempt(file.getParentFile).flatMap(getRootDir) | ||
|
||
def createGoldenDirectory(pathToDir: String)(implicit trace: Trace): Task[Path] = { | ||
val _ = disableAutoTrace // TODO: Find a way to suppress the unused import warning | ||
val rootFile = new JFile(getClass.getResource("/").toURI) | ||
for { | ||
baseFile <- getRootDir(rootFile) | ||
goldenDir = new JFile(baseFile.getParentFile, pathToDir) | ||
path = Path.fromJava(goldenDir.toPath) | ||
_ <- ZIO.attemptBlocking(goldenDir.mkdirs) | ||
} yield path | ||
} | ||
|
||
def writeSampleToFile(path: Path, sample: GoldenSample)(implicit trace: Trace): Task[Unit] = { | ||
val jsonString = sample.toJsonPretty | ||
Files.writeBytes(path, Chunk.fromArray(jsonString.getBytes("UTF-8"))) | ||
} | ||
|
||
def readSampleFromFile(path: Path)(implicit trace: Trace): Task[GoldenSample] = | ||
for { | ||
json <- zio.json.readJsonAs(path.toFile).runHead.someOrFailException | ||
sample <- ZIO.fromEither(json.as[GoldenSample].left.map(error => new Exception(error))) | ||
} yield sample | ||
|
||
} |
119 changes: 119 additions & 0 deletions
119
zio-json-golden/src/main/scala/zio/json/golden/package.scala
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,119 @@ | ||
package zio.json | ||
|
||
import scala.annotation.nowarn | ||
import scala.reflect.runtime.universe.TypeTag | ||
|
||
import zio.{ test => _, _ } | ||
import zio.json.golden.filehelpers._ | ||
import zio.nio.file._ | ||
import zio.stacktracer.TracingImplicits.disableAutoTrace | ||
import zio.test._ | ||
import zio.test.diff._ | ||
import zio.test.diff.Diff._ | ||
|
||
import zio.json._ | ||
import zio.json.ast._ | ||
|
||
package object golden { | ||
|
||
implicit private lazy val diffJsonValue: Diff[Json] = { | ||
case (x: Json.Obj, y: Json.Obj) => | ||
mapDiff[String, Json].diff(x.fields.toMap, y.fields.toMap) | ||
|
||
case (x: Json.Arr, y: Json.Arr) => | ||
seqDiff[Json].diff(x.elements, y.elements) | ||
|
||
case (x, y) => | ||
if (x == y) DiffResult.Identical(x) | ||
else DiffResult.Different(x, y) | ||
} | ||
|
||
@nowarn implicit private lazy val diff: Diff[GoldenSample] = (x: GoldenSample, y: GoldenSample) => | ||
Diff[Json].diff(x.samples, y.samples) | ||
|
||
def goldenTest[A: TypeTag: JsonEncoder]( | ||
gen: Gen[Sized, A] | ||
)(implicit | ||
trace: Trace, | ||
config: GoldenConfiguration | ||
): Spec[TestEnvironment, Throwable] = { | ||
val _ = disableAutoTrace // TODO: Find a way to suppress the unused import warning | ||
val name = getName[A] | ||
test(s"golden test for $name") { | ||
import config.{ relativePath, sampleSize } | ||
val lowerCasedName = name.toLowerCase | ||
for { | ||
resourceDir <- createGoldenDirectory(s"src/test/resources/golden/$relativePath") | ||
fileName = Path(s"$lowerCasedName.json") | ||
filePath = resourceDir / fileName | ||
assertion <- ZIO.ifZIO(Files.exists(filePath))( | ||
validateTest(resourceDir, name, gen, sampleSize), | ||
createNewTest(resourceDir, name, gen, sampleSize) | ||
) | ||
} yield assertion | ||
} | ||
} | ||
|
||
private def validateTest[A: JsonEncoder]( | ||
resourceDir: Path, | ||
name: String, | ||
gen: Gen[Sized, A], | ||
sampleSize: Int | ||
)(implicit trace: Trace): ZIO[Sized, Throwable, TestResult] = { | ||
val fileName = Path(s"$name.json") | ||
val filePath = resourceDir / fileName | ||
for { | ||
currentSample <- readSampleFromFile(filePath) | ||
sample <- generateSample(gen, sampleSize) | ||
assertion <- if (sample == currentSample) { | ||
ZIO.succeed(assertTrue(sample == currentSample)) | ||
} else { | ||
val diffFileName = Path(s"${name}_changed.json") | ||
val diffFilePath = resourceDir / diffFileName | ||
writeSampleToFile(diffFilePath, sample) *> | ||
ZIO.succeed(assertTrue(sample == currentSample)) | ||
} | ||
} yield assertion | ||
} | ||
|
||
private def createNewTest[A: JsonEncoder]( | ||
resourceDir: Path, | ||
name: String, | ||
gen: Gen[Sized, A], | ||
sampleSize: Int | ||
)(implicit trace: Trace): ZIO[Sized, Throwable, TestResult] = { | ||
val fileName = s"${name}_new.json" | ||
val filePath = resourceDir / Path(fileName) | ||
|
||
val failureString = | ||
s"No existing golden test for ${resourceDir / Path(s"$name.json")}. Remove _new from the suffix and re-run the test." | ||
|
||
for { | ||
sample <- generateSample(gen, sampleSize) | ||
_ <- ZIO.ifZIO(Files.exists(filePath))(ZIO.unit, Files.createFile(filePath)) | ||
_ <- writeSampleToFile(filePath, sample) | ||
assertion = TestArrow.make((_: Any) => TestTrace.fail(failureString).withLocation(Some(trace.toString))) | ||
} yield TestResult(assertion) | ||
} | ||
|
||
private def generateSample[A: JsonEncoder]( | ||
gen: Gen[Sized, A], | ||
sampleSize: Int | ||
)(implicit trace: Trace): ZIO[Sized, Exception, GoldenSample] = | ||
Gen | ||
.listOfN(sampleSize)(gen) | ||
.sample | ||
.collectSome | ||
.map(_.value) | ||
.map { elements => | ||
val jsonElements = elements.map(_.toJsonAST).collect { case Right(a) => a } | ||
val jsonArray = new Json.Arr(Chunk.fromIterable(jsonElements)) | ||
GoldenSample(jsonArray) | ||
} | ||
.runHead | ||
.someOrFailException | ||
|
||
private def getName[A](implicit typeTag: TypeTag[A]): String = | ||
typeTag.tpe.typeSymbol.name.decodedName.toString | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
{ | ||
"samples" : [ | ||
-1295463240, | ||
421138995, | ||
-1691075023, | ||
296005039, | ||
21640766, | ||
2046851285, | ||
-246096894, | ||
1074000849, | ||
51614485, | ||
167418620, | ||
-859731203, | ||
-1341091779, | ||
222129442, | ||
1975841777, | ||
-1084799896, | ||
-1630179995, | ||
476860873, | ||
1033483801, | ||
144405415, | ||
-1001611156 | ||
] | ||
} |
Oops, something went wrong.