Skip to content

Commit

Permalink
Feat/support golden testing (zio#636)
Browse files Browse the repository at this point in the history
* Add golden testing functionality

* Add tests for golden testing functionality

* Add README
  • Loading branch information
ghidei authored May 30, 2022
1 parent 0aed7b6 commit 9d47559
Show file tree
Hide file tree
Showing 13 changed files with 796 additions and 1 deletion.
23 changes: 22 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ addCommandAlias("prepare", "fmt")

addCommandAlias(
"testJVM",
"zioJsonJVM/test; zioJsonYaml/test; zioJsonMacrosJVM/test; zioJsonInteropHttp4s/test; zioJsonInteropScalaz7xJVM/test; zioJsonInteropScalaz7xJS/test; zioJsonInteropRefinedJVM/test; zioJsonInteropRefinedJS/test"
"zioJsonJVM/test; zioJsonYaml/test; zioJsonGolden/test; zioJsonMacrosJVM/test; zioJsonInteropHttp4s/test; zioJsonInteropScalaz7xJVM/test; zioJsonInteropScalaz7xJS/test; zioJsonInteropRefinedJVM/test; zioJsonInteropRefinedJS/test"
)

addCommandAlias(
Expand All @@ -48,6 +48,7 @@ lazy val root = project
zioJsonJVM,
zioJsonJS,
zioJsonYaml,
zioJsonGolden,
zioJsonMacrosJVM,
zioJsonMacrosJS,
zioJsonInteropHttp4s,
Expand Down Expand Up @@ -220,6 +221,24 @@ lazy val zioJsonJS = zioJson.js

lazy val zioJsonJVM = zioJson.jvm

lazy val zioJsonGolden = project
.in(file("zio-json-golden"))
.settings(stdSettings("zio-json-golden"))
.settings(buildInfoSettings("zio.json.golden"))
.settings(
libraryDependencies ++= Seq(
"dev.zio" %% "zio" % zioVersion,
"dev.zio" %% "zio-nio" % "2.0.0-RC7",
"dev.zio" %% "zio-test" % zioVersion,
"dev.zio" %% "zio-test-sbt" % zioVersion,
"dev.zio" %% "zio-test-magnolia" % zioVersion,
"org.scala-lang" % "scala-reflect" % scalaVersion.value % Provided,
),
testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework")
)
.dependsOn(zioJsonJVM)
.enablePlugins(BuildInfoPlugin)

lazy val zioJsonYaml = project
.in(file("zio-json-yaml"))
.settings(stdSettings("zio-json-yaml"))
Expand Down Expand Up @@ -316,6 +335,7 @@ lazy val docs = project
.dependsOn(
zioJsonJVM,
zioJsonYaml,
zioJsonGolden,
zioJsonMacrosJVM,
zioJsonInteropHttp4s,
zioJsonInteropRefined.jvm,
Expand All @@ -340,6 +360,7 @@ lazy val docs = project
ScalaUnidoc / unidoc / unidocProjectFilter := inProjects(
zioJsonJVM,
zioJsonYaml,
zioJsonGolden,
zioJsonMacrosJVM,
zioJsonInteropHttp4s,
zioJsonInteropRefined.jvm,
Expand Down
83 changes: 83 additions & 0 deletions zio-json-golden/README.md
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])
}

```


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 zio-json-golden/src/main/scala/zio/json/golden/GoldenSample.scala
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 zio-json-golden/src/main/scala/zio/json/golden/filehelpers.scala
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 zio-json-golden/src/main/scala/zio/json/golden/package.scala
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

}
24 changes: 24 additions & 0 deletions zio-json-golden/src/test/resources/golden/Int.json
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
]
}
Loading

0 comments on commit 9d47559

Please sign in to comment.