How to buildOctober 11, 202613 min read
DevOps, Testing & SecurityStep 9 of 12

The testing pyramid: unit, integration, e2e

In short

The testing pyramid is a rule for distributing automated tests: many fast unit tests at the bottom, fewer integration tests in the middle and only a handful of end-to-end tests at the top. The higher the level, the closer a test is to a real user, but the slower, costlier and more prone to failing for no reason it becomes. E2E tests are needed to confirm the key journeys work as a whole, but anything that can be checked a level lower is checked lower.

FundamentalsLevel: IntermediateTesting

After this you will be able to

  • split one feature's checks across the pyramid levels and write a "check → level → why" test plan
  • explain why there should be few e2e tests yet you cannot do without them, and how the pyramid differs from the trophy, diamond and honeycomb
  • cover one function with unit and integration tests in Vitest without duplicating checks
  • find the cause of a flaky test — isolation, time, async behaviour, an external service — and remove it

Verified with

Node.js 24 LTS (24.18)Vitest 5.0.3Express 5.3.0TypeScript 7.0.2SQLite 3.53 (node:sqlite)

Checked against docs: October 11, 2026

Contents
  1. 01Why automated tests, and how many
  2. 02The pyramid: unit, integration, e2e
  3. 03Trophy, diamond and honeycomb: alternative views
  4. 04One feature at two levels: a lead form
  5. 05Where to mock and how to prepare test data
  6. 06Flaky tests: causes and cures
  7. 07A test plan for a feature, tests in CI and exercises

Why automated tests, and how many

An automated test is code that checks other code and answers "works / doesn't work" in seconds instead of half an hour of clicking around. The value of tests is not their number but the feedback they give. In the Google Testing Blog post "Just Say No to More End-to-End Tests", the ideal feedback loop has three properties: it is fast, reliable (a test does not fail for no reason) and isolates failures — it shows exactly where things broke.

No single kind of test has all three. A unit test is fast and pinpoints the failure, but does not know whether the parts work together. An E2E test walks the user's path in a real browser, but takes minutes, fails because of animations and network delays, and when it fails it only says "something broke". So tests of different levels are combined — and the question "how many" really means "in what proportion".

In his note on testing "shapes", Martin Fowler quotes Justin Searls: debating what percentage of which type of tests to write is a distraction; what matters is writing expressive tests that establish clear boundaries, run quickly and reliably, and only fail for useful reasons. The pyramid and its alternatives are tools for getting there.

The pyramid: unit, integration, e2e

Mike Cohn introduced the test pyramid in his book "Succeeding with Agile"; Martin Fowler's note "Test Pyramid" and Ham Vocke's article "The Practical Test Pyramid" made it popular. Fowler puts the point this way: you should have many more unit tests than broad tests through the GUI, because UI tests are brittle, expensive to write and time-consuming to run. Vocke suggests remembering two things from the original pyramid: write tests with different granularity, and the higher the level, the fewer tests you should have.

  1. 01E2E — a handfulThe whole product in a browser or on a device: the key user journeys
  2. 02Integration — dozensSeveral parts together: HTTP route, database, JSON parsing
  3. 03Unit — hundredsOne function or class in memory, dependencies replaced
The pyramid: the higher up, the slower, costlier and closer to the user each test is.

Unit

What it checks
Logic of one function or class: branches, edge values, calculations
Speed
Milliseconds
What it costs
Blind to errors at the seams
Example for a lead form
normalizePhone turns "8 (707) 123-45-67" into +77071234567

Integration

What it checks
That the parts work together: route, validation, SQL, status codes
Speed
Tens of milliseconds to seconds
What it costs
Needs a database or its container, data setup
Example for a lead form
POST /api/leads stores a row and returns 201; a duplicate returns 409

E2E

What it checks
The whole user journey: browser, frontend, API, database
Speed
Seconds to minutes per test
What it costs
Slow, unstable, hard to locate the cause
Example for a lead form
A user fills in the form on the page and sees "Thanks, we'll call you back"

Level names are fuzzy. Fowler recalls a testing expert who covered 24 different definitions of unit test on the first morning of his course. Agree on terms within your team, and when reading advice, check what the author means by them. A useful distinction from the same Fowler note: a solitary unit test replaces all collaborators, a sociable one uses the real ones. Many "unit versus integration" debates are really about these two styles.

