Jest Inline Snapshots
Jest came out with Snapshot testing a few years back, then a little bit later this was extended to include inline snapshots. I use inline snapshots a lot because I think they get all the advantages of snapshot testing but by having the results inline, you are forced to reckon with the things you test.
Using inline snapshots in style
For a game I'm working on, I use Redux as a state management library [[games/phaser-redux]] and have an extensive Jest test suite around this code because it's all so self-contained. Games are particularly stateful apps, and relying on tests to handle all your logic usually saves a bunch of time because you don't have to get the game back into the same state as before.
Here's an example of the state which is controlled by redux:
ts
export interface GameState {game: "mygame"columns: Column[]cursor?: Positiondragging?: {surroundingPositions?: Position[]rowsToRemove?: number[]characters: LetterLoc[]isWord?: truemultiplier?: number}foundWords: string[]ui: UIChanges[]}
This game's state powers a Phaser game engine in production, and has a prototype SVG renderer, but in tests, it also has a text representation. One state, three renderers.
Text rendering
The text rendering is considerably more information dense than the JSON object it represents, and so that is used for snapshots via a custom Jest snapshot serializer: here's the module which sets that up:
ts
expect.addSnapshotSerializer({// This is why there is the weird 'game' property on the interface// to verify that this object can be serialized using the text renderertest: val => "game" in val && val["game"] === "mygame",print: val => textRender(val as any),})import type { Direction as Direction, GameState, Position } from "../../"import { letterAtPosition, positionSame } from "../positioning"export const textRender = (state: GameState) => {const longestColumnLength = state.columns.reduce((prev, cur) => (prev > cur.letters.length ? prev : cur.letters.length), -Infinity)let render = "- ".repeat(state.columns.length) + "\n"let dragStart: Position | undefined = undefinedlet dragEnd: Position | undefined = undefinedif (state.dragging) {const chars = state.dragging.charactersdragStart = { index: chars[0].index, col: chars[0].col }dragEnd = { index: chars[chars.length - 1].index, col: chars[chars.length - 1].col }}for (let i = longestColumnLength - 1; i > -1; i--) {for (let colI = 0; colI < state.columns.length; colI++) {const element = state.columns[colI].letters[i]render += element?.letter || " "const pos: Position = { col: colI, index: i }if (positionSame(state.cursor, pos)) {render += "x"} else if (positionSame(dragStart, pos)) {render += "#"} else if (positionSame(dragEnd, pos)) {render += "*"} else if (state.dragging?.characters.find(c => positionSame(c, pos))) {const char = state.dragging!.characters.find(c => positionSame(c, pos))!const index = state.dragging!.characters.indexOf(char)const next = state.dragging!.characters[index + 1]render += charForDraglineDir(next.dirToHere!)} else {render += " "}// Spacingrender += " "}render += "\n"}if (state.dragging) {render += "\nSelection: " + state.dragging.characters.map(c => letterAtPosition(state, c).letter).join(" ")if (state.dragging.isWord) {render += "\nIs word: " + state.dragging.isWord}}return render.trim()}
So, what does this look like in practice? Here's a test for the pressing the arrow buttons from the keyboard:
ts
import { colsFromStrings, createGameState } from "../util/tests/createTestGame"import { createGameStore, down, downLeft, downRight, left, right, up, upLeft, upRight } from ".."import { positionExists } from "../util/positioning"it("handles selection", () => {const state = createGameState(colsFromStrings(["ABC", "DEF", "GHI"]))const store = createGameStore(state)store.dispatch(up())expect(store.getState()).toMatchInlineSnapshot(`- - -C F IB E HAx D G`)store.dispatch(up())expect(store.getState()).toMatchInlineSnapshot(`- - -C F IBx E HA D G`)store.dispatch(up())expect(store.getState()).toMatchInlineSnapshot(`- - -Cx F IB E HA D G`)store.dispatch(up())expect(store.getState()).toMatchInlineSnapshot(`- - -C F IB E HAx D G`)store.dispatch(left())expect(store.getState()).toMatchInlineSnapshot(`- - -C F IB E HA D Gx`)store.dispatch(upLeft())expect(store.getState()).toMatchInlineSnapshot(`- - -C F IB Ex HA D G`)store.dispatch(right())expect(store.getState()).toMatchInlineSnapshot(`- - -C F IB E HxA D G`)})it("wraps correctly", () => {const state = createGameState(colsFromStrings(["ABCEFG", "DEF", "GHIJ"]))const store = createGameStore(state)store.dispatch(left())expect(store.getState()).toMatchInlineSnapshot(`- - -GFE JC F IB E HA D Gx`)store.dispatch(left())expect(store.getState()).toMatchInlineSnapshot(`- - -GFE JC F IB E HA Dx G`)store.dispatch(left())expect(store.getState()).toMatchInlineSnapshot(`- - -GFE JC F IB E HAx D G`)store.dispatch(left())expect(store.getState()).toMatchInlineSnapshot(`- - -GFE JC F IB E HA D Gx`)store.dispatch(down())expect(store.getState()).toMatchInlineSnapshot(`- - -GFE JxC F IB E HA D G`)store.dispatch(left())expect(store.getState()).toMatchInlineSnapshot(`- - -GFE JC Fx IB E HA D G`)store.dispatch(left())expect(store.getState()).toMatchInlineSnapshot(`- - -GFE JCx F IB E HA D G`)})
Don't think of these tests as a comprehensive test suite, but think of them as a communication pattern between me (as author) and you (or future me) as what should generally happen ([[js/testing-js]]). If this was a formal test suite, there would be it("wraps left")
, it("wraps right")
etc etc, but instead you see the actions which trigger the change in behavior and it tells an overall story. These are closer to integration tests than unit tests.
Here's an example unit test which very specifically tests one behaviour:
ts
describe(commitSelection.name, () => {it("when handles hard letters by removing the whole line", () => {const state = createGameState(colsFromStrings(["STAY", "SIZE", "STAY"]))const store = createGameStore(state)select(store, [[1, 0],[1, 1],[1, 2],[1, 3],])expect(store.getState()).toMatchInlineSnapshot(`- - -Y E* YA Z| AT I| TS S# SSelection: S I Z EIs word: true`)// Drops the full center row, but also the "A" from the two// outer columnsstore.dispatch(commitSelection())expect(store.getState()).toMatchInlineSnapshot(`- - -Y YT TS S`)})})
Snapshots as Story
These tests tell the story of how a system changes in a way that would be hard to describe as a series of individual, isolated tests. The use of inline snapshots just automates the process involved in creating and updating those tests with time.
The inline snapshots are used to help give you a much better idea of the before/after from the test.