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

ফুল-স্ট্যাক test কৌশল — pyramid, trophy আর কী test করবেন

Full-Stack Testing Strategy — Pyramid, Trophy and What to Test

Taught in Bangla, this lesson treats tests as buying confidence to refactor and deploy, maps four layers of full-stack tests from unit to end-to-end, compares the pyramid, trophy and honeycomb as answers for different systems, builds a risk map of what to test first in the Bazarghar shop, tests behaviour rather than implementation, runs one project at four layers with timings, and shows the limits of coverage and how mutation testing exposes weak tests.

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

  • ফুল-স্ট্যাক অ্যাপের চার স্তরের test কী চালায়, কী নকল রাখে, আর কী ধরতে পারে না
  • Pyramid, trophy আর honeycomb-এর আসল trade-off: গতি, আস্থা আর রক্ষণাবেক্ষণের দাম
  • “বাজারঘর”-এর জন্য ঝুঁকিভিত্তিক মানচিত্র: কোন feature কোন স্তরে, কোনটি test নয়
  • নিজের ল্যাবে Vitest, Testing Library, supertest আর Playwright-এর সত্যিকারের run আর সময়
  • Vitest-এর v8 coverage, Stryker দিয়ে mutation testing, AAA আকার আর ছোট দলের test budget

১ · Test কেন — refactor-এর সাহস আর deploy-এর ভরসা

বৃহস্পতিবার রাত। কাল থেকে বাজারঘরে ঈদের অফার শুরু। নতুন কুপন EID10: ১০% ছাড়, সর্বোচ্চ ২০০ টাকা। ডেভেলপার রাফি দামের হিসাবের function-এ তিন লাইন বদলালেন। এখন প্রশ্ন দুটি। এক, আগের নিয়মগুলো কি এখনো ঠিক আছে? ১০০০ টাকা বা তার বেশি কেনাকাটায় ফ্রি ডেলিভারি, স্টক না থাকলে অর্ডার বন্ধ, অন্যের অর্ডার দেখা যায় না। দুই, এই কোড কি রাতেই production-এ পাঠানো যায়?

Test ছাড়া দুটি প্রশ্নের উত্তর একটাই: “মনে হয় ঠিক আছে।” হাতে ক্লিক করে পাঁচটি পাতা দেখা যায়, পঞ্চাশটি নিয়ম দেখা যায় না। স্বয়ংক্রিয় test এখানে দুটি জিনিস দেয়।

  • Refactor-এর সাহস। Refactoring মানে বাইরের আচরণ না বদলে ভেতরের গঠন বদলানো। কিন্তু আচরণ সত্যিই বদলায়নি, তা জানবেন কীভাবে? পর্ব ২৯-এ service-কে নকল repository দিয়ে test করেছিলেন, যাতে database বদলালেও নিয়মগুলো পাহারায় থাকে। ভালো test থাকলে কোড পরিষ্কার করা ভয়ের কাজ থাকে না।
  • Deploy-এর ভরসা। প্রতিটি বদলের পরে একই পরীক্ষা যন্ত্র নিজে চালায়, মানুষ ক্লান্ত হলেও। পুরোনো ঠিক জিনিস নতুন বদলে ভেঙে যাওয়াকে বলে regression. Regression ধরাই test-এর প্রতিদিনের কাজ। এই test-গুলো প্রতিটি push-এ চালানোর pipeline আসবে পর্ব ৮১-এ।

গার্মেন্টস কারখানার মান যাচাই ভাবুন। সেলাই লাইনে ইনলাইন QC প্রতিটি টুকরোর সেলাই দেখেন: দ্রুত, সস্তা, হাজার বার। প্যাকিংয়ের আগে ফাইনাল ইন্সপেকশন পুরো শার্ট গায়ে দেওয়ার মতো করে দেখে: ধীর, দামি, কয়েকটি নমুনায়। শুধু ইনলাইন QC থাকলে বোতাম ঠিক কিন্তু মাপ ভুল শার্ট বেরিয়ে যায়। শুধু ফাইনাল ইন্সপেকশন থাকলে প্রতিটি ভুল ধরা পড়ে শেষ মুহূর্তে, আর কোন লাইনে ভুল হলো তা খুঁজতে দিন যায়। Test কৌশল মানে এই দুইয়ের মিশ্রণ ঠিক করা।

একটি সীমাও শুরুতেই মনে রাখুন। Edsger Dijkstra-র বহুল উদ্ধৃত কথা (১৯৬৯-৭০ সালের দিকের লেখা ও বক্তৃতায়): test বাগের উপস্থিতি দেখাতে পারে, অনুপস্থিতি কখনো নয়। সবুজ test মানে “যা যাচাই করেছি, তাতে ভুল পাইনি।” কী যাচাই করবেন, সেটি বাছাই করাই এই পর্বের বিষয়। Test-এর মৌলিক তত্ত্ব আছে Software Engineering কোর্সের testing fundamentals পর্বে; এখানে সেটি আবার পড়াব না।

বাজারঘরের একটি অনুরোধ কয়েকটি অংশ পার হয়: browser, React component, Express API, database, আর মাঝখানে দামের হিসাবের মতো খাঁটি logic. প্রতিটি ধরনের test এদের কোনটি আসলে চালায়, নিচের ছকে দেখুন। ছকের সময়গুলো আমার ল্যাবের একটি local run, সাধারণ ফল নয়।

কোন test কোন অংশ আসলে চালায়

Filled dot = the real thing runs; dashed ring = a stand-in (jsdom)

চার ধরনের test আর অ্যাপের পাঁচটি অংশ Unit Comp. API E2E Browser React UI Express API Database price.js প্রতি test 0–10 ms 5–230 ms 2–26 ms 373 ms পুরো run 0.2 s 1.1 s 0.3 s 2.9 s
ছবি ৭২.১ — বাঁ থেকে ডানে গেলে আসল অংশ বাড়ে। Component test-এ browser-এর জায়গায় jsdom, তাই রিংটি ফাঁপা। সময়গুলো অংশ ৬-এর সত্যিকারের run থেকে।

২ · ফুল-স্ট্যাকের চার স্তর — কোন test আসলে কী চালায়

নাম নিয়ে বিভ্রান্তি খুব বেশি। এক দলের “integration test” আরেক দলের “API test”. তাই নামের চেয়ে প্রশ্নটি জরুরি: এই test-এ কোন অংশ আসল, কোন অংশ নকল? বাজারঘরে চারটি স্তর এভাবে ভাগ করব, আর তার নিচে একটি পঞ্চম ভিত্তি।

স্তরবাজারঘরে উদাহরণTool (লেখার সময়ে)যা আসলযা ধরতে পারে না
StaticpricePaisa-কে string ভেবে যোগ করাTypeScript, ESLintকোড, না চালিয়েইযুক্তির ভুল
UnitcalcTotal-এর ছাড় আর ডেলিভারিVitest 5.1.0শুধু একটি functionঅংশগুলো জোড়ার ভুল
Componentকুপন লিখলে মোট বদলায়Testing Library 16.3.3 + jsdom 30.1.3React, আপনার componentআসল browser-এর layout, CSS
API + DBস্টক না থাকলে 409, কিছুই বদলায় নাsupertest 7.3.1, Express 5.3.0Route, middleware, SQL, transactionFrontend আর browser
E2Eপণ্য যোগ থেকে অর্ডার নিশ্চিতPlaywright 1.64.1প্রায় সবকোথায় ভাঙল, তা সহজে বলে না

মাঝের দুটি স্তরকে অনেকে একসাথে integration test বলেন: কয়েকটি আসল অংশ একসাথে চালানো test. Static স্তরে test লিখতে হয় না, কিন্তু সে বিনা খরচে একদল ভুল আগেই ধরে। এর ব্যবস্থা পর্ব ৫-এ (ESLint) আর পর্ব ৬-এ (TypeScript) দেখেছেন। একে বলে static analysis.

এখন দেখা যাক একটি test runner আসলে কী করে। Vitest এই পাতায় চলে না, তাই নিচে ২০ লাইনের একটি শেখার মডেল। এটি Vitest নয়, শুধু তার মূল ধারণা: test একটি নাম আর একটি function নেয়, চালায়, আর ব্যতিক্রম ছুড়লে ফেল ধরে। expect একটি assertion বানায়।

// Teaching model of a test runner. Not Vitest, only the idea.
const results = [];
function test(name, fn) {
  try { fn(); results.push(['PASS', name]); }
  catch (e) { results.push(['FAIL', name + ' -> ' + e.message]); }
}
function expect(actual) {
  return { toBe(want) {
    if (!Object.is(actual, want)) throw new Error(`expected ${want}, got ${actual}`);
  } };
}
// Code under test: paisa in, paisa out (100 paisa = 1 taka).
function calcTotal(items, coupon) {
  const subtotal = items.reduce((s, it) => s + it.pricePaisa * it.qty, 0);
  const discount = coupon === 'EID10' ? Math.min(Math.round(subtotal * 0.1), 20000) : 0;
  const delivery = subtotal - discount >= 100000 ? 0 : 6000;
  return subtotal - discount + delivery;
}
const rice = { pricePaisa: 45000, qty: 2 };
const dal = { pricePaisa: 15000, qty: 1 };
test('below 1000 taka pays 60 taka delivery', () => expect(calcTotal([dal])).toBe(21000));
test('1050 taka cart has free delivery', () => expect(calcTotal([rice, dal])).toBe(105000));
test('EID10 can bring delivery back', () => expect(calcTotal([rice, dal], 'EID10')).toBe(100500));
test('discount is capped at 200 taka', () =>
  expect(calcTotal([{ pricePaisa: 500000, qty: 1 }], 'EID10')).toBe(480000));
test('a wrong expectation fails loudly', () => expect(calcTotal([dal])).toBe(15000));

for (const [status, name] of results) console.log(status, name);
const failed = results.filter((r) => r[0] === 'FAIL').length;
console.log(`Tests: ${results.length - failed} passed, ${failed} failed`);

প্রথম চারটি লাইন PASS, পঞ্চমটি ইচ্ছা করে ভুল: FAIL a wrong expectation fails loudly -> expected 15000, got 21000. শেষে Tests: 4 passed, 1 failed. তৃতীয় test-টি লক্ষ করুন। ১০৫০ টাকার কার্টে কুপন দিলে ১০৫ টাকা ছাড়, মোট নামে ৯৪৫-এ, তাই ডেলিভারি আবার ফিরে আসে: ৯৪৫ + ৬০ = 100500 পয়সা, অর্থাৎ ১০০৫ টাকা। ক্রেতা অবাক হতে পারেন, তবে নিয়ম এটাই, আর test সেটি লিখে রাখল। ফেল হলে বার্তাটি “কী চেয়েছিলাম, কী পেলাম” দুটোই বলে; ভালো assertion-এর এটিই গুণ।

দামগুলো পয়সায়, পূর্ণসংখ্যায়, যেমন পর্ব ৪৩-এ দেখেছেন। Vitest-এর describe, it, expect একই আকারের, শুধু অনেক বেশি সুবিধাসহ: watch mode, mock, snapshot, coverage. সেগুলো আসবে পর্ব ৭৩-এ।

৩ · Pyramid, trophy আর honeycomb — একই প্রশ্নের তিন উত্তর

কোন স্তরে কতগুলো test রাখবেন, তার তিনটি পরিচিত ছবি আছে। বিস্তারিত ইতিহাস আর ছবি আছে test pyramid ও integration পর্বের অংশ ৩-এ. এখানে সংক্ষেপে, তারপর ফুল-স্ট্যাকের চোখে।