The Google Testing Blog post suggests a 70/20/10 split — unit, integration, e2e — as a first guess, noting that the exact mix differs for every team. It also describes two anti-patterns: the ice-cream cone (an inverted pyramid that relies mostly on e2e and manual testing) and the hourglass (many unit and e2e tests but few integration tests — e2e tests doing work a smaller test could do).

Trophy, diamond and honeycomb: alternative views

The pyramid is often criticised and has several well-known alternatives. The web.dev article "Pyramid or Crab? Find a testing strategy that fits" brings them together; the common thread in almost all of them is shifting the main weight to integration tests.

Pyramid

Author / source
Mike Cohn; Fowler, Vocke
Key idea
Most tests are unit tests, e2e kept to a minimum
Where it fits
Complex logic inside the code, libraries, calculations

Testing trophy

Author / source
Kent C. Dodds
Key idea
Static analysis (TypeScript, ESLint) at the base, the bulk is integration tests; "the more your tests resemble the way your software is used, the more confidence they can give you"
Where it fits
Frontends and monoliths where bugs tend to sit between components

Test diamond

Author / source
Described in the web.dev article
Key idea
Unit tests only for the most critical cases, because they erode during refactoring; the main layer is integration
Where it fits
Apps with simple logic but many connections

Testing honeycomb

