সরাসরি পর্বে যান
পর্ব ৭৫ · ৯৩-এর মধ্যে · মডিউল ১১

API integration test — Supertest আর test database

API Integration Testing — Supertest and a Real Test Database

Taught in Bangla, this lesson defines integration tests as HTTP in and real database out, tests an Express 5 app with Supertest without calling listen, runs a real PostgreSQL test database with migrate deploy, isolates data with truncate, rollback or a database per worker, builds data with factories, tests login with request.agent, permission matrices and validation errors, catches a race a memory fake hides, and checks idempotency and signed webhooks.

এই পর্বে যা শিখবেন

  • Express 5 app-কে test-যোগ্য করে লেখা, আর Supertest দিয়ে status, header ও JSON body যাচাই
  • Embedded PostgreSQL আর Prisma 7.10.1-এ test database, migrate deploy আর template database
  • Truncate, transaction rollback আর worker-প্রতি database: আসল সময় আর আসল ব্যর্থতা দেখে বাছাই
  • Factory দিয়ে ডেটা, request.agent দিয়ে login cookie, permission ছক আর problem details-এর test
  • Idempotency, একসাথে আসা অনুরোধ আর signed webhook test; OpenAPI চুক্তি আর Next.js-এর test-এর পথ

১ · এখানে integration test মানে কী

রেস্তোরাঁর কথা ভাবুন। ওয়েটার অর্ডারের স্লিপ রান্নাঘরে দিলেন। এখন দুটি প্রশ্ন। টেবিলে ঠিক খাবারটি এলো কি? আর ভাঁড়ারের খাতায় এক প্লেট ইলিশ কমল কি? প্রথমটি দেখে শুধু খাবার, দ্বিতীয়টি দেখে হিসাব। ভালো ম্যানেজার দুটোই মেলান।

এই পর্বের integration test ঠিক তাই। একটি HTTP অনুরোধ ঢোকে আসল Express app-এ। App চলে আসল middleware, আসল validation আর আসল Prisma দিয়ে, আর লেখে আসল PostgreSQL-এ। Test তারপর দুটি জিনিস যাচাই করে: উত্তরের status code, header আর body, এবং database-এ কী বদলাল।

Test-এর স্তর, pyramid আর trophy-র তত্ত্ব এখানে আবার পড়াব না। সেটি আছে Software Engineering কোর্সের test pyramid আর integration পর্বে, আর এই মডিউলের কৌশলের ছবি আসবে পর্ব ৭২-এ। পর্ব ৭৩-এর unit test-এ database নকল করা হয়; পর্ব ৭৬-এর end-to-end test-এ আসল browser চলে। আমাদের জায়গা মাঝখানে: browser নেই, কিন্তু server থেকে database পর্যন্ত সবকিছু আসল।

Testকী আসলকী নকলএকটি test-এর সময়, আমাদের ল্যাবে
Unit (পর্ব ৭৩)একটি ফাংশনDatabase, networkসাধারণত কয়েক ms
API integration (এই পর্ব)Express, validation, Prisma, PostgreSQLBrowser, বাইরের gateway১৬ থেকে ৩৯৩ ms (এক run)
End-to-end (পর্ব ৭৬)Browser, frontend, backend, databaseপ্রায় কিছুই নাসাধারণত সেকেন্ডের হিসাবে

কেন database নকল না করে আসলটি? কারণ বাজারঘরের সবচেয়ে দামি ভুলগুলো database-এর আচরণেই থাকে: unique constraint, foreign key, transaction, lock আর একসাথে আসা অনুরোধ। পর্ব ২৯-এ নকল repository দিয়ে service test করেছিলেন; সেটি দ্রুত, কিন্তু PostgreSQL কী বলবে তা জানে না। এই পর্বের অংশ ৮-এ দেখবেন, নকল store-এ পাস করা একটি কোড আসল database-এ ১টি পণ্য ১০ জনকে বিক্রি করেছে।

একটি integration test-এর পুরো পথ

Vitest → Supertest → Express → Prisma → PostgreSQL, and back

API integration test-এর স্তর Vitest test expect(status, header, body) Supertest request(app).post(...) Express app (no listen) middleware → Zod → route Prisma → PostgreSQL bazarghar_test_w1 template database migrate deploy, একবার সরাসরি DB পড়া কপি
ছবি ৭৫.১ — বাঁ দিকের তীর অনুরোধ, ডান দিকের তীর উত্তর। সবুজ ডটেড পথে test নিজেই database পড়ে দেখে, উত্তর যা বলছে টেবিলেও তাই হয়েছে কি না।

নিচের stepper-এ ল্যাবের একটি আসল test শুরু থেকে শেষ পর্যন্ত দেখুন। সব মান সত্যিকারের run থেকে নেওয়া।

  1. Templatemigrate deployএকবার
  2. Worker DBTEMPLATEকপি
  3. beforeEachTRUNCATE+ factory
  4. RequestGETSupertest
  5. Assertexpect৪টি যাচাই
global-setup.tsসত্যিকারের run
Datasource "db": PostgreSQL database "bazarghar_test_tpl"
Applying migration `20261102194340_init`
All migrations have been successfully applied.
[global-setup] migrate deploy: 1145 ms
helpers.tsworker ১
DROP DATABASE IF EXISTS "bazarghar_test_w1" WITH (FORCE)
CREATE DATABASE "bazarghar_test_w1"
  TEMPLATE bazarghar_test_tpl
Product টেবিলtest শুরুর আগে
TRUNCATE ... RESTART IDENTITY CASCADE   → ০টি সারি
createProduct({ stock: 3 })
createProduct({ stock: 0, pricePaisa: 45000 })
id  pricePaisa  stock
1   120000      3
2   45000       0
Supertestউত্তর
GET /api/products
200
Content-Type: application/json; charset=utf-8
X-Total-Count: 2
[{"id":1,"name":"Padma hilsa 1kg","pricePaisa":120000,"stock":3,...}, ...]
Vitestসত্যিকারের run
expect(res.status).toBe(200)
expect(res.headers['x-total-count']).toBe('2')
expect(res.body).toHaveLength(2)
expect(res.body[0]).toMatchObject({ id: 1, stock: 3 })
✓ returns the list as JSON with a total header 158ms
  1. একবার migration. পুরো run-এর শুরুতে prisma migrate deploy একটি template database-এ সব migration বসায়। আমাদের run-এ এতে ১,১৪৫ ms লেগেছে।
  2. Worker-এর নিজের কপি। প্রতিটি Vitest worker template থেকে নিজের database বানায়। Migration আর চালাতে হয় না, শুধু ফাইল কপি।
  3. পরিষ্কার টেবিল, তারপর জানা ডেটা। প্রতিটি test-এর আগে সব টেবিল খালি, id আবার 1 থেকে। Factory দুটি পণ্য বানাল; প্রথমটির stock 3.
  4. আসল HTTP. Supertest app-কে খালি port-এ চালিয়ে অনুরোধ পাঠাল। Express route Prisma দিয়ে database পড়ল। উত্তর 200, header-এ মোট ২টি।
  5. চার যাচাই। Status, header, তালিকার দৈর্ঘ্য আর প্রথম পণ্যের মান। সব মিলেছে; এই test নিল ১৫৮ ms, যার বড় অংশ ফাইলের প্রথম test-এর সংযোগ খোলা।

২ · Supertest আর Express 5 — listen ছাড়া app

লেখার সময়ে npm-এ Express-এর stable version 5.3.0, Supertest 7.3.1 আর Vitest 5.1.0. ল্যাবে এগুলোই বসানো। Version বদলায়, তাই নিজের প্রজেক্টে npm view supertest version দিয়ে দেখে নিন।

প্রথম নিয়মটি নকশার: app বানানো আর app চালানো আলাদা ফাইলে রাখুন। createApp() শুধু একটি Express app ফেরত দেয়; database আর webhook-এর secret সে বাইরে থেকে পায়। server.ts সেটি নিয়ে listen করে। Test শুধু প্রথমটি import করে। এটি পর্ব ২৯-এর dependency injection-এরই ফল।

export type AppOptions = { db: Db; webhookSecret: string; naiveStock?: boolean };

export function createApp({ db, webhookSecret, naiveStock = false }: AppOptions) {
  const app = express();
  // ... routes ...
  return app;            // no listen() here
}
const app = createApp({ db: makePrisma(process.env.DATABASE_URL!), webhookSecret: process.env.WEBHOOK_SECRET! });
app.listen(3000, '127.0.0.1');

Supertest-এর request(app)-কে একটি app দিলে, সেটি এখনো না চললে, Supertest নিজেই তাকে একটি সাময়িক port-এ চালায়, অনুরোধ পাঠায়, আর কাজ শেষে বন্ধ করে। Port ঠিক করে দিতে হয় না, তাই দুটি test ফাইল একই port নিয়ে ঝগড়া করে না। ভেতরের কৌশলটি ছোট; নিচে node:http দিয়ে একটি খেলনা রূপ। তৃতীয় যাচাইয়ে ইচ্ছা করে ভুল প্রত্যাশা দেওয়া আছে।

const http = require('node:http');
// The app: a plain handler, exported without listen()
const products = [{ id: 1, name: 'hilsa', stock: 3 }];
const app = (req, res) => {
  const m = req.url.match(/^\/api\/products\/(\d+)$/);
  const p = m && products.find((x) => x.id === Number(m[1]));
  res.statusCode = p ? 200 : 404;
  res.setHeader('content-type', p ? 'application/json' : 'application/problem+json');
  res.end(JSON.stringify(p ?? { title: 'product not found', status: 404 }));
};
// A tiny supertest-like helper: listen on port 0, send, assert, close
async function check(path, want) {
  const server = http.createServer(app);
  await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
  const port = server.address().port;
  try {
    const res = await fetch(`http://127.0.0.1:${port}${path}`);
    const body = await res.json();
    const errs = [];
    if (res.status !== want.status) errs.push(`status ${res.status} != ${want.status}`);
    if (!res.headers.get('content-type').startsWith(want.type)) errs.push(`type ${res.headers.get('content-type')}`);
    for (const [k, v] of Object.entries(want.body)) if (body[k] !== v) errs.push(`body.${k} ${body[k]} != ${v}`);
    console.log(errs.length ? 'FAIL' : 'PASS', 'GET', path, errs.join('; '));
  } finally {
    server.close(); server.closeAllConnections();
  }
  return port;
}

