The snaptest guide
snaptest stores what your tests produce as generated Scala sources, for seven test frameworks, on the JVM, Scala.js and Scala Native. Scala 3.9 or later.
Find your frameworkGetting started
Add the artifact for your test framework. Every artifact follows
the same pattern, snaptest-<framework>, and is
published for the JVM, Scala.js and Scala Native.
// sbt libraryDependencies += "com.github.lolgab" %%% "snaptest-munit" % "0.1.0" % Test // Mill def mvnDeps = Seq(mvn"com.github.lolgab::snaptest-munit::0.1.0") // Scala CLI //> using test.dep com.github.lolgab::snaptest-munit::0.1.0
Write a test:
package example
import snaptest.*
class ExampleSnapshotTest extends munit.FunSuite, MunitSnapshots:
snapshotTest("one plus one") {
1 + 1
}
The first run records the snapshots in
snapshots/example/ExampleSnapshotTestSnapshots.scala,
in the source directory of the test (src/test/scala
with sbt, test/src with Mill):
// Generated by snaptest, update with SNAPTEST=always
// format: off
// $COVERAGE-OFF$
package snapshots.example
/** Snapshots of [[example.ExampleSnapshotTest]] */
object ExampleSnapshotTestSnapshots extends snaptest.Snapshots {
def entries = Seq(
"one plus one" -> "2",
)
}
Snapshot objects are in the snapshots package,
followed by the package of their suite, and their directories match
it. Suites with the same name in different packages do not share a
file. When a
test file is not in the directory of its package, as is common with
Scala CLI, the snapshots directory is created next to
it instead. A suite in ExampleSnapshotTest.test.scala
gets
snapshots/example/ExampleSnapshotTestSnapshots.test.scala,
so the snapshots compile with the test dependencies.
Commit the generated file. It compiles with the tests, which compare their values with it and fail when they differ:
5:class ExampleSnapshotTest extends munit.FunSuite, MunitSnapshots:
6: snapshotTest("one plus one") {
7: 1 + 1
Snapshot 'one plus one' does not match, rerun with SNAPTEST=always to update it
at .../snapshots/example/ExampleSnapshotTestSnapshots.scala:9
=> Obtained
2
=> Diff (- expected, + obtained)
-3
+2
The path:line in the message is a link to the snapshot
in IDEs and terminals, and the [[example.ExampleSnapshotTest]]
comment links back to the suite. Because snapshots are Scala
sources, build tools that cache test results, like Mill
testCached, rerun the tests when a snapshot changes,
without any configuration.
Test frameworks
Every integration provides assertSnapshot, keys
snapshots the same way and stores them in the same files.
| Framework | Artifact | Mix in |
|---|---|---|
| munit | snaptest-munit | MunitSnapshots |
| ScalaTest | snaptest-scalatest | ScalaTestSnapshots |
| uTest | snaptest-utest | UTestSnapshots |
| specs2 | snaptest-specs2 | Specs2Snapshots |
| munit-cats-effect | snaptest-munit-cats-effect | CatsEffectSnapshots |
| weaver | snaptest-weaver | WeaverSnapshots |
| ZIO Test | snaptest-zio | ZioSnapshots |
snaptest-core holds the framework-independent parts,
to integrate other test frameworks.
munit
import snaptest.*
class ParserTest extends munit.FunSuite, MunitSnapshots:
snapshotTest("sum")(parse("1 + 2"))
test("parser") {
assertSnapshot(parse("1 * 2"))
}
Mismatches use munit's own diff. Snapshots of removed tests are deleted when every test of the suite ran and passed.
ScalaTest
import org.scalatest.funsuite.AnyFunSuite
import snaptest.*
class ParserTest extends AnyFunSuite, ScalaTestSnapshots:
test("parser") {
assertSnapshot(parse("1 + 2"))
}
Asynchronous suites, like AsyncFunSuite, mix in
AsyncScalaTestSnapshots. Mismatches fail with a line
diff of the snapshot, reported at the line of your test.
uTest
import snaptest.*
import utest.*
object ParserTests extends TestSuite, UTestSnapshots:
val tests = Tests {
test("parser") {
test("sum") {
assertSnapshot(parse("1 + 2")) // key: parser.sum
}
}
}
Snapshots are keyed by test path.
specs2
import org.specs2.mutable.Specification
import snaptest.*
class ParserSpec extends Specification, Specs2Snapshots:
"parser" should {
"parse sums" in {
parse("1 + 2") must matchSnapshot("parse sums")
}
}
specs2 does not expose the running example, so snapshots are keyed
explicitly. For the same reason, snapshots that are no longer
checked are only removed with SNAPTEST=prune.
munit-cats-effect
import cats.effect.IO
import munit.CatsEffectSuite
import snaptest.*
class ParserTest extends CatsEffectSuite, CatsEffectSnapshots:
test("parser") {
assertIOSnapshot(parseIO("1 + 2"))
}
assertIOSnapshot snapshots the result of an
IO, like assertIO compares it with an
expected value. Snapshotting an IO itself is a compile
error, since it would store its description instead of its result.
weaver
import cats.effect.IO
import snaptest.*
import weaver.SimpleIOSuite
object ParserSuite extends SimpleIOSuite, WeaverSnapshots:
test("parser") {
parseIO("1 + 2").flatMap(expectSnapshot("parser", _))
}
snapshotTest("sum")(parseIO("1 + 2")) // keyed by the test name
expectSnapshot returns IO[Expectations],
so it composes with other expectations. Weaver tests do not know
their name and run in parallel, so snapshots are keyed explicitly,
and the ones no longer checked are only removed with
SNAPTEST=prune. Snapshots are written when the shared
resource is released: a suite defining its own
sharedResource adds them with
myResource.onFinalize(finishSnapshots).
ZIO Test
import snaptest.*
import zio.*
import zio.test.*
object ParserSpec extends ZIOSpecDefault, ZioSnapshots:
def spec = suite("parser")(
test("sum") {
assertZIOSnapshot(parse("1 + 2")) // key: parser / sum
},
test("values") {
for
sum <- assertSnapshot(1 + 2)
product <- assertSnapshot(1 * 2)
yield sum && product
}
)
assertSnapshot returns a UIO[TestResult]
and assertZIOSnapshot snapshots the result of an
effect, like assertZIO. Snapshots are keyed by the
labels of the suites and the test, given to each test by an aspect,
so parallel tests work. Snapshotting a ZIO itself is a
compile error. Tests can be filtered, so snapshots that are no
longer checked are only removed with SNAPTEST=prune.
A spec overriding aspects keeps the snapshot aspects
with super.aspects ++ ....
Snapshot assertions
assertSnapshot can be used in any test of a suite,
more than once. Snapshots are keyed by test name, then numbered,
unless a label is given:
test("parser") {
assertSnapshot(parse("1 + 2")) // key: parser
assertSnapshot(parse("1 * 2")) // key: parser (2)
assertSnapshot(parse(""), label = "empty input") // key: parser: empty input
}
Rendering
Strings are stored verbatim, any other value is pretty-printed with pprint. The generated literals stay close to the values, so snapshot diffs are easy to review:
"list concatenation" -> "List(1, 2, 3, 4, 5, 6, 7, 8)",
"class" -> """TestClass(a = "Hello", b = Right("World"), c = List(1, 2, 3))""",
"long values are wrapped" ->
"""|List(
| "item1",
| "item2"
|)""".stripMargin,
Provide a SnapshotRender to customize how a type is
rendered, for example to hide values that change at every run:
given SnapshotRender[Event] = e => pprint.apply(e.copy(timestamp = 0)).plainText
Updating snapshots
The SNAPTEST environment variable controls when
snapshot files are written:
| Value | New | Mismatching | Unchecked |
|---|---|---|---|
auto | recorded | fail | removed if all tests ran and passed |
always | recorded | overwritten | removed if all tests ran and passed |
prune | recorded | fail | removed |
no | fail | fail | kept |
The default is no on CI, when the CI
environment variable is set, and auto otherwise, so
missing snapshots are never silently recorded on CI. To update
snapshots after an intended change, run:
SNAPTEST=always ./mill __.test SNAPTEST=always sbt test
Snapshots of removed tests are deleted only when every test of the
suite ran and passed, so running a subset of the tests keeps the
other snapshots. Use prune on a full run with
frameworks that cannot tell whether every test ran: specs2, weaver
and ZIO Test. Override snapshotMode in a suite to
change the default.
Generated sources
Snapshot files are Scala sources in the snapshots
directory of the test sources. They start with
// format: off and // $COVERAGE-OFF$, so
scalafmt and scoverage skip them. Scala 3 built-in coverage
(-coverage-out) ignores that comment: exclude the
generated objects with
-coverage-exclude-classlikes:.*Snapshots instead.
Scala.js and Scala Native
Scala.js tests run on Node.js, and every platform uses the same snapshots.
With Mill, Scala.js and Scala Native tests see the environment the
Mill daemon started with rather than the one of your shell. Use
--no-daemon when setting SNAPTEST:
SNAPTEST=always ./mill --no-daemon __.test