Author / source
Spotify Engineering, 2018
Key idea
For microservices: mostly integration tests of the service, a few implementation detail tests, and integrated tests (that depend on another system's correctness) ideally none
Where it fits
Small services whose complexity lies in their interactions

Dodds himself writes that the trophy is a view of tests within a single codebase, devised for frontend work; he never considered microservices. And Fowler, in "On the Diverse And Fantastical Shapes of Testing", shows that the "integration tests" of honeycomb advocates often match what he calls sociable unit tests — so the debate is largely about terminology.

In our experience, a typical web application with its own database does best with a hybrid: unit tests for pure logic (validation, pricing, permissions), integration tests for every endpoint and database query, and a few e2e tests for the journeys where the business loses money if they break: a lead, a payment, a sign-in.

One feature at two levels: a lead form

Take a lead form: name, phone and consent to data processing. The rules: the phone is normalised to +7XXXXXXXXXX, a repeat lead from the same number within 24 hours is a duplicate, and if the manager notification fails, the lead is still saved. We cover the feature with unit and integration tests in Vitest. To keep the example self-contained, the database is in-memory SQLite from the built-in node:sqlite module; the same approach with PostgreSQL in Docker is covered in the backend testing lesson.

bash
npm init -y && npm pkg set type=module
npm install --save-exact express@5.3.0
npm install -D --save-exact vitest@5.0.3 typescript@7.0.2 @types/express@5.0.6 @types/node@24
src/lead.ts
export type LeadInput = { name?: unknown; phone?: unknown; consent?: unknown };
export type Lead = { name: string; phone: string };
export type Validation =
  | { ok: true; lead: Lead }
  | { ok: false; errors: Record<string, string> };

// "8 (707) 123-45-67", "+7 707 123 45 67", "7071234567" -> "+77071234567"
export function normalizePhone(raw: string): string | null {
  const digits = raw.replace(/\D/g, "");
  if (digits.length === 10) return `+7${digits}`;
  if (digits.length === 11 && (digits[0] === "7" || digits[0] === "8")) return `+7${digits.slice(1)}`;
  return null;
}

export function validateLead(input: LeadInput): Validation {
  const errors: Record<string, string> = {};
  const name = typeof input.name === "string" ? input.name.trim() : "";
  const phone = typeof input.phone === "string" ? normalizePhone(input.phone) : null;
  if (name.length < 2) errors.name = "name_too_short";
  if (!phone) errors.phone = "phone_invalid";
  if (input.consent !== true) errors.consent = "consent_required";
  if (!phone || Object.keys(errors).length > 0) return { ok: false, errors };
  return { ok: true, lead: { name, phone } };
}
src/lead-service.ts
import { validateLead, type Lead, type LeadInput } from "./lead";

export interface LeadRepo {
  findRecentByPhone(phone: string, since: Date): Promise<{ id: number } | null>;
  insert(lead: Lead, createdAt: Date): Promise<number>;
}
export type Notify = (lead: Lead & { id: number }) => Promise<void>;

export type SubmitResult =
  | { status: "created"; id: number }
  | { status: "duplicate"; id: number }
  | { status: "invalid"; errors: Record<string, string> };

const DAY_MS = 24 * 60 * 60 * 1000;

export function createLeadService(deps: { repo: LeadRepo; notify: Notify; now?: () => Date }) {
  const now = deps.now ?? (() => new Date());

  return {
    async submit(input: LeadInput): Promise<SubmitResult> {
      const checked = validateLead(input);
      if (!checked.ok) return { status: "invalid", errors: checked.errors };

      const at = now();
      const since = new Date(at.getTime() - DAY_MS);
      const recent = await deps.repo.findRecentByPhone(checked.lead.phone, since);
      if (recent) return { status: "duplicate", id: recent.id };

      const id = await deps.repo.insert(checked.lead, at);
      try {
        await deps.notify({ ...checked.lead, id });
      } catch {
        // the lead is already saved: a failed notification must not lose it
      }
      return { status: "created", id };
    },
  };
}

export type LeadService = ReturnType<typeof createLeadService>;
src/sqlite-repo.ts
import { DatabaseSync } from "node:sqlite";
import type { LeadRepo } from "./lead-service";

export function openDb(path = ":memory:"): DatabaseSync {
  const db = new DatabaseSync(path);
  db.exec(`CREATE TABLE IF NOT EXISTS leads (
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
    name       TEXT NOT NULL,
    phone      TEXT NOT NULL,
    created_at TEXT NOT NULL
  )`);
  return db;
}

export function createSqliteRepo(db: DatabaseSync): LeadRepo {
  const findRecent = db.prepare(
    "SELECT id FROM leads WHERE phone = ? AND created_at >= ? ORDER BY id DESC LIMIT 1",
  );
  const insert = db.prepare("INSERT INTO leads (name, phone, created_at) VALUES (?, ?, ?)");

  return {
    async findRecentByPhone(phone, since) {
      const row = findRecent.get(phone, since.toISOString());
      return row ? { id: Number(row.id) } : null;
    },
    async insert(lead, createdAt) {
      const result = insert.run(lead.name, lead.phone, createdAt.toISOString());
      return Number(result.lastInsertRowid);
    },
  };
}
src/app.ts
import express from "express";
import type { LeadService } from "./lead-service";

export function createApp(leads: LeadService) {
  const app = express();
  app.use(express.json());

  app.post("/api/leads", async (req, res) => {
    const result = await leads.submit(req.body ?? {});
    if (result.status === "invalid") return res.status(422).json({ errors: result.errors });
    if (result.status === "duplicate") return res.status(409).json({ error: "duplicate", id: result.id });
    res.status(201).json({ id: result.id });
  });

  return app;
}

The service receives its dependencies as parameters: the repository, the notify function and a now clock. That is what makes it testable: in a unit test we pass an array instead of a database and a fixed time; in an integration test, real SQL. The tests are split into two Vitest projects so they can be run separately.

vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    projects: [
      { test: { name: "unit", include: ["tests/unit/**/*.test.ts"] } },
      { test: { name: "integration", include: ["tests/integration/**/*.test.ts"] } },
    ],
  },
});
tests/unit/lead.test.ts
import { describe, expect, it } from "vitest";
import { normalizePhone, validateLead } from "../../src/lead";

describe("normalizePhone", () => {
  it.each([
    ["8 (707) 123-45-67", "+77071234567"],
    ["+7 707 123 45 67", "+77071234567"],
    ["7071234567", "+77071234567"],
    ["87071234567", "+77071234567"],
  ])("%s -> %s", (raw, expected) => {
    expect(normalizePhone(raw)).toBe(expected);
  });

  it.each(["", "12345", "9 707 123 45 67", "+1 202 555 0100 1"])("rejects %j", (raw) => {
    expect(normalizePhone(raw)).toBeNull();
  });
});