Test pyramid

Test pyramid সাধারণত Mike Cohn-এর নামে বলা হয়; তাঁর Succeeding with Agile (২০০৯) বই ছবিটি জনপ্রিয় করে। নিচে অনেক unit test, মাঝে কম service test, ওপরে অল্প UI test. যুক্তি: নিচের test দ্রুত আর সস্তা।

হিসাবের নিয়মে ভরা কোড

Testing trophy

Testing trophy Kent C. Dodds-এর প্রস্তাব, আনুমানিক ২০১৮ সালে, JavaScript frontend মাথায় রেখে। ভিত্তিতে static check, তারপর unit, সবচেয়ে মোটা অংশ integration, ওপরে অল্প E2E.

Component-এ ভরা UI

Test honeycomb

Honeycomb Spotify-এর engineering blog-এ (আনুমানিক ২০১৮) microservice-এর জন্য বর্ণিত। ছোট service-এ ভেতরের যুক্তি কম, ঝুঁকি সীমানায়: database আর অন্য service. তাই বেশিরভাগ test service-কে তার API দিয়ে চালায়।

Database-এ কথা বলা API

তিনটি ছবি তিনটি মাপের মধ্যে টানাপোড়েন দেখায়। কোনো স্তর তিনটিতেই জেতে না।

স্তরগতিআস্থারক্ষণাবেক্ষণের দামফেল করলে কোথায় খুঁজবেন
Unitসবচেয়ে দ্রুতএক function-এর নিয়মে উচ্চ, পুরো অ্যাপে কমকম, যদি আচরণ ধরে লেখা হয়প্রায় নির্দিষ্ট লাইনে
Componentদ্রুতUI-র আচরণে ভালোমাঝারি; markup ধরে লিখলে বেশিএক component-এ
API + DBমাঝারিBackend-এ উচ্চ: SQL, transaction, অনুমতিমাঝারি; test ডেটা সামলাতে হয়এক route-এর পথে
E2Eসবচেয়ে ধীরসবচেয়ে বাস্তববেশি; সময়, নেটওয়ার্ক, অস্থিরতাপুরো stack-এর যেকোনো জায়গায়

হিসাব করে দেখুন: suite কত সময় নেয়

একটি ধরে নেওয়া suite: ৩০০টি unit test, প্রতিটি গড়ে ২ ms; ৮০টি component test, প্রতিটি ৩০ ms; ৬০টি API test, প্রতিটি ১০ ms; আর ১০টি E2E, প্রতিটি ৩ সেকেন্ড। সংখ্যাগুলো উদাহরণ, মাপা নয়।

  • Unit: ৩০০ × ২ = ৬০০ ms
  • Component: ৮০ × ৩০ = ২,৪০০ ms
  • API: ৬০ × ১০ = ৬০০ ms
  • E2E: ১০ × ৩,০০০ = ৩০,০০০ ms
  • মোট: ৩৩,৬০০ ms, অর্থাৎ ৩৩.৬ সেকেন্ড। E2E-র ভাগ ৩০,০০০ ÷ ৩৩,৬০০ ≈ ৮৯.৩%, অথচ সংখ্যায় সেগুলো মোট test-এর মাত্র ২%.

E2E চারটি worker-এ ভাগ করে চালালে তাদের অংশ হয় ৭.৫ সেকেন্ড, আর মোট ৩.৬ + ৭.৫ = ১১.১ সেকেন্ড. শিক্ষা দুটি। এক, suite-এর সময় নির্ধারণ করে ধীরতম স্তরের সংখ্যা, মোট test-এর সংখ্যা নয়। দুই, প্রতিটি নতুন E2E test ৩,০০০ ÷ ২ = ১,৫০০টি unit test-এর সমান সময় খায়। তাই E2E-তে শুধু সেই পথ রাখুন, যা নিচের স্তরে কোনোভাবেই ধরা যায় না।

বাজারঘরের জন্য কোন আকার

সৎ উত্তর: একটি আকার নয়। ফুল-স্ট্যাক অ্যাপ আসলে তিন রকম কোডের মিশ্রণ, আর প্রতিটি অংশ নিজের আকার চায়।

  • দাম, ছাড়, VAT, স্টক গোনা: খাঁটি নিয়ম, অনেক কিনারা। এখানে pyramid-এর মতো অনেক unit test.
  • React UI: খুঁটিনাটির unit test refactor-এ ভাঙে। এখানে trophy-র মতো component test, যা কয়েকটি component একসাথে চালায়।
  • Express API আর database: ভুল থাকে SQL, transaction আর অনুমতিতে। এখানে honeycomb-এর মতো API test, আসল database-সহ।
  • সব মিলিয়ে: অল্প কয়েকটি E2E, শুধু টাকা আর অর্ডারের প্রধান পথে।

সম্প্রদায়ে এ নিয়ে বিতর্ক আছে, আর সেটি স্বাভাবিক। Pyramid-এর সমর্থকেরা বলেন, integration-ভারী suite ধীর হয় আর ফেলের কারণ খুঁজতে সময় লাগে। Trophy-র সমর্থকেরা বলেন, ছোট ছোট unit test একসাথে কাজ করার ভুল ধরে না। দুই পক্ষই ঠিক, ভিন্ন ধরনের কোডের জন্য। Trophy-র “integration” প্রায়ই browser বা network ছাড়াই চলে, তাই আসলে দ্রুত। নাম না দেখে প্রতিটি test-এর গতি আর স্থিরতা দেখুন।

৪ · বাজারঘরে কী test করবেন — ঝুঁকির মানচিত্র

সময় সীমিত, তাই সব কিছুর সমান test হবে না। প্রশ্নটি হলো: কোথায় ভুল হলে সবচেয়ে বেশি ক্ষতি, আর কোথায় ভুল হওয়ার সম্ভাবনা বেশি? এই দুটি গুণ করলে পাওয়া যায় একটি ঝুঁকির নম্বর। এই পদ্ধতিকে বলে risk-based testing. নিচের সংখ্যাগুলো একটি দলের অনুমান, মাপা তথ্য নয়; আপনার দল নিজের সংখ্যা বসাবে।

// Risk = likelihood (1-5) x impact (1-5). The numbers are a team's guess.
const features = [
  ['Order total and coupon', 4, 5],
  ['Stock check on checkout', 3, 5],
  ['Payment webhook', 3, 5],
  ['Seller sees only own orders', 2, 5],
  ['Login and password reset', 2, 4],
  ['Product search filters', 3, 2],
  ['Bangla date on invoice', 2, 2],
  ['Footer link colours', 2, 1],
];
function layerFor(score) {
  if (score >= 15) return 'many unit + API tests, one E2E path';
  if (score >= 8) return 'API or component tests';
  if (score >= 4) return 'one test at the cheapest layer';
  return 'no automated test, look at it';
}
const rows = features
  .map(([name, likely, impact]) => ({ name, likely, impact, score: likely * impact }))
  .sort((a, b) => b.score - a.score);
console.log('feature'.padEnd(28), 'L', 'I', 'score', ' suggested');
let all = 0;
for (const r of rows) {
  console.log(r.name.padEnd(28), r.likely, r.impact, String(r.score).padStart(5), '', layerFor(r.score));
  all += r.score;
}
const top3 = rows.slice(0, 3).reduce((s, r) => s + r.score, 0);
console.log(`top 3 of ${rows.length} features carry ${Math.round((top3 / all) * 100)}% of the risk`);

সারিগুলো ঝুঁকি অনুযায়ী সাজানো। ওপরে Order total and coupon, score 20; তারপর স্টক আর payment webhook, দুটিই 15. নিচে Footer link colours, score 2, পরামর্শ no automated test, look at it. শেষ লাইন: top 3 of 8 features carry 63% of the risk. মোট ঝুঁকি ৮০, তার ৫০ মাত্র তিনটি feature-এ; ৫০ ÷ ৮০ = ৬২.৫%, যা পূর্ণসংখ্যায় ৬৩. অর্থাৎ test-এর সময়ের বড় অংশ কোথায় যাবে, সেটি এক নজরে পরিষ্কার।

এবার মানচিত্রটি স্তরসহ। প্রতিটি সারিতে প্রশ্ন একটাই: এই ভুলটি সবচেয়ে সস্তায় কোন স্তরে ধরা যায়?

Featureকী ভাঙতে পারেস্তরআগে দেখেছেন
মোট দাম, কুপন, ডেলিভারিসীমানায় ভুল, পয়সার গোলমালঅনেক unit, একটি APIপর্ব ৪৩
Checkout-এ স্টকশেষ পণ্য দুজনের কাছে বিক্রিAPI + DB, transaction-সহপর্ব ৪৩
Payment webhookএকই ঘটনা দুবার, ভুয়া সইAPI + DB, নকল gateway দিয়েপর্ব ৬৫
Seller শুধু নিজের অর্ডারIDOR, অন্যের তথ্য ফাঁসAPI-তে role বনাম route ছকপর্ব ৪৮
Login, password resetSession, rate limitAPI, আর একটি E2Eপর্ব ৪৫, ৪৯
পণ্য খোঁজা আর filterভুল ফল, ধীর queryAPI, query budgetপর্ব ৩৪, ৪২
কার্টের পাতামোট না বদলানো, বোতাম ভুল সময়ে চালুComponentপর্ব ১২, ২১
পুরো কেনাকাটাঅংশগুলো জোড়ায় ভুল১ থেকে ৩টি E2Eপর্ব ৬৫
বাংলা তারিখ, সংখ্যাভুল formatUnitপর্ব ৫৯
Footer-এর রং, pixel-এর মাপদেখতে খারাপস্বয়ংক্রিয় test নয়; চোখে দেখা, reviewপর্ব ২২

কী test করবেন না, সেটিও সিদ্ধান্ত। React নিজে ঠিক কাজ করে কি না, Prisma সারি লেখে কি না, এসব তাদের নিজেদের দলের কাজ। Private helper-এর আলাদা test সাধারণত লাগে না; বাইরের আচরণের test সেগুলো এমনিই চালায়। বাইরের payment gateway-কে প্রতিটি test-এ সত্যি ডাকবেন না; পর্ব ৬৫-এর নকল gateway-ই যথেষ্ট। আর pixel-এর মাপ test-এ বাঁধলে প্রতিটি design বদলে test লাল হবে, অথচ ক্রেতার কোনো ক্ষতি হয়নি।

একটি feature হাতে এলে স্তর বাছাইয়ের চিন্তা ধাপে ধাপে দেখুন। প্রতিটি ধাপে একটি নতুন feature, আর সবচেয়ে সস্তা যে স্তর ভুলটি ধরতে পারে, সেটি উজ্জ্বল হবে।