(async () => {
  const p1 = await check('/api/products/1', { status: 200, type: 'application/json', body: { stock: 3 } });
  const p2 = await check('/api/products/9', { status: 404, type: 'application/problem+json', body: { status: 404 } });
  await check('/api/products/1', { status: 200, type: 'application/json', body: { stock: 2 } });
  console.log('port 0: the OS picked a free port each time:', p1 > 1024 && p2 > 1024);
})();

প্রথম দুই লাইন PASS: পণ্য 1-এ 200 আর JSON, না-থাকা পণ্য 9-এ 404 আর application/problem+json. তৃতীয় লাইন FAIL GET /api/products/1 body.stock 3 != 2: test ভুল প্রত্যাশা করেছে, আর বার্তাটি বলে দিচ্ছে কোন ঘরে কী পাওয়া গেল। শেষ লাইন true: listen(0) মানে “operating system, একটি খালি port দাও”. finally-তে server বন্ধ, তাই test ফেল করলেও port খোলা থাকে না। Supertest এই কাজগুলোই করে, সাথে আরও সুন্দর assertion.

এবার আসল Supertest. Status, header আর JSON body, তিনটিই যাচাই হচ্ছে। ভুলের উত্তর পর্ব ৩৩-এর problem details ছাঁচে আসে কি না, সেটিও।

import request from 'supertest';
import { describe, it, expect } from 'vitest';
import { app } from './helpers.ts';
import { createProduct } from './factories.ts';

describe('GET /api/products', () => {
  it('returns the list as JSON with a total header', async () => {
    await createProduct({ stock: 3 });
    await createProduct({ stock: 0, pricePaisa: 45000 });

    const res = await request(app).get('/api/products');

    expect(res.status).toBe(200);
    expect(res.headers['content-type']).toMatch(/application\/json/);
    expect(res.headers['x-total-count']).toBe('2');
    expect(res.body).toHaveLength(2);
    expect(res.body[0]).toMatchObject({ id: 1, stock: 3, pricePaisa: 120000 });
  });

  it('answers a missing product with problem details', async () => {
    const res = await request(app).get('/api/products/999');
    expect(res.status).toBe(404);
    expect(res.headers['content-type']).toMatch(/application\/problem\+json/);
    expect(res.body).toEqual({ type: 'about:blank', title: 'product not found', status: 404 });
  });

  it('rejects a bad id with 422', async () => {
    const res = await request(app).get('/api/products/abc').expect(422);
    expect(res.body.title).toBe('validation failed');
  });
});

কয়েকটি খুঁটিনাটি, যা নতুনরা প্রায়ই ভুল করেন:

  • Header-এর নাম ছোট হাতের: Node সব header-এর নাম lowercase করে, তাই res.headers['x-total-count'], 'X-Total-Count' নয়।
  • Header-এর মান string: toBe('2'), সংখ্যা 2 নয়।
  • toEqual বনাম toMatchObject: ভুলের উত্তরে পুরো আকৃতি নির্দিষ্ট, তাই toEqual; পণ্যে যত ঘরই থাকুক, জরুরি তিনটি মিললেই চলে, তাই toMatchObject. সব ঘর চেপে ধরলে নতুন ঘর যোগ হলেই test ভাঙবে।
  • .expect(422): Supertest-এর নিজের assertion; মেলেনি হলে বার্তা হয় expected 422 "Unprocessable Entity", got 200 "OK" ধরনের।
  • Express 5-এর সুবিধা: async handler-এ throw করা ভুল নিজে থেকেই error middleware-এ যায়। তাই idParam.parse('abc')-এর ZodError ধরে 422 বানাতে আলাদা try/catch লাগেনি (পর্ব ২৮)।

৩ · আসল test database — embedded PostgreSQL আর migrate deploy

Test database হলো এমন একটি database, যা শুধু test-এর জন্য; বারবার মুছে ফেললেও কারও ক্ষতি নেই। নিয়ম একটাই: কখনো development বা production database-এ test চালাবেন না। TRUNCATE লেখা কোড ভুল URL পেলে আসল অর্ডার মুছে দেবে। আমাদের ল্যাবে সব database-এর নাম bazarghar_test দিয়ে শুরু, আর global setup শুধু এমন নামই মোছে।

PostgreSQL চালানো হয়েছে npm-এর embedded-postgres package দিয়ে (version 18.6.0-beta.19, ভেতরে PostgreSQL 18.6), কারণ এই ল্যাবে Docker ব্যবহার করা হয়নি। Server বসেছে শুধু 127.0.0.1-এ, একটি অস্বাভাবিক port-এ। Prisma 7.10.1: npm-এর latest tag এখন 8.0.0-এর release candidate, তাই stable 7.10.1 বেছে নেওয়া হয়েছে, আর docs পড়া হয়েছে v7-এর অংশ থেকে।

পর্ব ৪০-এ দেখেছেন, migrate dev development-এর জন্য: সে নতুন migration বানায়, আর দরকারে database reset করতে চায়। Test আর production-এ চাই migrate deploy: যা আগে থেকে লেখা আছে শুধু তাই চালায়, কিছু বানায় না, কিছু জিজ্ঞাসা করে না। Test database যেন ঠিক সেই schema পায় যা production পাবে, এটিই উদ্দেশ্য। তাই global setup-এ:

const BASE = 'postgresql://postgres:example-secret@127.0.0.1:38975/';

// Runs once, before any worker starts: build a migrated template database.
export default async function () {
  const c = new pg.Client({ connectionString: BASE + 'postgres' });
  await c.connect();
  const old = await c.query("SELECT datname FROM pg_database WHERE datname LIKE 'bazarghar_test%'");
  for (const r of old.rows) await c.query(`DROP DATABASE "${r.datname}" WITH (FORCE)`);
  await c.query('CREATE DATABASE bazarghar_test_tpl');
  const t0 = performance.now();
  execSync('npx prisma migrate deploy', {
    env: { ...process.env, DATABASE_URL: BASE + 'bazarghar_test_tpl' }, stdio: 'inherit',
  });
  console.log(`[global-setup] migrate deploy: ${Math.round(performance.now() - t0)} ms`);
  await c.end();
}
export default defineConfig({
  test: {
    globalSetup: ['./test/global-setup.ts'],   // once per run
    setupFiles: ['./test/setup.ts'],           // before every test file
    testTimeout: 10_000,
  },
});

সত্যিকারের run-এর শুরুর অংশ (Vitest 5.1.0, Prisma 7.10.1):

~/bazarghar$ npx vitest run

 RUN  v5.1.0 ~/bazarghar

Loaded Prisma config from prisma.config.ts.
Datasource "db": PostgreSQL database "bazarghar_test_tpl", schema "public" at "127.0.0.1:38975"

1 migration found in prisma/migrations
Applying migration `20261102194340_init`
All migrations have been successfully applied.
[global-setup] migrate deploy: 1152 ms
…
 Test Files  5 passed (5)
      Tests  20 passed (20)
   Duration  3.60s (tests 45%, setup 39%, import 13%, transform 2%)

পাসওয়ার্ডটি example-secret, শুধু এই local ল্যাবের। আসল প্রজেক্টে test-এর URL আসে environment থেকে (পর্ব ৭৮), আর CI-তে database কীভাবে ওঠে তা পর্ব ৮১-এর বিষয়।

Testcontainers নিয়ে এক কথা: অনেক দল Testcontainers ব্যবহার করে: test শুরুতে Docker container-এ আসল PostgreSQL ওঠে, শেষে মুছে যায়। সুবিধা হলো production-এর হুবহু version আর কোনো আগে থেকে বসানো database লাগে না। দাম হলো Docker লাগে, আর container ওঠার সময়। এই ল্যাবে Docker চালানো হয়নি, তাই এর কোনো সময় বা আউটপুট এখানে দেখানো হচ্ছে না। ধারণা একই: আসল database, আলাদা, ফেলে দেওয়ার যোগ্য।

SQLite দিয়ে PostgreSQL-এর test নয়: দ্রুত হবে ভেবে অনেকে test-এ SQLite বসান। কিন্তু lock, isolation level, RESTART IDENTITY, JSON operator আর ভুলের code, সবই আলাদা। যে database production-এ চলে, test-ও সেটিতেই চালান; একই major version হলে আরও ভালো।

৪ · Test-এর ডেটা আলাদা রাখা — truncate, rollback আর worker-প্রতি database

পরীক্ষার হলে প্রতিটি পরীক্ষার্থী নতুন খাতা পান। আগের জনের লেখা খাতায় লিখলে নম্বর কার, বোঝা যাবে না। Test-এর বেলাতেও তাই। একটি test অর্ডার রেখে গেলে পরের test “অর্ডার সংখ্যা ১” আশা করে ভুল ফল পাবে। একে বলে test isolation: প্রতিটি test অন্যদের রেখে যাওয়া জিনিস থেকে মুক্ত। এটি না থাকলে test-এর ক্রম বদলালে ফল বদলায়।

আগে ধারণাটি memory-তে একটি খেলনা database দিয়ে দেখুন। একবার test-এর মাঝে কিছু মোছা হয় না, আরেকবার প্রতিটি test-এর আগে snapshot ফিরিয়ে আনা হয়।

// Teaching model: an in-memory "database" with snapshot and restore.
const db = { products: [{ id: 1, stock: 5 }], orders: [] };
const snapshot = structuredClone(db);
const restore = () => Object.assign(db, structuredClone(snapshot));

function placeOrder(qty) {
  const p = db.products[0];
  if (p.stock < qty) return 409;
  p.stock -= qty;
  db.orders.push({ id: db.orders.length + 1, qty });
  return 201;
}

const tests = [
  ['buys 2', () => placeOrder(2) === 201 && db.products[0].stock === 3],
  ['buys 5 (needs a full shelf)', () => placeOrder(5) === 201 && db.products[0].stock === 0],
  ['new order gets id 1', () => placeOrder(1) === 201 && db.orders.at(-1).id === 1],
];

function run(label, isolate) {
  restore(); // both runs start from the same clean state
  console.log(`--- ${label}`);
  for (const [name, fn] of tests) {
    if (isolate) restore();                            // beforeEach
    const ok = fn();
    console.log(ok ? 'PASS' : 'FAIL', name.padEnd(28), `stock=${db.products[0].stock} orders=${db.orders.length}`);
  }
}

run('no reset between tests', false);
run('restore snapshot before each test', true);