describe("validateLead", () => {
  it("returns a normalized lead", () => {
    expect(validateLead({ name: "  Aigerim ", phone: "8 707 123 45 67", consent: true })).toEqual({
      ok: true,
      lead: { name: "Aigerim", phone: "+77071234567" },
    });
  });

  it("collects every error at once", () => {
    expect(validateLead({ name: "A", phone: "123", consent: false })).toEqual({
      ok: false,
      errors: { name: "name_too_short", phone: "phone_invalid", consent: "consent_required" },
    });
  });
});
Unit: every phone format and every error combination live here, not in the HTTP tests.
tests/unit/lead-service.test.ts
import { describe, expect, it, vi } from "vitest";
import { createLeadService, type LeadRepo } from "../../src/lead-service";

type Row = { id: number; phone: string; createdAt: Date };

// a fake repository: a plain array instead of a database
function fakeRepo(): LeadRepo & { rows: Row[] } {
  const rows: Row[] = [];
  return {
    rows,
    async findRecentByPhone(phone, since) {
      const row = rows.findLast((r) => r.phone === phone && r.createdAt >= since);
      return row ? { id: row.id } : null;
    },
    async insert(lead, createdAt) {
      rows.push({ id: rows.length + 1, phone: lead.phone, createdAt });
      return rows.length;
    },
  };
}

const valid = { name: "Aigerim", phone: "+7 707 123 45 67", consent: true };

describe("lead service", () => {
  it("rejects a repeat within 24 hours and accepts it after", async () => {
    let now = new Date("2026-10-11T09:00:00Z");
    const service = createLeadService({ repo: fakeRepo(), notify: vi.fn(), now: () => now });

    expect(await service.submit(valid)).toEqual({ status: "created", id: 1 });
    now = new Date("2026-10-12T08:59:00Z"); // 23 h 59 min later
    expect(await service.submit(valid)).toEqual({ status: "duplicate", id: 1 });
    now = new Date("2026-10-12T09:01:00Z"); // 24 h 1 min later
    expect(await service.submit(valid)).toEqual({ status: "created", id: 2 });
  });

  it("keeps the lead when the notification fails", async () => {
    const repo = fakeRepo();
    const notify = vi.fn().mockRejectedValue(new Error("telegram is down"));
    const service = createLeadService({ repo, notify });

    expect(await service.submit(valid)).toEqual({ status: "created", id: 1 });
    expect(repo.rows).toHaveLength(1);
    expect(notify).toHaveBeenCalledOnce();
  });

  it("does not touch the repository for invalid input", async () => {
    const repo = fakeRepo();
    const insert = vi.spyOn(repo, "insert");
    const service = createLeadService({ repo, notify: vi.fn() });

    const result = await service.submit({ name: "Aigerim", phone: "123", consent: true });
    expect(result.status).toBe("invalid");
    expect(insert).not.toHaveBeenCalled();
  });
});
Unit: service rules with a fake repository and a controlled clock.
tests/integration/leads.api.test.ts
import type { Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createApp } from "../../src/app";
import { createLeadService } from "../../src/lead-service";
import { createSqliteRepo, openDb } from "../../src/sqlite-repo";

// real HTTP, routing, JSON parsing and SQL; only the external notifier is a double
const db = openDb(":memory:");
const notify = vi.fn().mockResolvedValue(undefined);
let now = new Date("2026-10-11T09:00:00Z");
const app = createApp(createLeadService({ repo: createSqliteRepo(db), notify, now: () => now }));

let server: Server;
let base: string;