UnitVitest, খাঁটি function
ComponentTesting Library, jsdom
API + DBsupertest, আসল SQL
E2EPlaywright, আসল browser
StaticTypeScript, ESLint
Test নয়চোখে দেখা, review
FeatureEID10-এর সর্বোচ্চ সীমা
৫০০০ টাকার কার্ট, কুপন EID10
১০% = ৫০০ টাকা → সীমা ২০০ টাকা
calcTotal(items, 'EID10').discount === 20000
Featureকুপন ঘর আর অর্ডার বোতাম
ক্রেতা "eid10" লিখলেন → মোট ৳1,005
"অর্ডার করুন" চাপলেন
onOrder 100500 দিয়ে ডাকা হলো কি?
Featureস্টক আর rollback
ডাল স্টকে ১টি, অর্ডারে চাল ১ + ডাল ২
প্রত্যাশা: 409, চালের স্টকও আগের মতো ১০
(প্রথম লাইন rollback হয়েছে)
Featureপুরো কেনাকাটা
পাতা খোলা → চাল কার্টে → অর্ডার করুন
পাতায় দেখা যায়: অর্ডার নিশ্চিত, মোট ৳510
FeatureClient-এর পাঠানো দাম
body: { productId: 2, qty: 1, pricePaisa: 1 }
প্রত্যাশা: দাম database থেকে, মোট 21000
FeatureAPI আর React-এর type
API পাঠায় pricePaisa: "45000" (string)
React-এ "45000" + 6000 → "450006000"
ভাগ করা type থাকলে compile-এই ধরা পড়ে
FeatureFooter-এর link-এর রং
ঝুঁকি = ২ × ১ = ২
ভুল হলে: দেখতে খারাপ, ক্ষতি নেই
test লিখলে: প্রতিটি design বদলে লাল
  1. খাঁটি নিয়ম, unit. সীমার নিয়মটি শুধু সংখ্যা নিয়ে কাজ করে: কোনো DOM নেই, database নেই। তাই সবচেয়ে সস্তা স্তরেই একে বেঁধে ফেলুন, আর কিনারার মানগুলো আলাদা আলাদা test-এ দিন।
  2. ক্রেতার হাতের কাজ, component. টাইপ করা, বোতাম চাপা আর সেই চাপে সঠিক মোট পাঠানো: এর জন্য React আর DOM লাগে, কিন্তু server লাগে না। Testing Library ঠিক এখানে।
  3. Database-এর নিয়ম, API + DB. Rollback ঘটে database-এ। নকল repository দিয়ে এটি প্রমাণ হয় না, তাই আসল SQL চালানো API test দরকার।
  4. জোড়ার প্রমাণ, একটি E2E. HTML, JavaScript, API আর database একসাথে ঠিক আছে কি না, শুধু browser-ই বলতে পারে। এমন test অল্প রাখুন, প্রধান পথে।
  5. নিরাপত্তার নিয়ম, API. দাম client থেকে নেওয়া হয় কি না, তা বোঝা যায় শুধু server-এ অনুরোধ পাঠিয়ে। Frontend-এর test এখানে কিছুই প্রমাণ করে না, কারণ আক্রমণকারী frontend ব্যবহারই করেন না।
  6. আকৃতির ভুল, static. String আর number গুলিয়ে যাওয়া test লেখার আগেই TypeScript ধরতে পারে, যদি API আর React একই type ভাগ করে (পর্ব ৩৭-এর মতো)। এটি বিনা খরচের স্তর।
  7. Test নয়। ঝুঁকি খুব কম, অথচ test হবে ভঙ্গুর। এখানে চোখে দেখা আর code review যথেষ্ট। প্রতিটি জিনিসের test না লেখা অলসতা নয়, এটিও একটি সিদ্ধান্ত।

৫ · আচরণ test করুন, implementation-এর খুঁটিনাটি নয়

Testing Library-র মূল নীতিটি Kent C. Dodds এভাবে বলেন: test যত বেশি আপনার software-এর আসল ব্যবহারের মতো হবে, তত বেশি আস্থা দেবে। উল্টো দিকটি হলো implementation detail: class-এর নাম, ভেতরের state, কোন helper কতবার ডাকা হলো। ব্যবহারকারী এগুলো দেখেন না। এসব ধরে লেখা test হয় brittle test: আচরণ না বদলালেও ভাঙে।

ভঙ্গুর test-এর দুটি দোষ। এক, নির্দোষ refactor-এ লাল হয়ে মিথ্যা alarm দেয়। দুই, যা আসলে জরুরি তা যাচাই করে না, তাই আসল বাগ সবুজ রঙেই পার হয়ে যায়। নিচে একই কার্টের তিনটি বাস্তবায়ন: v1, তার নির্দোষ refactor v2, আর একটি সত্যিকারের বাগসহ v3.

// Three implementations of the same cart summary.
function summaryV1(items) {
  const lines = items.map((it) => it.pricePaisa * it.qty);
  const total = lines.reduce((a, b) => a + b, 0);
  return { lines, total, text: 'Total: Tk ' + total / 100 };
}
function summaryV2(items) { // harmless refactor: no lines array any more
  let total = 0;
  for (const it of items) total += it.pricePaisa * it.qty;
  return { total, text: 'Total: Tk ' + total / 100 };
}
function summaryV3(items) { // a real bug: the text shows only the first line
  const lines = items.map((it) => it.pricePaisa * it.qty);
  const total = lines.reduce((a, b) => a + b, 0);
  return { lines, total, text: 'Total: Tk ' + lines[0] / 100 };
}
const cart = [{ pricePaisa: 45000, qty: 2 }, { pricePaisa: 15000, qty: 1 }];
// Brittle: reaches into an internal field that exists only by accident.
const brittle = (s) => s.lines.length === 2 && s.lines[0] === 90000;
// Behaviour: checks only what the shopper reads.
const behaviour = (s) => s.text === 'Total: Tk 1050';

for (const [name, impl] of [['v1', summaryV1], ['v2', summaryV2], ['v3', summaryV3]]) {
  for (const [kind, check] of [['brittle', brittle], ['behaviour', behaviour]]) {
    let verdict;
    try { verdict = check(impl(cart)) ? 'PASS' : 'FAIL'; }
    catch (e) { verdict = `FAIL (${e.constructor.name}: ${e.message})`; }
    console.log(name, kind.padEnd(9), verdict);
  }
}

ছয় লাইনের ফলই পুরো শিক্ষা। v1-এ দুটিই PASS. v2-তে আচরণ একই, তবু v2 brittle FAIL (TypeError: Cannot read properties of undefined (reading 'length')), আর v2 behaviour PASS. v3-তে ক্রেতা ভুল মোট (৯০০ টাকা) দেখছেন, অথচ v3 brittle PASS; শুধু v3 behaviour FAIL বাগটি ধরল। ভঙ্গুর test দুবারই ভুল উত্তর দিল: একবার মিথ্যা লাল, একবার মিথ্যা সবুজ।

আসল React component-এ একই ঘটনা

এবার ল্যাবে আসল জিনিস: React 19.3.1-এর CartSummary, Vitest 5.1.0, Testing Library 16.3.3 আর jsdom 30.1.3. প্রথম test-টি markup চেনে:

// Brittle: it knows the class names and the order of the spans.
it('total cell shows 1,050', () => {
  const { container } = render(<CartSummary items={items} onOrder={() => {}} />);
  const cell = container.querySelector('.row.total span:last-child');
  expect(cell.textContent).toBe('৳1,050');
  expect(container.querySelectorAll('.row')).toHaveLength(2);
});

দ্বিতীয় test ক্রেতার চোখে দেখে: পাতার লেখা, label দিয়ে খোঁজা input, আর role ও নাম দিয়ে খোঁজা বোতাম।

it('a coupon changes the total the order is placed with', async () => {
  const user = userEvent.setup();
  const onOrder = vi.fn();
  render(<CartSummary items={items} onOrder={onOrder} />);
  await user.type(screen.getByLabelText('কুপন'), 'eid10');
  await user.click(screen.getByRole('button', { name: 'অর্ডার করুন' }));
  expect(onOrder).toHaveBeenCalledWith(100500);
});

তারপর একটি নির্দোষ refactor: div আর span-এর বদলে dl, dt, dd, আর পুরোনো class-এর বদলে Tailwind-এর class (পর্ব ২২)। ক্রেতার চোখে পাতা হুবহু একই। নিচের stepper-এ সত্যিকারের run ধরে এগিয়ে দেখুন।

v1 markupCartSummary.jsx
<div className="row total">
  <span>মোট</span><span>{formatTaka(t.total)}</span>
</div>
v1-এ runসত্যিকারের run
 ✓ tests/brittle/cart.brittle.test.jsx > total cell shows 1,050 32ms
 ✓ ... > shows the total and free delivery 24ms
 ✓ ... > a coupon changes the total the order is placed with 228ms
 ✓ ... > an empty cart cannot be ordered 4ms
      Tests  4 passed (4)
v2 markupনির্দোষ refactor
<dl className="grid grid-cols-2 gap-1">
  <dt className="font-bold">মোট</dt>
  <dd className="font-bold">{formatTaka(t.total)}</dd>
</dl>
v2-এ brittleসত্যিকারের run
 × tests/brittle/cart.brittle.test.jsx > total cell shows 1,050 32ms
   → Cannot read properties of null (reading 'textContent')
 ❯ tests/brittle/cart.brittle.test.jsx:13:15
     13|   expect(cell.textContent).toBe('৳1,050');
v2-এ behaviourসত্যিকারের run
 ✓ ... > shows the total and free delivery 25ms
 ✓ ... > a coupon changes the total the order is placed with 200ms
 ✓ ... > an empty cart cannot be ordered 5ms
      Tests  1 failed | 3 passed (4)
v1 + আসল বাগসত্যিকারের run
onClick={() => onOrder(t.subtotal)}   // should be t.total

 × a coupon changes the total the order is placed with 253ms
AssertionError: expected "vi.fn()" to be called with arguments: [ 100500 ]
-   100500,
+   105000,
 Test Files  1 failed | 1 passed (2)
  1. প্রথম রূপ। মোটের ঘরটি একটি div, যার class row total, ভেতরে দুটি span. Brittle test ঠিক এই গঠন মুখস্থ করে রেখেছে।
  2. সব সবুজ। v1-এ চারটি test-ই পাস। এই মুহূর্তে দুই ধরনের test আলাদা করা যায় না; পার্থক্য বোঝা যায় শুধু কোড বদলালে।
  3. Refactor. Markup dl-এ গেল, class বদলে Tailwind হলো। মোট এখনো ৳1,050, ডেলিভারি এখনো ফ্রি, বোতাম একই কাজ করে। ক্রেতার কাছে কিছুই বদলায়নি।
  4. মিথ্যা লাল। .row.total span:last-child আর কিছু খুঁজে পায় না, তাই cell হলো null, আর test ভাঙল TypeError দিয়ে। কোনো বাগ নেই, তবু CI লাল। কেউ test “ঠিক” করতে বসবেন, অর্থাৎ নতুন selector মুখস্থ করাবেন।
  5. Behaviour test অটল। লেখা, label আর বোতামের নাম বদলায়নি, তাই তিনটিই পাস। ফল: ৪টির মধ্যে ১টি ফেল, আর সেটিই ভুল কারণে।
  6. আসল বাগ। এবার v1-এ সত্যিকারের ভুল ঢোকানো হলো: বোতাম ছাড়ের আগের দাম পাঠায়। Brittle test পাস করল, কারণ সে শুধু ঘরের লেখা দেখে। Behaviour test ধরল: চাওয়া 100500, পাওয়া 105000. একজন ক্রেতা ৪৫ টাকা বেশি দিতেন (১০৫০ − ১০০৫)।