প্রথম দলে FAIL buys 5 (needs a full shelf) stock=3 orders=1: আগের test ২টি কিনে stock ৩ রেখে গেছে, তাই ৫টি কেনা যায়নি। তারপর FAIL new order gets id 1 stock=2 orders=2: নতুন অর্ডারের id হলো 2. কোডে কোনো ভুল নেই, ভুল শুধু ভাগ করা অবস্থায়। দ্বিতীয় দলে তিনটিই PASS, আর প্রতিটি লাইনে orders=1. আসল database-এ এই “snapshot ফিরিয়ে আনা” কাজটির তিনটি পথ আছে।

পথ ১: প্রতি test-এর আগে truncate

আমাদের setup.ts-এ beforeEach সব টেবিল খালি করে, id-র counter-ও শূন্যে ফেরায়:

export async function resetDb() {
  await db.$executeRawUnsafe(
    'TRUNCATE "Order", "Session", "WebhookEvent", "Product", "User" RESTART IDENTITY CASCADE',
  );
}

সরল, আর app যেভাবে খুশি transaction খুলুক বা commit করুক, কিছু যায় আসে না। দাম হলো প্রতি test-এ একটি বাড়তি query, যা টেবিল বেশি হলে ধীর হয়। DELETE-এর চেয়ে TRUNCATE বড় টেবিলে সাধারণত দ্রুত; তবে খুব ছোট টেবিলে, test-এর মতো কয়েক সারিতে, DELETE-ও সমান বা বেশি দ্রুত হতে পারে, তাই নিজের suite-এ মেপে দেখুন।

পথ ২: প্রতিটি test একটি transaction, শেষে rollback

Test শুরুতে BEGIN, শেষে ROLLBACK. মাঝখানে যা লেখা হলো, সব মুছে যায়, টেবিল খালি করতে হয় না। শুনতে নিখুঁত। কিন্তু একটি শর্ত আছে: app-কে ঠিক সেই connection আর সেই transaction ব্যবহার করতে হবে। না হলে কী হয়, সত্যিকারের run-এ (pg 8.23.1, PostgreSQL 18.6) দেখুন।

1test: BEGIN2test: INSERT 'only-in-test'3app (pool): SELECT count4test: ROLLBACK5test: BEGIN, INSERT; app: BEGIN6app: COMMIT; test: ROLLBACK
একই সারি, দুই দৃষ্টি
test সংযোগ দেখে
অন্য সংযোগ দেখে
খোলা transaction
  1. Test transaction খুলল। এখনো কোনো সারি নেই। যা লেখা হবে, commit না হওয়া পর্যন্ত শুধু এই সংযোগ দেখবে।
  2. Test ডেটা বসাল। নিজের সংযোগে সারিটি দেখা যাচ্ছে: ১টি।
  3. App অন্য সংযোগে। আসল app connection pool থেকে আরেকটি সংযোগ নেয়। সে commit না হওয়া সারি দেখে না: ০টি। Test-এর বসানো পণ্য app-এর কাছে “নেই”, তাই উত্তর হতো 404.
  4. Rollback. সব পরিষ্কার, কেউ সারিটি দেখে না। এই অংশটুকু ঠিকঠাক কাজ করেছে।
  5. এবার একই সংযোগে app. App-এর service নিজে BEGIN লিখল। PostgreSQL নেস্টেড BEGIN মানে না; শুধু WARNING দিয়ে উপেক্ষা করে। Transaction এখনো একটিই, test-এর।
  6. App-এর COMMIT সব commit করল। App ভেবেছে নিজের কাজ commit করছে, আসলে test-এর পুরো transaction commit হলো। Test-এর ROLLBACK পেল “there is no transaction in progress”, আর test-এর সারি অন্য সংযোগেও রয়ে গেল: ১টি।

Prisma-তে ছবিটি একটু আলাদা। Prisma 7.5.0 (মার্চ ২০২৬)-এর release notes অনুযায়ী interactive transaction-এর ভেতর থেকে আবার $transaction ডাকলে savepoint দিয়ে nested transaction হয়। আমাদের 7.10.1-এর run-এ ভেতরের ব্যর্থ transaction বাতিল হয়েছে, বাইরেরটির সারি থেকে গেছে। তাই app-কে db-এর জায়গায় test-এর tx দিলে একটি সাধারণ test ঠিকঠাক চলে:

~/bazarghar$ npx tsx scripts/tx-per-test.ts
  list: status 200, items 1
  list: 46 ms, products left after rollback = 0
  …
Error: Concurrent nested transactions are not supported
Error: Concurrent nested transactions are not supported
…
  race safe : 201 x 1, 409 x 0, other 500,500,500,500,500,500,500,500,500

প্রথম test পাস, rollback-এর পরে কোনো পণ্য নেই। কিন্তু ১০ জন ক্রেতার race test-এ ৯টি অনুরোধ পেল 500. কারণ সবাই একই transaction আর একই সংযোগ ভাগ করছে, আর Prisma একই transaction-এর ভেতরে একসাথে একাধিক nested transaction চালাতে দেয় না। অর্থাৎ transaction-per-test আর একসাথে আসা অনুরোধের test একসাথে যায় না। আর পারলেও ফল অর্থহীন হতো: একটি সংযোগে সব অনুরোধ লাইনে দাঁড়ায়, তাই আসল race ঘটেই না। পর্ব ৪৩-এর lock আর conditional update পরীক্ষা করতে আলাদা সংযোগ লাগে।

পথ ৩: Parallel worker-প্রতি আলাদা database

Vitest ডিফল্টে test ফাইলগুলো কয়েকটি worker-এ একসাথে চালায়। সবাই একটি database ভাগ করলে একজনের TRUNCATE আরেকজনের চলমান test-এর ডেটা মুছে দেয়। শুধু একটি environment variable (SHARED_DB=1) দিয়ে সব worker-কে এক database দিলাম, তিনবার চালালাম:

# সত্যিকারের run, একই কোড, একই database, ফাইলগুলো parallel
 Tests  11 failed | 9 passed (20)
 Tests  9 failed | 11 passed (20)
 Tests  9 failed | 11 passed (20)

 FAIL  test/auth.test.ts > login and session cookie > logs in through the API and keeps the cookie with an agent
AssertionError: expected 401 to be 204 // Object.is equality
    driverAdapterError: DriverAdapterError: ForeignKeyConstraintViolation
    driverAdapterError: DriverAdapterError: TransactionWriteConflict

# একই database, কিন্তু --no-file-parallelism
 Tests  20 passed (20)
   Duration  6.89s

কোডে কোনো বাগ নেই, তবু প্রতিবার ভিন্ন সংখ্যা। User তৈরি হয়েছিল, login-এর মুহূর্তে অন্য ফাইলের truncate তাকে মুছে দিয়েছে, তাই 401. এটিই flaky test-এর সবচেয়ে সাধারণ উৎস। ফাইলগুলো একটির পর একটি চালালে সব পাস, কিন্তু সময় প্রায় দ্বিগুণ।

সমাধান: প্রতিটি worker-এর নিজের database. PostgreSQL-এর template database এখানে কাজে আসে। CREATE DATABASE x TEMPLATE y পুরো schema আর ডেটাসহ y-এর হুবহু কপি বানায়, migration আবার না চালিয়েই। শর্ত: কপির সময় template-এ অন্য কোনো সংযোগ থাকা চলবে না। Vitest প্রতিটি worker-কে VITEST_POOL_ID নামে একটি নম্বর দেয়, সেটি দিয়েই নাম:

// One database per Vitest worker, cloned from the migrated template.
const worker = process.env.VITEST_POOL_ID ?? '1';
const dbName = `bazarghar_test_w${worker}`;
const admin = new pg.Client({ connectionString: BASE + 'postgres' });
await admin.connect();
await admin.query(`DROP DATABASE IF EXISTS "${dbName}" WITH (FORCE)`);
await admin.query(`CREATE DATABASE "${dbName}" TEMPLATE bazarghar_test_tpl`);
await admin.end();

export const db = makePrisma(BASE + dbName);
export const app = createApp({ db, webhookSecret: WEBHOOK_SECRET });

ফল: তিনটি run-এ ২০টির ২০টি পাস, সময় ৩.৬০, ৫.৮৪ আর ৬.৫৬ সেকেন্ড; পরে আরেক run-এ ৩.০৪ সেকেন্ড। একই মেশিনে সময় এত ওঠানামা করে, তাই একটি সংখ্যা দেখে সিদ্ধান্ত নেবেন না।

তিন পথের দাম — একটি মাপ

প্রতিটি কাজ ৩০ বার চালিয়ে গড় নেওয়া হয়েছে। এটি একটি local run, সাধারণ সত্য নয়; ছোট ৫টি টেবিল আর ৪টি seed সারি।

~/bazarghar$ node scripts/isolation-bench.mjs
average of 30 runs, one local run
TRUNCATE 5 tables + seed                 10.4 ms
BEGIN + seed + ROLLBACK                  0.2 ms
CREATE DATABASE ... TEMPLATE + DROP      296.9 ms
পথপ্রতি বারের দাম (এক run)কোথায় ভাঙেকখন নেবেন
Truncate প্রতি test-এ~১০ msএকই database-এ parallel ফাইলসাধারণ ডিফল্ট
Transaction rollback~০.২ msApp নিজে transaction খুললে, অন্য সংযোগ, একসাথে আসা অনুরোধশুধু পড়া বা সরল লেখার অনেক test
Worker-প্রতি database (template)~৩০০ ms, প্রতি worker-এ একবারTemplate-এ খোলা সংযোগ থাকলে কপি হয় নাParallel run; truncate-এর সাথে মিলিয়ে
হিসাব করে দেখুন: ২০০টি test-এর suite

ধরে নিন (অনুমান) প্রতিটি test-এর নিজের কাজ ৬০ ms, worker ৪টি, আর ওপরের মাপগুলো।

  • Truncate, একটি worker: ২০০ × (৬০ + ১০.৪) = ১৪,০৮০ ms ≈ ১৪.১ সেকেন্ড।
  • Rollback, একটি worker: ২০০ × (৬০ + ০.২) = ১২,০৪০ ms ≈ ১২.০ সেকেন্ড। বাঁচল মাত্র ২ সেকেন্ড, কারণ test-এর নিজের কাজই বড়।
  • Worker-প্রতি database + truncate, ৪ worker: migrate একবার ১.১৫ সেকেন্ড + কপি ০.৩০ সেকেন্ড (চার worker একসাথে) + ১৪.০৮ ÷ ৪ = ৩.৫২ সেকেন্ড। মোট ≈ ৪.৯৭ সেকেন্ড.

