snaptest

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 framework

Getting 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:

ExampleSnapshotTest.scalamunit
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):

snapshots/example/ExampleSnapshotTestSnapshots.scala1 recorded
// 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:

sbt test1 failed
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.

FrameworkArtifactMix in
munitsnaptest-munitMunitSnapshots
ScalaTestsnaptest-scalatestScalaTestSnapshots
uTestsnaptest-utestUTestSnapshots
specs2snaptest-specs2Specs2Snapshots
munit-cats-effectsnaptest-munit-cats-effectCatsEffectSnapshots
weaversnaptest-weaverWeaverSnapshots
ZIO Testsnaptest-zioZioSnapshots

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:

snapshots/ParserTestSnapshots.scalaexcerpt
    "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:

ValueNewMismatchingUnchecked
autorecordedfailremoved if all tests ran and passed
alwaysrecordedoverwrittenremoved if all tests ran and passed
prunerecordedfailremoved
nofailfailkept

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:

terminalsnapshots updated
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:

terminalScala.js and Scala Native
SNAPTEST=always ./mill --no-daemon __.test