তাহলে implementation-এর test কি সবসময় নিষেধ? না। কিছু “ভেতরের” বিষয় আসলে চুক্তি। পর্ব ৪২-এর query budget test গোনে একটি পাতায় কতগুলো SQL চলল; এটি implementation, কিন্তু performance-এর চুক্তি, তাই ইচ্ছা করেই বাঁধা। নিয়ম হলো: যা ভাঙলে ব্যবহারকারী বা অন্য দল ক্ষতিগ্রস্ত হন, সেটি test করুন; যা বদলালে কারও কিছু যায় আসে না, সেটি test-এ বাঁধবেন না।

৬ · এক project, চার স্তর — সত্যিকারের run আর সময়

সব কথার প্রমাণ হিসেবে একটি ছোট project বানিয়েছি, যেখানে প্রতিটি স্তরে কয়েকটি test আছে। লেখার সময়ের version: Node.js 24.15.0, Vitest 5.1.0 আর @vitest/coverage-v8 5.1.0, React 19.3.1, @testing-library/react 16.3.3, @testing-library/user-event 14.6.8, @testing-library/jest-dom 7.0.1, jsdom 30.1.3, Express 5.3.0, supertest 7.3.1, @playwright/test 1.64.1 (Microsoft Edge-এ), আর Stryker 10.1.1. Database হিসেবে Node-এর নিজস্ব node:sqlite-এর in-memory database, যাতে প্রতিটি test নতুন, খালি database পায়। আসল PostgreSQL-এ test database বানানো আসবে পর্ব ৭৫-এ।

~/bazarghar
├── src/price.js            calcTotal, formatTaka
├── src/CartSummary.jsx     React component
├── src/app.js              Express API + node:sqlite
├── public/index.html       a tiny shop page for E2E
├── tests/unit/price.test.js
├── tests/component/cart.behaviour.test.jsx
├── tests/api/orders.test.js
├── e2e/checkout.spec.js
├── vitest.config.js
└── playwright.config.js

API স্তরের একটি test পুরোটা দেখুন। এখানে আসল Express route, আসল SQL আর আসল transaction চলে; শুধু network-এর বদলে supertest app-কে সরাসরি ডাকে।

beforeEach(() => {
  db = openDb();          // a fresh in-memory database for every test
  app = makeApp(db);
});

it('refuses more than the stock and changes nothing', async () => {
  const res = await request(app).post('/api/orders').set(auth)
    .send({ items: [{ productId: 1, qty: 1 }, { productId: 2, qty: 2 }] });
  expect(res.status).toBe(409);
  expect(stockOf(1)).toBe(10);   // the first line was rolled back
  expect(stockOf(2)).toBe(1);
});

আর একমাত্র E2E test, যা Playwright নিজেই server চালু করে, Edge খোলে, ক্লিক করে, শেষে server বন্ধ করে:

test('a shopper adds rice and places an order', async ({ page }) => {
  await page.goto('/');
  await page.getByRole('button', { name: 'Chinigura rice 5kg কার্টে যোগ করুন' }).click();
  await page.getByRole('button', { name: 'অর্ডার করুন' }).click();
  await expect(page.getByRole('status')).toHaveText('অর্ডার নিশ্চিত, মোট ৳510');
});

প্রথমে Vitest-এর তিন স্তর একসাথে, সত্যিকারের run:

~/bazarghar$ npx vitest run --reporter=verbose
 RUN  v5.1.0 ~/bazarghar
 ✓ tests/unit/price.test.js > calcTotal > returns all zeros for an empty cart 1ms
 ✓ tests/unit/price.test.js > calcTotal > charges 60 taka delivery below 1000 taka 0ms
 ✓ tests/unit/price.test.js > calcTotal > gives free delivery above 1000 taka 0ms
 ✓ tests/unit/price.test.js > calcTotal > EID10 can push a cart back under the free-delivery line 0ms
 ✓ tests/unit/price.test.js > formatTaka > formats paisa as taka with Indian grouping 11ms
 ✓ tests/api/orders.test.js > POST /api/orders > creates the order and takes the stock 26ms
 ✓ tests/api/orders.test.js > POST /api/orders > refuses more than the stock and changes nothing 4ms
 ✓ tests/api/orders.test.js > POST /api/orders > ignores a price sent by the client 3ms
 ✓ tests/api/orders.test.js > POST /api/orders > needs a login 3ms
 ✓ tests/component/cart.behaviour.test.jsx > CartSummary (what the shopper sees and does) > shows the total and free delivery 25ms
 ✓ tests/component/cart.behaviour.test.jsx > CartSummary (what the shopper sees and does) > a coupon changes the total the order is placed with 216ms
 ✓ tests/component/cart.behaviour.test.jsx > CartSummary (what the shopper sees and does) > an empty cart cannot be ordered 4ms
 Test Files  3 passed (3)
      Tests  12 passed (12)
   Duration  1.06s (environment 42%, tests 22%, setup 20%, import 13%, transform 2%, worker 1%)

তারপর Playwright, সত্যিকারের run:

~/bazarghar$ npx playwright test --reporter=list
Running 1 test using 1 worker

  ok 1 e2e\checkout.spec.js:3:1 › a shopper adds rice and places an order (373ms)

  1 passed (2.9s)

প্রতিটি স্তর আলাদা করে চালিয়ে পাওয়া সময় নিচে। এগুলো আমার ল্যাবের একটি local run; আপনার যন্ত্রে সংখ্যা আলাদা হবে, তবে অনুপাত মোটামুটি এমনই থাকার কথা।

স্তরTestপ্রতি testপুরো runসময় কোথায় গেল
Unit৫০–১০ ms205mssetup 76%
Component৩৫–২৩০ ms1.10senvironment 56% (jsdom তৈরি)
API + DB৪২–২৬ ms327msimport 42%
E2E১৩৭৩ ms2.9sserver চালু আর browser খোলা

তিনটি জিনিস লক্ষ করুন। এক, unit test এত দ্রুত যে সময়ের বেশিরভাগ যায় runner চালু করতে। দুই, component test-এর ২৩০ ms-এর প্রায় সবটা user.type-এর পাঁচটি অক্ষর এক এক করে টাইপ করায়; Testing Library সত্যিকারের keystroke-এর মতো event পাঠায়। তিন, এখানে API test প্রতি test-এ component test-এর চেয়েও দ্রুত, কারণ database memory-তে। “Integration মানেই ধীর” কথাটি সবসময় সত্য নয়। আর একমাত্র E2E test-টি একাই বাকি বারোটির মোট সময়ের চেয়ে বেশি নিল।

Playwright-এর webServer নিজে server চালু করে আর শেষে বন্ধ করে। নিজে হাতে server চালালে কাজ শেষে শুধু নিজের চালানো process-টিই তার ID দিয়ে বন্ধ করুন। নাম ধরে সব node বন্ধ করলে একই যন্ত্রে চলা অন্যদের কাজও বন্ধ হয়ে যায়। Server বসান শুধু 127.0.0.1-এ।

৭ · Test-এর নাম, Arrange-Act-Assert আর ছোট দলের test budget

নাম একটি বাক্য

Test ফেল করলে প্রথমে চোখে পড়ে তার নাম। নামটি পড়েই বোঝা উচিত কোন আচরণ ভাঙল। উপরের run-এ calcTotal > EID10 can push a cart back under the free-delivery line পড়লেই নিয়মটি জানা যায়, কোড না খুলেই।

দুর্বল নামভালো নামকেন
test1charges 60 taka delivery below 1000 takaনিয়মটি নামেই লেখা
should work correctlyrefuses more than the stock and changes nothing“ঠিক” কী, সেটি বলা
calls updateStockcreates the order and takes the stockফল বলে, ভেতরের ডাক নয়

তিন অংশের আকার

প্রায় প্রতিটি ভালো test তিন ভাগে পড়া যায়, যাকে বলে Arrange-Act-Assert (AAA). Arrange: শুরুর অবস্থা সাজানো। Act: একটিমাত্র কাজ করা। Assert: ফল মিলিয়ে দেখা। ধারণাটি সাধারণত Bill Wake-এর নামে বলা হয় (আনুমানিক ২০০১); BDD-তে একই আকারের নাম Given-When-Then. বিস্তারিত আছে unit testing পর্বের অংশ ২-এ.

it('gives free delivery above 1000 taka', () => {
  // Arrange
  const items = [rice, dal];          // 900 + 150 = 1050 taka
  // Act
  const t = calcTotal(items);
  // Assert
  expect(t.delivery).toBe(0);
  expect(t.total).toBe(105000);
});

একটি test-এ একাধিক Act থাকলে সাধারণত সেটি দুটি test. আর Assert-এ if বা loop দেখলে সন্দেহ করুন: test-এর ভেতরে যুক্তি থাকলে সেই যুক্তিরও test লাগবে।

দুই-তিনজনের দলের test budget

ছোট দলে সময়ই আসল সীমা। নিচের নিয়মগুলো একটি শুরুর বিন্দু, কোনো মান নয়; সংখ্যাগুলো ধরে নেওয়া।

  1. ঝুঁকির প্রথম তিন feature-এ বেশি সময়। টাকা, স্টক আর অনুমতির প্রতিটি নিয়মে unit test, আর প্রতিটি route-এ অন্তত একটি API test, যাতে “যা পাওয়ার কথা নয়” ঘরটিও আছে (পর্ব ৪৮-এর ছক)।
  2. প্রতিটি বাগের সাথে একটি test. বাগ ঠিক করার আগে এমন test লিখুন যা বাগটি দেখায় আর লাল হয়। এটিই সবচেয়ে কাজের regression test.
  3. E2E এক হাতের আঙুলে গোনা। কেনাকাটা, login, আর হয়তো seller-এর পণ্য যোগ। এর বেশি হলে প্রতিটির জন্য কারণ লিখুন।
  4. সময়ের সীমা। ধরা যাক, প্রতিটি push-এ পুরো suite পাঁচ মিনিটের মধ্যে। সীমা পার হলে নতুন test নয়, আগে ধীর test সারান।
  5. অস্থির test-এর নিয়ম। কোনো কোড না বদলেও কখনো পাস, কখনো ফেল করা test-কে বলে flaky test. এমন test হয় সেদিনই সারান, নয় আলাদা করে রাখুন; কারণ আর সমাধান আসবে পর্ব ৭৭-এ।

৮ · Coverage — Vitest-এর v8 provider আর সংখ্যার সীমা

Code coverage মাপে test চলার সময় কোডের কোন অংশ অন্তত একবার চলেছে। চার রকম সংখ্যা আসে: statement, branch, function আর line. Vitest দুটি provider দেয়। v8 provider JavaScript engine V8-এর নিজস্ব গোনার ব্যবস্থা ব্যবহার করে, কোডে বাড়তি কিছু ঢোকায় না। istanbul provider কোডে গোনার লাইন বসিয়ে (instrument করে) মাপে। তত্ত্ব, যেমন statement বনাম branch আর MC/DC, আছে coverage ও complexity পর্বে.

test: {
  coverage: {
    provider: 'v8',
    include: ['src/**'],
    exclude: ['src/server.js'],
    reporter: [['text', { skipFull: false }]],
  },
},

আমার প্রথম run-এ ১০০% ফাইলগুলো text report-এ দেখায়নি; skipFull: false দেওয়ার পরে দেখাল। সত্যিকারের run:

~/bazarghar$ npx vitest run --coverage
 % Coverage report from v8
-----------------|---------|----------|---------|---------|-------------------
File             | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-----------------|---------|----------|---------|---------|-------------------
All files        |   92.85 |    92.85 |    90.9 |   92.15 |
 CartSummary.jsx |     100 |      100 |     100 |     100 |
 app.js          |   89.18 |    88.23 |      80 |   88.57 | 27,35,55-56
 price.js        |     100 |      100 |     100 |     100 |