শিক্ষা: reset-এর খরচ কমানোর চেয়ে parallel চালানো অনেক বেশি লাভ দেয়, আর parallel-এর শর্ত হলো আলাদা database. বাস্তবে worker বাড়ালে database-এর ওপর চাপ বাড়ে, তাই ভাগফল পুরোপুরি ৪ হয় না।

৫ · Factory দিয়ে ডেটা — প্রতিটি test নিজের গল্প বলে

একটি বড় seed ফাইল সব test-এর জন্য ডেটা রাখলে সমস্যা হয়: কোন test কোন সারির ওপর নির্ভর করে, পড়ে বোঝা যায় না, আর একটি সারি বদলালে দূরের test ভাঙে। Test factory উল্টো পথ নেয়। প্রতিটি সারির জন্য যুক্তিসংগত ডিফল্ট আছে; test শুধু তার কাছে যা জরুরি তা বদলায়। “stock ১” লেখা দেখলেই বোঝা যায় test-টি শেষ পণ্য নিয়ে।

faker.seed(75);           // same "random" names on every run
let n = 0;

export async function createUser(over: { role?: Role; sellerId?: number | null; password?: string } = {}) {
  n += 1;
  const password = over.password ?? 'example-pass-123';
  const user = await db.user.create({ data: {
    email: `user${n}@example.test`, name: faker.person.firstName(),
    role: over.role ?? 'CUSTOMER', sellerId: over.sellerId ?? null, passwordHash: hashPassword(password),
  } });
  return { ...user, password };   // the test needs the plain password to log in
}

export function createProduct(over: { stock?: number; sellerId?: number; pricePaisa?: number } = {}) {
  return db.product.create({ data: { name: 'Padma hilsa 1kg', pricePaisa: 120000, stock: 5, sellerId: 1, ...over } });
}
  • অনন্য মান counter দিয়ে: email-এ user${n}, যাতে unique constraint কখনো দুই test-এ ধাক্কা না খায়। Faker-এর random email-এ সংঘর্ষের সম্ভাবনা কম, কিন্তু শূন্য নয়।
  • Seed দেওয়া faker: @faker-js/faker 10.6.0-এ faker.seed(75); একই নাম প্রতিবার, তাই ফেল করলে আবার একই অবস্থা পাওয়া যায়।
  • আসল নিয়মে hash: Factory password-ও আসল scrypt দিয়ে hash করে, যাতে login route আসলভাবে যাচাই হয়। এর দাম আছে: আমাদের permission test-এর beforeEach ৪ জন user বানায়, আর প্রতিটি test ১২০ থেকে ৩৯৩ ms নিয়েছে। শুধু login-এর test ছাড়া অন্য জায়গায় আগে থেকে hash করা একটি মান রাখলে সময় কমবে।

Builder pattern, faker-এর ভালো-মন্দ আর test data-কে আরও গুছিয়ে রাখার উপায় আসবে পর্ব ৭৭-এ। পর্ব ৪০-এর seed development-এর জন্য বাস্তব দেখতে ডেটা; factory test-এর জন্য ঠিক যতটুকু দরকার ততটুকু ডেটা। Software Engineering কোর্সের আসল dependency আর test data অংশে এর তত্ত্ব আছে।

৬ · Login-এর test — request.agent আর তৈরি করা session

পর্ব ৪৫-এ login করলে server একটি cookie দেয়, আর browser পরের প্রতিটি অনুরোধে সেটি ফেরত পাঠায়। সাধারণ request(app) browser নয়; প্রতিটি অনুরোধ আলাদা, কিছুই মনে রাখে না। Supertest-এর request.agent একটি ছোট browser-এর মতো: cookie জমা রাখে আর পরের অনুরোধে পাঠায়।

it('logs in through the API and keeps the cookie with an agent', async () => {
  const mina = await createUser();
  const agent = request.agent(app);

  const login = await agent.post('/api/login').send({ email: mina.email, password: mina.password });
  expect(login.status).toBe(204);
  expect(login.headers['set-cookie'][0]).toMatch(/^sid=.+; Path=\/; HttpOnly; SameSite=Lax$/);

  const me = await agent.get('/api/me').expect(200);
  expect(me.body).toEqual({ id: mina.id, role: 'CUSTOMER', sellerId: null });
});

it('a minted session skips the login route', async () => {
  const admin = await createUser({ role: 'ADMIN' });
  const res = await request(app).get('/api/me').set('Cookie', await sessionCookie(admin.id));
  expect(res.body.role).toBe('ADMIN');
});
1agent = request.agent(app)2agent.post('/api/login')3agent.get('/api/me')4request(app).get('/api/me')5.set('Cookie', minted)
Cookie আর session
agent-এর ঝুলি
পাঠানো Cookie
Session সারি
  1. Agent তৈরি। একটি খালি cookie-ঝুলি নিয়ে নতুন “browser”. এখনো কোনো অনুরোধ যায়নি।
  2. Login. Password আসল scrypt-এ মিলল, database-এ একটি session সারি হলো, উত্তর 204 আর Set-Cookie. Agent cookie-টি ঝুলিতে রাখল।
  3. পরের অনুরোধ। Agent নিজে থেকেই Cookie: sid=… পাঠাল, তাই /api/me মিনাকে চিনল: 200.
  4. Agent ছাড়া। নতুন request(app)-এর কোনো ঝুলি নেই। Login সফল হলেও পরের অনুরোধ খালি হাতে যায়: 401. এটিই সবচেয়ে সাধারণ ভুল।
  5. তৈরি করা session. Login route না ডেকে factory সরাসরি একটি session সারি লিখল, আর test cookie নিজে বসাল। Login route-এর test একবারই যথেষ্ট; বাকি test-এ এই পথ দ্রুত।

দুই পথের কোনটি কখন? Login-এর আচরণ (ভুল password-এ 401, cookie-র attribute, session fixation) যাচাই করতে API দিয়ে login করুন। বাকি সব test-এ তৈরি করা session বা token, কারণ প্রতিটি test-এ scrypt চালানো সময় নষ্ট। JWT-ভিত্তিক app-এ (পর্ব ৪৬) একই কাজ হয় test-এর secret দিয়ে নিজে token সই করে, যেমন example-secret; আসল secret কখনো test-এ নয়। আমাদের ভুল password-এর test-ও দেখে উত্তরে কোনো set-cookie নেই: “যা হওয়ার কথা নয়” তার একটি ছোট উদাহরণ।

৭ · Permission ছক আর validation-এর ভুল

পর্ব ৪৮-এ role বনাম route-এর test matrix দেখেছেন। এখানে নতুন যা: প্রতিটি ঘর আসল cookie, আসল session আর আসল database দিয়ে চলে, আর ছকে “অন্য seller” কলামটি আছে। নিচের খেলনা server-এ একটি ভুল লুকানো; ছক সেটি ধরে কি না দেখুন।

const http = require('node:http');
// Sessions and one product. The PATCH handler has a deliberate bug.
const sessions = { c1: { role: 'CUSTOMER' }, s1: { role: 'SELLER', sellerId: 1 },
  s2: { role: 'SELLER', sellerId: 2 }, a1: { role: 'ADMIN' } };
const product = { id: 1, sellerId: 1 };

const app = http.createServer((req, res) => {
  const sid = /sid=(\w+)/.exec(req.headers.cookie ?? '')?.[1];
  const u = sessions[sid];
  if (!u) res.statusCode = 401;
  else if (u.role === 'CUSTOMER') res.statusCode = 403;
  else res.statusCode = 200; // BUG: forgot to compare product.sellerId with u.sellerId
  res.end();
});

const who = { anon: '', customer: 'c1', seller: 's1', otherSeller: 's2', admin: 'a1' };
const expected = { anon: 401, customer: 403, seller: 200, otherSeller: 403, admin: 200 };

app.listen(0, '127.0.0.1', async () => {
  const url = `http://127.0.0.1:${app.address().port}/api/products/${product.id}`;
  let pass = 0, fail = 0;
  for (const [name, sid] of Object.entries(who)) {
    const headers = sid ? { cookie: `sid=${sid}` } : {};
    const got = (await fetch(url, { method: 'PATCH', headers })).status;
    const ok = got === expected[name];
    ok ? pass++ : fail++;
    console.log(ok ? 'PASS' : 'FAIL', name.padEnd(12), 'want', expected[name], 'got', got);
  }
  console.log(`pass=${pass} fail=${fail}`);
  app.close(); app.closeAllConnections();
});

চারটি ঘর PASS, একটি FAIL otherSeller want 403 got 200, শেষে pass=4 fail=1. Seller নিজের পণ্য বদলাতে পারেন, এই happy-path test সবুজই থাকত। “অন্য seller” কলাম না থাকলে বাগটি অদৃশ্য: যেকোনো seller যেকোনো দোকানের দাম বদলাতে পারতেন। Role ঠিক, মালিকানা যাচাই নেই।

আসল ল্যাবে একই ছক Vitest-এর it.each দিয়ে। প্রতিটি ঘর আলাদা test হিসেবে reporter-এ নাম পায়, তাই কোন ঘর লাল তা খুঁজতে হয় না:

const expected: Record<Who, number> = { anon: 401, customer: 403, seller: 200, otherSeller: 403, admin: 200 };

describe('PATCH /api/products/1', () => {
  it.each(Object.entries(expected))('%s -> %i', async (who, status) => {
    let req = request(app).patch('/api/products/1');
    if (cookies[who as Who]) req = req.set('Cookie', cookies[who as Who]);
    const res = await req.send({ pricePaisa: 99000 });
    expect(res.status).toBe(status);
  });
});
 ✓ test/permissions.test.ts > PATCH /api/products/1 > anon -> 401 393ms
 ✓ test/permissions.test.ts > PATCH /api/products/1 > customer -> 403 140ms
 ✓ test/permissions.test.ts > PATCH /api/products/1 > seller -> 200 148ms
 ✓ test/permissions.test.ts > PATCH /api/products/1 > otherSeller -> 403 120ms
 ✓ test/permissions.test.ts > PATCH /api/products/1 > admin -> 200 121ms

Validation-এর ভুলও একটি চুক্তি