beforeAll(async () => {
  server = app.listen(0); // port 0: the OS picks a free port
  await new Promise((resolve) => server.once("listening", resolve));
  base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(() => new Promise((resolve) => server.close(resolve)));

beforeEach(() => {
  db.exec("DELETE FROM leads"); // every test starts from an empty table
  notify.mockClear();
  now = new Date("2026-10-11T09:00:00Z");
});

const post = (body: unknown) =>
  fetch(`${base}/api/leads`, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
  });

describe("POST /api/leads", () => {
  it("201: saves a normalized phone and notifies once", async () => {
    const res = await post({ name: "Aigerim", phone: "8 (707) 123-45-67", consent: true });

    expect(res.status).toBe(201);
    const { id } = await res.json();
    expect(db.prepare("SELECT name, phone FROM leads WHERE id = ?").get(id)).toEqual({
      name: "Aigerim",
      phone: "+77071234567",
    });
    expect(notify).toHaveBeenCalledOnce();
  });

  it("422: returns field errors and writes nothing", async () => {
    const res = await post({ name: "Aigerim", phone: "123" });

    expect(res.status).toBe(422);
    expect(await res.json()).toEqual({ errors: { phone: "phone_invalid", consent: "consent_required" } });
    expect(db.prepare("SELECT count(*) AS n FROM leads").get()).toEqual({ n: 0 });
  });

  it("409: the same phone in another format within 24 hours", async () => {
    await post({ name: "Aigerim", phone: "+7 707 123 45 67", consent: true });
    now = new Date("2026-10-11T20:00:00Z");
    const res = await post({ name: "Aigerim", phone: "87071234567", consent: true });

    expect(res.status).toBe(409);
    expect(db.prepare("SELECT count(*) AS n FROM leads").get()).toEqual({ n: 1 });
  });
});
Integration: real HTTP, routing, JSON parsing and SQL; only the external notification is replaced.
terminal
$ npx tsc -p .
$ npx vitest run --project unit
 Test Files  2 passed (2)
      Tests  13 passed (13)
   Duration  183ms (transform 54%, import 27%, worker 9%, tests 9%)

$ npx vitest run --project integration --reporter verbose
 ✓ |integration| tests/integration/leads.api.test.ts > POST /api/leads > 201: saves a normalized phone and notifies once 32ms
 ✓ |integration| tests/integration/leads.api.test.ts > POST /api/leads > 422: returns field errors and writes nothing 3ms
 ✓ |integration| tests/integration/leads.api.test.ts > POST /api/leads > 409: the same phone in another format within 24 hours 18ms
 Test Files  1 passed (1)
      Tests  3 passed (3)
   Duration  315ms (import 46%, tests 30%, transform 22%, worker 2%)
A real run; tsc finished with no errors and no output.

There are more tests at the lower level — 13 versus 3 — and they enumerate the variants: phone formats, the "23 h 59 min" and "24 h 1 min" boundaries, a failed notification. The integration tests do not repeat that enumeration; they check one representative per HTTP outcome and what a unit test cannot see. In this example the levels differ little in speed because SQLite lives in memory; with PostgreSQL in a container, the integration run in our backend testing lesson took about 6 seconds versus 0.2 seconds for the unit tests.

Now let's see why the second level is needed. Flip the comparison operator in the SQL — a mistake that is easy to make and hard to spot in review:

src/sqlite-repo.ts
--- a/src/sqlite-repo.ts
+++ b/src/sqlite-repo.ts
   const findRecent = db.prepare(
-    "SELECT id FROM leads WHERE phone = ? AND created_at >= ? ORDER BY id DESC LIMIT 1",
+    "SELECT id FROM leads WHERE phone = ? AND created_at <= ? ORDER BY id DESC LIMIT 1",
   );
terminal
$ npx vitest run
 ❯ |integration| tests/integration/leads.api.test.ts (3 tests | 1 failed) 72ms
   ❯ POST /api/leads (3)
     × 409: the same phone in another format within 24 hours 31ms

 FAIL  |integration| tests/integration/leads.api.test.ts > POST /api/leads > 409: the same phone in another format within 24 hours
AssertionError: expected 201 to be 409 // Object.is equality
 ❯ tests/integration/leads.api.test.ts:63:24

 Test Files  1 failed | 2 passed (3)
      Tests  1 failed | 15 passed (16)
Real output, with the value diff and code frame trimmed.

All 13 unit tests passed: the fake repository is written correctly and they never execute SQL. Only the integration test for 409 caught the bug. The same happens if you remove app.use(express.json()) from app.ts: the unit tests stay green, while all three integration tests fail — the request body is not parsed and the server answers 422 to a valid lead. The reverse happens too: break parsing of the "8 (707)…" format and the normalizePhone unit test points at the exact line, while the integration test only says it got 422 instead of 201.

Where to mock and how to prepare test data

Mocks, stubs and fakes are kinds of test double — an object that stands in for a real dependency. The higher the test level, the fewer doubles: every double is a place where the test stops checking reality.

Unit

What we replace
Database, network, time, random generators, other modules
What stays real
The function or class under test

Integration

What we replace
Only what is outside your responsibility: payment gateway, messenger, third-party API
What stays real
HTTP layer, routes, validation, your own database and migrations

E2E

What we replace
As little as possible; paid external services in their sandbox modes
What stays real
The whole application: frontend, API, database

Test data is a common source of brittle tests. Working rules:

  • Each test creates the data it needs and does not rely on what the previous one left behind: in the example beforeEach clears the table and resets the clock
  • Data is minimal and self-explanatory: the duplicate test makes it obvious the number is the same and only the format differs
  • Time, randomness and ids do not come from the environment: the clock is passed as a parameter or frozen with vi.useFakeTimers() and vi.setSystemTime()
  • Complex objects get a factory with sensible defaults, overriding only the field that matters for the test
  • No real personal data and no copies of the production database: phones and names are made up, keys come from the test environment's variables
  • Tests sharing a database either run sequentially or each works in its own schema or transaction

Flaky tests: causes and cures

A flaky test is one that both passes and fails on the same code. The Google Testing Blog reports that across Google's whole corpus about 1.5% of test runs produce such a result, and 84% of observed transitions from pass to fail involve a flaky test. The main damage is lost trust: the team gets used to re-running a red build and misses a real bug.

Lack of isolation

How it shows up
A test passes alone but fails in the full run or in a different order
Cure
Fresh data in every test; run in random order to expose dependencies

Asynchronous behaviour

How it shows up
sleep(500) "to give it time"; on a slow machine it isn't enough
Cure
Wait for an event or condition (callback, polling with a timeout, await), not a fixed time

Remote services

How it shows up
The test fails when someone else's API or test environment is down
Cure
A double at integration level; the real service only in separate contract or e2e checks

Time

How it shows up
The test fails around midnight, at month end or in another time zone
Cure
A clock wrapper the test can replace: now in the example, vi.setSystemTime()

Resource leaks

How it shows up
The longer the run, the more failures: connections, ports or memory run out
Cure
Close servers and connections in afterAll; listen on port 0, not a fixed one
terminal
$ npx vitest run --sequence.shuffle
 Test Files  3 passed (3)
      Tests  16 passed (16)
Shuffling files and tests is a cheap way to find tests that depend on each other.

In "Eradicating Non-Determinism in Tests" Fowler advises moving a flaky test into quarantine straight away — a separate suite that does not block the build — and fixing it quickly, limiting the size of the quarantine or how long a test may stay there. Automatic retries (retry in Vitest, retries in Playwright, which labels such tests "flaky") reduce noise, but the same Google post warns that retries teach people to ignore flakiness. Use them to collect a list of problem tests, not to hide it.

A test plan for a feature, tests in CI and exercises

Before writing tests it helps to lay out a feature's checks by level — that gives you a short test plan. For the lead form it looks like this:

Phone formats, required fields, error codes

Level
Unit
Why here
Many variants, pure logic

Duplicate within 24 hours, the time boundary

Level
Unit + one integration
Why here
The rule lives in the service but works through an SQL query

A failed notification does not lose the lead

Level
Unit
Why here
Replacing the external service is easiest here

Status codes 201, 409, 422, JSON parsing, database write

Level
Integration
Why here
Only testable with the real route and database together

Form behaviour: labels, field errors, disabled button

Level
Component (frontend unit/integration)
Why here
Testing Library without a browser is faster than e2e

A user submits the form and sees a confirmation

Level
E2E, one or two tests
Why here
The key journey that brings in money

How to write each level is covered separately: component tests in the frontend testing lesson, unit and integration API tests with PostgreSQL in the backend testing lesson, and the specifics of phones and app stores in the mobile testing lesson. A lesson on e2e tests with Playwright is in the library plan.

  1. On every commit and pull request

    Linter, type check and unit tests: seconds, and a failure blocks the merge.

  2. Same pipeline, next step

    Integration tests with a database in a container; GitHub Actions has service containers for this.

  3. Before a deploy or on a schedule

    A small e2e set for the key journeys in an environment as close to production as possible.

  4. After a deploy

    A smoke check of the live address and monitoring: tests do not replace alerts on real user errors.

How this was verified4 · passedchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.