11 — Testing & QA Strategy
Status: ✅ Built — all three layers run. npm test is 322 unit and component tests; npm run test:e2e is 199 Playwright tests across a desktop and a phone viewport, covering the journeys in §3. QA for anything the unit layer doesn't reach is still manual (npx tsc --noEmit + npm run lint + npm run build + exercising the change in a real browser, as performed after every feature change in this project's own history).
🟢 বাংলা ভূমিকা: এই ডকুমেন্টে টেস্টিং পিরামিড, Vitest/Playwright/RTL টুলিং, মাল্টি-টেন্যান্ট টেস্ট ডেটা কৌশল, এবং গুরুত্বপূর্ণ ইউজার জার্নির টেস্ট প্ল্যান বর্ণনা করা হয়েছে।
1. Testing Pyramid
▲ E2E (Playwright) — few, slow, highest confidence
╱ ╲ Critical user journeys only
╱───╲
╱ ╲ Integration (RTL + mocked hooks) — moderate count
╱───────╲ Component behavior + hook interaction
╱ ╲
╱───────────╲ Unit (Vitest) — many, fast
╱_____________╲ Pure functions: lib/*, matches(), lineTotal(), getEffectivePrice()
- Unit tests (Vitest) — pure, side-effect-free logic first: no DOM, no async, pure input→output assertions. This layer is built. Getting there meant extracting three rules that were previously trapped inside components and hooks, since logic that can't be imported can't be tested:
calculateOrderTotals()inlib/order-totals.ts— was inline arithmetic incart-sheet.tsx. Covers rounding, the free-delivery threshold, the delivery minimum, stacked discounts, and the floor at zero.matchesAllergyFilter()inlib/allergy-filter.ts— was a closure insideuse-allergy-filter.ts, which now delegates to it. This is a safety claim rather than a preference, so the rules are pinned explicitly: any one excluded allergen rejects a dish, diets AND together, the spice cap is inclusive.resolveFeature()inlib/feature-resolution.ts— the plan-whitelist rule from 03 §4, whichuse-tenant-features.tsnow delegates to. Includes the load-bearing subtlety that an emptyfeatureFlagIdsmeans unrestricted, not feature-less.nextOrderNumber(),generateDeterministicToken(),getTenantOrigin()andvariantIndex()— order/QR identity, where a silent change breaks printed table tents.
- Drift tests — a category worth naming separately, because the failure they catch has already happened here once: seven of fifteen feature flags were sold on a pricing card while gating nothing at all, with nothing anywhere to say so.
feature-resolution.test.tsreads the source tree and fails if a catalog flag has no namedFEATUREentry, if a named entry is never used, or if one points at a flag that no longer exists. - Component/integration tests (Vitest + React Testing Library) — render real components with mocked/seeded hook state (e.g. render
menu-grid.tsxwith a fixedMenuItem[]and assert filtering/rendering), verify accessible roles/labels, and test interactive flows within a single component tree (cart sheet quantity stepper, plan-builder catalog checkbox toggling). - E2E tests (Playwright) — full-browser tests against a running
next build && next start(or Vercel preview URL), covering only the journeys listed in §3 — kept deliberately small in count since E2E is the slowest/flakiest layer.
🟢 বাংলা সারাংশ: টেস্টিং পিরামিডের ভিত্তি হলো Unit টেস্ট (দ্রুত, বিশুদ্ধ ফাংশন যেমন allergy filter, cart calculation, feature-flag whitelist লজিক), মাঝে Component/Integration টেস্ট (React Testing Library দিয়ে আসল কম্পোনেন্ট রেন্ডার করে যাচাই), এবং সবার উপরে অল্প কিছু E2E টেস্ট (Playwright দিয়ে, শুধু সবচেয়ে গুরুত্বপূর্ণ ইউজার জার্নি কভার করে)।
2. Tooling Setup
What is installed today
// package.json — actual
{
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
},
"devDependencies": {
"vitest": "^4",
"jsdom": "^28",
"@vitejs/plugin-react": "^5",
"@testing-library/react": "^16",
"@testing-library/jest-dom": "^6",
"@testing-library/user-event": "^14"
}
}
// vitest.config.mts — actual (.mts because the config is ESM-only)
import { defineConfig } from "vitest/config";
import path from "node:path";
const rootDir = path.dirname(new URL(import.meta.url).pathname);
export default defineConfig({
test: {
// Node by default; component files opt into jsdom with a docblock.
environment: "node",
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
setupFiles: ["./vitest.setup.ts"],
},
resolve: {
// Mirrors tsconfig's "@/*" so tests import exactly what the app does.
alias: { "@": path.resolve(rootDir, "./src") },
},
});
Tests live next to the code they cover (src/lib/order-totals.test.ts), not in a
separate __tests__/ tree — the proximity is what makes a stale test visible when
the module beside it changes.
Component tests opt into a DOM with a @vitest-environment jsdom docblock at
the top of the file. Most of the suite is arithmetic and would pay for a
document it never touches; putting the opt-in in the file that needs it also
keeps the split visible to whoever reads that file, rather than in a config
glob nobody opens.
src/test/render.tsx wraps a component in the providers the app puts above it,
using the real en.json rather than a fixture: these components read
dozens of keys, and a fixture drifts silently — the test keeps passing against
copy the app no longer has.
What the component layer covers, and why those
Chosen for where a bug is invisible rather than for coverage:
FeatureGate— decides whether a restaurant sees something it pays for. The resolution rule is unit tested; these assert it reaches the screen, and that the locked state distinguishes its three causes (plan excludes it, admin override, globally unbuilt) since treating them alike misleads the owner.StatusBadge— the label a guest reads on their own order tracker. It came from a literal for months, so a Bengali diner was told "Preparing" in English. Every status is asserted to have a real label in both languages.TablePicker— the no-QR entry. A mistake sends food to the wrong table, so the tests pin that every table stays selectable (including "in use", which is usually the guest's own) and that no QR token is minted for a table the guest merely picked.CartSheettotals — the number a guest agrees to pay.calculateOrderTotalsis unit tested; only a render can show that the figure on screen is the one it produced. A component can compute correctly and display something else, and that gap is invisible to both a unit test and a glance at the UI.- The dictionaries — key parity, no empty values, no Bengali value left
identical to its English source, and every
{placeholder}present in both. This was a Node one-liner run by hand, which held until it did not.
The E2E layer
npm run test:e2e runs Playwright against a production build —
next build && next start on its own port, not the dev server. Dev compiles a
route on first request, which turns a first assertion into a timing race and
makes a real failure indistinguishable from a slow compile; the separate port
stops a npm run dev already running from being tested instead of the build.
Two projects: desktop, and mobile at an iPhone 14 viewport. The mobile one
runs Chromium with the phone's viewport, touch and user agent rather than
WebKit — what these assert is layout and reach at 390px, and pinning a second
engine costs a 100MB download on every machine and in CI for no extra signal.
Real Safari behaviour is worth testing when there is a Safari-specific bug to
test.
What the suite taught while being written, all of it product behaviour the tests had assumed away:
-
A guest arriving at a table is asked their name, and that dialog blocks the menu until answered.
openTableMenuanswers it, because a test that ignores it fails with an overlay intercepting clicks — which reads as a broken menu rather than an unanswered question. -
The cart lives in three places depending on width: docked beside the menu on desktop, behind the bottom tab bar on a phone, behind a floating button in between.
openCartfinds whichever one this viewport has; the first version spent thirty seconds trying to click a button the docked panel was covering. -
Cart figures must be read by their label, not by position — the summary is preceded by each line's price, so reading the first few numbers gets item prices and calls one of them a subtotal. That "proved" 320 + 350 + 670 should equal the total.
-
The POS journey is desktop-only. It is a till or a counter tablet, and the mandate below names
/table,/waiterand/rideras the phone-first surfaces. -
openTableMenuhad a race that hid for months. The scan intro runs for two and a half seconds and the guest-name prompt only mounts after it, so an immediateisVisible()check usually returned before the intro had finished and left the prompt to open on top of whatever the test did next. It surfaced as a dish that never appeared, or a click swallowed by an overlay — intermittently, and only under parallel load. The helper waits for the prompt now. Worth recording because the first instinct was to blame the newest change; the flake predated it and was only exposed by a faster page load. -
The staff screens sit behind
SessionGuard, so tests about the kitchen or the dashboard arrive already signed in —signInAsseeds the session with an init script rather than driving the login form, so no test about the kitchen depends on the shape of the sign-in page.
The account suite also found a real defect. SessionGuard first decided on
the render where useLocalStorage still serves its server snapshot, so a
signed-in person was sometimes bounced straight back to /login — it looked
fine by hand and failed two existing tests intermittently. The guard now waits
for useHydrated() before deciding anything.
It also found a real defect rather than a test problem: the mobile tab bar
rendered its cart badge before its label, so a cart with two items announced as
"2 Cart" — spoken by a screen reader as "two cart". It carries an explicit
aria-label now.
🟢 বাংলা সারাংশ: npm run test:e2e প্রোডাকশন বিল্ডের বিপরীতে Playwright চালায় (dev সার্ভার নয় — dev প্রথম অনুরোধে রুট কম্পাইল করে, ফলে আসল ব্যর্থতা আর ধীর কম্পাইল আলাদা করা যায় না)। দুটি প্রজেক্ট: ডেস্কটপ ও ফোন (৩৯০px)। টেস্ট লিখতে গিয়ে যা শেখা গেল তা মূলত অ্যাপের আসল আচরণ, যা টেস্ট ধরে নিয়েছিল ভিন্নভাবে — টেবিলে বসা অতিথিকে নাম জিজ্ঞেস করা হয়, কার্ট প্রস্থভেদে তিন জায়গায় থাকে, আর কার্টের অঙ্ক অবস্থান নয়, লেবেল ধরে পড়তে হয়। একটা আসল ত্রুটিও ধরা পড়েছে: মোবাইল ট্যাব বারে ব্যাজ লেবেলের আগে থাকায় স্ক্রিন রিডার "two cart" পড়ত।
// package.json additions
{
"scripts": {
"test": "vitest",
"test:e2e": "playwright test"
},
"devDependencies": {
"@testing-library/react": "^16.x",
"@testing-library/jest-dom": "^6.x",
"@testing-library/user-event": "^14.x",
"@playwright/test": "^1.x",
"jsdom": "^25.x"
}
}
// vitest.config.ts — the jsdom/RTL variant, once component tests are added
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths"; // resolves the existing `@/` path alias used throughout src/
export default defineConfig({
plugins: [react(), tsconfigPaths()],
test: { environment: "jsdom", setupFiles: ["./vitest.setup.ts"], globals: true },
});
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
webServer: { command: "npm run build && npm run start", url: "http://localhost:3000", reuseExistingServer: !process.env.CI },
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "mobile-safari", use: { ...devices["iPhone 14"] } }, // critical for /table, /waiter, /rider mobile-first surfaces
],
});
🟢 বাংলা সারাংশ: এখন ইউনিট ও কম্পোনেন্ট — দুই স্তরই বসানো আছে (১০৯টি টেস্ট)। ডিফল্ট এনভায়রনমেন্ট node; যে ফাইলে DOM লাগে সেটি ফাইলের মাথায় @vitest-environment jsdom লিখে নিজে থেকে jsdom চায়, কারণ বেশিরভাগ টেস্টই নিছক হিসাব — তাদের জন্য একটা document বানানো অপচয়। কম্পোনেন্ট টেস্ট বাছা হয়েছে "কভারেজ" দেখে নয়, বরং যেখানে ভুল হলে চোখে পড়ে না সেই জায়গা দেখে: ফিচার গেট, স্ট্যাটাস ব্যাজ, টেবিল পিকার, কার্টের মোট, আর দুই অভিধানের মিল। নিচের অংশটা ভবিষ্যতের পরিকল্পনা: Vitest ব্যবহার হবে কম্পোনেন্ট টেস্টের জন্যও (JSDOM এনভায়রনমেন্টে, বিদ্যমান @/ পাথ অ্যালিয়াস সহ), এবং Playwright ব্যবহার হবে E2E টেস্টের জন্য — বিশেষভাবে মোবাইল ভিউপোর্ট (iPhone 14 ডিভাইস প্রোফাইল) দিয়েও টেস্ট করা হবে যেহেতু /table, /waiter, /rider মোবাইল-ফার্স্ট সারফেস।
3. Critical User Journeys (E2E coverage priority)
| # | Journey | Why critical |
|---|---|---|
| 1 | Scan QR → browse menu → apply allergy filter → add to cart → checkout | The core revenue-generating flow of the entire product |
| 2 | Owner creates a menu item with variants/addons → item appears correctly priced on /table | Menu builder ↔ customer menu consistency |
| 3 | Order placed on /table → appears on KDS → status advanced → reflected back on /table's order tracker | The cross-surface real-time contract (06 §1) |
| 4 | Platform admin creates a plan with a restricted feature catalog → tenant on that plan sees only those features active | The plan-whitelist enforcement logic — high business risk if broken (over- or under-granting paid features) |
| 5 | Scan a promo QR → land on /promo/[promoId] → order with code pre-applied → revisit the same link later and re-order | The promo marketing loop explicitly required by product (04 §12) |
| 6 | Subdomain resolution: {slug}.restaurant-os.com routes to the correct tenant; unknown slug shows a not-found state | Core multi-tenancy correctness — a routing bug here is catastrophic (wrong tenant's data shown) |
| 7 | Mobile viewport: full /table ordering flow with zero horizontal scroll / clipped UI at 375px | Explicit mobile-first design mandate for this product |
| 11 | A new party at a table does not inherit the last one's cart | The shared cart was keyed by table and never ended — a guest-facing correctness bug in the one journey the whole product is built around |
| 10 | The setup checklist starts at zero on a seeded tenant, ticks when the work is actually done, and can be dismissed for good | The whole feature is worthless if the seed ticks its boxes — a new owner would learn on day one that the checklist is decoration |
| 9 | Every footer link goes somewhere and every destination answers; the legal pages still say their text is a placeholder | Fifteen of them were href="#". A dead link is the kind of regression that returns the moment somebody adds a column, and the placeholder notice is the one thing keeping three unreviewed documents from reading as a binding agreement |
| 8 | Sign in → land where you were headed → sign out → be turned away again; sign-up wizard refuses to skip a step or take a taken subdomain | The account journeys are the first thing a new restaurant meets, and the guard they lean on is the one thing standing between a signed-out visitor and a staff screen — even though it is presentation only |
🟢 বাংলা সারাংশ: সবচেয়ে গুরুত্বপূর্ণ ৭টি ইউজার জার্নি E2E টেস্টে কভার করা হবে — QR স্ক্যান থেকে চেকআউট পর্যন্ত পুরো ফ্লো, মেনু বিল্ডার ও কাস্টমার মেনুর সামঞ্জস্য, KDS-এর সাথে রিয়েল-টাইম সিঙ্ক, প্ল্যান-হোয়াইটলিস্ট এনফোর্সমেন্ট (ভুল হলে বড় ব্যবসায়িক ঝুঁকি), প্রোমো লুপ, সাবডোমেইন রাউটিং সঠিকতা, এবং মোবাইল রেসপনসিভনেস।
4. Multi-Tenant Test Data Strategy
- Fixture tenants: maintain 2-3 permanent, well-known seed tenants in every environment (
test-swadh-ghar,test-kacchi-bhai) with deterministic ids, distinct plans (one on a legacy unrestricted plan, one on a catalog-whitelisted plan) — specifically to exercise the backward-compatibility nuance in the feature-flag whitelist logic. - Test isolation: each Playwright test that mutates tenant data (creates an order, edits a menu item) should either run against a dedicated ephemeral tenant created in a
beforeEach/fixture, or reset via a test-only API endpoint (/api/test/reset-tenant, disabled outside CI/test environments) — never mutate the shared fixture tenants' state destructively. - RLS-aware test users: integration tests that exercise Supabase queries directly should run as a real authenticated "test staff" role per tenant, not a service-role bypass, so RLS policy bugs are caught by the same tests that catch application logic bugs.
🟢 বাংলা সারাংশ: প্রতিটি এনভায়রনমেন্টে ২-৩টি স্থায়ী টেস্ট টেন্যান্ট রাখা হবে (আলাদা প্ল্যানের, যাতে feature-flag whitelist লজিকের ব্যাকওয়ার্ড কম্প্যাটিবিলিটি টেস্ট করা যায়)। যেসব টেস্ট ডেটা পরিবর্তন করে তারা আলাদা ephemeral টেন্যান্ট ব্যবহার করবে বা টেস্ট-অনলি রিসেট API দিয়ে রিসেট করবে। RLS পলিসি টেস্ট করার জন্য service-role বাইপাস না করে আসল স্টাফ রোল দিয়ে টেস্ট চালানো হবে।
5. Accessibility & Visual Regression
jest-axe/@axe-core/playwrightintegrated into both RTL and Playwright suites to catch color-contrast, missing-label, and ARIA-role regressions automatically on every PR.- Visual regression (Playwright's built-in screenshot comparison, or Chromatic if Storybook is later adopted) reserved for the highest-risk shared UI primitives (
components/ui/*— buttons, sheets, dialogs) rather than every page, to keep the suite maintainable and avoid noisy false positives from animation timing.
🟢 বাংলা সারাংশ: প্রতিটি PR-তে অ্যাক্সেসিবিলিটি চেক (কালার কনট্রাস্ট, ARIA লেবেল) স্বয়ংক্রিয়ভাবে চালানো হবে। ভিজ্যুয়াল রিগ্রেশন টেস্ট শুধু সবচেয়ে গুরুত্বপূর্ণ শেয়ার্ড UI কম্পোনেন্টের (বাটন, শিট, ডায়ালগ) জন্য রাখা হবে, প্রতিটি পেজের জন্য নয় — যাতে অ্যানিমেশন টাইমিং থেকে false positive কম হয়।