পর্ব ২৭-এ Zod দিয়ে ইনপুট যাচাই করেছেন, আর পর্ব ৩৩-এ ভুলের উত্তরের ছাঁচ ঠিক করেছেন। Frontend সেই ছাঁচ পড়ে form-এর ঠিক ঘরের নিচে লাল লেখা দেখায়। ছাঁচ বদলালে frontend চুপচাপ ভাঙে। তাই test শুধু 422 নয়, content type আর কোন ঘরে ভুল তাও দেখে:

it('validation errors come back as problem details', async () => {
  const res = await request(app).post('/api/orders').set('Cookie', await customer())
    .send({ productId: 'one', qty: 0 });
  expect(res.status).toBe(422);
  expect(res.type).toBe('application/problem+json');
  expect(res.body.errors.map((e: { path: string }) => e.path)).toEqual(['productId', 'qty']);
});

লক্ষ করুন, ভুল বার্তার পুরো লেখা মেলানো হয়নি, শুধু path. Zod-এর বার্তা version বদলালে বদলাতে পারে; ঘরের নাম আপনার চুক্তি।

৮ · Idempotency, একসাথে আসা অনুরোধ আর signed webhook

এই তিনটি এমন আচরণ, যা শুধু আসল database-এর সাথেই সত্যিকারভাবে test করা যায়, কারণ এদের নিশ্চয়তা আসে unique constraint, lock আর transaction থেকে।

একই Idempotency-Key দুবার

পর্ব ৩৩-এ idempotency key দেখেছেন: network কেটে গেলে client একই key দিয়ে আবার পাঠায়, server দ্বিতীয় অর্ডার বানায় না। Test-এ দুবার পাঠিয়ে উত্তর আর টেবিল দুটোই দেখুন:

const first = await send();     // Idempotency-Key: k-123, qty 2
const second = await send();
expect([first.status, second.status]).toEqual([201, 200]);
expect(second.body.id).toBe(first.body.id);
expect(await db.order.count()).toBe(1);
expect((await db.product.findUnique({ where: { id: 1 } }))!.stock).toBe(3);

শেষ লাইনটি জরুরি। উত্তর দুটো ঠিক থাকলেও stock ৫ থেকে ১ হয়ে গেলে বোঝা যেত, দ্বিতীয় অনুরোধ অর্ডার না বানালেও stock কমিয়েছে। আরেকটি test একই key দিয়ে দুটি অনুরোধ একসাথে পাঠায় (দুবার ক্লিক)। তখন দুটোই “আগে নেই” দেখে; database-এর @@unique([userId, idempotencyKey]) একটিকে আটকায়, আর তার transaction-এ stock কমানোও বাতিল হয়। আমাদের run-এ ফল [200, 201], অর্ডার ১টি, stock ৪.

শেষ পণ্যের জন্য দশজন

পর্ব ৪৩-এ দেখেছেন কেন “পড়ো, JS-এ যাচাই করো, তারপর লেখো” oversell করে। এটি test-এ ধরা যায় কি? আগে একটি খেলনা দেখুন: একই handler, দুই রকম store.

const http = require('node:http');
// The same "check stock, then write" handler, against two kinds of store.
const tick = () => new Promise((r) => setTimeout(r, 5)); // one DB round trip

function makeStore(roundTrip) {
  let stock = 1;
  return {
    read: async () => { if (roundTrip) await tick(); return stock; },
    write: async (v) => { if (roundTrip) await tick(); stock = v; },
    get stock() { return stock; },
  };
}

async function race(label, store) {
  const app = http.createServer(async (req, res) => {
    const s = await store.read();               // check
    if (s < 1) { res.statusCode = 409; return res.end(); }
    await store.write(s - 1);                    // then write
    res.statusCode = 201; res.end();
  });
  await new Promise((ok) => app.listen(0, '127.0.0.1', ok));
  const url = `http://127.0.0.1:${app.address().port}/api/orders`;
  const codes = await Promise.all(Array.from({ length: 10 }, () => fetch(url, { method: 'POST' }).then((r) => r.status)));
  const won = codes.filter((c) => c === 201).length;
  console.log(label.padEnd(26), `201 x ${won}, 409 x ${10 - won}, stock=${store.stock}`);
  app.close(); app.closeAllConnections();
}

(async () => {
  await race('in-memory, no await gap', makeStore(false));
  await race('with a round-trip gap', makeStore(true));
})();

প্রথম লাইন in-memory, no await gap 201 x 1, 409 x 9, stock=0: memory-র store-এ পড়া আর লেখার মাঝে অন্য কেউ ঢোকার সুযোগ পায় না, তাই ভুল কোডও নিখুঁত দেখায়। দ্বিতীয় লাইন 201 x 10, 409 x 0, stock=0: পড়া আর লেখার মাঝে একটি round trip থাকলেই দশজন stock ১ দেখে, দশজনই কেনেন। আর stock শেষে ০, তাই শুধু stock দেখলে ভুলটি ধরাই পড়ে না। নকল store-এর সময় আসল database-এর মতো নয়; আর আসল সমাধান, conditional UPDATE ও lock, নকল store-এ থাকেই না। তাই race test আসল database-এ।

আসল ল্যাবে test-টি এই রকম। দুজন নয়, দশজন, কারণ দুটি অনুরোধ অনেক সময় একটির পরে আরেকটি শেষ হয়ে যায়; আমাদের প্রথম চেষ্টায় দুজনের test ভুল কোডেও পাস করেছিল।

it('ten buyers race for the last item: exactly one wins', async () => {
  await createProduct({ stock: 1 });
  const buyers = await Promise.all(Array.from({ length: 10 }, customer));
  const buy = (c: string) => request(app).post('/api/orders').set('Cookie', c).send({ productId: 1, qty: 1 });

  const results = await Promise.all(buyers.map(buy));

  expect(results.filter((r) => r.status === 201)).toHaveLength(1);
  expect(results.filter((r) => r.status === 409)).toHaveLength(9);
  expect((await db.product.findUnique({ where: { id: 1 } }))!.stock).toBe(0);
  expect(await db.order.count()).toBe(1);
});

App-এ naiveStock চালু করলে (শুধু ল্যাবের জন্য: পড়া, JS-এ যাচাই, তারপর লেখা), সত্যিকারের run:

~/bazarghar$ NAIVE_STOCK=1 npx vitest run test/orders.test.ts
 ❯ test/orders.test.ts (4 tests | 1 failed) 981ms
   ❯ POST /api/orders (4)
     × ten buyers race for the last item: exactly one wins 506ms

 FAIL  test/orders.test.ts > POST /api/orders > ten buyers race for the last item: exactly one wins
AssertionError: expected [ …(10) ] to have a length of 1 but got 10

- Expected
+ Received

- 1
+ 10

 ❯ test/orders.test.ts:40:53
     40|     expect(results.filter((r) => r.status === 201)).toHaveLength(1);

      Tests  1 failed | 3 passed (4)

১টি ইলিশ, ১০টি অর্ডার: overselling. সঠিক কোডে (updateMany-এ stock: { gte: qty } শর্ত, পর্ব ৪৩-এর অংশ ৪) একই test পাস করে। মনে রাখবেন, race test পাস করা মানে race নেই তা প্রমাণ নয়; এটি শুধু বলে এই run-এ ধরা পড়েনি। তবে ফেল করা race test নিশ্চিত প্রমাণ।

Signed webhook

পর্ব ৬৫ আর পর্ব ৫৭-এ দেখেছেন, gateway-র webhook body-র ওপর HMAC সই পাঠায়, আর server ঠিক সেই byte-গুলোর ওপর সই আবার হিসাব করে মেলায়। Test-ও তাই নিজের test secret দিয়ে body সই করে পাঠায়:

function sign(raw: string, secret = WEBHOOK_SECRET) {
  return 'sha256=' + createHmac('sha256', secret).update(raw).digest('hex');
}
const raw = JSON.stringify({ eventId: 'evt_1', orderId: 1, status: 'PAID' });

it('one changed byte breaks the signature', async () => {
  const tampered = raw.replace('"orderId":1', '"orderId": 1');   // just one space
  await post(tampered, sign(raw)).expect(401);
});

it('the same event twice is processed once', async () => {
  await post(raw, sign(raw)).expect(200, { duplicate: false });
  await post(raw, sign(raw)).expect(200, { duplicate: true });
  expect(await db.webhookEvent.count()).toBe(1);
});

একটি space যোগ হলে JSON-এর অর্থ একই, কিন্তু byte আলাদা, তাই সই মেলেনি: 401. এজন্যই route-এ express.raw লাগে; express.json body পার্স করে ফেললে আসল byte হারিয়ে যায়। Test-এ string নিজে বানিয়ে .send(raw) করা হয়েছে, object নয়, যাতে Supertest আবার serialize না করে। ভুল secret-এর test আরও দেখে, order এখনো PENDING: ভুল সই এলে কিছুই বদলায়নি।

৯ · OpenAPI চুক্তি, Next.js আর পুরো run-এর হিসাব

চুক্তির সাথে মিলিয়ে দেখা (নোট)

পর্ব ৩৫-এ OpenAPI নথিতে API-র চুক্তি লিখেছেন। Integration test-এর উত্তর সেই নথির schema-র সাথে মেলানো যায়: প্রতিটি উত্তরের body-কে নথির JSON Schema দিয়ে যাচাই (যেমন Ajv-এর মতো validator দিয়ে)। তাহলে কেউ field-এর নাম বদলালে docs আর কোড আলাদা হয়ে যাওয়ার আগেই test লাল হয়। আরেক ধাপ এগিয়ে কিছু tool, যেমন Python-এর Schemathesis, নথি পড়ে নিজেই অনেক রকম অনুরোধ বানিয়ে পাঠায়। এই ল্যাবে এগুলো চালানো হয়নি। দুই দলের মধ্যে চুক্তির test, যেমন consumer-driven contract, এর তত্ত্ব আছে Software Engineering কোর্সের contract test অংশে.

Next.js: route handler আর server action