-----------------|---------|----------|---------|---------|-------------------

সংখ্যার চেয়ে কাজের হলো শেষ কলাম। app.js-এর ২৭ নম্বর লাইন GET /api/products; Vitest-এর কোনো test এটি ডাকে না, শুধু E2E ডাকে, আর E2E এই হিসাবে নেই। ৩৫ নম্বর লাইন 422-এর পথ: ভুল ইনপুট পাঠানো test নেই। ৫৫-৫৬ হলো catch-এর ভেতরের ROLLBACK, যা অপ্রত্যাশিত error-এ চলে। Coverage এভাবে বলে কোথায় test নেই. এখন প্রতিটি লাইনের জন্য সিদ্ধান্ত নিন: 422-এর পথে একটি test সস্তা আর কাজের; catch-এর পথ test করতে database-এ ভুল ঘটাতে হবে, তাই হয়তো পর্ব ৭৫-এর মতো আলাদা ব্যবস্থায়।

১০০% covered, তবু ভুল

উল্টো দিকটি আরও জরুরি: coverage বলে না, যা চলেছে তা ঠিক কি না। পরীক্ষাটি করলাম। price.js-এর নিয়ম “১০০০ টাকা বা তার বেশি হলে ফ্রি ডেলিভারি”; আমি ইচ্ছা করে একটি অক্ষর মুছলাম:

- const delivery = afterDiscount >= FREE_DELIVERY_MIN ? 0 : DELIVERY_FEE;
+ const delivery = afterDiscount > FREE_DELIVERY_MIN ? 0 : DELIVERY_FEE;
~/bazarghar$ npx vitest run tests/unit --coverage --coverage.include=src/price.js
 Test Files  1 passed (1)
      Tests  5 passed (5)
----------|---------|----------|---------|---------|-------------------
File      | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------|---------|----------|---------|---------|-------------------
All files |     100 |      100 |     100 |     100 |
 price.js |     100 |      100 |     100 |     100 |
----------|---------|----------|---------|---------|-------------------

সব সবুজ, সব ১০০. অথচ ঠিক ১০০০ টাকার কার্টে ক্রেতা এখন ৬০ টাকা ডেলিভারি দেবেন। দুটি branch-ই চলেছে, ১৫০ টাকার কার্টে একটি আর ১০৫০ টাকার কার্টে অন্যটি, কিন্তু সীমানার মানটি কোনো test দেয়নি। এই কিনারার মান বাছাইয়ের কৌশলকে বলে boundary value analysis, বিস্তারিত test design পর্বের অংশ ৩-এ. একটি test যোগ করলাম:

~/bazarghar$ npx vitest run -c vitest.boundary.config.js
 FAIL  tests/boundary/boundary.test.js > exactly 1000 taka gets free delivery
AssertionError: expected 6000 to be +0 // Object.is equality
- Expected
+ Received
- 0
+ 6000
      Tests  1 failed | 5 passed (6)

তাহলে লক্ষ্য কত?

১০০% coverage লক্ষ্য বানালে দুটি বিপদ। এক, শেষ কয়েক শতাংশ সাধারণত সবচেয়ে দামি: error-এর পথ, ঘটানো কঠিন ঘটনা। দুই, সংখ্যা লক্ষ্য হলে মানুষ সংখ্যা বাড়ায়, মান নয়: assertion ছাড়া test-ও coverage বাড়ায়। অর্থনীতিবিদ Charles Goodhart-এর নামে পরিচিত এই কথাটির সারাংশ: কোনো মাপ লক্ষ্য হয়ে উঠলে সেটি আর ভালো মাপ থাকে না। এর নাম Goodhart's law.

ব্যবহারিক পথ: coverage-কে মেঝে হিসেবে ব্যবহার করুন, ছাদ হিসেবে নয়। ঝুঁকিপূর্ণ ফাইলে, যেমন price.js-এ, Vitest-এর coverage.thresholds দিয়ে উঁচু সীমা রাখুন, যাতে কেউ test মুছলে CI জানায়। নতুন কোডের coverage কমছে কি না দেখুন। আর সবচেয়ে বেশি দেখুন “Uncovered Line” কলাম, শতাংশ নয়।

৯ · Mutation testing — test-এর test

আগের অংশে নিজে হাতে একটি ভুল ঢুকিয়ে দেখলাম test সেটি ধরে কি না। এই কাজটিই স্বয়ংক্রিয়ভাবে করার নাম mutation testing. একটি tool কোডে ছোট ছোট বদল আনে, যেমন >= থেকে >, - থেকে +, true থেকে false. প্রতিটি বদলানো রূপকে বলে mutant. তারপর প্রতিটি mutant-এর ওপর পুরো suite চালায়। কোনো test লাল হলে mutant “killed”; সব সবুজ থাকলে “survived”, অর্থাৎ এমন একটি ভুল আছে যা আপনার test দেখতে পায় না। মোট mutant-এর কত ভাগ মারা গেল, সেটি mutation score.

পুরো ধারণাটি ৩০ লাইনে, শেখার মডেল হিসেবে। একটি দুর্বল suite আর একটি শক্ত suite, একই চারটি mutant-এর বিরুদ্ধে:

// A tiny mutation tester: change one thing, run the tests, see who notices.
const source = `(subtotal, discount) => {
  const after = subtotal - discount;
  const delivery = after >= 100000 ? 0 : 6000;
  return after + delivery;
}`;
const mutants = [
  ['>= to >', source.replace('>=', '>')],
  ['>= to <=', source.replace('>=', '<=')],
  ['- to +', source.replace('subtotal - discount', 'subtotal + discount')],
  ['6000 to 0', source.replace('6000', '0')],
];
const weak = [
  ['dal 150 taka', (f) => f(15000, 0) === 21000],
  ['cart 1050 taka', (f) => f(105000, 0) === 105000],
];
const strong = [...weak,
  ['exactly 1000 taka', (f) => f(100000, 0) === 100000],
  ['EID10 on 1050', (f) => f(105000, 10500) === 100500],
];
const original = new Function('return ' + source)();
for (const [suiteName, suite] of [['weak', weak], ['strong', strong]]) {
  const greenOnOriginal = suite.every(([, t]) => t(original));
  console.log(`--- ${suiteName} suite: ${suite.length} tests, original passes: ${greenOnOriginal}`);
  let killed = 0;
  for (const [name, code] of mutants) {
    const f = new Function('return ' + code)();
    const catchers = suite.filter(([, t]) => !t(f)).map(([n]) => n);
    if (catchers.length) killed++;
    console.log(name.padEnd(9), catchers.length ? 'KILLED by ' + catchers.join(', ') : 'SURVIVED');
  }
  console.log(`score ${killed}/${mutants.length}`);
}

দুর্বল suite-এ >= to > আর - to + দুটিই SURVIVED, ফল score 2/4. দ্বিতীয়টি বেঁচে গেল কারণ দুর্বল suite-এ কোনো test-এ ছাড় নেই, ছাড় ০ হলে বিয়োগ আর যোগ একই। শক্ত suite-এ >= to > KILLED by exactly 1000 taka আর - to + KILLED by EID10 on 1050, ফল score 4/4. দুই suite-এরই আসল কোডে original passes: true; এই যাচাইটি জরুরি, কারণ আসল কোডেই লাল suite দিয়ে mutant মাপা অর্থহীন।

সীমানার mutant-টি কীভাবে বেঁচে যায়, ধাপে ধাপে দেখুন। মানগুলো আসল Stryker run-এর test থেকে নেওয়া।

1mutate: after >= 100000 → after > 1000002test: dal, 150 taka3test: rice + dal, 1050 taka4test: rice + dal with EID105report: Survived6new test: exactly 1000 taka
একই ইনপুট, দুই কোড
after
আসল delivery
mutant delivery
ফল
  1. Mutant তৈরি। Stryker price.js-এ মোট ২৬টি mutant বানাল। এটি তার একটি: ১২ নম্বর লাইনে >= হলো >. ঠিক সেই ভুল, যা অংশ ৮-এ হাতে ঢুকিয়েছিলাম।
  2. ১৫০ টাকার কার্ট। after 15000, যা ১০০০০০-এর অনেক নিচে। >= হোক বা >, দুটিতেই উত্তর না, ডেলিভারি 6000. Test পাস করল।
  3. ১০৫০ টাকার কার্ট। after 105000, সীমার বেশ ওপরে। দুই কোডেই উত্তর হ্যাঁ, ডেলিভারি 0. আবার পাস।
  4. কুপনসহ। ছাড়ের পরে 94500, আবার সীমার নিচে, দুই কোডেই 6000. তিনটি test-ই সীমা থেকে দূরে, তাই পার্থক্য দেখতে পায় না।
  5. Survived. কোনো test লাল হয়নি। Stryker বলছে: আপনার suite এই ভুল আর আসল কোডের মধ্যে পার্থক্য করতে পারে না। অথচ coverage এই লাইনে ১০০% ছিল।
  6. সীমানার test. ঠিক 100000-এ আসল কোড বলে 0, mutant বলে 6000. মান আলাদা, test লাল, mutant killed. একটি test যোগ করেই score বাড়ল।

Stryker দিয়ে সত্যিকারের run

JavaScript-এর জনপ্রিয় mutation tool Stryker. লেখার সময়ে @stryker-mutator/core আর @stryker-mutator/vitest-runner দুটিই 10.1.1, আর runner-এর peer dependency vitest >=2.0.0, তাই Vitest 5.1.0-এর সাথে চলে। পুরো project নয়, শুধু একটি ছোট ফাইলে চালালাম:

{
  "testRunner": "vitest",
  "vitest": { "configFile": "vitest.unit.config.js", "related": false },
  "mutate": ["src/price.js"],
  "reporters": ["clear-text", "progress"],
  "coverageAnalysis": "perTest"
}
~/bazarghar$ npx stryker run
INFO Instrumenter Instrumented 1 source file(s) with 26 mutant(s)
INFO DryRunExecutor Initial test run succeeded. Ran 5 tests in 0 seconds ...
…
[Survived] EqualityOperator
src/price.js:12:20
-     const delivery = afterDiscount >= FREE_DELIVERY_MIN ? 0 : DELIVERY_FEE;
+     const delivery = afterDiscount > FREE_DELIVERY_MIN ? 0 : DELIVERY_FEE;

[Survived] ObjectLiteral
src/price.js:17:54
-     return '৳' + (paisa / 100).toLocaleString('en-IN', { maximumFractionDigits: 2 });
+     return '৳' + (paisa / 100).toLocaleString('en-IN', {});

File      |  total | covered | # killed | # timeout | # survived | # no cov | # errors |
All files |  92.31 |   92.31 |       24 |         0 |          2 |        0 |        0 |
INFO MutationTestExecutor Done in 2 seconds.

২৬টির মধ্যে ২৪টি মারা গেল: ২৪ ÷ ২৬ = ৯২.৩১%. সীমানার test যোগ করে আবার চালালে সত্যিকারের ফল 25 killed, 1 survived, score 96.15. বাকি একটি mutant কেন মরে না? toLocaleString-এ সংখ্যা দেখানোর ডিফল্ট সীমা তিন দশমিক ঘর। পয়সার পূর্ণসংখ্যাকে ১০০ দিয়ে ভাগ করলে দশমিকের পরে কখনো দুই ঘরের বেশি হয় না, তাই { maximumFractionDigits: 2 } থাকা আর না থাকা একই ফল দেয়। এমন mutant-কে বলে equivalent mutant: কোড বদলেছে, আচরণ বদলায়নি, তাই কোনো test তাকে মারতে পারবে না। Mutation testing-এর ফল তাই মানুষকে পড়ে বিচার করতে হয়; ১০০% score তাড়া করা এখানেও ভুল লক্ষ্য।

