Assuming you have installed selfie and glanced through the quickstart, then you're ready to start taking multifaceted snapshots of arbitrary typed data.


Our toy project  

We'll be using the example-junit5 project from the selfie GitHub repo. You can clone the code and follow along, but there's no need to. If you did clone the project, you could run gradlew exampleAppJvm and you'd have a little jooby webapp running at localhost:8080.

It has a homepage where we can login. We can go to /email to see the emails the server has sent and click our login link, and boom we've got some auth cookies.

There's nothing web-specific about selfie, it's just a familiar example.


Typed snapshots  

Let's use REST-assured to do gets and posts. So if we want to assert that the homepage is working, we can do this:

@Test
public void homepage() {
  expectSelfie(RestAssured.get("/").body().asString()).toBe("""
<html><body>
\s <h1>Please login</h1>
\s <form action="/login" method="post">
\s   <input type="text" name="email" placeholder="email">
\s   <input type="submit" value="login">
\s </form>
</body></html>""");
}

Since you saw the quickstart, you know that selfie wrote that big bad string literal for us. The \s is just escaped whitespace, to protect it from getting mangled by terrible autoformatters like spotless.

The first thing to notice is that we'll be doing a lot of RestAssured.get().body().asString(). It would be nice if we could just do expectSelfie(get("/")), but we'll have to write our own expectSelfie(io.restassured.response.Response) method. Selfie gives us expectSelfie(T, Camera<T>) and Camera to do exactly that.

class Selfie {
  public static <T> Selfie.DiskSelfie expectSelfie(T actual, Camera<T> camera) { ... }
}
@FunctionalInterface
interface Camera<T> {
  Snapshot snapshot(T subject);
}

We can write our expectSelfie(Response) anywhere, but we recommend putting it into a class named SelfieSettings in the package selfie, but you can use any name and put these methods anywhere. We recommend expectSelfie because it's a good hint that the string constants are self-updating.

package selfie; // recommend using this package

import com.diffplug.selfie.Camera;
import com.diffplug.selfie.Selfie;
import com.diffplug.selfie.Snapshot;
import com.diffplug.selfie.junit5.SelfieSettingsAPI;
import io.restassured.response.Response;

// Recommend using SelfieSettings so that all your project-specific selfie entry points are in one place.
public class SelfieSettings extends SelfieSettingsAPI {
  private static final Camera<Response> RESPONSE_CAMERA = (Response response) ->
    Snapshot.of(response.getBody().asString());

  public static Selfie.DiskSelfie expectSelfie(Response response) {
    return Selfie.expectSelfie(response, RESPONSE_CAMERA);
  }
}

Facets  

Every snapshot has a "subject": Snapshot.of(String subject). But each snapshot can also have an unlimited number of "facets", which are other named values. For example, maybe we want to add the response's status line.

private static final Camera<Response> RESPONSE_CAMERA = (Response response) ->
   Snapshot.of(response.getBody().asString())
           .plusFacet("statusLine", response.getStatusLine());

And now our snapshot has statusLine at the bottom, which we can use in both literal and disk snapshots.

@Test
public void homepage() {
  expectSelfie(get("/")).toBe("""
<html><body>
\s <h1>Please login</h1>
\s <form action="/login" method="post">
\s   <input type="text" name="email" placeholder="email">
\s   <input type="submit" value="login">
\s </form>
</body></html>
╔═ [statusLine] ═╗
HTTP/1.1 200 OK""");
}

Now that we have the status code, it begs the question: what should the subject be for a 301 redirect? Surely the redirected URL, not just an empty string?

private static final Camera<Response> RESPONSE_CAMERA = (Response response) -> {
    var redirectReason = REDIRECTS.get(response.getStatusCode());
    if (redirectReason != null) {
        return Snapshot.of("REDIRECT " + response.getStatusCode() + " " + redirectReason + " to " + response.getHeader("Location"));
    } else {
        return Snapshot.of(response.getBody().asString()).plusFacet("statusLine", response.getStatusLine());
    }
  };
private static final Map<Integer, String> REDIRECTS = Stream.of(
    StatusCode.SEE_OTHER,
    StatusCode.FOUND,
    StatusCode.TEMPORARY_REDIRECT,
    StatusCode.MOVED_PERMANENTLY
  ).collect(Collectors.toMap(StatusCode::value, StatusCode::reason));

So a snapshot doesn't have to be only one value, and it's fine if the schema changes depending on the content of the value being snapshotted. The snapshots are for you to read (and look at diffs of), so record whatever is meaningful to you.


Lenses  

A Lens is a function that transforms one Snapshot into another Snapshot, transforming / creating / removing values along the way. For example, we might want to pretty-print the HTML in our snapshots.

// need 'org.jsoup:jsoup:1.17.1' on the test claspath
private static String prettyPrintHtml(String html) {
  var doc = Jsoup.parse(html);
  doc.outputSettings().prettyPrint(true);
  return doc.outerHtml();
}

