Intermediate

Unit Tests

Real tests for this site's pure filters, run live at build time and gating every real pull request.

3 min read

Live Implementation

These numbers aren't typed in. This exact page's build ran the real test suite with Node's built-in test runner and reports exactly what happened, right now:

  • 14 / 14 tests passing
[
  {
    "name": "pluralize uses the singular form for exactly one",
    "passed": true
  },
  {
    "name": "pluralize adds an s by default for anything else",
    "passed": true
  },
  {
    "name": "pluralize uses an explicit plural when given one",
    "passed": true
  },
  {
    "name": "excerpt returns short text unchanged",
    "passed": true
  },
  {
    "name": "excerpt truncates long text with an ellipsis",
    "passed": true
  },
  {
    "name": "formatNumber adds thousands separators",
    "passed": true
  },
  {
    "name": "formatNumber leaves small numbers unchanged",
    "passed": true
  },
  {
    "name": "timeAgo reports today for the current moment",
    "passed": true
  },
  {
    "name": "timeAgo reports a pluralized unit for the past",
    "passed": true
  },
  {
    "name": "timeAgo uses the singular form for exactly one unit",
    "passed": true
  },
  {
    "name": "toJson produces indented, parseable JSON",
    "passed": true
  },
  {
    "name": "jsonLd produces parseable JSON",
    "passed": true
  },
  {
    "name": "jsonLd escapes < so </script> can never appear literally",
    "passed": true
  },
  {
    "name": "demosToItemList builds real ListItem entries with correct positions",
    "passed": true
  }
]

How It Works

Every filter this site adds a demo for — pluralize, excerpt, formatNumber, timeAgo, toJson, jsonLd, demosToItemList — is a small, pure function: same input, same output, no Eleventy, no DOM, nothing to mock. That’s exactly the shape Node’s own built-in test runner wants, so this site uses it directly instead of adding a testing framework as a dependency:

import { test } from "node:test";
import assert from "node:assert/strict";
import pluralize from "../src/_11ty/filters/pluralize.js";

test("pluralize uses the singular form for exactly one", () => {
    assert.equal(pluralize(1, "demo"), "1 demo");
});

node --test finds and runs every *.test.js file under test/ with zero configuration. No dependency was added for this — node:test and node:assert have shipped inside Node itself since version 18.

Making the results actually live

Most sites would stop at “there’s a test suite.” This one goes one step further: a global data file runs the real suite, programmatically, at the exact moment this page builds, using the same node:test module’s run() function instead of shelling out to a CLI:

import { run } from "node:test";

const stream = run({ files: [testFile] });
for await (const event of stream) {
    if (event.type === "test:pass") pass++;
    if (event.type === "test:fail") fail++;
}

The numbers in the Live Implementation above aren’t written by hand anywhere — they’re the actual result of actually running the actual tests, for this actual build. If a test ever failed, this page would show that failure the next time it built, not a stale “✔ all passing” someone forgot to update.

Folder Structure

test/filters.test.js       ← the real tests
src/_data/testResults.js   ← runs them at build time, feeds this page
.github/workflows/lighthouse-ci.yml ← runs npm test on every real pull request

Important Files

test/filters.test.js

import { test } from "node:test";
import assert from "node:assert/strict";

import pluralize from "../src/_11ty/filters/pluralize.js";
import excerpt from "../src/_11ty/filters/excerpt.js";
import formatNumber from "../src/_11ty/filters/formatNumber.js";
import timeAgo from "../src/_11ty/filters/timeAgo.js";
import toJson from "../src/_11ty/filters/toJson.js";
import jsonLd from "../src/_11ty/filters/jsonLd.js";
import demosToItemList from "../src/_11ty/filters/demosToItemList.js";

test("pluralize uses the singular form for exactly one", () => {
    assert.equal(pluralize(1, "demo"), "1 demo");
});

test("pluralize adds an s by default for anything else", () => {
    assert.equal(pluralize(0, "demo"), "0 demos");
    assert.equal(pluralize(3, "demo"), "3 demos");
});

test("pluralize uses an explicit plural when given one", () => {
    assert.equal(pluralize(2, "story", "stories"), "2 stories");
});

test("excerpt returns short text unchanged", () => {
    assert.equal(excerpt("short text", 20), "short text");
});

test("excerpt truncates long text with an ellipsis", () => {
    const text = "one two three four five six seven";
    assert.equal(excerpt(text, 3), "one two three…");
});

test("formatNumber adds thousands separators", () => {
    assert.equal(formatNumber(1234567), "1,234,567");
});

test("formatNumber leaves small numbers unchanged", () => {
    assert.equal(formatNumber(42), "42");
});

test("timeAgo reports today for the current moment", () => {
    assert.equal(timeAgo(new Date()), "today");
});

test("timeAgo reports a pluralized unit for the past", () => {
    const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
    assert.equal(timeAgo(threeDaysAgo), "3 days ago");
});

test("timeAgo uses the singular form for exactly one unit", () => {
    const oneDayAgo = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000);
    assert.equal(timeAgo(oneDayAgo), "1 day ago");
});

test("toJson produces indented, parseable JSON", () => {
    const output = toJson({ a: 1 });
    assert.equal(JSON.parse(output).a, 1);
    assert.match(output, /\n/);
});

test("jsonLd produces parseable JSON", () => {
    const output = jsonLd({ headline: "Test" });
    assert.deepEqual(JSON.parse(output), { headline: "Test" });
});

test("jsonLd escapes < so </script> can never appear literally", () => {
    const output = jsonLd({ title: "</script><script>alert(1)</script>" });
    assert.equal(output.includes("</script>"), false);
    assert.equal(JSON.parse(output).title, "</script><script>alert(1)</script>");
});

test("demosToItemList builds real ListItem entries with correct positions", () => {
    const demos = [
        { url: "/demos/a/", data: { title: "A" } },
        { url: "/demos/b/", data: { title: "B" } },
    ];
    const list = demosToItemList(demos, "https://example.com");
    assert.deepEqual(list, [
        { "@type": "ListItem", position: 1, url: "https://example.com/demos/a/", name: "A" },
        { "@type": "ListItem", position: 2, url: "https://example.com/demos/b/", name: "B" },
    ]);
});

src/_data/testResults.js

import { run } from "node:test";
import path from "node:path";

export default async function () {
    const testFile = path.resolve(process.cwd(), "test/filters.test.js");
    const stream = run({ files: [testFile] });

    let pass = 0;
    let fail = 0;
    const results = [];

    for await (const event of stream) {
        if (event.type === "test:pass") {
            pass++;
            results.push({ name: event.data.name, passed: true });
        }
        if (event.type === "test:fail") {
            fail++;
            results.push({ name: event.data.name, passed: false });
        }
    }

    return { pass, fail, total: pass + fail, results };
}

Notes

  • These tests run in the real GitHub Actions workflow that gates pull requests, before the site even builds — a broken filter fails the check, it doesn’t just sit unnoticed in a test/ folder nobody runs.
  • Writing these caught a real mistake: an early version of the pluralize test asserted pluralize(0, "demo") should return "0 demo". The actual function was already correct — zero is not one, so it correctly pluralizes to "0 demos" — the test itself had the bug. That’s the whole point of a test: it doesn’t know which side is wrong until you look.