Next.js-এ কোনো Express app নেই যে request(app)-এ দেবেন। Next.js-এর testing guide (lastUpdated ২০২৬-০২-০৩) unit, component, integration আর E2E-র কথা বলে, Vitest, Jest, Playwright আর Cypress-এর setup দেখায়, আর async Server Component-এর জন্য বলে আপাতত unit test-এর চেয়ে E2E ভালো, কারণ সব tool এখনো সেগুলো পুরোপুরি সমর্থন করে না। এর বাইরে নিচের পথগুলো প্রচলিত, তবে সতর্কতার সাথে নিন:

  • Route handler: route.ts-এর GET বা POST সাধারণ async ফাংশন, যা web Request নেয় আর Response দেয়। যেগুলো next/headers-এর cookies() বা headers()-এর মতো request-এর প্রেক্ষাপট চায় না, সেগুলো Vitest-এ import করে সরাসরি ডাকা যায়। যেগুলো চায়, সেগুলোর জন্য next build আর next start করে আসল server-এ fetch পাঠানো নিরাপদ পথ।
  • Server action: Database-এর কাজ একটি সাধারণ ফাংশনে রাখুন (যেমন placeOrder(userId, input)), আর সেটিকে এই পর্বের মতো আসল test database-এ test করুন। Action নিজে পাতলা থাকুক: session পড়া, validation, ফাংশন ডাকা, revalidatePath. Form থেকে action পর্যন্ত পুরো পথ পর্ব ৭৬-এর Playwright test-এ।
// উদাহরণ, এই ল্যাবে চালানো হয়নি: একটি সরল route handler সরাসরি ডাকা
import { GET } from './route';

it('health answers 200 JSON', async () => {
  const res = await GET(new Request('http://localhost/api/health'));
  expect(res.status).toBe(200);
  expect(await res.json()).toEqual({ ok: true });
});

পুরো run একসাথে

সব ফাইলের সত্যিকারের verbose run (ছোট করা)। ফাইলগুলোর লাইন মিশে আছে, কারণ ফাইলগুলো parallel worker-এ একসাথে চলছে:

~/bazarghar$ npx vitest run --reporter=verbose
[global-setup] migrate deploy: 1145 ms
 ✓ test/products.test.ts > GET /api/products > returns the list as JSON with a total header 158ms
 ✓ test/products.test.ts > GET /api/products > answers a missing product with problem details 21ms
 ✓ test/products.test.ts > GET /api/products > rejects a bad id with 422 16ms
 ✓ test/orders.test.ts > POST /api/orders > validation errors come back as problem details 198ms
 ✓ test/webhook.test.ts > POST /webhooks/payment > a correctly signed event marks the order paid 218ms
 ✓ test/auth.test.ts > login and session cookie > logs in through the API and keeps the cookie with an agent 233ms
 ✓ test/auth.test.ts > login and session cookie > without the agent, the next request has no cookie 77ms
 …
 ✓ test/orders.test.ts > POST /api/orders > ten buyers race for the last item: exactly one wins 384ms
 ✓ test/orders.test.ts > POST /api/orders > a double click with one key, sent together, still makes one order 54ms

 Test Files  5 passed (5)
      Tests  20 passed (20)
   Duration  3.04s (tests 45%, setup 39%, import 13%, transform 2%)

প্রতিটি ফাইলের প্রথম test বেশি সময় নেয় (১৫৮ থেকে ২৩৩ ms), কারণ Prisma তখন প্রথম সংযোগ খোলে; পরের test-গুলো ১৬ থেকে ৮০ ms-এর ঘরে। setup 39% মানে সময়ের বড় অংশ database কপি আর setup-এ। এই suite CI-তে কীভাবে চলবে, database কোথা থেকে আসবে, তা পর্ব ৮১-এর বিষয়।

আরও গভীরে: PostgreSQL-এ CREATE DATABASE আসলে সবসময় একটি template কপি করে; কিছু না বললে template1. PostgreSQL 15 থেকে STRATEGY বিকল্প এসেছে: ডিফল্ট WAL_LOG ব্লক ধরে কপি করে WAL-এ লেখে, আর FILE_COPY পুরোনো ফাইল-কপির পথ; ছোট template-এ দ্বিতীয়টি অনেক সময় দ্রুত হতে পারে। Nested BEGIN-এ WARNING দিয়ে উপেক্ষা করা PostgreSQL-এর লিখিত আচরণ; আংশিক rollback-এর আসল উপায় savepoint, যা SQL মানেরই অংশ। Rails-এর transactional test আর Django-র TestCase বহু বছর ধরে এই rollback কৌশলে চলে, আর দুটোরই আলাদা পথ আছে সেই test-এর জন্য, যেখানে আসল commit লাগে।

পরীক্ষা ও ইন্টারভিউতে: (১) Unit আর API integration test-এর পার্থক্য? দ্বিতীয়টিতে HTTP থেকে database পর্যন্ত আসল অংশ জোড়া। (২) Supertest-এ app থেকে listen কেন বাদ? Supertest নিজে খালি port-এ চালায়, আর port-এর সংঘর্ষ হয় না। (৩) Test-এর ডেটা আলাদা রাখার তিন পথ? Truncate, transaction rollback, worker-প্রতি database. (৪) Transaction rollback কখন ভাঙে? App অন্য সংযোগ ব্যবহার করলে, নিজে transaction commit করলে, বা একসাথে অনেক অনুরোধ এলে। (৫) Race test কেন আসল database-এ? Lock আর conditional update database-এর আচরণ; নকল store-এ সময়ের ফাঁকই থাকে না।

১০ · AI-এর যুগে integration test

AI খুব দ্রুত test লিখে দেয়, আর প্রায় সবই সবুজ হয়। সমস্যা সেখানেই। সবুজ test যদি কিছু যাচাই না করে, সেটি নিরাপত্তার মিথ্যা অনুভূতি দেয়। আমাদের ল্যাবে দুজনের race test ভুল কোডেও পাস করেছিল; AI এমন test লিখলে সেটি পাস দেখে আপনি নিশ্চিন্ত হয়ে যেতেন।

AI-এর ভুলফলধরার উপায়
Prisma-কে vi.mock করে “integration test”Constraint, transaction কিছুই যাচাই হয় নাTest-এ আসল DATABASE_URL আছে কি না দেখা
Development database-এ TRUNCATEনিজের ডেটা মুছে যায়Database-এর নামে _test শর্ত, না মিললে থামা
শুধু status দেখা, database নয়উত্তর ঠিক, টেবিল ভুলCount আর stock-এর assertion
শুধু happy pathঅন্যের পণ্য, ভুল সই ধরা পড়ে না“যা হওয়ার কথা নয়” ঘরের তালিকা
পুরোনো API, যেমন Prisma 5-এর ধাঁচে new PrismaClient() adapter ছাড়া, বা migrate dev CI-তেচলে না, বা database reset চায়নিজের version-এর docs আর আসল run
ভুল লুকানো: try { … } catch {} বা it.skipলাল test চুপDiff-এ catch আর skip খোঁজা

AI-কে test লিখতে বলার সময় পরিবেশ আর উল্টো দিকের কেস আগে বলে দিন:

আমার Express 5.3 + Prisma 7.10.1 + PostgreSQL অ্যাপে POST /api/orders-এর integration test লিখে দাও, Vitest 5 আর Supertest 7 দিয়ে। ১. Database mock করবে না। test/helpers.ts থেকে app আর db নাও; প্রতিটি test-এর আগে truncate হয়। ২. ডেটা বানাতে test/factories.ts-এর createUser, createProduct আর sessionCookie ব্যবহার করো। ৩. প্রতিটি test-এ উত্তর আর database দুটোই যাচাই করো: status, content type, body, আর order count ও stock. ৪. এই কেসগুলো চাই: validation ভুলে 422 problem details; একই Idempotency-Key দুবার; stock 1-এ দশজন একসাথে; login ছাড়া 401. ৫. কোনো test skip করবে না, কোনো error চেপে রাখবে না, নতুন package যোগ করলে কেন তা বলবে।

AI-এর লেখা test হাতে পেলে

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।

প্র ০১Database mock করলে test দ্রুত হয়। তবু অনেক দল API test-এ আসল database চায় কেন?

কারণ mock শুধু সেটুকুই জানে, যা আপনি তাকে শিখিয়েছেন। আপনি যদি ভাবেন “একই email-এ দ্বিতীয় user হলে error আসবে”, mock-এ সেটি লিখবেন। কিন্তু আসলে error আসে কি না, কোন code-এ আসে, transaction-এর বাকি কাজ বাতিল হয় কি না, সেটি জানে শুধু database. Mock আপনার ধারণার প্রতিচ্ছবি; ধারণা ভুল হলে test-ও একই ভুল করে আর সবুজ থাকে।

Test double নিয়ে আলোচনা পুরোনো। Martin Fowler-এর লেখা “Mocks Aren't Stubs” (যার বহুল পঠিত রূপটি ২০০৭ সালের) classicist আর mockist ধারার পার্থক্য তুলে ধরেছিল। Steve Freeman আর Nat Pryce তাঁদের ২০০৯ সালের বই “Growing Object-Oriented Software, Guided by Tests”-এ যে নীতিটি জনপ্রিয় করেন, তা প্রায়ই “নিজের নয় এমন type mock করবেন না” রূপে বলা হয়। Database driver আর ORM আপনার নয়; তার চারপাশে নিজের ছোট interface বানিয়ে unit test-এ সেটি নকল করুন, আর সেই interface-এর আসল রূপ test করুন আসল database-এ। পর্ব ২৯-এর নকল repository আর এই পর্বের test একে অন্যের পরিপূরক।

এই পর্বের ল্যাবে উদাহরণ তিনটি। Idempotency-র দুবার-ক্লিক test-এ দ্বিতীয় অনুরোধ আটকেছে unique constraint, আর একই transaction-এ stock কমানোও বাতিল হয়েছে; mock এই দুটির কোনোটিই জানে না। Race test-এ ভুল কোড ১০টি অর্ডার বানিয়েছে; memory-র store-এ একই কোড ১টি বানিয়েছে। আর ভাগ করা database-এ foreign key-র ভুল দেখিয়েছে test-এর নকশার ত্রুটি।

তাহলে কি সব test আসল database-এ? না। দাম আছে: আমাদের এক run-এ একটি test ১৬ থেকে ৩৯৩ ms, যেখানে বিশুদ্ধ ফাংশনের unit test সাধারণত কয়েক ms. হাজারটি দামের হিসাব বা ছাড়ের নিয়ম unit test-এ রাখুন। যেখানে database-এর নিয়ম ফলের অংশ, যেমন constraint, lock, transaction, permission-এর query, সেখানে integration test.

মূল উপলব্ধি: Mock আপনার বিশ্বাস যাচাই করে, আসল database বাস্তবতা যাচাই করে। যেখানে ভুল হলে ক্ষতি সবচেয়ে বেশি আর বিশ্বাস সবচেয়ে দুর্বল, সেখানে বাস্তবতা দিয়ে test করুন।

