Assuming you have installed selfie and glanced through the quickstart, then you're ready to start taking multifaceted snapshots of arbitrary typed data.
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.
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);
}
}
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.
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 Camera
s 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));
}
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);
Snapshot testing has been badly underused for three reasons:
Inline snapshots are a partial fix for storytelling within a test, but the harnessing can become verbose. This is where we combine it all:
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.