দাম মনে রাখুন: প্রতিটি mutant-এর জন্য test আবার চলে। এই ছোট ফাইলে ২ সেকেন্ড লাগল, বড় project-এ ঘণ্টা লাগতে পারে। তাই সাধারণ পথ হলো টাকা আর অনুমতির মতো গুরুত্বপূর্ণ কয়েকটি module-এ চালানো, অথবা শুধু বদলানো ফাইলে। Stryker-এর --incremental বিকল্প আগের ফল মনে রেখে শুধু বদলানো অংশ আবার চালায়; ব্যবহারের আগে আপনার version-এর docs দেখে নিন।

আরও গভীরে: Mutation testing-এর ধারণা ১৯৭০-এর দশকের শুরুর। Richard Lipton-এর ১৯৭১ সালের একটি লেখাকে প্রায়ই সূচনা ধরা হয়, আর DeMillo, Lipton ও Sayward-এর ১৯৭৮ সালের “Hints on Test Data Selection” লেখাটি এর দুটি ভিত্তি-অনুমান জনপ্রিয় করে: competent programmer hypothesis (আসল ভুল সাধারণত ঠিক কোড থেকে সামান্য দূরে) আর coupling effect (ছোট ভুল ধরা test বড় ভুলও ধরে)। Equivalent mutant নির্ণয় করা সাধারণভাবে undecidable, অর্থাৎ কোনো algorithm সব ক্ষেত্রে তা বলতে পারে না; তাই tool-গুলো অনুমান আর মানুষের বিচারের ওপর নির্ভর করে।

পরীক্ষা ও ইন্টারভিউতে: (১) Test pyramid আর testing trophy-র মূল পার্থক্য? Pyramid-এ সবচেয়ে বেশি unit, trophy-তে সবচেয়ে বেশি integration, ভিত্তিতে static. (২) ১০০% coverage মানে কি বাগ নেই? না, শুধু কোড চলেছে; assertion ঠিক কি না বলে না। (৩) Mutation score কীভাবে বের করেন? Killed ÷ মোট mutant; ২৬-এ ২৪ হলে ৯২.৩%. (৪) Brittle test কী? Implementation-এ বাঁধা test, যা আচরণ না বদলালেও ভাঙে। (৫) E2E কম রাখবেন কেন? ধীর, দামি, অস্থির, আর ফেল হলে কারণ খোঁজা কঠিন।

১০ · AI-এর যুগে test — পাস করে, অথচ কিছুই যাচাই করে না

AI-কে “calcTotal-এর test লিখে দাও” বললে সে মুহূর্তে test দেয়, আর সেগুলো সবুজও হয়। প্রশ্ন হলো, সেগুলো কিছু ধরে কি না। ল্যাবে এমন ধাঁচের তিনটি test লিখলাম, যেমনটা প্রায়ই দেখা যায়:

it('should calculate total correctly', () => {
  const result = calcTotal(items, 'EID10');
  expect(result).toBeDefined();
  expect(result.total).toBeGreaterThan(0);
});
it('should handle a big cart', () => {
  const r = calcTotal([{ pricePaisa: 300000, qty: 1 }]);
  expect(r.total).toBe(r.subtotal - r.discount + r.delivery); // restates the code
});
it('should handle empty cart', () => {
  expect(() => calcTotal([])).not.toThrow();
});

সত্যিকারের run-এর ফল: তিনটিই পাস। price.js-এ coverage: statement 92.85, branch 100. দেখতে চমৎকার। এবার একই test দিয়ে Stryker:

~/bazarghar$ npx stryker run      # same price.js, only the three tests above
[Survived] MethodExpression
-     if (coupon === 'EID10') discount = Math.min(Math.round(subtotal * 0.1), EID_CAP);
+     if (coupon === 'EID10') discount = Math.max(Math.round(subtotal * 0.1), EID_CAP);
[Survived] ArithmeticOperator
-     const afterDiscount = subtotal - discount;
+     const afterDiscount = subtotal + discount;
…
File      |  total | covered | # killed | # timeout | # survived | # no cov | # errors |
All files |  23.08 |   28.57 |        6 |         0 |         15 |        5 |        0 |

Branch coverage ১০০%, mutation score ২৩.০৮%. ছাড় যোগ হলেও, সীমা উল্টে Math.max হলেও, ডেলিভারির শর্ত উল্টে গেলেও কোনো test লাল হয় না। পাঁচটি mutant formatTaka-তে, যেখানে কোনো test পৌঁছায়ইনি (no cov); তাই মোট ২৬-এর মধ্যে ৬, কিন্তু যেখানে test পৌঁছেছে সেই ২১-এর মধ্যে ৬, অর্থাৎ ২৮.৫৭%. ফাঁকা test-এর সাধারণ চেহারাগুলো:

লক্ষণকেন কিছু ধরে নাকী চাইবেন
toBeDefined(), toBeGreaterThan(0)প্রায় যেকোনো ভুল উত্তরও পাসনির্দিষ্ট সংখ্যা, হাতে হিসাব করা
প্রত্যাশা কোডের সূত্র দিয়েই হিসাবকোড ভুল হলে প্রত্যাশাও একই রকম ভুলবাইরে থেকে জানা মান
যে function test হচ্ছে, তাকেই mockনকলকে test করা হচ্ছে, আসলকে নয়শুধু বাইরের সীমানায় নকল
শুধু not.toThrow()ভুল ফল দিলেও পাসফলের মান যাচাই
ফেল হলে test-এর প্রত্যাশা বদলে বাগের সাথে মেলানোবাগটি test-এ পাকা হয়ে যায়কোনটি ঠিক, নিয়ম দেখে ঠিক করা
Snapshot চোখ বন্ধ করে updateযা আছে তাই ঠিক ধরে নেয়Diff পড়ে তবেই গ্রহণ

AI-কে test লেখাতে চাইলে কোড নয়, নিয়ম আর কিনারা আগে দিন। তারপর ফল যাচাইয়ের দায় নিজের কাছে রাখুন।

AI-কে test লিখতে বলার একটি নমুনা prompt:

আমার Vitest 5 project-এ src/price.js-এর calcTotal(items, coupon)-এর test লিখে দাও। নিয়ম: দাম পয়সায় পূর্ণসংখ্যা; EID10 কুপনে ১০% ছাড়, সর্বোচ্চ 20000 পয়সা; ছাড়ের পরে 100000 পয়সা বা তার বেশি হলে ডেলিভারি 0, নইলে 6000. ১. প্রতিটি নিয়মের জন্য আলাদা test, নাম হবে নিয়মের বাক্য। ২. প্রত্যাশিত মান হাতে হিসাব করা সংখ্যা হবে; calcTotal-এর সূত্র test-এ আবার লিখবে না। ৩. সীমানার মান রাখো: 99999, 100000, 100001 আর ছাড়ের সীমা ঠিক 200000 পয়সার কার্টে। ৪. calcTotal-কে mock করবে না; toBeDefined বা toBeGreaterThan দিয়ে কাজ সারবে না। ৫. কোন test কোন ভুল ধরবে, প্রতিটির পাশে এক লাইনে বলো।

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

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

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

প্র ০১Pyramid আর trophy নিয়ে বিতর্ক এত পুরোনো হয়েও মেটে না কেন?

কারণ দুই পক্ষ আসলে ভিন্ন সময়ের, ভিন্ন ধরনের কোড দেখে কথা বলছেন। Mike Cohn-এর pyramid জনপ্রিয় হয় ২০০০-এর দশকের শেষে, যখন UI test মানে ছিল ধীর, ভঙ্গুর browser automation; একটি test চালাতে মিনিট লাগত, আর selector বদলালেই ভাঙত। সেই অবস্থায় “নিচে অনেক দ্রুত test, ওপরে অল্প” খুবই যুক্তিসংগত পরামর্শ। Martin Fowler ২০১২ সালে তাঁর ওয়েবসাইটে ছবিটি আরও ছড়িয়ে দেন।

২০১০-এর দশকে JavaScript frontend বদলে গেল। React-এর মতো component-ভিত্তিক UI এল, আর jsdom-এর মতো নকল browser-এ মিলিসেকেন্ডে component চালানো সম্ভব হলো। তখন দেখা গেল, প্রতিটি ছোট function আলাদা করে test করলে test-গুলো ভেতরের গঠনে বাঁধা পড়ে। ২০১৬ সালে Guillermo Rauch-এর একটি টুইট, “Write tests. Not too many. Mostly integration.”, এই অনুভূতির স্লোগান হয়ে যায়। Kent C. Dodds আনুমানিক ২০১৮ সালে trophy আঁকেন, যেখানে মাঝের অংশ সবচেয়ে মোটা। একই সময়ে Spotify-এর engineering blog microservice-এর জন্য honeycomb বর্ণনা করে।

বিতর্ক জিইয়ে রাখে নামের অস্পষ্টতাও। Trophy-র “integration” মানে প্রায়ই কয়েকটি component একসাথে, jsdom-এ, network ছাড়া; গতিতে সেটি pyramid-এর unit test-এর কাছাকাছি। আবার কেউ “integration” বলতে বোঝান আসল database আর অন্য service-সহ test, যা অনেক ধীর। একই শব্দে দুই জিনিস, তাই একজনের “বেশি integration” আরেকজনের কাছে “ধীর suite”.

ফুল-স্ট্যাক অ্যাপে বিতর্কটি অনেকটাই মিটে যায়, যদি অংশ ধরে ভাবেন। দাম আর ছাড়ের হিসাব pyramid চায়, React-এর পাতা trophy চায়, database-এ কথা বলা API honeycomb চায়। এই পর্বের ল্যাবে API test component test-এর চেয়েও দ্রুত চলেছে; তাই আকার না মুখস্থ করে প্রতিটি test-এর আসল গতি, স্থিরতা আর ধরতে পারা ভুলের তালিকা দেখুন।

মূল উপলব্ধি: প্রতিটি আকার একটি নির্দিষ্ট যুগের নির্দিষ্ট কোডের পরামর্শ। আকার নয়, ঝুঁকি কোথায় আর কোন test সবচেয়ে সস্তায় সেটি ধরে, সেটিই সিদ্ধান্তের ভিত্তি।

প্র ০২কিছু প্রকল্প সত্যিই ১০০% branch coverage দাবি করে। তাহলে এই পর্বে ১০০%-কে লক্ষ্য না বানাতে বলা হলো কেন?

কারণ ১০০% coverage দুই ধরনের প্রতিষ্ঠানে দুই রকম অর্থ বহন করে। একদিকে আছে সেই সফটওয়্যার, যার ভুলের দাম মানুষের জীবন বা কোটি মানুষের ডেটা। বিমানের সফটওয়্যারের মান DO-178C সবচেয়ে গুরুত্বপূর্ণ স্তরের কোডে MC/DC নামের কঠোর coverage চায়, যেখানে প্রতিটি শর্ত আলাদাভাবে ফলের ওপর প্রভাব ফেলে কি না দেখাতে হয়। SQLite-এর দল তাদের ওয়েবসাইটে জানায়, তাদের test harness ১০০% branch coverage আর MC/DC অর্জন করে। এসব প্রকল্পে coverage একটি বড় পদ্ধতির অংশ: আলাদা স্বাধীন যাচাই, review, আর বহু বছরের শৃঙ্খলা।

