snapshot testing for
Which is
literal, lensable
and like a filesystem
Snapshot testing is the
fastest and most precise
mechanism to record
and specify the
behavior of your
system and its
components.
Robots are
writing their
own code. Are
you still writing
assertions by
hand?
This is a reasonable way to test.
def test_primes_below_100():
result = primes_below(100)
assert result[:4] == [2, 3, 5, 7]
assert result[-2:] == [89, 97]
But oftentimes a more useful way to test is actually:
def test_mc_test_face():
print(primes_below(100))
With literal snapshots, you can print
directly into your testcode, combining the speed and freedom of print
with the repeatability and collaborative spirit of conventional assertions.
def test_primes_below_100():
expect_selfie(primes_below(100)).to_be_TODO()
When you run the test, selfie will automatically rewrite _TODO()
into whatever it turned out to be.
def test_primes_below100():
expect_selfie(primes_below(100))
.to_be([2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97])
And from now on it's a proper assertion, but you didn't have to spend any time writing it. It's not only less work, but also more complete than the usual .startsWith().endsWith()
rigamarole.
That primes_below(100)
snapshot above is almost too long. Something bigger, such as primes_below(10_000)
is definitely too big. To handle this, selfie lets you put your snapshots on disk.
def test_gzip_favicon():
expect_selfie(get("/favicon.ico", ContentEncoding.GZIP)).to_match_disk()
def test_order_flow():
expect_selfie(get("/orders")).to_match_disk("initial")
post_order()
expect_selfie(get("/orders")).to_match_disk("ordered")
This will generate a snapshot file like so:
╔═ gzipFavicon ═╗ base64 length 12 bytes
Umlja1JvbGwuanBn
╔═ orderFlow/initial ═╗
<html>
<body>
<button>Submit order</button>
</body>
</html>
╔═ orderFlow/ordered ═╗
<html>
<body>
<p>Thanks for your business!</p>
<details>
<summary>Order information</summary>
<p>Tracking #ABC123</p>
</details>
</body>
</html>
Selfie's snapshot files .ss
are simple to parse, just split them up on \n╔═
. Escaping rules only come into play if the content you are escaping has lines that start with ╔
, and you can always use selfie-lib
as a parser if you want.
You can treat your snapshot files as an output deliverable of your code, and use them as an input to other tooling.
A problem with the snapshots we've shown so far is that they are one dimensional. What about headers and cookies? What about the content the user actually sees, without all the markup? What if we could do this?
╔═ orderFlow/initial [md] ═╗
Submit order
╔═ orderFlow/ordered [md] ═╗
Thanks for your business!
Well, you can! Every snapshot has a subject, which is the main thing you are recording. And that subject can have any number of facets, which are named views of the subject from a different lens.
html = "<html>..."
snapshot = Snapshot(html).plus_facet("md", parse_html_to_md(html))
expect_selfie(snapshot).to_match_disk()
You can also use facets in combination with disk and inline literal snapshots to make your tests more like a story.
def test_order_flow():
expect_selfie(get("/orders")).to_match_disk("initial")
.facet("md").to_be("Submit order")
post_order()
expect_selfie(get("/orders")).to_match_disk("ordered")
.facet("md").to_be("Thanks for your business!")
Selfie's faceting is built around Camera, Lens, and Snapshot, whose API is roughly:
class Snapshot:
subject: SnapshotValue
facets: frozendict[str, SnapshotValue]
class Lens(ABC) :
def transform(self, snapshot: Snapshot) -> Snapshot:
pass
class Camera(Generic [T]):
def snapshot(self, subject: T) -> Snapshot:
pass
def with_lens(self, lens: Lens) -> 'Camera':
# returns a new Camera which applies the given lens to every snapshot
See the facets section for more details and example code.
Sometimes a test has a component which is slow, expensive, or non-deterministic. In cases like this, it can be useful to save the result of a previous execution of the API call, and use that as a mock for future tests.
client = expensive_ai_service()
chat_response = cache_selfie(client.chat("What's your favorite number today?")).to_be("Since it's March 14, my favorite number is π")
# build other stuff with the chat response
You can cache simple strings, but you can also cache typed API objects, binary data, or anything else you can serialize to a string or a byte array.
imageBytes = cache_selfie_binary(() -> {
return client.generate_image("A robot making a self portrait")
}).to_be_file("selfie.png")
For more information on how to use cache_selfie
, see the cache example.