Monorepo and testing
A library is easy to write and hard to change. Every change might break somebody, and without help you find out when they tell you. Two things make change safe. The first is seeing the effect of a change straight away, which means keeping the library next to the code that uses it. The second is a machine that notices when behaviour changes, which is what tests are. This lecture builds both for async-kit, from the folder layout up to a server that checks every commit.
One repository
1.1 async-kit, and a page that uses it
The example is async-kit, and a web page that uses it: six photos to load, at most two at a time, with a button that cancels. Kept apart, every change to the library has to be published before the page can try it. Kept together, the page uses the library as it is on disk, and one commit can change both. The goal is one pnpm workspace:
kit/
package.json the repository's own
pnpm-workspace.yaml which folders are packages
packages/
async-kit/ the library, published to a registry
async-kit-web/ the page, private, uses async-kit
1.2 Moving packages in
Most monorepos start as one package that gained a neighbour. Moving an existing package in is mechanical:
mkdir packages
git mv async-kit packages/async-kit
git rm packages/async-kit/package-lock.json
git mv keeps each file's history. The npm lockfile goes, because the repository will have one pnpm-lock.yaml for every package. The library's own package.json doesn't change: its name, version, exports and files are still about the library.
Two files at the root make the folders a workspace. pnpm-workspace.yaml lists the packages:
packages:
- "packages/*"
and the root package.json, which is never published, holds what the whole repository shares: scripts that run in every package, and which package manager and Node version the repository expects:
{
"name": "kit",
"private": true,
"scripts": {
"build": "pnpm -r build",
"typecheck": "pnpm -r typecheck",
"test": "pnpm -r test"
},
"devEngines": {
"packageManager": { "name": "pnpm", "version": "^12.0.0", "onFail": "error" },
"runtime": { "name": "node", "version": ">=22.18", "onFail": "error" }
}
}
The page names its sibling with the workspace protocol, "async-kit": "workspace:^", so it always gets the library from this repository. One pnpm install at the root installs everything:
Scope: all 3 workspace projects
The two packages, and the root.
1.3 Build before use
async-kit's exports point at dist, as a published library's do, so the page imports the built JavaScript and declaration files. On a fresh clone there is no dist yet, and the page's type check fails until the library is built. pnpm build at the root runs pnpm -r build, which builds every package after the packages it depends on, so async-kit is always built first.
A package that is only ever used inside the repository can skip the build. Its exports can point straight at its TypeScript source, "exports": "./src/index.ts": Vite and Vitest compile TypeScript as they load it, and Node strips the types from any .ts file outside node_modules. A workspace link resolves to the package's real folder, so its files aren't inside node_modules.
Why test
2.1 What a test is for
A test runs a piece of code with a chosen input and checks the result. That proves very little on its own: one input out of millions. What makes tests worth writing is what they do over time:
- They notice change. A test states what the code does today. When a later change alters that, the test fails, and somebody has to decide whether the change was meant. That is the whole point: a test suite is a machine that tells you when behaviour changed.
- They are documentation that can't go stale.
it("never runs more than limit at once")says whatmapLimitdoes, and the moment it stops being true, it goes red. - They push design. Code that is hard to test usually has hidden inputs: the clock, the network, a global. Testing it forces them into the open.
A passing suite doesn't mean the code is correct. It means none of the things anybody thought to check has changed.
2.2 Small, medium and large
Tests differ in how much of the system they run:
| Kind | Runs | Takes | When it fails, it tells you |
|---|---|---|---|
| Unit | one function or module, in Node | milliseconds | exactly which function, and how |
| Integration | several modules together, still in Node | milliseconds | which modules disagree |
| End-to-end | the whole application, in a real browser | seconds | that a user can't do something |
The faster kinds are cheap enough to run on every save, and precise about what broke. The slower kind is the only one that checks what a user actually experiences. The usual advice is a pyramid: many unit tests, fewer integration tests, a handful of end-to-end tests. For a library such as async-kit, almost everything is a unit test. For an application, a few end-to-end tests are worth more than they cost, because they cover what no unit test can: that the pieces were put together correctly.
Unit tests with Vitest
3.1 Choosing a test runner
A test runner finds test files, runs them, and reports what passed. JavaScript has had several, and the choice has shifted in the last few years:
- Jest, open-sourced by Facebook in 2014, was the default for most of the 2010s: test functions, assertions, test doubles and snapshots in one package. It was built for CommonJS. Its support for ES modules is still marked experimental in Jest 30 and needs a Node flag, and TypeScript goes through Babel or
ts-jest. In the State of JavaScript 2025 survey it is still the most used, with satisfaction falling. - Vitest, from the team behind Vite, since 2021, offers Jest's API on top of Vite. TypeScript and ES modules work without configuration, because Vite compiles them the same way it does for the application. Angular made it the default for new projects in 2025.
node:test, built into Node since 2022, needs nothing installed, and suits a small Node library.- Mocha and Jasmine are older, and still run many existing projects. Karma, which ran Jasmine's tests in a browser, was deprecated in 2023.
The direction is clear: new projects pick Vitest, and existing ones move from Jest to it. Its API matches Jest's, so most examples on the web work as they are, and a move rarely means rewriting the tests. That is what this course uses.
3.2 A first test
Vitest is a development dependency of the package it tests, and runs as its test script:
pnpm --filter async-kit add -D vitest
pnpm --filter async-kit pkg set scripts.test="vitest run"
The tests go in a folder of their own, test/, next to src/. They are for the library's developers, and never part of what is published. A vitest.config.ts in the package says where they are, and which files coverage reports on:
// packages/async-kit/vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/**/*.test.ts"],
coverage: {
include: ["src/**/*.ts"],
},
},
});
Without it, Vitest would read vite.config.ts, the library's build configuration, plugins and all. With a file of its own, tests and build stay apart. The first test:
// packages/async-kit/test/map-limit.test.ts
import { describe, expect, it } from "vitest";
import { mapLimit } from "../src/index.ts";
const double = async (n: number) => n * 2;
describe("mapLimit", () => {
it("resolves with the results in order", async () => {
expect(await mapLimit([1, 2, 3], double, { limit: 2 })).toEqual([2, 4, 6]);
});
});
describe groups tests, and it is one test. Inside, a test usually does three things: sets something up, calls the code, and checks the result with expect. Here they fit in one line. The test function is async, because mapLimit returns a promise, and the test awaits it before checking. The tests are TypeScript too, so tsconfig.json includes test as well as src, and the type check covers them. The import names the .ts file, as Node does when it runs TypeScript, and allowImportingTsExtensions lets the type check accept it.
pnpm --filter async-kit test
Test Files 1 passed (1)
Tests 1 passed (1)
pnpm vitest, without run, watches instead: it reruns the affected tests on every save.
The method after expect is a matcher. Why toEqual, and not toBe?
expect(await mapLimit([1, 2, 3], double, { limit: 2 })).toBe([2, 4, 6]); // ?
Output
AssertionError: expected [ 2, 4, 6 ] to be [ 2, 4, 6 ] // Object.is equality
If it should pass with deep equality, replace "toBe" with "toStrictEqual"
Expected: [ 2, 4, 6 ]
Received: serializes to the same string
toBe asks whether two values are the same value, with Object.is. Two arrays with the same contents are still two arrays, so use it for strings, numbers and booleans. toEqual compares contents, recursively. toStrictEqual also checks what toEqual forgives: properties set to undefined, and whether both objects are instances of the same class.
3.3 Test what it does, not how
A good test fails when the behaviour breaks, and only then. A few habits get you there:
-
Test through the public API, what users import, not the helpers behind it. The helpers can then change freely.
-
Check what a caller could observe.
mapLimitpromises that no more thanlimitcalls run at once. A caller sees that from insidefn, and so can the test, with async-kit's ownsleepto keep each call running for a moment:it("never runs more than limit at once", async () => {
let running = 0;
let most = 0;
await mapLimit(
[1, 2, 3, 4, 5],
async () => {
running++;
most = Math.max(most, running);
await sleep(10);
running--;
},
{ limit: 2 }
);
expect(most).toBe(2);
}); -
Don't tie a test to wording. Check an error's class,
nameorcode, not its message, unless the message is part of what you promise. A clearer message then doesn't break the tests. -
One behaviour per test, named after it. The name is what you read when it fails.
3.4 Throwing, and rejecting
A function's errors are part of what it promises, so they get tests too. A function that throws is checked with toThrow, which takes the function to call:
expect(() => JSON.parse("{")).toThrow(SyntaxError);
toThrow has to call the function itself, inside a try, to see what it throws. expect(JSON.parse("{")).toThrow(SyntaxError) calls JSON.parse first, while working out the argument for expect. The error is thrown before expect exists, and fails the test with the very error it meant to check. Wrapped in an arrow function, the call happens inside the check.
async-kit's argument checks look the same, and aren't:
it("throws for a limit below 1", () => {
expect(() => mapLimit([1], double, { limit: 0 })).toThrow(RangeError); // ?
});
Output
AssertionError: expected function to throw an error, but it didn't
⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯
RangeError: limit must be a whole number of 1 or more, got 0
mapLimit is an async function, and an async function never throws at its caller. The RangeError became a rejected promise, which nobody was waiting for. The test failed because nothing was thrown, and Vitest reported the rejection separately. A promise's failure is checked with rejects, which waits for it:
it("rejects a limit below 1", async () => {
await expect(mapLimit([1], double, { limit: 0 })).rejects.toThrow(RangeError);
});
3.5 Await what you check
Every test so far awaits what it checks. Here is one that doesn't. slowDouble is double with a ten-millisecond sleep in front:
it("checks the result after the test has ended", () => {
mapLimit([1, 2, 3], slowDouble, { limit: 2 }).then((result) => {
expect(result).toEqual([2, 4, 7]);
});
}); // ?
Output
Test Files 1 passed (1)
Tests 1 passed (1)
It passes, although [2, 4, 6] is not [2, 4, 7]. The test function returned straight away, so Vitest counted it as passed. The expect ran later, after the run had finished, and nothing reported it at all. A test that passes whatever the code does is worse than no test, because it says something is checked when nothing is.
The fix is to make the test function async and await what it checks, so the test ends only when the check has run:
it("waits for the result", async () => {
expect(await mapLimit([1, 2, 3], slowDouble, { limit: 2 })).toEqual([2, 4, 7]);
});
AssertionError: expected [ 2, 4, 6 ] to deeply equal [ 2, 4, 7 ]
- Expected
+ Received
[
2,
4,
- 7,
+ 6,
]
Now it fails, as it should. Correct the expected value to [2, 4, 6], and it passes for the right reason.
Vitest catches one version of the mistake for you: an expect(...).rejects or .resolves that isn't awaited fails the test, with "Promise returned by expect(actual).rejects.toThrow(expected) was not awaited". It can't catch a .then it doesn't know about. Every test of asynchronous code is an async function, and awaits every promise it checks.
3.6 Test doubles
createOnce promises that two calls with the same key, while the first is still running, share one call of fn. The results can't show that: both are 42 whether fn ran once or twice. The test needs to ask fn itself, so it passes a test double, a stand-in that records how it was used. vi.fn() makes one:
it("shares one call per key", async () => {
const run = createOnce();
const load = vi.fn(async () => 42);
const [a, b] = await Promise.all([run("answer", load), run("answer", load)]);
expect([a, b]).toEqual([42, 42]);
expect(load).toHaveBeenCalledTimes(1);
});
vi.spyOn does the same to a method that already exists. The deprecated once warns the first time it is called, and only then:
it("warns once that once() is deprecated", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
await once("a", async () => 1);
await once("b", async () => 2);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(expect.stringContaining("createOnce"));
});
mockImplementation replaces what console.warn does, so the warning is recorded instead of printed.
Doubles are tools for the edges of a program: the network, the clock, the console, randomness. Your code doesn't own those, and a test can't control them any other way. Your own modules are different. Take a function that imports another module of yours and calls it:
// report.ts
import { readOrders } from "./db.ts";
export async function dailyTotal(day: string): Promise<number> {
const orders = await readOrders(day);
return orders.reduce((sum, order) => sum + order.total, 0);
}
To test the sum, the test has to keep readOrders away from the database, so it replaces a module of your own with a fake one, vi.mock("./db.ts", …). That works, but the test now depends on which file dailyTotal imports and which function it calls there. Move readOrders to another file, and the test breaks, though the total is still right. The problem is in dailyTotal: it does two jobs, fetching orders and adding them up, and it reaches for its data instead of being given it. Give it the data, and the double disappears:
export function total(orders: { total: number }[]): number {
return orders.reduce((sum, order) => sum + order.total, 0);
}
expect(total([{ total: 500 }, { total: 250 }])).toBe(750);
Whoever calls total fetches the orders, and that code is tested where the database is. Passing a function what it depends on, instead of letting it import or create it, is called dependency injection. async-kit does it with time: retry takes its wait as an option, baseMs, so a test can set the wait to zero.
A test that needs doubles for your own modules is telling you something about the design. It usually means a function reaches for something it should be given.
3.7 Controlling time
retry waits a random, growing time between attempts, by default up to 200 ms, then 400 ms. A test that really waited would be slow, and a suite of them would take minutes. There are two ways out.
The first is design. The wait is an option, so a test that doesn't care about it turns it off:
it("gives up with a RetryError", async () => {
const fn = vi.fn().mockRejectedValue(new Error("offline"));
await expect(retry(fn, { attempts: 3, baseMs: 0 })).rejects.toMatchObject({
code: "ERR_RETRY_EXHAUSTED",
});
expect(fn).toHaveBeenCalledTimes(3);
});
A double can also be told how to behave. mockRejectedValue makes every call reject, and the versions ending in Once set up one call at a time.
The second is fake timers, for when the waiting is what you test, or the code gives you no option. They replace setTimeout and the clock with versions the test controls:
it("tries again until fn succeeds", async () => {
vi.useFakeTimers();
const fn = vi.fn().mockRejectedValueOnce(new Error("offline")).mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce("ok");
const result = retry(fn, { attempts: 3, baseMs: 1000 });
expect(fn).toHaveBeenCalledTimes(1);
await vi.runAllTimersAsync();
expect(fn).toHaveBeenCalledTimes(3);
await expect(result).resolves.toBe("ok");
});
Up to three seconds of waiting, and the test takes about ten milliseconds. Before the clock moves, fn has been called once. vi.runAllTimersAsync() runs every pending timer, and lets the promises waiting on each one settle, until none are left. An afterEach(() => vi.useRealTimers()) in the file puts the real clock back after every test, even one that fails halfway.
3.8 Coverage
Coverage measures which parts of the code ran during the tests. With the @vitest/coverage-v8 package installed:
pnpm --filter async-kit test --coverage
----------|---------|----------|---------|---------|--------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|--------------------
All files | 72.97 | 69.69 | 77.77 | 75.36 |
index.ts | 72.97 | 69.69 | 77.77 | 75.36 | 9,21,38-48,117-126
----------|---------|----------|---------|---------|--------------------
The uncovered lines tell a story. Lines 9 and 21 are two argument checks no test tried. Lines 38 to 48 are sleep with a signal: nothing ever cancelled a sleep. Lines 117 to 126 are mapSettled, which has no tests at all. A branch is each way an if can go, and the percentage of branches is usually the most telling number.
That is what coverage is good for: finding code that no test runs. It can't say whether the tests check anything. A test that calls mapLimit and never looks at the result gives the same coverage as one that checks it. Coverage as a target, "90% or the build fails", tends to produce exactly those tests.
Vitest has two ways to measure. v8, the default, reads counts the JavaScript engine keeps anyway. istanbul, the long-standing standard that Jest used, rewrites the code with counters before it runs. Since Vitest 3.2 both produce the same report, and Vitest recommends v8, which is faster. istanbul is for code that runs on another engine, such as tests in Firefox, or under Bun.
In a real browser
4.1 What a unit test can't see
Every test so far ran in Node, which has no page, no buttons and no layout. The page puts async-kit together with HTML and event listeners:
// packages/async-kit-web/src/main.ts
load.addEventListener("click", async () => {
controller = new AbortController();
const { signal } = controller;
// ...
try {
await mapLimit(
names,
async (name) => {
/* load one, count it */
},
{ limit: limit.valueAsNumber, signal }
);
} catch (error) {
status.textContent = signal.aborted ? `Cancelled after ${done} of ${names.length}` : String(error);
}
// ...
});
async-kit's tests say nothing about whether this code finds the right elements, passes the right signal, or updates the status. Two kinds of tool reach that far.
A simulated DOM, Document Object Model, such as jsdom or happy-dom, implements the browser's document and events in JavaScript, inside Node. It is fast, and good enough for code that builds or changes elements, but it has no layout, no CSS and no real rendering. It is often used with Testing Library, best known as React Testing Library, which finds elements the way a user does: by their role and their label, not by class names.
A real browser shows everything a user would see. The two common tools are Cypress and Playwright, and most new projects now choose Playwright: it is made by Microsoft, drives all three browser engines, Chromium, Firefox and WebKit, the engine behind Safari, and its test runner is downloaded from npm more than ten times as often as Cypress.
4.2 Playwright
Playwright has its own test runner, separate from Vitest, with a similar API:
pnpm --filter async-kit-web add -DE @playwright/test@1.64.1
pnpm --filter async-kit-web exec playwright install chromium
-E saves the exact version, without a range, because Playwright's browsers are built for one version of it. The second command downloads the browser itself.
Playwright downloads its own browsers, matched to its version, because each release is tested against those exact builds. With Google Chrome installed, channel: "chrome" in a project's use drives that Chrome instead, and nothing is downloaded. The price is that the tests then run on whichever version Chrome has updated itself to.
The configuration tells Playwright where the tests are, and how to start the page:
// packages/async-kit-web/playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "e2e",
use: {
baseURL: "http://localhost:5173",
trace: "on-first-retry",
},
retries: process.env.CI ? 2 : 0,
webServer: {
command: "pnpm dev --port 5173 --strictPort",
url: "http://localhost:5173",
reuseExistingServer: !process.env.CI,
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
});
webServer starts Vite before the tests and stops it after. A test reads like instructions to a person:
// packages/async-kit-web/e2e/photos.spec.ts
import { expect, test } from "@playwright/test";
test("loads every photo", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Load" }).click();
await expect(page.getByRole("status")).toHaveText("Loaded 6 of 6");
await expect(page.getByRole("listitem")).toHaveCount(6);
});
test("cancel stops loading", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Load" }).click();
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByRole("status")).toHaveText(/^Cancelled after \d of 6$/);
await expect(page.getByRole("button", { name: "Load" })).toBeEnabled();
});
pnpm --filter async-kit-web e2e
Running 2 tests using 1 worker
✓ 1 [chromium] › e2e/photos.spec.ts:3:1 › loads every photo (3.9s)
✓ 2 [chromium] › e2e/photos.spec.ts:11:1 › cancel stops loading (522ms)
2 passed (16.1s)
getByRole finds elements the way assistive technology does, by their role in the ARIA (Accessible Rich Internet Applications) sense and their accessible name: a button labelled "Load", the paragraph with role="status", each <li> as a listitem. Restyling the page doesn't break the test. And a page a test can't find by role is often a page a screen reader can't use either.
4.3 Wait for the page, not the clock
Loading takes a second and a half, two photos at a time. Two ways to check the result:
test("reads the status once", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Load" }).click();
expect(await page.getByRole("status").textContent()).toBe("Loaded 6 of 6"); // ?
});
test("waits for the status", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "Load" }).click();
await expect(page.getByRole("status")).toHaveText("Loaded 6 of 6"); // ?
});
Output
✘ 1 [chromium] › e2e/wait.spec.ts:3:1 › reads the status once (699ms)
✓ 2 [chromium] › e2e/wait.spec.ts:9:1 › waits for the status (2.3s)
Error: expect(received).toBe(expected) // Object.is equality
Expected: "Loaded 6 of 6"
Received: "Loaded 0 of 6"
The first reads the text once, right after the click, and gets the start. The second is a web-first assertion: expect(locator).toHaveText(...) keeps checking until the text matches, or until it gives up after five seconds. Playwright's actions wait too: click waits until the button is there, visible, not moving, not covered by anything else, and enabled.
The tempting fix for the first test is a pause, await page.waitForTimeout(2000). It passes on your machine, fails on a slower one, and makes every run two seconds longer even when the page was ready sooner. A fixed wait in a test is a bug that hasn't happened yet. Wait for what you expect to see, with the assertions that retry.
4.4 When an end-to-end test fails
A failing web-first assertion says what it looked for, and everything it saw while it waited. Here the page counted one photo too few:
Error: expect(locator).toHaveText(expected) failed
Locator: getByRole('status')
Expected: "Loaded 6 of 6"
Received: "Loaded 5 of 6"
Timeout: 5000ms
Call log:
- Expect "toHaveText" getByRole('status') with timeout 5000ms
- waiting for getByRole('status')
5 × locator resolved to <p id="status" role="status">Loaded 0 of 6</p>
- unexpected value "Loaded 0 of 6"
- locator resolved to <p id="status" role="status">Loaded 1 of 6</p>
- unexpected value "Loaded 1 of 6"
- locator resolved to <p id="status" role="status">Loaded 3 of 6</p>
- unexpected value "Loaded 3 of 6"
7 × locator resolved to <p id="status" role="status">Loaded 5 of 6</p>
- unexpected value "Loaded 5 of 6"
When that isn't enough, Playwright can record a trace: every action, a snapshot of the page before and after each one, the console and the network. The configuration above records one when a test fails and is retried, which happens on the build server. pnpm exec playwright show-trace with the trace file lets you step through the failed run as if you had watched it. Locally, pnpm exec playwright test --ui runs the tests in a window where you can watch each step, pick a locator by clicking on the page, and rerun.
4.5 Few, and the ones that matter
End-to-end tests are slow, and they fail for reasons that have nothing to do with your change: a slow machine, a timing difference, a browser update. So they are for the paths that must never break: a user can sign in, can find a product, can pay. Everything a unit test can check, a unit test should check. The limit, the retries, the shared calls and every argument check are tested in Node, in milliseconds. Two end-to-end tests check that the page put the pieces together.
Every package, on every push
5.1 One command for the whole repository
The root scripts run in every package that has the script, in dependency order:
pnpm build && pnpm typecheck && pnpm test
$ pnpm -r test
Scope: 2 of 3 workspace projects
$ vitest run
Test Files 3 passed (3)
Tests 8 passed (8)
async-kit-web has no test script, so -r skips it. Its end-to-end tests run on their own, with pnpm --filter async-kit-web e2e, because they need a browser.
Vitest can also run every package's tests in one process, with one report: a root vitest.config.ts with test: { projects: ["packages/*"] }. Vitest then runs every *.spec.ts it finds, Playwright's included, so async-kit-web needs a Vitest configuration of its own that excludes its e2e folder.
5.2 Continuous integration
Tests only help if they run. Developers forget, run only the package they changed, or have a node_modules that has drifted from the lockfile. Continuous integration, CI, takes the decision away: on every push, a server checks out the commit on a clean machine, installs exactly what the lockfile says, and runs every check. A commit that breaks something is marked red within minutes, with the name of whoever pushed it.
The clean machine is the point. "It works on my machine" is often true, and it is about a machine nobody else has.
5.3 A GitLab pipeline
GitLab reads a file called .gitlab-ci.yml at the root of the repository, written in YAML, a configuration format based on indentation, and runs it as a pipeline on every push. A pipeline is made of jobs, the jobs are grouped into stages, and each job runs its commands in a fresh container started from a Docker image:
stages:
- check
- e2e
default:
image: node:24
before_script:
- npm install --global --allow-scripts=pnpm pnpm@12
- pnpm install --frozen-lockfile --store-dir .pnpm-store
- pnpm build
cache:
key:
files:
- pnpm-lock.yaml
paths:
- .pnpm-store
unit:
stage: check
script:
- pnpm typecheck
- pnpm test
e2e:
stage: e2e
image: mcr.microsoft.com/playwright:v1.64.1-noble
script:
- pnpm --filter async-kit-web e2e
artifacts:
when: on_failure
paths:
- packages/async-kit-web/test-results/
defaultapplies to every job: the image to run in, and the commands that come first. Every job installs pnpm, then the dependencies, then builds async-kit, which everything else needs.--frozen-lockfilerefuses to install ifpnpm-lock.yamldoesn't match thepackage.jsonfiles. A lockfile somebody forgot to commit fails here, instead of quietly installing different versions.cachekeeps pnpm's store between pipelines, and starts over whenever the lockfile changes.unittype-checks every package and runs every package's unit tests.e2eruns in Playwright's own image, which comes with the browsers and everything they need. Its version has to match the@playwright/testversion in the lockfile exactly, which is why that dependency is pinned to1.64.1, without a range.artifactskeep the job's files after it ends. When an end-to-end test fails, the traces intest-results/can be downloaded from the job's page and opened withshow-trace.
Jobs in the same stage run in parallel. A later stage starts only when the earlier one passed, so a type error stops the pipeline before any browser starts.
5.4 Red means stop
A pipeline is only worth its cost if a red result stops the change. In GitLab, a project's merge request settings can require it: Pipelines must succeed blocks merging a merge request whose latest pipeline failed.
The enemy of that rule is the flaky test, one that sometimes passes and sometimes fails without any change to the code. Usually it waits for the clock instead of the page, depends on another test having run first, or leaks a fake timer into the next test. Each flaky failure teaches the team that red might mean nothing, and soon nobody looks. The retries setting in the Playwright configuration lets a test that failed once try again on the build server, which keeps a rare hiccup from blocking a merge. It also hides flakiness, so Playwright reports those tests as flaky rather than passed. A flaky test is a bug, in the test or in the code, and it gets fixed like one.
Take these 5 things from here
- A monorepo puts a library next to the code that uses it, so a change and its effects are seen and committed together. A published library is built first, and
pnpm -rruns everything in dependency order. - A test suite is a machine that tells you when behaviour changed. Test what a caller can observe, through the public API, and check an error's kind, not its wording.
- Await what you check. An
asyncfunction rejects instead of throwing, so its failures are checked withrejects, and every test of asynchronous code awaits its promises. - Test at the lowest level that can see the problem. Unit tests in milliseconds for logic, with doubles and fake timers at the edges. A few end-to-end tests in a real browser, with assertions that wait for the page instead of the clock.
- Tests only count if they run on every push, on a clean machine, from the lockfile, and if red stops the merge. A flaky test is a bug.
You should now be able to
- Move existing packages into a pnpm workspace, and explain why a published library has to be built before the packages that use it.
- Say what tests are for, and what a passing suite doesn't prove.
- Choose between unit, integration and end-to-end tests for a given behaviour.
- Explain where Jest, Vitest,
node:test, Testing Library, Cypress and Playwright fit, and why new projects choose Vitest and Playwright. - Write Vitest tests with
describe,itandexpect, and choose betweentoBe,toEqualandtoStrictEqual. - Test a function that throws and one that rejects, and explain why
toThrowneeds a function. - Explain why an asynchronous test that doesn't await can pass whatever the code does, and write one that can't.
- Use
vi.fnandvi.spyOnto see how code used its inputs, and explain when doubles point to a design problem. - Test code that waits, by design or with fake timers.
- Run coverage, read its report, and explain what it can't tell you.
- Write a Playwright test with role-based locators and web-first assertions, and explain why fixed waits are bugs.
- Find a failing end-to-end test's cause with its error, a trace or UI mode.
- Read and write a GitLab pipeline that installs from the lockfile, builds, type-checks, runs unit tests and runs end-to-end tests.
- Explain what flaky tests cost, and why retries hide them.
Reading
Required
- Vitest: Getting Started and Vitest: Mocking
- Testing Library: Guiding principles — one page, and the idea behind role-based locators
- Playwright: Writing tests and Locators
- Playwright: Auto-waiting
- GitLab: Get started with GitLab CI/CD
Recommended
- State of JavaScript 2025: Testing
- Vitest: Fake timers
- Vitest: Coverage
- Vitest: Test Projects and Browser Mode
- Announcing Vitest 5.0
- Jest: ECMAScript Modules — why moving from Jest is common in projects that use ES modules
- Playwright: Browsers, Trace viewer and UI mode
- Playwright: Continuous Integration
- Node.js: Test runner
- Martin Fowler, "The Practical Test Pyramid"
- Kent C. Dodds, "Write tests. Not too many. Mostly integration."
- Google Testing Blog, "Flaky Tests at Google and How We Mitigate Them"
Demos
- practicum-09.zip — async-kit and an app that uses it, async-kit's Vitest tests, the page with its Playwright tests, and the GitLab pipeline