অন্যদিকে সাধারণ ব্যবসার ওয়েব অ্যাপ। সেখানে ১০০% বাধ্যতামূলক করলে প্রায়ই উল্টো ফল হয়। শেষ কয়েক শতাংশ পেতে লাগে কঠিন error-এর পথ সাজানো, যার জন্য জটিল mock বানাতে হয়, আর সেই mock ভেতরের গঠনে বাঁধা পড়ে। আরও খারাপ, সংখ্যা লক্ষ্য হয়ে গেলে মানুষ সংখ্যা মেলায়: assertion ছাড়া একটি test-ও লাইনগুলো “চালিয়ে” দেয়, coverage বাড়ায়। Charles Goodhart ১৯৭৫ সালে অর্থনীতির প্রসঙ্গে যে পর্যবেক্ষণ দেন, পরে Marilyn Strathern-এর ভাষায় যা জনপ্রিয় হয়, তার সারকথা এটিই: মাপ লক্ষ্য হলে মাপ নষ্ট হয়।

এই পর্বের ল্যাবেই দুটি প্রমাণ আছে। > বসানো ভুল কোডে coverage ছিল পুরো ১০০, সব test সবুজ। আর AI-ধাঁচের ফাঁপা test-এ branch coverage ১০০%, অথচ mutation score মাত্র ২৩%. দুই ক্ষেত্রেই সংখ্যাটি মিথ্যা বলেনি, কোড সত্যিই চলেছিল। সমস্যা হলো আমরা সংখ্যাটিকে এমন প্রশ্নের উত্তর ভেবেছি, যার উত্তর সে দেয় না।

তাই কাজের পথ হলো ঝুঁকি অনুযায়ী মেঝে। টাকার হিসাবের ফাইলে উঁচু সীমা আর mutation testing, সাধারণ UI-তে মাঝারি সীমা, আর সবসময় “কোন লাইন চলেনি” তালিকাটি পড়ে সিদ্ধান্ত নেওয়া।

মূল উপলব্ধি: Coverage বলে কোথায় test নেই, বলে না যেখানে test আছে সেটি কাজের কি না। উঁচু coverage তখনই অর্থবহ, যখন তার পেছনে কঠোর assertion আর আলাদা যাচাই থাকে।

প্র ০৩২০১২ সালের Knight Capital-এর ঘটনা থেকে test আর deploy-এর আস্থা নিয়ে কী শেখা যায়?

২০১২ সালের ১ আগস্ট যুক্তরাষ্ট্রের শেয়ারবাজারের বড় একটি trading প্রতিষ্ঠান Knight Capital প্রায় ৪৫ মিনিটে বিপুল ক্ষতির মুখে পড়ে। মার্কিন নিয়ন্ত্রক SEC-এর ২০১৩ সালের আদেশে বর্ণনা অনুযায়ী, ক্ষতি ছিল ৪৬ কোটি ডলারের বেশি, আর প্রতিষ্ঠানটি কার্যত টিকে থাকতে পারেনি। ঘটনাটি software deploy-এর ইতিহাসে বহুল আলোচিত।

SEC-এর বর্ণনার মূল কথা এরকম। একটি নতুন feature-এর জন্য কোড আটটি server-এ বসানোর কথা ছিল, কিন্তু একটিতে বসেনি। নতুন কোড একটি পুরোনো flag-কে নতুন অর্থে ব্যবহার করছিল। যে server-এ পুরোনো কোড রয়ে গিয়েছিল, সেখানে সেই flag বহু বছর আগে বন্ধ হয়ে যাওয়া একটি পুরোনো feature চালু করে দিল, যা বাজারে অসংখ্য অনাকাঙ্ক্ষিত অর্ডার পাঠাতে থাকে। পুরোনো কোডটি মুছে ফেলা হয়নি, শুধু অব্যবহৃত পড়ে ছিল।

শিক্ষাগুলো বহুমুখী, আর সব test-এর নয়। প্রথমত, unit test এখানে সাহায্য করত না: নতুন কোড হয়তো নিজে ঠিকই ছিল। ভুল ছিল জোড়ায়: একটি পুরোনো কোড আর একটি নতুন অর্থের flag একসাথে। এ ধরনের ভুল ধরা পড়ে শুধু সত্যিকারের পরিবেশে পুরো পথ চালিয়ে দেখলে, অর্থাৎ deploy-এর পরে একটি smoke check বা ছোট E2E দিয়ে। দ্বিতীয়ত, মৃত কোড ঝুঁকি; যে কোড আর চলে না, তা মুছে ফেলা নিরাপদ, আর ভালো test থাকলে সেই মুছে ফেলার সাহস আসে। তৃতীয়ত, deploy নিজেই স্বয়ংক্রিয় আর যাচাইযোগ্য হওয়া দরকার: সব server-এ একই version আছে কি না, যন্ত্র নিজে দেখবে।

বাজারঘরের মাপে ভাবুন। ঈদের অফারের flag চালু করলেন, আর একটি পুরোনো instance-এ পুরোনো কোড রয়ে গেল। এক মুহূর্তে কিছু ক্রেতা ছাড় পেলেন, কিছু পেলেন না, কিছু অর্ডারে ছাড় দুবার। এর বিরুদ্ধে প্রতিরক্ষা কয়েক স্তরের: হিসাবের unit test, API test, deploy-এর পরে একটি স্বয়ংক্রিয় checkout E2E, আর পর্ব ৮৩-এর monitoring.

মূল উপলব্ধি: Deploy-এর আস্থা আসে test, স্বয়ংক্রিয় deploy আর deploy-পরবর্তী যাচাই মিলিয়ে। কোনো একটি স্তর একা যথেষ্ট নয়, আর জোড়ার ভুল ধরার জন্য অল্প কিছু ওপরের স্তরের test সবসময় লাগে।

প্র ০৪Mutation testing প্রায় ৫০ বছরের পুরোনো ধারণা। তবু সব দল এটি নিয়মিত চালায় না কেন?

মূল বাধা তিনটি: সময়, equivalent mutant, আর ফল ব্যবহারের পথ। প্রথমত সময়। প্রতিটি mutant-এর জন্য test আবার চালাতে হয়। আমাদের ছোট price.js-এ ২৬টি mutant আর পাঁচটি দ্রুত test, তাই ২ সেকেন্ড। কিন্তু হাজার ফাইলের project-এ লক্ষাধিক mutant হতে পারে, আর suite যদি কয়েক মিনিটের হয়, পুরো হিসাব দিনের পর দিন লাগবে। আধুনিক tool-গুলো এই খরচ কমায়: প্রতিটি mutant-এর জন্য শুধু সেই test চালায় যা ওই লাইন ছোঁয় (Stryker-এর perTest coverage analysis), আগের ফল মনে রাখে, আর একসাথে অনেক process চালায়।

দ্বিতীয়ত equivalent mutant. আমাদের maximumFractionDigits-এর mutant কোনো test মারতে পারবে না, কারণ আচরণ বদলায়নি। কোনটি equivalent আর কোনটি আসলে দুর্বল test-এর লক্ষণ, তা বুঝতে একজন মানুষকে কোড পড়তে হয়। Survived-এর তালিকা যদি এমন “শব্দ”-এ ভরে যায়, ডেভেলপাররা তালিকা পড়াই বন্ধ করে দেন।

তৃতীয়ত ফল কোথায় দেখাবেন। পুরো project-এর একটি score মাসে একবার দেখলে কেউ কিছু করে না। Google-এর গবেষকেরা ২০১৮ সালের দিকে প্রকাশিত একটি গবেষণাপত্রে জানান, তাঁরা পুরো project-এর score-এর বদলে code review-এর সময়, শুধু বদলানো লাইনে, বাছাই করা কিছু mutant দেখান, আর অপ্রয়োজনীয় ধরনের mutant বাদ দেন। এতে ফলটি সঠিক মুহূর্তে সঠিক মানুষের কাছে পৌঁছায়।

ছোট দলের জন্য ব্যবহারিক পথ তাই নির্বাচিত ব্যবহার। বাজারঘরে দাম, ছাড়, স্টক আর অনুমতির নিয়ম যে কয়েকটি ফাইলে, সেখানে মাঝে মাঝে Stryker চালানো; survived mutant দেখে সীমানার test যোগ করা; আর AI-লেখা test গ্রহণের আগে একবার চালিয়ে দেখা সেগুলো ফাঁপা কি না। পুরো project-এ প্রতিটি push-এ চালানো ছোট দলের জন্য সাধারণত দরকার নেই।

মূল উপলব্ধি: Mutation testing দামি কিন্তু তীক্ষ্ণ যন্ত্র। গুরুত্বপূর্ণ অল্প কোডে, বদলানো লাইনে আর সন্দেহজনক test-এ ব্যবহার করলে এটি এমন দুর্বলতা দেখায়, যা coverage কখনো দেখায় না।

প্র ০৫Implementation-এর খুঁটিনাটি test করা কি সবসময় ভুল? কখন সেটি ঠিক সিদ্ধান্ত?

না, সবসময় ভুল নয়। প্রশ্নটি আসলে “এটি কি কারও কাছে চুক্তি?” যে ভেতরের জিনিস বদলালে ব্যবহারকারী, অন্য দল বা খরচের হিসাব ক্ষতিগ্রস্ত হয়, সেটি test-এ বাঁধা যুক্তিসংগত। যা বদলালে কারও কিছু যায় আসে না, সেটি বাঁধলে শুধু ভঙ্গুরতা বাড়ে।

কয়েকটি বৈধ উদাহরণ। পর্ব ৪২-এর query budget test গোনে একটি পাতায় কতগুলো SQL চলল। ক্রেতা SQL দেখেন না, কিন্তু N+1 ফিরে এলে পাতা ধীর হয় আর database-এর খরচ বাড়ে; এটি performance-এর চুক্তি। Webhook-এর ক্ষেত্রে, payment gateway-কে ঠিক একবার ডাকা হলো কি না দেখা জরুরি, কারণ দুবার ডাকলে টাকা দুবার কাটতে পারে; এখানে mock দিয়ে “কতবার ডাকা হলো” যাচাই আসলে বাইরের আচরণই। পর্ব ৪৮-এর ছকে ভুল অনুরোধে database-এ কিছু বদলায়নি, এটিও যাচাই করা হয়েছে; ভেতরের অবস্থা দেখা হলেও সেটি নিরাপত্তার চুক্তি।

পুরোনো কোডের ক্ষেত্রে আরেকটি পরিস্থিতি। Michael Feathers তাঁর Working Effectively with Legacy Code (২০০৪) বইয়ে characterization test-এর কথা বলেন: কোড এখন যা করে, ঠিক বা ভুল যা-ই হোক, সেটি লিখে রাখা, যাতে refactor করার সময় অজান্তে কিছু না বদলায়। এগুলো ইচ্ছা করেই খুঁটিনাটিতে বাঁধা, আর refactor শেষে অনেকগুলো মুছে আচরণভিত্তিক test দিয়ে বদলানো হয়।