প্র ০২Transaction rollback এত দ্রুত (আমাদের মাপে ০.২ ms) হলে সব দল সেটিই ব্যবহার করে না কেন?

পুরোনো ও জনপ্রিয় framework-গুলো এই কৌশল বহু বছর ধরে ব্যবহার করে। Ruby on Rails-এ test-গুলো ডিফল্টে একটি transaction-এ মোড়ানো থাকে, যাকে transactional test বলা হয়। Django-র TestCase-ও প্রতিটি test একটি transaction-এ চালিয়ে শেষে ফিরিয়ে নেয়। কিন্তু দুটি framework-ই আলাদা পথ রেখেছে: Django-র TransactionTestCase আসল commit হতে দেয়, আর শেষে টেবিল মুছে দেয়। এই আলাদা পথের অস্তিত্বই বলে দেয়, rollback সব ক্ষেত্রে চলে না।

কোথায় ভাঙে, আমাদের run-এ তিনটি জায়গা দেখা গেছে। এক, app অন্য সংযোগে চললে test-এর বসানো ডেটা দেখে না, কারণ commit না হওয়া সারি অন্য সংযোগে অদৃশ্য; উত্তর হয় 404. Framework-গুলো তাই test-এর সময় একটি সংযোগ ভাগ করার ব্যবস্থা করে। দুই, app নিজে transaction খুললে। PostgreSQL দ্বিতীয় BEGIN-এ শুধু WARNING দেয়, আর app-এর COMMIT test-এর পুরো transaction commit করে ফেলে; test-এর rollback পায় “there is no transaction in progress”. Savepoint এর সমাধান, আর Prisma 7.5 থেকে nested $transaction savepoint ব্যবহার করে। তিন, একসাথে আসা অনুরোধ। এক সংযোগে সবাই লাইনে দাঁড়ায়; Prisma-তে আমরা পেয়েছি “Concurrent nested transactions are not supported”, ৯টি 500.

আরও সূক্ষ্ম সমস্যা আছে। Commit-এর পরে চলা কোড, যেমন DEFERRABLE constraint বা commit-এর পরে পাঠানো event, rollback-এ কখনো চলে না। ফলে test এমন একটি পৃথিবী দেখে যেখানে commit কখনো ঘটে না। আর Prisma-র interactive transaction-এর ডিফল্ট সময়সীমা ৫ সেকেন্ড; ধীর test সেখানে নিজেই ভেঙে যেতে পারে, যা আমরা timeout বাড়িয়ে এড়িয়েছি।

তাই বাস্তব নকশা প্রায়ই মিশ্র: বেশিরভাগ test-এ truncate বা rollback, আর race, webhook-এর মতো test-এ আসল commit ও আলাদা সংযোগ। দামের হিসাবটাও মনে রাখুন: ২০০টি test-এ rollback truncate-এর চেয়ে মাত্র ~২ সেকেন্ড বাঁচায়, কারণ test-এর নিজের কাজই বড়।

মূল উপলব্ধি: Rollback-এর গতি সত্য, কিন্তু সে app-এর transaction আর সংযোগের নকশার সাথে বোঝাপড়া চায়। দ্রুততম পথ তখনই ভালো, যখন সে আপনার আসল কোডকেই test করে।

প্র ০৩Parallel test-এর জন্য worker-প্রতি database, Testcontainers, নাকি একটি database-এ ভিন্ন schema? কীভাবে বেছে নেবেন?

তিনটিরই লক্ষ্য এক: প্রতিটি worker-এর নিজের পরিষ্কার জায়গা। পার্থক্য খরচ আর পরিবেশে।

Template থেকে database: PostgreSQL-এ CREATE DATABASE আসলে সবসময় একটি template কপি করে। ডিফল্ট template হলো template1, যেখানে কোনো extension বসালে পরের সব নতুন database তা পায়। আমরা নিজের template-এ একবার migration চালিয়ে প্রতিটি worker-এর জন্য কপি করেছি; আমাদের এক run-এ কপি আর মোছা মিলে গড় ~৩০০ ms. শর্ত হলো কপির সময় template-এ কোনো সংযোগ থাকবে না, নইলে PostgreSQL কপি করতে রাজি হয় না। PostgreSQL 15-এ আসা STRATEGY বিকল্প কপির পদ্ধতি বদলাতে দেয়।

Testcontainers: শুরুতে Java-র library হিসেবে জনপ্রিয় হয়, পরে Node, Go, Python-সহ অনেক ভাষায় এসেছে। প্রতিটি run-এ Docker container-এ নতুন database ওঠে; production-এর হুবহু version, কোনো আগে থেকে বসানো server লাগে না। দাম হলো Docker থাকা বাধ্যতামূলক, আর container ওঠার সময়, যা সাধারণত কয়েক সেকেন্ড। অনেক দল একটি container চালিয়ে তার ভেতরে worker-প্রতি database বানায়, দুই পথ মিলিয়ে।

একটি database, ভিন্ন schema: প্রতিটি worker-এর জন্য আলাদা PostgreSQL schema. সস্তা, কিন্তু migration-কে schema-সচেতন হতে হয়, আর কিছু ORM-এ এই কনফিগারেশন ঝামেলার।

বেছে নেওয়ার প্রশ্নগুলো: Docker কি সব ডেভেলপার আর CI-তে আছে? Production-এর version কতটা জরুরি? কতজন worker? আমাদের মাপে তুলনাটি এমন: worker-প্রতি database + truncate-এ ২০টি test ৩ থেকে ৬.৫ সেকেন্ড, এক database-এ ক্রমে ৬.৮৯ সেকেন্ড, আর এক database-এ parallel মানে ৯ থেকে ১১টি ভুয়া ফেল। শেষেরটি কোনো বিকল্পই নয়।

মূল উপলব্ধি: Parallel চালানো সবচেয়ে বড় গতি, আর তার শর্ত আলাদা জায়গা। কীভাবে আলাদা করবেন সেটি পরিবেশের সিদ্ধান্ত; আলাদা না করা কোনো সিদ্ধান্ত নয়, সেটি flaky test-এর রেসিপি।

প্র ০৪একটি race test পাস করলে কি বলা যায় কোডে race নেই?

না। Race-এর ফল নির্ভর করে কোন অনুরোধ কখন কোন ধাপে আছে তার ওপর, আর সেটি প্রতিবার আলাদা। আমাদের প্রথম চেষ্টাই উদাহরণ: দুজন ক্রেতার test ভুল কোডেও পাস করেছিল, কারণ দুটি অনুরোধ একে অন্যের সাথে মিশে যায়নি। দশজন করার পরে একই ভুল কোড ১০টি অর্ডার বানাল। অর্থাৎ পাস মানে “এই run-এ ধরা পড়েনি”, ফেল মানে “নিশ্চিতভাবে ভুল আছে”।

Distributed database-এর জগতে এই শিক্ষা বিখ্যাত। Kyle Kingsbury ২০১৩ সাল থেকে Jepsen নামে একটি পরীক্ষার কাঠামো দিয়ে নানা database-এ network ভাঙা আর একসাথে আসা কাজ চালিয়ে দেখিয়েছেন, অনেক সিস্টেম তাদের ঘোষিত নিশ্চয়তা পুরোপুরি রাখে না। তাঁর পদ্ধতির মূল কথা: অনেক বার, অনেক রকম ক্রমে চালান, আর ফলের ইতিহাস যাচাই করুন।

এই পর্বের মাপে তা কীভাবে প্রয়োগ করবেন? এক, সংখ্যা বাড়ান: দুজনের বদলে দশ বা পঞ্চাশ জন। দুই, পুনরাবৃত্তি: CI-তে race test কয়েকবার চালানো সস্তা। তিন, ফাঁক বড় করুন: test-এর জন্য পড়া আর লেখার মাঝে কৃত্রিম দেরি ঢোকালে ভুল কোড প্রায় সবসময় ধরা পড়ে, যেমন আমাদের খেলনা store-এ ৫ ms-এর round trip. চার, সবচেয়ে জরুরি, ফলের অপরিবর্তনীয় শর্ত যাচাই করুন, ক্রম নয়: “201 ঠিক একটি”, “অর্ডার সংখ্যা ১”, “stock কখনো ঋণাত্মক নয়”।

আর test-এর পাশে নকশার প্রমাণও রাখুন। Conditional UPDATE ... WHERE stock >= qty আর database-এর CHECK (stock >= 0) নিয়ম race-কে নকশা দিয়েই অসম্ভব করে, test শুধু সেটি নিশ্চিত করে। পর্ব ৪৩-এ এগুলো বিস্তারিত।

মূল উপলব্ধি: Concurrency-তে test প্রমাণ নয়, প্রমাণের চেষ্টা। অনেক বার চালান, ফলের শর্ত দেখুন, আর সবচেয়ে বড় ভরসা রাখুন এমন নকশায়, যেখানে ভুল ক্রম ঘটতেই পারে না।

প্র ০৫OpenAPI নথি থাকলে API-র integration test আর contract test কি একই কাজ করে?

কাছাকাছি, কিন্তু এক নয়। Integration test জিজ্ঞাসা করে, “আমার server কি ঠিক কাজ করছে?” যেমন শেষ পণ্যে একটিই অর্ডার, ভুল সইয়ে 401. Contract test জিজ্ঞাসা করে, “আমার server আর যারা তাকে ডাকে, তারা কি একই ভাষায় কথা বলছে?” যেমন stock নামের ঘরটি সংখ্যা কি না, frontend যে ঘর পড়ে তা উত্তরে আছে কি না।

OpenAPI-র ইতিহাস এখানে প্রাসঙ্গিক। Tony Tam ২০১১ সালের দিকে Swagger নামে API বর্ণনার একটি format শুরু করেন। ২০১৫ সালে নির্দিষ্টকরণটি Linux Foundation-এর অধীনে OpenAPI Initiative-এ যায়, আর ২০১৭ সালে OpenAPI 3.0, ২০২১ সালে 3.1 আসে; 3.1 JSON Schema-র সাথে পুরোপুরি মিলিয়ে নেওয়া হয়। এই মিলের ফলে একই নথি দিয়ে docs, client code আর উত্তর যাচাই সব করা যায়।