private static final Camera<Response> RESPONSE_CAMERA = (Response response) -> {
  (...)
  // call prettyPrint when we take the snapshot
  return Snapshot.of(prettyPrintHtml(response.getBody().asString()))
      .plusFacet("statusLine", response.getStatusLine());
};

Calling transformation functions inside the Camera is fine, but another option is to create a Lens and then use Camera.withLens. This approach is especially helpful if there are multiple Cameras which need the same transformation.

private static final Lens PRETTY_PRINT = (Snapshot snapshot) -> {
  String subject = snapshot.getSubject().valueString();
  if (subject.contains("<html>")) {
    // the facet "" is another name for the subject
    return snapshot.plusOrReplace("", prettyPrintHtml(subject));
  } else {
    return snapshot;
  }
};

public static Selfie.DiskSelfie expectSelfie(Response response) {
  return Selfie.expectSelfie(response, RESPONSE_CAMERA.withLens(PRETTY_PRINT));
}
public static Selfie.DiskSelfie expectSelfie(Email email) {
  return Selfie.expectSelfie(email, EMAIL_CAMERA.withLens(PRETTY_PRINT));
}

Compound lens  

Selfie has a useful class called CompoundLens. It is a fluent API for mutating facets and piping data through functions from one facet into another. An important gotcha here is that the subject can be treated as a facet named "" (empty string). CompoundLens uses this hack to simplify a snapshot into only a map of facets, instead of a subject plus a map of facets.

We can easily mutate a specific facet, such as to pretty-print HTML in the subject...

private static final Lens HTML = new CompoundLens()
  .mutateFacet("", (String maybeHtml) -> maybeHtml.contains("<html>") ? prettyPrintHtml(maybeHtml) : null);

Or we can mutate all facets, such as to remove a random local port number...

private static final Lens HTML = new CompoundLens()
  .mutateFacet("", maybeHtml -> maybeHtml.contains("<html>") ? prettyPrintHtml(maybeHtml) : null)
  .replaceAllRegex("http://localhost:\\d+/", "https://www.example.com/")

Or we can render HTML into markdown, and store the easy-to-read markdown in its own facet...

// need 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8' on the test classpath
private static String htmlToMd(String html) {
  return new FlexmarkHtmlConverter.Builder().build().convert(html);
}
private static final Lens HTML = new CompoundLens()
  .mutateFacet("", SelfieSettings::prettyPrintHtml)
  .replaceAllRegex("http://localhost:\\d+/", "https://www.diffplug.com/")
  .setFacetFrom("md", "", SelfieSettings::htmlToMd);

Harmonizing disk and inline literals  

Snapshot testing has been badly underused for three reasons:

  • controlling read vs write used to be cumbersome (fixed by control comments)
  • stale snapshots used to pile up (fixed by garbage collection)
  • a great test should tell a story, and disk snapshots can't do that

Inline snapshots are a partial fix for storytelling within a test, but the harnessing can become verbose. This is where we combine it all:

  • exhaustive specification on disk
  • succinct storytelling inline
  • minimal boilerplate thanks to Camera and CompoundLens

Let's look at a test that puts all of this together.

@Test
public void loginFlow(Jooby app) {
  expectSelfie(get("/")).toMatchDisk("1. not logged in").facet("md").toBe("Please login");
  expectSelfie(given().param("email", "user@domain.com").post("/login")).toMatchDisk("2. post login form")
    .facet("md").toBe("""
Email sent!

Check your email for your login link.""");

  var email = EmailDev.waitForIncoming(app);
  expectSelfie(email).toMatchDisk("3. login email")
    .facet("md").toBe("Click [here](https://www.example.com/login-confirm/erjchFY=) to login.");

  expectSelfie(get("/login-confirm/erjchFY=")).toMatchDisk("4. open login email link")
    .facets("", "cookies").toBe("""
REDIRECT 302 Found to /
╔═ [cookies] ═╗
login=user@domain.com|JclThw==;Path=/""");
  expectSelfie(given().cookie("login", "user@domain.com|JclThw==").get("/")).toMatchDisk("5. follow redirect")
    .facet("md").toBe("Welcome back user@domain.com");

  expectSelfie(given().cookie("login", "user@domain.com|badsignature").get("/")).toMatchDisk("6. bad signature")
    .facets("md").toBe("""
Unauthorized

status code: 401""");
}

We just wrote a high-level specification of a realistic login flow, and it only took 25 lines of java code — most of which were generated for us, and could be regenerated on a whim if we want to change our copywriting. The corresponding disk snapshot gives us an exhaustive specification and description of the server's behavior.

Didn't think that adopting a bugfixed version of your internationalization lib would cause any changes to your website whatsever? Oops. Don't wade through failed assertions, get a diff in every failure. If you want, regenerate all the snapshots to get a full view of the problem across the whole codebase in your git client.

Testing software is a bit like tailoring a suit for an octopus. Not because the octopus needs a suit — because we need a map! And we only have one hand — better hand some pins to the octopus!

Pull requests to improve the landing page and documentation are greatly appreciated, you can find the source code here.