A blog exploring advanced programming topics in Swift.

Inline Snapshot Testing

Wednesday Sep 13, 2023

We are excited to announce the biggest update to our popular SnapshotTesting library since 1.0: inline snapshot testing. This allows your text-based snapshots to live right in the test source code, rather than in an external file:

This makes it simpler to verify your snapshots are correct, and even allows you to build your own inline testing tools on top of it.

Join us for a quick overview of snapshot testing, and a preview of what inline snapshotting brings to the table.

Snapshot testing

Snapshot testing is a style of testing where you don’t explicitly provide both values you are asserting against, but rather you provide a single value that can be snapshot into some serializable format. When you run the test the first time, a snapshot is recorded to disk, and future runs of the test will take a new snapshot of the value and compare it against what is on disk. If those snapshots differ, then the test will fail.

The most canonical example of this is snapshot views into images. This is because testing views can be quite difficult in general. You can sometimes perform hacks to actually assert on what kinds of view components are on the screen and what data they hold, but this often feels like testing an implementation detail. And it’s also possible to perform UI tests, but those are very slow, can be flakey, and test a wide range of behavior that you may not really care about.

Our snapshot testing library allows you to test just the very basics of what a view looks like. For example, we could test a very small, simple SwiftUI view by asserting its snapshot as an image like this:

import SnapshotTesting
import SwiftUI

class Test: XCTestCase {
  func testView() {
    let view = ZStack {
      Rectangle()
        .fill(
          LinearGradient(
            gradient: Gradient(colors: [.white, .black]),
            startPoint: .top,
            endPoint: .bottom
          )
        )
      Text("Point-Free").bold()
    }
    .frame(width: 200, height: 200)
    
    assertSnapshot(of: view, as: .image)
  }
}

The first time we run this the test fails because there is no snapshot of this view already on disk:

❌ testView(): failed - No reference was found on disk. Automatically recorded snapshot: …

open "file:///…/__Snapshots__/ExperimentationTests/testView.1.png"

Re-run “testView” to test against the newly-recorded snapshot.

And it even helpfully let’s us know where the new snapshot was recorded so that we can easily preview it:

The next time we run this test it passes because it made a new snapshot of the image and compared it to the previously recorded snapshot. Since nothing changed in the view, the test passes.

But, if we change something in the view, say like swapping the order of the gradient:

-gradient: Gradient(colors: [.white, .black]),
+gradient: Gradient(colors: [.black, .white]),

…then the test fails:

❌ testView(): failed - Snapshot does not match reference.

@− “file:///…/__Snapshots__/ExperimentationTests/testView.1.png”

@+ “file:///…/tmp/ExperimentationTests/testView.1.png”

To configure output for a custom diff tool, like Kaleidoscope:

SnapshotTesting.diffTool = "ksdiff"

And we helpfully get easy links to the expected and actual images so that we can see the difference. Or, if we have an application on our computers that can do image diffing, such as Kaleidoscope, then we can use it:

SnapshotTesting.diffTool = "ksdiff"

Now the test fails with a command that we can copy-and-paste into terminal to open Kaleidoscope and show us a very nice diff of the images:

❌ testView(): failed - Snapshot does not match reference.

ksdiff \
  "…/__Snapshots__/ExperimentationTests/testView.1.png"
  "…/tmp/ExperimentationTests/testView.1.png"

Newly-taken snapshot does not match reference.

Pasting this command into Terminal opens up Kaleidoscope with both the expected and actual images presented to make it easy to see what changed:

So, this is pretty great, but snapshot testing goes well beyond just snapshotting views into images. You can snapshot any Swift data type into any kind of format you want.

For example, if you have very custom JSON encoding and decoding logic in one of your models (see, for example, this complex Codable conformance in our isowords codebase), then you probably want to write a test to make sure you are getting everything right. But doing so can be quite onerous. You have to assert against a big, hardcoded JSON string, and you can easily get a test failure if your formatting is slightly wrong.

Well, snapshot testing makes this incredibly easy. You can instantly test any data type that is Codable by turning it into a JSON file:

struct User: Codable {
  let id: Int
  var isAdmin: Bool
  var name: String
}
let user = User(id: 42, isAdmin: true, name: "Blob")
assertSnapshot(of: user, as: .json)

Running this fails letting us know that a new file was saved to disk:

❌ testView(): failed - No reference was found on disk. Automatically recorded snapshot: …

open "file:///…/__Snapshots__/ExperimentationTests/testView.1.json"

Re-run “testView” to test against the newly-recorded snapshot.

And that file contains the JSON representation of the data type:

{
  "id" : 42,
  "isAdmin": true,
  "name" : "Blob"
}

And of course if this data type was a lot more complicated we would have a lot more JSON here.

Inline snapshot testing

So this is great, but also sometimes it can be a bit of a pain to have the snapshot stored in an external file, especially for text-based snapshot formats.

Well, our library has another snapshotting tool that makes this a lot nicer, and it is called “inline” snapshots. This was actually a tool first contributed to the library by a Point-Free viewer, Rob Chatfield, over 4 years ago, and we have finally put the finishing touches to it to make it ready for prime time.

You can assert an inline snapshot by first importing InlineSnapshotTesting instead of SnapshotTesting:

-import SnapshotTesting
+import InlineSnapshotTesting

And then change assertSnapshot to assertInlineSnapshot:

-assertSnapshot(of: user, as: .json)
+assertInlineSnapshot(of: user, as: .json)

Running this test causes the library to see that you are not currently asserting against a particular snapshot, and so generates a fresh one and inserts it directly into your test source code as a trailing closure:

assertInlineSnapshot(of: user, as: .json)  {
  """
  {
    "id" : 42,
    "isAdmin": true,
    "name" : "Blob"
  }
  """
}

It feels almost magical, but unfortunately static text in a blog post does not do it justice. This is what it looks like when you run the test in Xcode:

And you can run this over and over and it will pass, but now the snapshot lives right alongside the value you are snapshotting.

Even better, the assertInlineSnapshot testing tool is fully customizable so that you can build your own testing helpers on top of it without your users even knowing they are using snapshot testing. In fact, we do this to create a testing tool that helps us test the Swift code that powers this very site. It’s called assertRequest, and it allows you to simultaneously assert the request being made to the server (including URL, query parameters, headers, POST body) as well as the response from the server (including status code and headers).

For example, to test that when a request is made for a user to join a team subscription, we can write the following:

await assertRequest(
  connection(
    from: request(
      to: .teamInviteCode(.join(code: subscription.teamInviteCode, email: nil)),
      session: .loggedIn(as: currentUser)
    )
  )
)

And when we first run the test it will automatically expand:

await assertRequest(
  connection(
    from: request(
      to: .teamInviteCode(.join(code: subscription.teamInviteCode, email: nil)),
      session: .loggedIn(as: currentUser)
    )
  )
) {
  """
  POST http://localhost:8080/join/subscriptions-team_invite_code3
  Cookie: pf_session={"userId":"00000000-0000-0000-0000-000000000001"}
  """
} response: {
  """
  302 Found
  Location: /account
  Referrer-Policy: strict-origin-when-cross-origin
  Set-Cookie: pf_session={"flash":{"message":"You now have access to Point-Free!","priority":"notice"},"userId":"00000000-0000-0000-0000-000000000001"}; Expires=Sat, 29 Jan 2028 00:00:00 GMT; Path=/
  X-Content-Type-Options: nosniff
  X-Download-Options: noopen
  X-Frame-Options: SAMEORIGIN
  X-Permitted-Cross-Domain-Policies: none
  X-XSS-Protection: 1; mode=block
  """
}

This shows that the response redirects the user back to their account page and shows them the flash message that they now have full access to Point-Free. This makes writing complex and nuanced tests incredibly easy, and so there is no reason to not write lots of tests for all the subtle edge cases of your application’s logic.

Get started today

Bring SnapshotTesting 1.13’s new InlineSnapshotTesting module into your project today to start writing powerful tests in just a few lines of code.


Subscribe to Point-Free

👋 Hey there! If you got this far, then you must have enjoyed this post. You may want to also check out Point-Free, a video series covering advanced programming topics in Swift. Consider subscribing today!