বাস্তবে দুটি মিলিয়ে নেওয়ার সহজ পথ হলো integration test-এর প্রতিটি উত্তরকে নথির schema দিয়ে যাচাই করা। তাতে আলাদা test না লিখেই প্রতিটি পুরোনো test চুক্তির পাহারাদার হয়ে যায়। আরও এগিয়ে, consumer-driven contract-এ frontend বা মোবাইল দল নিজেরা লিখে দেয় তারা কোন উত্তর আশা করে, আর backend-এর CI সেটি যাচাই করে। আরেক ধারা হলো নথি থেকে নিজে থেকে অনেক রকম অনুরোধ বানিয়ে পাঠানো, যা অপ্রত্যাশিত ইনপুটে 500 খুঁজে পায়।

কিন্তু চুক্তির test ব্যবসার নিয়ম জানে না। Schema বলবে stock একটি integer; বলবে না যে দশজনের মধ্যে একজনই পাবেন। আবার integration test সাধারণত দেখে না যে কেউ নথি না বদলেই একটি ঘরের নাম বদলে দিয়েছেন।

মূল উপলব্ধি: Integration test আচরণ পাহারা দেয়, contract test আকৃতি পাহারা দেয়। একই উত্তরকে দুই চোখে দেখলে কম খরচে দুটোই পাওয়া যায়।

অনুশীলন

হিসাব, নকশা, আউটপুট আগাম বলা আর একটি কমান্ডের কাজ। সব পরীক্ষা নিজের local ল্যাবে।

  1. Suite-এর সময়ের হিসাব: বাজারঘরের ৩০০টি integration test, প্রতিটির নিজের কাজ ৪০ ms (অনুমান), worker ৪টি। মাপ: truncate ১০.৪ ms, template থেকে database কপি ২৯৭ ms, migrate deploy ১,১৫০ ms (একবার)। (ক) Worker-প্রতি database + প্রতি test-এর আগে truncate করলে মোট সময়? (খ) কেউ প্রস্তাব দিলেন প্রতিটি test-এর জন্য নতুন database কপি করতে। মোট সময়? (গ) দ্বিতীয়টি প্রথমটির কত গুণ?

    (ক) Test-এর কাজ: ৩০০ × (৪০ + ১০.৪) = ১৫,১২০ ms; ৪ worker-এ ভাগ: ৩,৭৮০ ms. সাথে migrate ১,১৫০ ms আর worker-এর কপি ২৯৭ ms (একসাথে, একবার)। মোট = ৩,৭৮০ + ১,১৫০ + ২৯৭ = ৫,২২৭ ms ≈ ৫.২ সেকেন্ড.

    (খ) ৩০০ × (৪০ + ২৯৭) = ১,০১,১০০ ms; ৪ ভাগে ২৫,২৭৫ ms; সাথে migrate ১,১৫০ ms. মোট = ২৬,৪২৫ ms ≈ ২৬.৪ সেকেন্ড.

    (গ) ২৬,৪২৫ ÷ ৫,২২৭ ≈ ৫.১ গুণ. Database কপি worker-প্রতি একবার করুন, test-প্রতি নয়। মাপগুলো এক local run-এর; আপনার মেশিনে নিজে মাপুন।

  2. নকশা ও সমালোচনা: AI নিচের test দিল। কী কী সমস্যা? it('creates order', async () => { vi.mock('../src/db'); const res = await request(app).post('/api/orders').send({ productId: 1, qty: 1 }); expect(res.status).toBeLessThan(500); });
    • Database mock করা: নাম integration হলেও কোনো constraint বা transaction যাচাই হয় না। আসল test database নিন।
    • Login নেই: Cookie ছাড়া এই route 401 দেবে, আর toBeLessThan(500) সেটিকেও পাস বলবে! প্রত্যাশা হোক নির্দিষ্ট: 201.
    • ডেটা নেই: পণ্য 1 কোথা থেকে এলো? Factory দিয়ে test-এর ভেতরেই বানান।
    • শুধু status: Body-তে order id, Location header, database-এ order count আর stock কমেছে কি না, কিছুই দেখা হয়নি।
    • উল্টো কেস নেই: Stock শেষ হলে 409, ভুল ইনপুটে 422, একই key দুবার।
  3. আউটপুট আগাম বলুন: একটি PostgreSQL সংযোগে ক্রমে চালানো হলো: BEGIN; INSERT INTO "Product" ... 'only-in-test'; BEGIN; COMMIT; ROLLBACK;. তারপর আরেকটি সংযোগ থেকে SELECT count(*) FROM "Product" WHERE name = 'only-in-test'. কোন দুটি WARNING আসবে, আর count কত হবে?

    দ্বিতীয় BEGIN-এ WARNING: there is already a transaction in progress, আর তা উপেক্ষিত হয়। COMMIT প্রথম transaction-টিই commit করে, তাই সারিটি স্থায়ী। শেষের ROLLBACK-এ WARNING: there is no transaction in progress. অন্য সংযোগ থেকে count 1. আমাদের সত্যিকারের run-এ ঠিক এই লাইন এসেছে: after test ROLLBACK, other connection sees 1. Transaction-per-test কৌশলে app নিজের transaction খুললে এভাবেই test-এর ডেটা থেকে যায়।

  4. কমান্ড ও পরীক্ষা: নিজের Express + Prisma প্রজেক্টে একটি test database বানান, global setup-এ npx prisma migrate deploy চালান, তারপর npx vitest run. এরপর ইচ্ছা করে সব worker-কে একটি database দিন। কী দেখবেন, আর npx vitest run --no-file-parallelism দিলে কী বদলাবে?

    আমাদের ল্যাবে আলাদা database-এ Tests 20 passed (20). এক database-এ parallel চালালে তিন run-এ 11 failed, 9 failed, 9 failed: প্রতিবার ভিন্ন, আর ভুলগুলোও ভিন্ন, যেমন expected 401 to be 204, ForeignKeyConstraintViolation. --no-file-parallelism দিলে সব পাস, কিন্তু সময় বেড়ে ৬.৮৯ সেকেন্ড। কাজ শেষে নিজের database server বন্ধ করুন, আর test-এর database-এর নামে _test রাখুন, যাতে ভুল করে development database মুছে না যায়।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমএকটি API integration test run-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 🧱 Template database-এ একবার migrate deploy
  2. 📋 প্রতিটি worker-এর জন্য template থেকে database কপি
  3. 🧹 Test-এর আগে টেবিল খালি করে factory দিয়ে ডেটা
  4. 📨 Supertest দিয়ে app-এ HTTP অনুরোধ
  5. ✅ উত্তর আর database-এর অবস্থা যাচাই

Supertest দিয়ে test করার জন্য Express app-কে listen না করেই export করা হয় কেন?

  • Express 5-এ listen বাতিল হয়ে গেছে
  • Supertest নিজেই একটি খালি port-এ চালিয়ে কাজ শেষে বন্ধ করে
  • Supertest HTTP ছাড়াই সরাসরি route ফাংশন ডাকে
  • listen করলে test database-এর বদলে production database ব্যবহার হয়

Test একটি PostgreSQL সংযোগে BEGIN করে ডেটা বসাল। App-এর service একই সংযোগে নিজে BEGIN আর COMMIT চালাল। শেষে test ROLLBACK করল। ফল কী?

  • App-এর BEGIN error দেয়, তাই কিছুই commit হয় না
  • App-এর কাজ একটি savepoint, তাই সব কিছু rollback হয়
  • App-এর COMMIT test-এর ডেটাসহ সব commit করে; test-এর rollback কিছুই ফেরায় না
  • Connection বন্ধ হয়ে যায়

Supertest-এ request.agent(app) দিয়ে login করার সুবিধা কী?

  • Login-এর cookie মনে রেখে পরের অনুরোধে নিজে পাঠায়
  • Password যাচাই ছাড়াই login করায়
  • Database-এ নিজে session সারি লেখে
  • সব অনুরোধ parallel-এ পাঠায়

একটি পণ্যের stock 1. Handler আগে stock পড়ে, JavaScript-এ যাচাই করে, তারপর stock - 1 লেখে। আসল PostgreSQL-এ দশজন একসাথে অর্ডার দিলে কী হতে পারে, আর সঠিক সমাধান কোনটি?

  • একজনই পাবেন, কারণ Node.js single-threaded
  • Stock ঋণাত্মক হবে, তাই ভুলটি সহজে ধরা পড়বে
  • কয়েকজন পাবেন; সমাধান হলো ব্যর্থ অনুরোধ retry করা
  • কয়েকজন, এমনকি দশজনই পেতে পারেন; সমাধান হলো শর্তসহ একটি UPDATE

Webhook-এর test-এ body-র JSON-এ শুধু একটি space যোগ করা হলো, সই আগের body-র। Server HMAC যাচাই করে। উত্তর কী হওয়া উচিত?

  • 200, কারণ JSON-এর অর্থ বদলায়নি
  • 401, কারণ সই byte-এর ওপর হয়, আর byte বদলেছে
  • 400, কারণ JSON ভুল
  • 200 duplicate

পাঁচটি test ফাইল parallel worker-এ চলে, সবাই একটি database ভাগ করে, আর প্রতিটি test-এর আগে TRUNCATE হয়। প্রতিবার ভিন্ন test ফেল করছে। সবচেয়ে ভালো সমাধান কোনটি?

  • প্রতিটি test-এর timeout বাড়ানো
  • ফেল করা test নিজে থেকে আবার চালানো
  • প্রতিটি worker-কে template থেকে আলাদা database দেওয়া
  • Truncate বাদ দেওয়া

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. API integration test-এ HTTP ঢোকে, আসল database-এ ফল; test উত্তর আর টেবিল দুটোই যাচাই করে।
  2. createApp() listen ছাড়া; Supertest নিজে খালি port-এ চালায়। Status, header আর body, তিনটিই দেখুন।
  3. Test database-এ migrate deploy একবার, template থেকে worker-প্রতি কপি, আর প্রতিটি test-এর আগে truncate.
  4. Transaction rollback দ্রুত, কিন্তু অন্য সংযোগ, app-এর নিজের transaction আর একসাথে আসা অনুরোধে ভাঙে।
  5. Idempotency, race আর signed webhook-এর “যা হওয়ার কথা নয়” কেস আসল database-এ test করুন; ভুল কোড ঢুকিয়ে দেখুন test লাল হয়।

পরের পর্বে: পর্ব ৭৬-এ Playwright দিয়ে end-to-end test: আসল browser, নির্ভরযোগ্য selector আর বাজারঘরের checkout-এর পুরো পথ।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান