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-pytest-selfie
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 uv run python app.py
and you'd have a little flask webapp running at 127.0.0.1:5000
(localhost
might not work, make sure to use 127.0.0.1
!).
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.
Since it's a flask app, we can use its built-in test client. So if we want to assert that the homepage is working, we can do this:
@pytest.fixture
def client():
app.config["TESTING"] = True
with app.test_client() as client:
yield client
def test_homepage_v1(client):
expect_selfie(client.get("/").data.decode()).to_be("""
<html><body>
<h1>Please login</h1>
<form action="/login" method="post">
<input type="text" name="email" placeholder="email">
<input type="submit" value="login">
</form>
</body></html>""")
Since you saw the quickstart, you know that selfie wrote that big bad string literal for us.
The first thing to notice is that we'll be doing a lot of .data.decode()
. It would be nice if we could just do expect_selfie(get("/"))
, so let's add a web_selfie
method to handle that. I'm going to use static types, but you can ignore those if you want.
from selfie_lib import expect_selfie, StringSelfie
from werkzeug.test import TestResponse # this is what `app.test_client().get` returns
...
def web_selfie(response: TestResponse) -> StringSelfie:
return expect_selfie(response.data.decode())
def test_homepage_v2(client):
web_selfie(client.get("/")).to_be("""
<html><body>
<h1>Please login</h1>
<form action="/login" method="post">
<input type="text" name="email" placeholder="email">
<input type="submit" value="login">
</form>
</body></html>""")
You can write web_selfie
anywhere, but we recommend putting it into selfie_settings.py
. We allso recommend keeping the xxx_selfie
pattern because it's a good hint that the string constants are self-updating.
Every snapshot has a subject: Snapshot.of(subject: str)
. 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 code.
def web_selfie(response: TestResponse) -> StringSelfie:
actual = Snapshot.of(response.data.decode()) \
.plus_facet("status", response.status)
return expect_selfie(actual)
And now our snapshot has status
at the bottom, which we can use in both literal and disk snapshots.
def test_homepage_v2():
expect_selfie(get("/")).toBe("""
<html><body>
<h1>Please login</h1>
<form action="/login" method="post">
<input type="text" name="email" placeholder="email">
<input type="submit" value="login">
</form>
</body></html>
╔═ [status] ═╗
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?
REDIRECTS = {
303: "See Other",
302: "Found",
307: "Temporary Redirect",
301: "Moved Permanently",
}
def web_selfie(response: TestResponse) -> StringSelfie:
redirect_reason = REDIRECTS.get(response.status_code)
if redirect_reason is not None:
actual = Snapshot.of(f"REDIRECT {response.status_code} {redirect_reason} to {response.headers.get("Location")}")
else:
actual = Snapshot.of(response.data.decode()) \
.plus_facet("status", response.status)
return expect_selfie(actual)
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.
If you want to capture multiple facets of something, you need a function which turns that something into a Snapshot
. Selfie calls this a Camera
. You can pass a Camera
as the second argument to expect_selfie
, which would look like so:
def _web_camera(response: TestResponse) -> Snapshot:
redirect_reason = REDIRECTS.get(response.status_code)
if redirect_reason is not None:
return Snapshot.of(
f"REDIRECT {response.status_code} {redirect_reason} to "
+ response.headers.get("Location", "<unknown>")
)
else:
return Snapshot.of(response.data.decode()).plus_facet("status", response.status)
def web_selfie(response: TestResponse) -> StringSelfie:
return expect_selfie(response, _web_camera)
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.
from bs4 import BeautifulSoup
def _pretty_print_html(html : str) -> str:
return BeautifulSoup(html, 'html.parser').prettify()
One option is to call this function inside the Camera
. But this mixes concerns - its better to have one function that grabs all the data (the Camera
), and other functions that clean it up (the Lens
es). Selfie makes it easy to combine these like so:
def _web_camera(response: TestResponse) -> Snapshot: ...
def _pretty_print_html(html : str) -> str: ...
def _pretty_print_lens(snapshot: Snapshot) -> Snapshot:
if "<html" in snapshot.subject.value_string():
# You can think of a `Snapshot` is an immutable dict of facets
# The value of each facet is either `str` or `bytes`
# The "subject" is a special facet whose key is ""
return snapshot.plus_or_replace("", _pretty_print_html(snapshot.subject.value_string()))
else:
return snapshot
_WEB_CAMERA = Camera.of(_web_camera).with_lens(_pretty_print_lens)
def web_selfie(response: TestResponse) -> StringSelfie:
return expect_selfie(response, _WEB_CAMERA)
By keeping the lens separate from the camera, you can also reuse the lens in other cameras. For example, you might want to pretty-print the HTML in an email.
The example above has some nasty plumbing for dealing with the Snapshot
API. To make this easier, you can use 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...
_HTML_LENS = CompoundLens().mutate_facet("", _pretty_print_html)
Or we can mutate every facet, such as to remove a random local port number...
_HTML_LENS = CompoundLens() \
.mutate_facet("", _pretty_print_html) \
.replace_all_regex("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...
from markdownify import markdownify as md
def _html_to_md(html: str) -> str:
return md(html) if "<html" in html else None
HTML = (
CompoundLens()
.mutate_facet("", _pretty_print_html)
.replace_all_regex("http://localhost:\\d+/", "https://www.diffplug.com/")
.set_facet_from("md", "", _html_to_md)
)
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.
def test_login_flow(app):
web_selfie(get("/")).to_match_disk("1. not logged in").facet("md").to_be("Please login")
expect_selfie(given().param("email", "[email protected]").post("/login")).to_match_disk("2. post login form")\
.facet("md").to_be("""Email sent!
Check your email for your login link.""")
email = EmailDev.wait_for_incoming(app)
expect_selfie(email).to_match_disk("3. login email").facet("md").to_be("Click [here](https://www.example.com/login-confirm/erjchFY=) to login.")
expect_selfie(get("/login-confirm/erjchFY=")).to_match_disk("4. open login email link")\
.facets("", "cookies").to_be("""REDIRECT 302 Found to /
╔═ [cookies] ═╗
[email protected]|JclThw==;Path=/""")
expect_selfie(given().cookie("login", "[email protected]|JclThw==").get("/")).to_match_disk("5. follow redirect")\
.facet("md").to_be("Welcome back [email protected]")
expect_selfie(given().cookie("login", "[email protected]|badsignature").get("/")).to_match_disk("6. bad signature")\
.facets("md").to_be("""Unauthorized
status code: 401""")
We just wrote a high-level specification of a realistic login flow, and it only took 24 lines of python 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.