তাহলে কোথায় সীমা টানবেন? একটি সহজ পরীক্ষা: এমন একটি refactor কল্পনা করুন যা ব্যবহারকারীর কাছে কিছুই বদলায় না, যেমন class-এর নাম বদল, helper ভেঙে দুটি করা, useState থেকে useReducer. Test যদি তাতে ভাঙে, আর সেই ভাঙার পেছনে কোনো চুক্তি না থাকে, তবে test-টি ভঙ্গুর। এই পর্বের brittle test ঠিক এভাবে ভেঙেছিল, আবার আসল বাগে চুপ ছিল।

মূল উপলব্ধি: নিয়মটি “implementation কখনো নয়” নয়; নিয়মটি হলো “যা চুক্তি, শুধু সেটিই”. চুক্তি হতে পারে ব্যবহারকারীর চোখের আচরণ, performance-এর সীমা, নিরাপত্তা বা বাইরের সেবার সাথে কথা।

অনুশীলন

হাতে হিসাব, নকশার সমালোচনা, আউটপুট আগাম বলা আর নিজের project-এ কমান্ড।

  1. হিসাব: ধরে নিন প্রতিটি E2E test স্বাধীনভাবে ১% সম্ভাবনায় কোনো বাগ ছাড়াই ফেল করে। (ক) ১০টি E2E থাকলে একটি run-এ অন্তত একটি মিথ্যা লাল হওয়ার সম্ভাবনা কত? (খ) ৫০টি থাকলে? (গ) ১০টি test-এর প্রতিটির অস্থিরতা ০.৫%-এ নামালে (ক)-এর উত্তর কত হবে?

    সূত্র: অন্তত একটি ফেল = ১ − (সবগুলো পাস) = ১ − (১ − p)n. (ক) ১ − ০.৯৯১০ ≈ ১ − ০.৯০৪৪ = ৯.৫৬%, অর্থাৎ প্রায় প্রতি ১০টি run-এ একটিতে মিথ্যা লাল। (খ) ১ − ০.৯৯৫০ ≈ ১ − ০.৬০৫০ = ৩৯.৫%. প্রায় প্রতি পাঁচটির দুটি build কোনো কারণ ছাড়াই লাল, আর দল লাল রং বিশ্বাস করা বন্ধ করে দেবে। (গ) ১ − ০.৯৯৫১০ ≈ ৪.৮৯%. শিক্ষা: E2E-র সংখ্যা বাড়লে অস্থিরতা গুণিতকে বাড়ে, তাই E2E কম রাখা আর প্রতিটিকে স্থির করা দুটোই জরুরি।

  2. নকশা ও সমালোচনা: একজন সহকর্মী বাজারঘরের অর্ডার API-র জন্য নিচের test পাঠালেন। এতে কী কী দুর্বলতা? vi.mock('../src/db.js'); it('creates order', async () => { db.insertOrder.mockResolvedValue({ id: 1 }); const res = await request(app).post('/api/orders').send({ items: [{ productId: 1, qty: 1 }] }); expect(res.status).toBeDefined(); expect(db.insertOrder).toHaveBeenCalled(); });
    • Database পুরো নকল: স্টক কমল কি না, transaction rollback হয় কি না, SQL ঠিক কি না, কিছুই যাচাই হয় না। এই route-এর ঝুঁকি ঠিক database-এ, তাই আসল test database লাগবে।
    • Assertion ফাঁপা: toBeDefined() 500-এও পাস। নির্দিষ্ট 201 আর totalPaisa-এর হাতে হিসাব করা মান চাই।
    • ভেতরের ডাক যাচাই: insertOrder ডাকা হয়েছে কি না, এটি implementation. Function-এর নাম বদলালে ভাঙবে, অথচ ভুল দামে অর্ডার হলেও পাস করবে।
    • Login নেই, তবু পাস হতে পারে: Header-এ token নেই; route যদি login চাইত, test-এর 401-ও toBeDefined পার হতো। আলাদা “login ছাড়া 401” test লাগবে।
    • নেতিবাচক কেস নেই: স্টকের বেশি চাওয়া, client-এর পাঠানো দাম, ভুল qty, এসবের test নেই।
    • নাম অস্পষ্ট: creates order-এর বদলে creates the order and takes the stock.
  3. আউটপুট আগাম বলুন: const free = (t) => t >= 100000; এর জন্য দুটি test আছে: free(150000) === true আর free(50000) === false. একটি mutation tool চারটি mutant বানাল: >= থেকে >, >= থেকে <=, পুরো শর্ত true, আর পুরো শর্ত false. কোনগুলো survived, আর score কত? কোন একটি test যোগ করলে সব মরবে?

    >: 150000 > 100000 সত্য, 50000 > 100000 মিথ্যা, দুটি test-ই পাস, তাই SURVIVED. <=: 150000-এ মিথ্যা, প্রথম test লাল, KILLED. true: 50000-এ সত্য, দ্বিতীয় test লাল, KILLED. false: 150000-এ মিথ্যা, প্রথম test লাল, KILLED. Score ৩/৪ = ৭৫%. free(100000) === true যোগ করলে > mutant মিথ্যা দেবে, তাই সেটিও মরবে, score ৪/৪. পাতার tiny-mutation.js block-এ এই কেস বসিয়ে মিলিয়ে দেখতে পারেন।

  4. কমান্ড ও পরীক্ষা: নিজের কোনো Vite বা Node project-এ @vitest/coverage-v8 বসিয়ে npx vitest run --coverage চালান। “Uncovered Line” কলাম থেকে একটি লাইন বেছে ঠিক করুন সেখানে test লাগবে কি না। তারপর একটি গুরুত্বপূর্ণ ছোট ফাইলে Stryker চালান, আর একটি survived mutant-এর জন্য test লিখুন। কাজটি শুধু নিজের local project-এ করুন।

    আমাদের ল্যাবে coverage দিল app.js | 89.18 | 88.23 | 80 | 88.57 | 27,35,55-56. সিদ্ধান্ত: ৩৫ নম্বর লাইনের 422-এর পথে একটি test লিখব, কারণ সস্তা আর ভুল ইনপুট ঠেকানো নিরাপত্তার কাজ। ২৭ নম্বর লাইন E2E ছোঁয়, তবু একটি ছোট API test দেওয়া ভালো, যাতে Vitest-এর হিসাবেও আসে। ৫৫-৫৬-এর catch পথের জন্য database-এ ভুল ঘটাতে হবে, তাই পর্ব ৭৫-এর ব্যবস্থায় দেখব। Stryker price.js-এ দিয়েছিল ২৬টি mutant, ২টি survived; সীমানার test যোগ করে 96.15, আর বাকিটি equivalent mutant. আপনার project-এ ফল আলাদা হবে; মূল কথা হলো প্রতিটি survived mutant-এর সামনে প্রশ্ন করা: test দুর্বল, নাকি mutant আচরণই বদলায় না?

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

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

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

  1. ⚖️ ক্ষতি আর সম্ভাবনা দিয়ে ঝুঁকি মাপা
  2. 🧭 ভুলটি সবচেয়ে সস্তায় ধরে এমন স্তর বাছাই
  3. ✍️ আচরণ ধরে, নিয়মের নামে AAA আকারে test লেখা
  4. 🧪 ইচ্ছা করে ভুল ঢুকিয়ে বা mutation দিয়ে দেখা test লাল হয় কি না
  5. 🔁 প্রতিটি push-এ স্বয়ংক্রিয়ভাবে চালানো

একটি অনলাইন শপের নিয়ম: “EID10 কুপনে ১০% ছাড়, সর্বোচ্চ ২০০ টাকা।” নিয়মটি একটি খাঁটি function-এ লেখা, যা কোনো DOM বা database ছোঁয় না। এই সীমা যাচাইয়ের সবচেয়ে সস্তা আর উপযুক্ত স্তর কোনটি?

  • Unit test
  • E2E test
  • Component test
  • শুধু চোখে দেখা

একটি component test container.querySelector('.row.total span:last-child') দিয়ে মোট দাম পড়ে। Developer markup-কে dl, dt, dd-তে বদলালেন আর class-এর নাম বদলালেন; পাতায় দেখানো মোট আর বোতামের কাজ একই রইল। Test ফেল করল। এটিকে কী বলবেন?

  • একটি আসল regression ধরা পড়েছে
  • একটি flaky test
  • Implementation-এ বাঁধা brittle test-এর মিথ্যা লাল
  • Coverage কম হওয়ার লক্ষণ

একটি function-এ after >= 100000 ? 0 : 6000 ভুল করে after > 100000 লেখা হলো। Test দুটি: after = 15000 আর after = 105000. দুটিই পাস, branch coverage ১০০%. এ থেকে কী বোঝা যায়?

  • ১০০% branch coverage থাকায় কোডে ভুল নেই
  • Coverage বলে কোড চলেছে, ঠিক কি না বলে না; সীমানার মান 100000-এর test নেই
  • আরও বেশি এলোমেলো মানের test দিলেই নিশ্চিতভাবে ধরা পড়ত
  • v8 provider ভুল হিসাব দিয়েছে, istanbul দিলে ধরা পড়ত

একটি ফাইলে mutation tool ২৬টি mutant বানাল। ২৪টি কোনো না কোনো test-এ লাল হলো, ২টি সব test পার হয়ে গেল, timeout বা error নেই। Mutation score কত?

  • প্রায় ৭.৭%
  • ১০০%
  • প্রায় ৮৪.৬%
  • প্রায় ৯২.৩%

Kent C. Dodds-এর testing trophy-তে কোন স্তরটি সবচেয়ে বড় অংশ, আর তার ভিত্তিতে কী থাকে?

  • সবচেয়ে বড় unit, ভিত্তিতে manual test
  • সবচেয়ে বড় integration, ভিত্তিতে static check
  • সবচেয়ে বড় E2E, ভিত্তিতে unit
  • সবচেয়ে বড় contract test, ভিত্তিতে honeycomb

AI একটি দামের function-এর জন্য এই test দিল: ফল নিয়ে expect(result).toBeDefined() আর expect(result.total).toBeGreaterThan(0). Test পাস করে। মূল সমস্যা কী?

  • Test-এর নাম ইংরেজিতে
  • toBeDefined এই version-এ নেই
  • প্রায় যেকোনো ভুল ফলও এই দুটি শর্ত পূরণ করে, তাই test বাগ ধরে না
  • এটি coverage কমিয়ে দেয়

শব্দকোষ

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

মনে রাখুন

  1. Test কেনে আস্থা: refactor-এর সাহস আর deploy-এর ভরসা। নাম নয়, দেখুন কোন অংশ আসল, কোনটি নকল।
  2. Pyramid, trophy, honeycomb ভিন্ন কোডের পরামর্শ; ফুল-স্ট্যাকে হিসাবে unit, UI-তে component, API-তে database-সহ test, আর অল্প E2E.
  3. ঝুঁকি আগে: টাকা, স্টক, login আর অনুমতি; footer-এর রং শেষে, বা test-ই নয়।
  4. আচরণ test করুন: brittle test নির্দোষ refactor-এ ভাঙে আর আসল বাগে চুপ থাকে।
  5. Coverage মেঝে, mutation আয়না: ১০০% coverage-এও সীমানার বাগ থাকে; survived mutant দুর্বল বা ফাঁপা test দেখায়।

পরের পর্বে: কৌশল ঠিক হলো, এবার প্রথম স্তরের গভীরে। পর্ব ৭৩-এ Unit test: Vitest, mock আর snapshot, বাজারঘরের দামের নিয়ম ধরে।

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

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