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

Unit test — Vitest, mock আর snapshot

Unit Testing with Vitest — Matchers, Mocks, Fake Timers and Snapshots

Taught in Bangla, this lesson sets up Vitest in a Vite and TypeScript project, writes small fast unit tests for the Bazarghar cart, uses expect and matchers including toBe versus toEqual, toThrow and async checks, table tests with test.each, watch mode and the danger of .only, test doubles with vi.fn, vi.spyOn and vi.mock at boundaries only, fake timers and setSystemTime for time-zone bugs, snapshots and blind updates, coverage and moving from Jest.

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

  • একটি Vite আর TypeScript প্রজেক্টে Vitest বসানো, চালানো আর reporter-এর ভাষা পড়া
  • describe, it, expect-এর matcher, async test আর beforeEach/afterEach
  • test.each দিয়ে টাকা, পয়সা আর বাংলা সংখ্যার ছক, আর float-এর ফাঁদ ধরা
  • vi.fn, vi.spyOn, vi.mock, fake timer আর vi.setSystemTime
  • Snapshot, coverage, type test, Jest থেকে আসা আর গতি নিয়ে সৎ মাপ

১ · Unit test কেন, আর Vitest কোথায় বসে

বাজারঘরের মালিক একদিন জিজ্ঞেস করলেন, “শুক্রবারে ৫% বাড়তি ছাড় দিলে EID10 কুপনের সাথে ঠিক যোগ হবে তো? আর রাত বারোটার পরেই কি শুক্রবার ধরা হবে?” এই প্রশ্নের উত্তর দিতে browser খুলে কার্টে মাল ভরে ঘড়ি দেখে বসে থাকা যায়। অথবা একটি ছোট program লেখা যায়, যে এক সেকেন্ডের কম সময়ে একই প্রশ্ন একশোবার করে, আর প্রতিবার উত্তর মিলিয়ে দেখে। দ্বিতীয়টিই unit test.

মুদি দোকানের হিসাব খাতার কথা ভাবুন। মাস শেষে পুরো খাতা মেলানো কঠিন, কোথায় ভুল তা খুঁজে পেতে সারা রাত লাগে। কিন্তু প্রতিটি পাতার নিচে যদি ছোট একটি যোগফল মিলিয়ে রাখা থাকে, তাহলে ভুল হলে ঠিক কোন পাতায়, সেটি সাথে সাথে বোঝা যায়। Unit test হলো প্রতিটি পাতার সেই ছোট মিলানো। পুরো দোকান চালু না করেই একটি হিসাব আলাদা করে যাচাই হয়।

Test-এর তত্ত্ব, মানে কোনটি unit, কোনটি integration আর কতটুকু কোনটি লাগবে, এই কোর্সে আগের পর্ব ৭২-এ (ফুল-স্ট্যাক test কৌশল) দেখেছেন। আরও পূর্ণ তত্ত্ব আছে Unit testing আর Testing-এর মূল ধারণা পর্বে। এখানে আমরা হাতে-কলমে কাজ করব। আগেও কয়েকবার ছোট করে Vitest এসেছে: পর্ব ১৭-এ reducer test, পর্ব ২৯-এ নকল repository দিয়ে service test, পর্ব ৪২-এ query-র বাজেট, আর পর্ব ৪৮-এ role বনাম route-এর ছক। এবার পুরো টুলটি ভালো করে চিনব।

Vitest একটি test runner: সে আপনার test ফাইল খুঁজে বের করে, চালায়, আর ফল দেখায়। তার বিশেষত্ব হলো সে Vite-এর একই কাঠামো ব্যবহার করে। ফলে TypeScript, ES module আর Vite-এর config আলাদা ঝামেলা ছাড়াই test-এও চলে।

JavaScript-এর জগতে দীর্ঘদিন সবচেয়ে পরিচিত test runner ছিল Jest, যা Facebook ২০১৪ সালে open source করে। Vite আসে ২০২০ সালে, Evan You-এর হাত ধরে। Vite প্রজেক্টে Jest চালাতে আলাদা করে TypeScript আর ESM অনুবাদের ব্যবস্থা লাগত, আর এই ঘর্ষণ থেকেই ২০২১ সালের শেষ দিকে Vitest শুরু হয়। শুরুর দিকের প্রধান কারিগর হিসেবে Anthony Fu আর Matías Capeletto-র নাম পরিচিত। লেখার সময়ে npm-এ Vitest-এর সর্বশেষ স্থির version 5.1.0.

শুরুতে একটি ছোট মডেল দেখুন। Vitest-এর test আর expect ভেতরে কী করে, তার একটি খুব ছোট রূপ নিচে আছে। এটি আসল Vitest নয়, শুধু শেখার জন্য বানানো, কিন্তু মূল চিন্তা একই। চালিয়ে দেখুন।

// শিক্ষণ মডেল: Vitest-এর test আর expect ভেতরে মোটামুটি এমন
const results = [];
function deepEqual(a, b) {
  if (Object.is(a, b)) return true;
  if (typeof a !== 'object' || typeof b !== 'object' || !a || !b) return false;
  const ka = Object.keys(a), kb = Object.keys(b);
  return ka.length === kb.length && ka.every((k) => deepEqual(a[k], b[k]));
}
function expect(got) {
  const fail = (msg) => { throw new Error(msg); };
  return {
    toBe: (want) => Object.is(got, want) || fail(`expected ${got} to be ${want}`),
    toEqual: (want) => deepEqual(got, want) ||
      fail(`expected ${JSON.stringify(got)} to equal ${JSON.stringify(want)}`),
    toThrow(text) {
      try { got(); } catch (e) { return e.message.includes(text) || fail(`wrong error: ${e.message}`); }
      fail('expected function to throw');
    },
  };
}
function test(name, fn) {
  try { fn(); results.push(`✓ ${name}`); } catch (e) { results.push(`× ${name} → ${e.message}`); }
}
// বাজারঘরের ছোট একটি নিয়ম
const totals = (paisa, qty) => {
  if (qty < 1) throw new Error('BAD_QTY');
  return { subtotal: paisa * qty, delivery: paisa * qty >= 100000 ? 0 : 6000 };
};
test('toEqual: content', () => expect(totals(42000, 2)).toEqual({ subtotal: 84000, delivery: 6000 }));
test('toBe: same object?', () => expect(totals(42000, 2)).toBe(totals(42000, 2)));
test('toBe on a number', () => expect(totals(50000, 2).delivery).toBe(0));
test('toThrow', () => expect(() => totals(42000, 0)).toThrow('BAD_QTY'));
test('NaN is NaN with Object.is', () => expect(0 / 0).toBe(NaN));
console.log(results.join('\n'));
console.log(`Tests: ${results.filter((r) => r[0] === '×').length} failed | ${results.filter((r) => r[0] === '✓').length} passed`);

আউটপুট:

✓ toEqual: content
× toBe: same object? → expected [object Object] to be [object Object]
✓ toBe on a number
✓ toThrow
✓ NaN is NaN with Object.is
Tests: 1 failed | 4 passed

একটি test লাল, আর সেটি ইচ্ছা করেই। totals(42000, 2) দুবার ডাকলে দুটি আলাদা object তৈরি হয়। ভেতরের মান এক, কিন্তু তারা একই object নয়। toBe ব্যবহার করে Object.is, তাই সে ফেল করল। toEqual ভেতরে ঢুকে প্রতিটি key মিলিয়েছে, তাই পাস। শেষের test দেখায় Object.is আর === এক নয়: NaN === NaN মিথ্যা, কিন্তু Object.is(NaN, NaN) সত্য। একটি test ফেল করলেও বাকিগুলো চলেছে, কারণ প্রতিটি test নিজের try/catch-এ আলাদা। আসল runner-ও এভাবেই একটির ব্যর্থতা অন্যটিতে ছড়াতে দেয় না।

২ · Vite আর TypeScript প্রজেক্টে Vitest বসানো

আমরা একটি নতুন প্রজেক্ট বানিয়েছি, সত্যিই চালিয়েছি, আর নিচের সব আউটপুট সেই প্রজেক্টের। লেখার সময়ের version: Node 24.15.0, create-vite 9.3.3, Vite 8.3.5, TypeScript 7.0.2, Vitest 5.1.0, @vitest/coverage-v8 5.1.0. Vitest-এর docs অনুযায়ী Vitest 5-এর জন্য Vite 6.4.0 বা নতুন আর Node 22.12.0 বা নতুন লাগে। কাজের পরিবেশের পুরো ছবি আছে পর্ব ০৫-এ।

$ npm create vite@9.3.3 bazarghar -- --template vanilla-ts --no-interactive
$ cd bazarghar
$ npm install
$ npm install -D vitest@5.1.0 @vitest/coverage-v8@5.1.0

তারপর দুটি ছোট কাজ। package.json-এ script, আর একটি config ফাইল। Vite-এর config থাকলে সেখানেই test অংশ যোগ করা যায়। আমাদের template-এ vite.config.ts ছিল না, তাই আলাদা vitest.config.ts বানানো হলো।

"scripts": {
  "dev": "vite",
  "build": "tsc && vite build",
  "test": "vitest",
  "test:run": "vitest run",
  "coverage": "vitest run --coverage"
}
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    include: ['src/**/*.test.ts'],
    coverage: {
      provider: 'v8',
      reporter: [['text', { skipFull: false }]],
      include: ['src/shop/**/*.ts'],
    },
  },
});

vitest একা চালালে সে watch mode-এ থাকে, ফাইল বদলালেই আবার চালায়। vitest run একবার চালিয়ে বেরিয়ে যায়, যা CI-র জন্য দরকার। আমাদের test ফাইলগুলো কোডের পাশেই থাকে: cart.ts-এর পাশে cart.test.ts.

src/shop/
  money.ts         টাকা, পয়সা, বাংলা লেখা
  cart.ts          কার্টের মোট, ছাড়, delivery
  reservation.ts   ১৫ মিনিটের stock ধরে রাখা
  sms.ts           SMS gateway-তে HTTP ডাক
  order.ts         অর্ডার + SMS
  receipt.ts       রসিদের লেখা
  *.test.ts        প্রতিটির পাশে তার test

এবার npx vitest run চাপলে ভেতরে কী কী ঘটে, ধাপে ধাপে দেখুন। সংখ্যাগুলো আমাদের সত্যিকারের run-এর।

  1. Configincludeনিয়ম পড়া
  2. খোঁজা5 filesdiscovery
  3. Workerfile ↔ workerআলাদা ঘর
  4. অনুবাদTS → JSVite
  5. চালানোhooks + itcollect, run
  6. রিপোর্ট29 passedreporter
vitest.config.tsকোন ফাইল test
test: {
  include: ['src/**/*.test.ts'],
}
পাওয়া গেলglob মিলিয়ে
src/shop/cart.test.ts
src/shop/money.test.ts
src/shop/order.test.ts
src/shop/receipt.test.ts
src/shop/reservation.test.ts
5 files
Isolationএক ফাইলের mock অন্যটিতে যায় না
worker 1 ← cart.test.ts
worker 2 ← money.test.ts
worker 3 ← order.test.ts   (এখানে vi.mock('./sms'))
...
order.test.ts-এর নকল sms cart.test.ts দেখে না
order.test.tsঅনুবাদের সময় vi.mock ওপরে ওঠে
vi.mock('./sms', ...)     // hoisted, সবার আগে
import { placeOrder } from './order';
import { sendSms } from './sms';
cart.test.ts৬টি test, প্রতিটির আগে-পরে hook
describe('cartTotals')        → ৬টি it জমা হলো
  beforeEach  useFakeTimers, setSystemTime
  it('coupon: 10% off, then delivery')  ✓ 3ms
  afterEach   useRealTimers
  ... আরও ৫টি
Reporterসত্যিকারের শেষ অংশ
 Test Files  5 passed (5)
      Tests  29 passed (29)
   Duration  159ms (import 45%, transform 33%,
             tests 14%, worker 8%)
  1. ধাপ ১ — Config পড়া। Vitest প্রথমে vitest.config.ts খোঁজে, না পেলে Vite-এর config. include বলছে কোন ফাইলগুলো test. Vitest 5 আর parent folder-এ config খোঁজে না, migration guide-এ এটি লেখা আছে।
  2. ধাপ ২ — খোঁজা। Glob মিলিয়ে ৫টি ফাইল পাওয়া গেল। Test ফাইলের নাম .test.ts দিয়ে শেষ না হলে সে বাদ পড়ত, আর কোনো error-ও দেখাত না। নতুনদের একটি চেনা ভুল এটি।
  3. ধাপ ৩ — আলাদা worker. ডিফল্টে প্রতিটি test ফাইল আলাদা পরিবেশে চলে। তাই order.test.ts-এর নকল SMS module cart.test.ts-এ পৌঁছায় না। ফাইলগুলো সমান্তরালে চলে, তাই মোট সময় কম।
  4. ধাপ ৪ — অনুবাদ। Vite TypeScript-কে JavaScript-এ বদলায়। এই সময় vi.mock ফাইলের সবার ওপরে তুলে দেওয়া হয়, যাতে import-এর আগেই নকল তৈরি থাকে। Docs-এর ভাষায় এটি hoisted.
  5. ধাপ ৫ — জমা আর চালানো। প্রথমে describe আর it শুধু test-এর তালিকা জমা করে। তারপর প্রতিটি test-এর আগে beforeEach, পরে afterEach. “coupon” test-টি 3ms নিয়েছে।
  6. ধাপ ৬ — রিপোর্ট। ৫টি ফাইল, ২৯টি test, সব পাস, মোট 159ms. বন্ধনীর ভাগ দেখায় সময়ের বড় অংশ গেছে import আর অনুবাদে, test-এর নিজের কাজে মাত্র ১৪%. এটি এক কম্পিউটারের একবারের মাপ।

৩ · expect আর matcher — toBe, toEqual, toThrow, async

প্রতিটি test-এর হৃদয় একটি assertion: expect(যা পেলাম).matcher(যা চাই). toBe, toEqual এগুলোকে বলে matcher. বাজারঘরের কার্টের আসল test দেখুন।

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cartTotals, type Line } from './cart';

const chal = (qty: number): Line => ({ sku: 'chal-5kg', unitPaisa: 42000, qty });

describe('cartTotals', () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-11-04T06:00:00Z')); // বুধবার, ঢাকায় দুপুর ১২টা
  });
  afterEach(() => {
    vi.useRealTimers();
  });

  it('coupon: 10% off, then delivery', () => {
    expect(cartTotals([chal(2)], 'EID10')).toEqual({
      subtotal: 84000, discount: 8400, delivery: 6000, total: 81600,
    });
  });

  it('toBe compares identity, toEqual compares content', () => {
    const a = cartTotals([chal(1)]);
    const b = cartTotals([chal(1)]);
    expect(a).toEqual(b);
    expect(a).not.toBe(b);
    expect(a.total).toBe(48000);
  });

  it('rejects bad input', () => {
    expect(() => cartTotals([])).toThrow('EMPTY_CART');
    expect(() => cartTotals([chal(0)])).toThrow(RangeError);
    expect(() => cartTotals([chal(1)], 'FREE100')).toThrow(/BAD_COUPON/);
  });
});

describe কয়েকটি test একসাথে বাঁধে, আর reporter-এ নামের আগে বসে: cartTotals > coupon: .... it আর test একই জিনিস, শুধু পড়তে আলাদা। beforeEach আর afterEach হলো test hook: প্রতিটি test-এর আগে একই প্রস্তুতি, পরে একই পরিষ্কার। এখানে প্রস্তুতি হলো ঘড়ি একটি জানা সময়ে বসানো, যা অংশ ৭-এ বিস্তারিত দেখবেন।

toThrow-এর তিনটি ধরন খেয়াল করুন: একটি লেখা (বার্তার ভেতরে থাকলেই চলবে), একটি error class, আর একটি regular expression. খেয়াল করুন, ফাংশনটি সরাসরি না ডেকে () =>-এর ভেতরে মুড়ে দেওয়া হয়েছে। সরাসরি ডাকলে error test-এর ভেতরেই ফেটে যেত, expect ধরার সুযোগই পেত না।

Matcherকী মেলায়বাজারঘরে ব্যবহার
toBeObject.is: একই মান বা একই objectপয়সার সংখ্যা, লেখা
toEqualভেতরের মান, গভীর পর্যন্তমোটের object
toStrictEqualtoEqual-এর মতো, তবে undefined key আর class-ও মেলায়API-র উত্তরের আকার
toMatchObjectযে key-গুলো দিলেন, শুধু সেগুলোবড় object-এর একটি অংশ
toThrowফাংশন error ছোড়ে কি নাখালি কার্ট, ভুল কুপন
toHaveBeenCalledWithনকল ফাংশন কোন argument পেলSMS-এর লেখা

সত্যিকারের Vitest-এ দুটি সমান object-এ toBe লিখলে কী বার্তা আসে, সেটিও আমরা চালিয়ে দেখেছি। বার্তাটি নিজেই সমাধান বলে দেয়:

 FAIL  src/shop/tobe.test.ts > toBe on two equal objects
AssertionError: expected { subtotal: 42000, discount: +0, …(2) } to be { subtotal: 42000, discount: +0, …(2) } // Object.is equality

If it should pass with deep equality, replace "toBe" with "toStrictEqual"

Expected: { subtotal: 42000, discount: +0, …(2) }
Received: serializes to the same string

Async test: resolves আর rejects

অর্ডার দেওয়ার ফাংশন async, কারণ সে SMS পাঠায়। Async test লিখতে test ফাংশনকেও async করুন, আর await দিন। .resolves promise পূর্ণ হলে তার মান মেলায়, .rejects ব্যর্থ হলে error মেলায়। Async-এর ভিত্তি আছে async/await পর্বে।

it('bad phone: rejects, no sms', async () => {
  await expect(placeOrder('12345', [chal])).rejects.toThrow('BAD_PHONE');
  expect(sendSms).not.toHaveBeenCalled();
});

await ভুলে গেলে পুরোনো সংস্করণে test অনেক সময় চুপচাপ পাস করে যেত, কারণ test শেষ হয়ে যেত promise মেটার আগেই। Vitest 5-এর migration guide বলছে, await ছাড়া resolves বা rejects থাকলে এখন test ফেল করে। তবু অভ্যাসটি নিজের রাখুন: async assertion-এর আগে সবসময় await.

৪ · test.each — টাকা, পয়সা আর বাংলা সংখ্যার ছক

বাজারঘরে টাকা সবসময় পূর্ণসংখ্যা পয়সায় রাখা হয়। কারণটি আপনি floating point নিয়ে আগে শুনেছেন, কিন্তু test দিয়ে নিজের চোখে দেখা আলাদা জিনিস। একই নিয়ম অনেক মানে যাচাই করার জন্য আছে test.each, যাকে সাধারণভাবে table-driven test বলে। একটি ছক দিন, প্রতিটি সারির জন্য একটি আলাদা test তৈরি হবে।

describe('formatTaka', () => {
  test.each([
    [0, '০.০০৳'],
    [1250, '১২.৫০৳'],
    [123450, '১,২৩৪.৫০৳'],
    [12345678, '১,২৩,৪৫৬.৭৮৳'],
  ])('%i paisa -> %s', (paisa, text) => {
    expect(formatTaka(paisa)).toBe(text);
  });
});

describe('paisa rounding', () => {
  test.each([
    { taka: 12.5, paisa: 1250 },
    { taka: 19.99, paisa: 1999 },
    { taka: 1.005, paisa: 101 },
  ])('float: $taka taka -> $paisa paisa', ({ taka, paisa }) => {
    expect(takaToPaisaFloat(taka)).toBe(paisa);   // Math.round(taka * 100)
  });
});

formatTaka ভেতরে Intl.NumberFormat('bn-BD', {style: 'currency', currency: 'BDT'}) ব্যবহার করে। ফলে বাংলা অঙ্ক আসে, আর লাখের হিসাবে কমা বসে: ১,২৩,৪৫৬.৭৮৳. বাংলা সংখ্যা আর তারিখের পুরো আলোচনা আছে পর্ব ৫৯-এ। Test-এর নাম %i, %s বা $taka দিয়ে সারির মান থেকে বানানো হয়, তাই কোন সারি ভাঙল তা নামেই বোঝা যায়। সত্যিকারের run-এ তৃতীয় সারিটি ভাঙল:

 ❯ src/shop/money.test.ts (16 tests | 1 failed) 8ms
   ❯ paisa rounding (11)
     × float: 1.005 taka -> 101 paisa 4ms

 FAIL  src/shop/money.test.ts > paisa rounding > float: 1.005 taka -> 101 paisa
AssertionError: expected 100 to be 101 // Object.is equality

- Expected
+ Received

- 101
+ 100

 ❯ src/shop/money.test.ts:26:36
     24|     { taka: 1.005, paisa: 101 },
     25|   ])('float: $taka taka -> $paisa paisa', ({ taka, paisa }) => {
     26|     expect(takaToPaisaFloat(taka)).toBe(paisa);
       |                                    ^
…
      Tests  1 failed | 29 passed (30)

Diff-এর ভাষাটি পড়তে শিখুন: - Expected হলো যা চেয়েছিলেন, + Received হলো যা পেলেন। কারণ হলো 1.005 * 100 আসলে 100.49999999999999, আর Math.round সেটিকে 100 বানায়। Float-এ 1.005 সংখ্যাটিই ঠিকঠাক রাখা যায় না। সমাধান float মেরামত নয়, float এড়ানো: ফর্মের লেখা থেকে সরাসরি পয়সা বানানো, আর দুই ঘরের বেশি দশমিক এলে প্রত্যাখ্যান করা।

নিচের ছকে আরেকটি চেনা ভুল দেখুন: Math.floor(taka * 100). অনেক কোডে এটি পাওয়া যায়, কারণ “পয়সা তো কাটতেই হবে”। চালিয়ে দেখুন কয়টি সারি টেকে।

// শিক্ষণ মডেল: test.each — একই test, অনেক সারি
const toPaisaFloat = (taka) => Math.floor(taka * 100);          // চেনা ভুল পথ
function parseTaka(text) {                                        // ঠিক পথ
  const m = /^(\d+)(?:\.(\d{1,2}))?$/.exec(text);
  if (!m) throw new Error('BAD_AMOUNT ' + text);
  return Number(m[1]) * 100 + Number((m[2] ?? '0').padEnd(2, '0'));
}
const each = (rows) => (title, fn) => {
  let pass = 0;
  for (const row of rows) {
    const name = title.replace(/\$(\w+)/g, (_, k) => row[k]);
    try { fn(row); pass++; console.log('✓', name); }
    catch (e) { console.log('×', name, '→', e.message); }
  }
  console.log(`  ${pass}/${rows.length} passed`);
};
const rows = [
  { text: '12.5', want: 1250 },
  { text: '19.99', want: 1999 },
  { text: '0.07', want: 7 },
  { text: '1.15', want: 115 },
  { text: '4.35', want: 435 },
];
const check = (got, want) => { if (got !== want) throw new Error(`expected ${want}, got ${got}`); };
each(rows)('float  $text', ({ text, want }) => check(toPaisaFloat(Number(text)), want));
each(rows)('string $text', ({ text, want }) => check(parseTaka(text), want));
try { parseTaka('1.005'); } catch (e) { console.log('1.005 →', e.message); }
console.log('raw:', 19.99 * 100, 1.15 * 100, 4.35 * 100);

আউটপুট:

✓ float  12.5
× float  19.99 → expected 1999, got 1998
✓ float  0.07
× float  1.15 → expected 115, got 114
× float  4.35 → expected 435, got 434
  2/5 passed
✓ string 12.5
✓ string 19.99
✓ string 0.07
✓ string 1.15
✓ string 4.35
  5/5 passed
1.005 → BAD_AMOUNT 1.005
raw: 1998.9999999999998 114.99999999999999 434.99999999999994

Float পথে ৫টির মধ্যে ৩টি সারি এক পয়সা কম দিয়েছে। শেষ লাইন কারণ দেখায়: 19.99 × 100 আসলে 1998.999…, আর floor সেটিকে 1998 বানায়। হাজার অর্ডারে এই এক পয়সা জমে হিসাব মেলে না। লেখা থেকে সরাসরি পয়সা বানানো পথটি ৫টিতেই ঠিক, আর 1.005-কে ভুল ইনপুট বলে থামিয়ে দেয়। একটি সারি দিয়ে এই ভুল প্রায়ই ধরা পড়ে না। ছক থাকলে ধরা পড়ে, কারণ সীমানার কাছের মান ইচ্ছা করে রাখা যায়।

পয়সা গোল করার নিয়ম, হিসাব করে

ছাড়ের নিয়ম: শতাংশ ছাড় নিচের দিকে গোল করা পয়সায়, floor(paisa × pct / 100). পরীক্ষায় এমন হিসাব প্রায়ই আসে, আর test-এর ছকেও ঠিক এগুলোই বসে:

Subtotal (পয়সা)ছাড় %গুণফল ÷ 100ছাড় (floor)
840001084008400
1099991010999.910999
99915149.85149
95000151425014250

পূর্ণসংখ্যায় গুণ আগে, ভাগ পরে। ফলে ভগ্নাংশ আসে একবারই, শেষে, আর নিয়ম মেনে কাটা হয়। “গোল করা নিচে, না কাছের দিকে?” এটি প্রোগ্রামারের সিদ্ধান্ত নয়, ব্যবসার সিদ্ধান্ত। সিদ্ধান্ত হলে সেটি test-এর ছকে লিখে রাখুন, তাহলে কেউ চুপচাপ বদলাতে পারবে না।

৫ · Watch mode, filter আর .only-র বিপদ

কাজ করার সময় npm test (মানে vitest) চালিয়ে রেখে দিন। আমরা watch mode চালু রেখে reservation.ts-এ একটি comment বদলেছি। সত্যিকারের আউটপুট, রং বাদে:

 DEV  v5.1.0 ~/bazarghar

 Test Files  5 passed (5)
      Tests  29 passed (29)
   Duration  168ms (import 57%, tests 17%, transform 15%, worker 10%)

 PASS  Waiting for file changes...
       press h to show help, press q to quit

 RERUN  src/shop/reservation.ts x1

 Test Files  1 passed (1)
      Tests  3 passed (3)
   Duration  27ms

খেয়াল করুন, দ্বিতীয়বার ৫টি নয়, শুধু ১টি ফাইল চলেছে। Vitest জানে কোন test ফাইল কোন source ফাইল import করে, কারণ Vite-এর module graph তার হাতে আছে। reservation.ts শুধু reservation.test.ts ব্যবহার করে, তাই ৩টি test, 27ms. বড় প্রজেক্টে এই পার্থক্যই watch mode-কে আরামদায়ক রাখে।

কয়েকটি test আলাদা করে চালানোর উপায়:

যা চানউপায়
একটি ফাইলnpx vitest run src/shop/cart.test.ts
নামে মেলে এমন testnpx vitest run -t Friday
কোডেই একটিকে বেছে নেওয়াit.only(...), describe.only(...)
সাময়িক বাদit.skip(...), লেখা বাকি থাকলে it.todo(...)

Vitest 5-এ -t-এর নিয়ম একটু বদলেছে: describe আর test-এর নাম এখন ' > ' দিয়ে জোড়া হয়। তাই -t 'cartTotals coupon' আর মেলে না, -t coupon লিখুন।

.only সবচেয়ে বিপজ্জনক, কারণ সে চুপচাপ কাজ করে। আমরা cart.test.ts-এ একটি test-কে it.only করে দুইভাবে চালিয়েছি:

$ npx vitest run
 Test Files  5 passed (5)
      Tests  24 passed | 5 skipped (29)

$ CI=true npx vitest run
 FAIL  src/shop/cart.test.ts > cartTotals > coupon: 10% off, then delivery
Error: [Vitest] Unexpected .only modifier. Remove it or pass --allowOnly argument to bypass this error
 Test Files  1 failed | 4 passed (5)
      Tests  1 failed | 23 passed | 5 skipped (29)

নিজের কম্পিউটারে সব সবুজ, শুধু “5 skipped” লেখা, যা চোখ এড়িয়ে যায়। সেই ৫টি test-এর মধ্যে আছে শুক্রবারের ছাড়ের test. আরও খেয়াল করুন, .only শুধু নিজের ফাইলের অন্য test বাদ দিয়েছে, বাকি ৪টি ফাইল পুরোটাই চলেছে। CI-তে ব্যাপারটি আলাদা: Vitest-এর allowOnly option-এর ডিফল্ট !process.env.CI, তাই CI-তে .only পেলেই সে ফেল করায়। এই সুরক্ষাটি আছে বলে --allowOnly দিয়ে বন্ধ করবেন না। CI-র পুরো pipeline আসবে পর্ব ৮১-তে।

.only commit হওয়া ঠেকানোর আরও একটি স্তর রাখতে পারেন: lint নিয়ম বা commit-এর আগের একটি ছোট খোঁজ। তবে আসল সুরক্ষা হলো CI-র লাল দাগ, কারণ সেটি সবার জন্য একই।

৬ · Test double — vi.fn, vi.spyOn আর vi.mock

অর্ডার হলে বাজারঘর ক্রেতাকে SMS পাঠায় (পর্ব ৬৩)। Test-এ কি সত্যিই SMS যাবে? না। টাকা খরচ হবে, ধীর হবে, আর network না থাকলে test ভাঙবে। তাই আসল sendSms-এর বদলে একটি নকল বসাতে হয়। এমন নকলের সাধারণ নাম test double. Stub, mock, spy, fake-এর পূর্ণ তত্ত্ব আছে Test double পর্বে। এখানে Vitest-এর তিনটি যন্ত্র:

vi.fn()

একটি নতুন নকল ফাংশন। কে কতবার কী দিয়ে ডাকল মনে রাখে, আর আপনি চাইলে কী ফেরত দেবে ঠিক করে দেন।

callback, inject করা নির্ভরতা

vi.spyOn(obj, 'name')

একটি আসল object-এর method-এর ওপর নজর বসায়। চাইলে আসলটি চলতে দেয়, চাইলে বদলে দেয়। পরে আগের অবস্থায় ফেরানো যায়।

console.warn, Math

vi.mock('./path')

পুরো একটি module বদলে দেয়। যে কোড সেই module import করে, সে নকলটি পায়।

SMS gateway, payment SDK

কোথায় নকল বসাবেন, সেটিই আসল সিদ্ধান্ত। নিচের ছবিতে placeOrder-এর চারপাশ দেখুন।

নকল কোথায় — সীমানায়, ভেতরে নয়

Own logic stays real; only the network edge is replaced

placeOrder-এর নিজের হিসাব আসল, শুধু sendSms নকল, আর network বন্ধ placeOrder যাকে test করছি cartTotals আসল হিসাব formatTaka আসল Intl sendSms vi.mock: নকল ডাক মনে রাখে Network · SMS gateway test-এ কখনো নয় console.warn vi.spyOn: নজর, চুপ করানো
ছবি ৭৩.১ — নিজের হিসাবের কোড আসল রাখুন। নকল শুধু সেখানে, যেখানে কোড বাইরের জগতে বেরোয়।

নকল ফাংশন ভেতরে কী করে আর module বদলানো আসলে কী, একটি ছোট মডেলে দেখুন। আসল Vitest-এ “module registry” Vite চালায়। এখানে একটি সাধারণ object দিয়ে বোঝানো হলো।

// শিক্ষণ মডেল: vi.fn কীভাবে ডাক মনে রাখে, আর vi.mock কীভাবে module বদলায়
function fn(impl = () => undefined) {
  const mock = function (...args) {
    mock.calls.push(args);
    const once = mock.onceQueue.shift();
    return (once ?? impl)(...args);
  };
  mock.calls = [];
  mock.onceQueue = [];
  mock.mockImplementationOnce = (f) => { mock.onceQueue.push(f); return mock; };
  mock.mockClear = () => { mock.calls = []; };
  return mock;
}
// "module registry": import করলে যা পাওয়া যায়
const registry = { './sms': { sendSms: () => { throw new Error('REAL NETWORK CALL'); } } };
const load = (name) => registry[name];

function placeOrder(phone, total) {           // order.ts-এর মতো
  const { sendSms } = load('./sms');
  try { sendSms(phone, `মোট ${total / 100} টাকা`); return 'OK'; }
  catch (e) { return 'OK, sms failed: ' + e.message; }
}
// vi.mock('./sms', ...) — import-এর আগেই বদলে দেওয়া
registry['./sms'] = { sendSms: fn(() => ({ id: 'sms-1' })) };
const { sendSms } = load('./sms');

console.log(placeOrder('01700000000', 81600));
sendSms.mockImplementationOnce(() => { throw new Error('SMS_FAILED'); });
console.log(placeOrder('01800000000', 90000));
console.log('calls:', sendSms.calls.length, JSON.stringify(sendSms.calls[0]));
sendSms.mockClear();                            // Vitest 5-এ প্রতিটি test-এর আগে নিজে থেকে
console.log('after clear:', sendSms.calls.length);

আউটপুট:

OK
OK, sms failed: SMS_FAILED
calls: 2 ["01700000000","মোট 816 টাকা"]
after clear: 0

প্রথম অর্ডারে আসল REAL NETWORK CALL error আসেনি, কারণ registry-তে আগেই নকল বসানো হয়েছিল। দ্বিতীয় অর্ডারের জন্য একবারের একটি ব্যর্থতা সাজানো হলো, আর placeOrder সেটি সামলাল। নকলটি দুটি ডাকই মনে রেখেছে, প্রথমটির argument-সহ। শেষে mockClear হিসাব মুছে দিল, কিন্তু নকলটি নকলই রইল। vi.mock import-এর আগে বসানো কেন জরুরি, সেটি এই মডেলেই দেখা যায়: placeOrder যে মুহূর্তে module চায়, তখন যা আছে তাই পায়।

এবার আসল Vitest-এ বাজারঘরের অর্ডারের test:

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { placeOrder } from './order';
import { sendSms } from './sms';

vi.mock('./sms', () => ({
  sendSms: vi.fn(async () => ({ id: 'sms-1' })),
}));

const chal = { sku: 'chal-5kg', unitPaisa: 42000, qty: 2 };

describe('placeOrder', () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date('2026-11-04T06:00:00Z'));
  });
  afterEach(() => {
    vi.useRealTimers();
    vi.restoreAllMocks();
  });

  it('sends exactly one sms with the Bangla total', async () => {
    await expect(placeOrder('01700000000', [chal], 'EID10')).resolves.toMatchObject({ total: 81600 });
    expect(sendSms).toHaveBeenCalledTimes(1);
    expect(sendSms).toHaveBeenCalledWith('01700000000', 'বাজারঘর: অর্ডার নিশ্চিত, মোট ৮১৬.০০৳');
  });

  it('bad phone: rejects, no sms', async () => {
    await expect(placeOrder('12345', [chal])).rejects.toThrow('BAD_PHONE');
    expect(sendSms).not.toHaveBeenCalled();
  });

  it('sms failure does not cancel the order, but warns', async () => {
    vi.mocked(sendSms).mockRejectedValueOnce(new Error('SMS_FAILED'));
    const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
    await expect(placeOrder('01700000000', [chal])).resolves.toMatchObject({ total: 90000 });
    expect(warn).toHaveBeenCalledWith('sms failed', 'SMS_FAILED');
  });
});

ধাপে ধাপে দেখুন, কোন মুহূর্তে কোন জিনিস আসল আর কোনটি নকল। নিচের কোডটি ওপরের test-এর সংক্ষিপ্ত রূপ।

1import { placeOrder } from './order';2import { sendSms } from './sms';3vi.mock('./sms', () => ({ sendSms: vi.fn(...) }));4it('one sms', ...) placeOrder(phone, [chal], 'EID10')5  expect(sendSms).toHaveBeenCalledTimes(1)6it('bad phone', ...) rejects BAD_PHONE7  expect(sendSms).not.toHaveBeenCalled()8it('sms fails', ...) mockRejectedValueOnce(...)9  vi.spyOn(console, 'warn')10afterEach: vi.restoreAllMocks()
এই মুহূর্তের অবস্থা
sendSms
ডাকের সংখ্যা
console.warn
ফল
  1. ধাপ ১ — Hoist. ফাইলে vi.mock তিন নম্বর লাইনে লেখা, কিন্তু Vitest অনুবাদের সময় সেটিকে সবার ওপরে তোলে। তাই import চলার আগেই ঠিক হয়ে আছে যে ./sms চাইলে নকল যাবে। sendSms এখনো লোড হয়নি।
  2. ধাপ ২ — Import. order.ts নিজের ভেতরে ./sms import করে, আর পায় নকলটি। Test ফাইলও একই নকল পায়, তাই পরে তাকে প্রশ্ন করা যায়। ডাকের সংখ্যা 0.
  3. ধাপ ৩ — প্রথম test. মোট 81600 পয়সা, আর SMS-এর লেখা “মোট ৮১৬.০০৳”. নকল একবার ডাকা হয়েছে, সঠিক নম্বর আর লেখাসহ। কোনো network অনুরোধ যায়নি।
  4. ধাপ ৪ — দ্বিতীয় test. Vitest 5-এ clearMocks-এর ডিফল্ট true, তাই এই test শুরুর আগে ডাকের হিসাব নিজে থেকে 0 হয়েছে। ভুল ফোন নম্বরে অর্ডার আগেই থামে, তাই ডাক 0-ই থাকে। Test পাস।
  5. ধাপ ৫ — তৃতীয় test. এবার নকলকে বলা হলো একবার ব্যর্থ হতে। console.warn-এর ওপর spy বসিয়ে চুপ করানো হলো, যাতে test-এর আউটপুট নোংরা না হয়। অর্ডার তবু হয়েছে, 90000 পয়সা, আর সতর্কবার্তা ঠিক লেখায় গেছে।
  6. ধাপ ৬ — Restore. vi.restoreAllMocks শুধু vi.spyOn-এর বসানো জিনিস আসলে ফেরায়, তাই console.warn আবার আসল। vi.mock-এর নকল এই ফাইলের শেষ পর্যন্ত থাকে, আর docs অনুযায়ী restoreAllMocks ডাকের হিসাবও মোছে না, তাই সংখ্যা 1-ই রইল।

দুটি সত্যিকারের পরীক্ষা এই নকশার মূল্য দেখায়। প্রথমে আমরা vi.mock-এর লাইনটি মুছে চালিয়েছি:

stderr | src/shop/order.test.ts > placeOrder > sends exactly one sms with the Bangla total
sms failed fetch failed

 ❯ src/shop/order.test.ts (3 tests | 3 failed) 40ms
 FAIL  src/shop/order.test.ts > placeOrder > sends exactly one sms with the Bangla total
TypeError: [AsyncFunction sendSms] is not a spy or a call to a spy!

প্রথম লাইনটিই সবচেয়ে ভয়ের: fetch failed. মানে test সত্যিই network-এ বেরোনোর চেষ্টা করেছে। আমাদের ঠিকানা ইচ্ছা করে example.invalid, তাই কিছু যায়নি। আসল gateway-র ঠিকানা আর আসল key থাকলে একজন ক্রেতার ফোনে সত্যিকারের SMS চলে যেতে পারত। আর test ছিল 4ms-এর জায়গায় 37ms.

দ্বিতীয় পরীক্ষায় config-এ clearMocks: false দিয়ে Vitest 4-এর মতো আচরণ ফেরানো হলো:

 FAIL  src/shop/order.test.ts > placeOrder > bad phone: rejects, no sms
AssertionError: expected "vi.fn()" to not be called at all, but actually been called 1 times

Received:

  1st vi.fn() call:

    Array [
      "01700000000",
      "বাজারঘর: অর্ডার নিশ্চিত, মোট ৮১৬.০০৳",
    ]

আগের test-এর ডাক পরের test-এ রয়ে গেছে। এক test-এর অবস্থা অন্য test-এ ছড়ালে test-এর ক্রম বদলালেই ফল বদলায়। Vitest 5 এই কারণেই ডিফল্ট বদলেছে। পুরোনো প্রজেক্টে clearMocks বন্ধ থাকলে নিজে beforeEach-এ vi.clearAllMocks() দিন।

ডাকডাকের হিসাববানানো আচরণspyOn-এর আসলটি
mockClear / vi.clearAllMocksমোছেরাখেফেরায় না
mockReset / vi.resetAllMocksমোছেশুরুর রূপে ফেরেফেরায় না
vi.restoreAllMocksমোছে না—ফেরায়

একটি সূক্ষ্ম পার্থক্য: Jest-এ mockReset নকলকে খালি ফাংশন বানিয়ে দেয়, আর Vitest-এ vi.fn(impl)-এর mockReset আবার impl-এ ফেরে। Vitest-এর Jest migration পাতায় এটি লেখা আছে। vi.mock-এর factory-তে বাইরের variable সরাসরি ব্যবহার করা যায় না, কারণ hoist হওয়ার পরে সেগুলো তখনো তৈরি হয়নি। দরকার হলে vi.hoisted দিয়ে সেগুলো বানাতে হয়।

৭ · সময়কে বশ করা — fake timer আর setSystemTime

বাজারঘরে চেকআউট শুরু হলে পণ্য ১৫ মিনিট ধরে রাখা হয়, একে বলে stock reservation (পর্ব ৬৫)। টাকা না এলে ১৫ মিনিট পরে stock ফেরত যায়। এর test-এ কি ১৫ মিনিট বসে থাকবেন? না। Fake timer setTimeout, setInterval আর Date-কে নকল ঘড়িতে বাঁধে। তারপর আপনি নিজে ঘড়ির কাঁটা ঘোরান।

রান্নার অনুষ্ঠানে উপস্থাপক বলেন, “এবার এটি দুই ঘণ্টা দমে থাকবে”, আর পরের দৃশ্যেই তৈরি বিরিয়ানি বের করেন। দুই ঘণ্টা দর্শককে বসিয়ে রাখা হয় না, কিন্তু রান্নার প্রতিটি ধাপ ঠিকই ঘটেছে। Fake timer test-এর জন্য সেই সম্পাদনা: সময় লাফ দেয়, কিন্তু যে কাজ যে মুহূর্তে হওয়ার কথা, ঠিক সেই ক্রমেই হয়।

// শিক্ষণ মডেল: fake timer — সময় আমাদের হাতে, আসল ঘড়ি নয়
function createClock(start) {
  let now = start, seq = 0;
  const timers = [];                                  // { id, at, fn }
  return {
    now: () => now,
    setTimeout(fn, ms) { const id = ++seq; timers.push({ id, at: now + ms, fn }); return id; },
    clearTimeout(id) { const i = timers.findIndex((t) => t.id === id); if (i >= 0) timers.splice(i, 1); },
    advance(ms) {                                     // vi.advanceTimersByTime
      const end = now + ms;
      for (;;) {
        timers.sort((a, b) => a.at - b.at || a.id - b.id);
        if (!timers.length || timers[0].at > end) break;
        const t = timers.shift(); now = t.at; t.fn();
      }
      now = end;
    },
    count: () => timers.length,
  };
}
const MIN = 60 * 1000, clock = createClock(0), stock = { 'chal-5kg': 3 };
function reserve(sku, qty) {
  stock[sku] -= qty;
  return clock.setTimeout(() => { stock[sku] += qty; console.log(`  t=${clock.now() / MIN}min: hold expired, +${qty}`); }, 15 * MIN);
}
reserve('chal-5kg', 2);                              // ২ প্যাকেট ধরে রাখা
console.log('t=0     stock', stock['chal-5kg'], 'timers', clock.count());
clock.advance(14 * MIN + 59 * 1000);
console.log('t=14:59 stock', stock['chal-5kg']);
clock.advance(1000);
console.log('t=15:00 stock', stock['chal-5kg'], 'timers', clock.count());
const b = reserve('chal-5kg', 1);
clock.advance(5 * MIN); clock.clearTimeout(b);      // টাকা এলো, confirm
clock.advance(60 * MIN);
console.log('t=80    stock', stock['chal-5kg'], 'timers', clock.count());

আউটপুট:

t=0     stock 1 timers 1
t=14:59 stock 1
  t=15min: hold expired, +2
t=15:00 stock 3 timers 0
t=80    stock 2 timers 0

১৪ মিনিট ৫৯ সেকেন্ডে কিছু হয়নি। আর এক সেকেন্ড এগোতেই timer চলল, আর তার ভেতরে now ঠিক 15 মিনিট দেখাল, ১৫:৫৯ নয়। এটিই advanceTimersByTime-এর মূল কথা: ঘড়ি লাফ দেয়, কিন্তু প্রতিটি timer নিজের সময়ে চলে। দ্বিতীয় reservation ৫ মিনিটে confirm হওয়ায় তার timer মুছে গেছে, তাই ৬০ মিনিট পরেও stock 2. পুরো ৮০ মিনিট আসলে কয়েক মিলিসেকেন্ডে শেষ।

আসল Vitest test আর তার ধাপগুলো দেখুন। Verbose reporter-এ এই test-এর সময় ছিল 4ms.

1vi.useFakeTimers();2const r = createReservations({ 'chal-5kg': 3 }, onExpire);3const id = r.reserve('chal-5kg', 2);4vi.advanceTimersByTime(HOLD_MS - 1);5expect(r.available('chal-5kg')).toBe(1);6vi.advanceTimersByTime(1);7expect(r.available('chal-5kg')).toBe(3);8vi.useRealTimers();   // afterEach
নকল ঘড়ি আর দোকান
ঘড়ি
stock
অপেক্ষমাণ timer
onExpire ডাক
  1. ধাপ ১ — নকল ঘড়ি চালু। এখন থেকে setTimeout আসল সময়ের ওপর চলবে না। ঘড়ি 00:00-এ দাঁড়িয়ে, কোনো timer নেই।
  2. ধাপ ২ — Reserve. ৩টির মধ্যে ২ প্যাকেট ধরে রাখা হলো, stock 1. ভেতরে একটি setTimeout বসল, যা HOLD_MS মানে 900000 ms পরে চলবে। অপেক্ষমাণ timer 1.
  3. ধাপ ৩ — শেষ মিলিসেকেন্ডের আগে। ঘড়ি 14:59.999-এ। Timer চলেনি, stock এখনো 1, onExpire একবারও ডাকা হয়নি। সীমার ঠিক আগের মানটি test করা জরুরি, নইলে “এক মিনিট আগেই ছেড়ে দেওয়া” bug ধরা পড়ে না।
  4. ধাপ ৪ — ১ মিলিসেকেন্ড পরে। ঘড়ি ঠিক 15:00.000. Timer চলল, ২ প্যাকেট ফেরত এসে stock 3, আর onExpire পেল r1. অপেক্ষমাণ timer 0.
  5. ধাপ ৫ — আসল ঘড়িতে ফেরা। afterEach-এ vi.useRealTimers. এটি না দিলে পরের test-ও নকল ঘড়িতে চলত। Docs বলছে, তখন আগে বসানো নকল timer-গুলো বাতিল হয়।

Confirm হওয়া reservation-এর test-এ আরেকটি কাজের জিনিস আছে: vi.getTimerCount(). টাকা আসার পরে timer মুছে গেছে কি না, সেটি সরাসরি দেখা যায়। Timer মুছতে ভুলে গেলে stock একসময় নিজে থেকে বেড়ে যেত, আর সেই bug সাধারণ চোখে ধরা পড়া কঠিন।

Docs অনুযায়ী vi.useFakeTimers() ডিফল্টে process.nextTick আর queueMicrotask নকল করে না। দরকার হলে toFake option দিয়ে বলতে হয়। Vitest 5-এ fake timer আর setSystemTime Temporal-ও নকল করে, যদি পরিবেশে সেটি থাকে।

তারিখ-নির্ভর ছাড়: ঢাকার শুক্রবার

মালিকের নিয়ম: শুক্রবারে বাড়তি ৫% ছাড়। “শুক্রবার” মানে ঢাকার শুক্রবার। কিন্তু server প্রায়ই UTC-তে চলে, আর ঢাকা UTC-র ৬ ঘণ্টা আগে। Time zone-এর এই ফাঁকে bug লুকায়। vi.setSystemTime দিয়ে আমরা ঠিক সেই সীমার মুহূর্তগুলো বেছে নিই।

const dhakaWeekday = new Intl.DateTimeFormat('en-US', { timeZone: 'Asia/Dhaka', weekday: 'short' });

export function isFridayInDhaka(at: Date): boolean {
  return dhakaWeekday.format(at) === 'Fri';
}
it('Friday in Dhaka adds 5%, even when UTC still says Thursday', () => {
  vi.setSystemTime(new Date('2026-11-05T18:30:00Z')); // ঢাকায় শুক্রবার রাত ১২:৩০
  expect(cartTotals([tel(1)], 'EID10').discount).toBe(14250);
});

it('no Friday discount at 23:59 Thursday in Dhaka', () => {
  vi.setSystemTime(new Date('2026-11-05T17:59:00Z')); // ঢাকায় বৃহস্পতিবার রাত ১১:৫৯
  expect(cartTotals([tel(1)], 'EID10').discount).toBe(9500);
});

হিসাবটি নিজে মিলিয়ে নিন। তেলের দাম 95000 পয়সা। শুক্রবারে ১০% + ৫% = ১৫%, 95000 × 15 ÷ 100 = 14250. বৃহস্পতিবারে শুধু ১০%, 9500. ১৮:৩০ UTC + ৬ ঘণ্টা = পরের দিন ০০:৩০, তাই ঢাকায় শুক্রবার। এবার কেউ “সহজ” করতে isFridayInDhaka-কে at.getUTCDay() === 5 বানালেন। সত্যিকারের ফল:

 FAIL  src/shop/cart.test.ts > cartTotals > Friday in Dhaka adds 5%, even when UTC still says Thursday
AssertionError: expected 9500 to be 14250 // Object.is equality

- Expected
+ Received

- 14250
+ 9500

 ❯ src/shop/cart.test.ts:43:52
…
      Tests  1 failed | 5 passed (6)

খেয়াল করুন, ছয়টির মধ্যে শুধু একটি test ভেঙেছে, আর সেটিই এই সীমার test. দিনের মাঝখানের একটি সময় দিয়ে test লিখলে UTC আর ঢাকা একই দিন বলত, আর ভুলটি production-এ শুক্রবার রাতে প্রথম ছয় ঘণ্টা ধরে চুপচাপ চলত। beforeEach-এ ঘড়ি একটি নির্দিষ্ট সময়ে বসানো আছে বলে বাকি test-গুলো সপ্তাহের কোন দিনে চালানো হলো তার ওপর নির্ভর করে না। নইলে শুক্রবারে CI চালালে “coupon” test হঠাৎ ভাঙত।

Docs সাবধান করে: vi.setSystemTime-এর মান test-এর মাঝে নিজে থেকে ফেরে না। আর fake timer চালু না থাকলে এটি শুধু Date নকল করে। তাই আমরা beforeEach-এ fake timer চালু করে সময় বসিয়েছি, আর afterEach-এ আসল timer-এ ফিরেছি। আরেকটি পথ হলো সময়কে parameter বানানো: cartTotals(lines, coupon, now). আমাদের ফাংশন দুটিই মেনে চলে, কারণ now-এর ডিফল্ট new Date().

৮ · Snapshot — inline, ফাইল, update আর অন্ধ অনুমোদন

Snapshot test প্রথমবার ফলটি লিখে রাখে, আর পরের প্রতিবার নতুন ফলকে সেই লেখার সাথে মেলায়। এটি characterization test-এর একটি সহজ রূপ: “এখন যা আছে, সেটিই যেন থাকে।” দুই ধরন আছে। Inline snapshot ফলটি test ফাইলের ভেতরেই লেখে, আর ফাইল snapshot পাশের __snapshots__ folder-এ .snap ফাইলে রাখে।

test('receipt text (inline snapshot)', () => {
  expect(renderReceipt(lines, totals)).toMatchInlineSnapshot();
});

test('receipt model (file snapshot)', () => {
  expect(receiptModel(lines, totals)).toMatchSnapshot();
});

প্রথম run-এ reporter বলল Snapshots 2 written. আর Vitest আমাদের test ফাইলটি নিজেই বদলে দিল, ফাঁকা বন্ধনীর ভেতরে রসিদের লেখা বসিয়ে:

test('receipt text (inline snapshot)', () => {
  expect(renderReceipt(lines, totals)).toMatchInlineSnapshot(`
    "chal-5kg x2  ৮৪০.০০৳
    dal-1kg x1  ১৪০.০০৳
    ছাড়      -৯৮.০০৳
    ডেলিভারি  ৬০.০০৳
    মোট      ৯৪২.০০৳"
  `);
});

ছোট আর মানুষের পড়ার মতো ফলের জন্য inline snapshot চমৎকার। Review-এর সময় পাশেই দেখা যায় কী চাওয়া হয়েছে। ফাইল snapshot-টি ৩৬ লাইনের একটি .snap ফাইল হলো, যেখানে রসিদের পুরো object আছে। এবার আসল বিপদটি দেখুন।

একটি সত্যিকারের অন্ধ অনুমোদন

আমরা ইচ্ছা করে receipt.ts-এ একটি bug ঢোকালাম: linePaisa: l.unitPaisa * l.qty-এর জায়গায় linePaisa: l.unitPaisa. মানে ২ প্যাকেট চালের লাইনের দাম এক প্যাকেটের সমান। Snapshot ঠিকই ধরল:

 FAIL  src/shop/receipt.test.ts > receipt model (file snapshot)
Error: Snapshot `receipt model (file snapshot) 1` mismatched

- Expected
+ Received

@@ -7,11 +7,11 @@
      "total": "৯৪২.০০৳",
    },
    "lines": [
      {
        "label": "৮৪০.০০৳",
-       "linePaisa": 84000,
+       "linePaisa": 42000,
        "qty": 2,
        "sku": "chal-5kg",
        "unitPaisa": 42000,
      },
      {
…
  Snapshots  1 failed

এখন কল্পনা করুন, একজন ব্যস্ত developer এই লাল দেখে ভাবলেন, “আমি তো রসিদের কোড বদলেছি, snapshot তো বদলাবেই।” তিনি চাপলেন:

$ npx vitest run src/shop/receipt.test.ts --update

 RUN  v5.1.0 ~/bazarghar

  Snapshots  1 updated
 Test Files  1 passed (1)
      Tests  2 passed (2)

সব সবুজ। .snap ফাইলে এখন "linePaisa": 42000 লেখা, অর্থাৎ ভুলটিই এখন “প্রত্যাশিত”। পরের যে মানুষ bug ঠিক করবেন, তাঁর test-ই লাল হবে। আমাদের snapshot মাত্র ৩৬ লাইনের, আর diff-এ বদলটি একটি লাইনে। বাস্তবের component বা API-র snapshot প্রায়ই কয়েকশো লাইনের হয়, আর review-এ সেই diff কেউ লাইন ধরে পড়েন না। একে অনেকে snapshot fatigue বলেন।

আরেকটি ফাঁদও খেয়াল করুন। -u নিজে একটি মান নিতে পারে (--update [type], help-এ লেখা আছে)। আমাদের run-এ npx vitest run -u src/shop/receipt.test.ts লেখায় ফাইলের নামটি -u-এর মান হিসেবে চলে গিয়েছিল, আর পাঁচটি ফাইলই চলেছিল। ফাইলের নাম আগে, --update পরে লিখুন।

Snapshot-এর নিয়ম

১. ছোট রাখুন, যাতে diff একনজরে পড়া যায়। পুরো object না রেখে দরকারি অংশ রাখুন। ২. টাকার মতো মূল নিয়ম snapshot-এ নয়, স্পষ্ট toBe/toEqual-এ যাচাই করুন। Snapshot ধরে “কিছু বদলেছে”, নিয়ম বলে “কী ঠিক”। ৩. -u চালানোর আগে diff পড়ুন, আর .snap ফাইলের বদল code review-তে কোডের মতোই দেখুন। ৪. Intl-এর আউটপুট Node-এর ICU version-এর ওপর নির্ভর করে, তাই Node আপগ্রেডের পরে snapshot বদলালে আগে কারণ খুঁজুন।

৯ · Coverage, type test, গতি আর Jest থেকে আসা

Coverage — v8 দিয়ে

Code coverage দেখায় test চলার সময় কোডের কোন অংশ অন্তত একবার চলেছে। কতটুকু coverage যথেষ্ট আর কেন ১০০% লক্ষ্য বিপজ্জনক, সেই দর্শন আছে পর্ব ৭২-এ। এখানে শুধু মাপা আর পড়া। সত্যিকারের run:

$ npx vitest run --coverage
 RUN  v5.1.0 ~/bazarghar
      Coverage enabled with v8

 Test Files  5 passed (5)
      Tests  29 passed (29)

 % Coverage report from v8
----------------|---------|----------|---------|---------|-------------------
File            | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
----------------|---------|----------|---------|---------|-------------------
All files       |   92.75 |    84.84 |   94.11 |   94.73 |
 cart.ts        |     100 |      100 |     100 |     100 |
 money.ts       |     100 |      100 |     100 |     100 |
 order.ts       |     100 |      100 |     100 |     100 |
 receipt.ts     |     100 |      100 |     100 |     100 |
 reservation.ts |   95.23 |     62.5 |     100 |     100 | 10-28
 sms.ts         |       0 |        0 |       0 |       0 | 3-9
----------------|---------|----------|---------|---------|-------------------
Statements   : 92.75% ( 64/69 )
Branches     : 84.84% ( 28/33 )
Functions    : 94.11% ( 16/17 )
Lines        : 94.73% ( 54/57 )

দুটি জিনিস পড়তে শিখুন। প্রথমত, sms.ts ০%. এটি ভালো খবর: test কখনো আসল SMS কোড চালায়নি। কিন্তু এর মানে sendSms-এর নিজের কোনো যাচাই এখনো নেই। HTTP-র নকল server দিয়ে সেই যাচাই আসবে পর্ব ৭৭-এ। দ্বিতীয়ত, reservation.ts-এ প্রতিটি লাইন চলেছে, তবু branch 62.5%. stock[sku] ?? 0-এর “নেই” দিক, বা onExpire?.()-এর “callback নেই” দিক কখনো চলেনি। লাইন চলা আর প্রতিটি পথ চলা আলাদা জিনিস।

সংখ্যাগুলো নিজে হিসাব করুন, পরীক্ষায় এমন প্রশ্ন আসে:

মাপচলেছে / মোটভাগফলরিপোর্টে
Statements64 / 690.927536…92.75
Branches28 / 330.848484…84.84
Functions16 / 170.941176…94.11
Lines54 / 570.947368…94.73
reservation.ts branch5 / 80.62562.5

খেয়াল করুন, 84.848…% রিপোর্টে 84.84, আর 94.736…% রিপোর্টে 94.73. মানে এই রিপোর্ট দুই দশমিকে কেটে দেখায়, গোল করে না। Statements-এর 92.7536 কেটে 92.75, গোল করলেও তাই। Coverage-এর সীমা (threshold) ধরলে এই কাটার কারণে ৮৫% সীমায় 84.84 আটকে যাবে। আর >= বদলে > লিখলে কী হয়, পর্ব ২৯-এর অনুশীলনে দেখেছিলেন: সব লাইন চলেও ভুল ধরা পড়েনি। ১০০% coverage মানে প্রতিটি লাইন অন্তত একবার চলেছে, প্রতিটি নিয়ম যাচাই হয়েছে এমন নয়।

Type test — একটি ছোট নোট

Vitest TypeScript-এর type-ও test করতে পারে। .test-d.ts ফাইলে expectTypeOf লিখে vitest --typecheck চালাতে হয়। এখানে কোড চলে না, শুধু type checker দেখে।

import { expectTypeOf, test } from 'vitest';
import { cartTotals, type Totals } from './cart';
import { parseTaka } from './money';

test('public types stay stable', () => {
  expectTypeOf(cartTotals).returns.toEqualTypeOf<Totals>();
  expectTypeOf(parseTaka).parameter(0).toBeString();
  expectTypeOf(parseTaka).returns.toBeNumber();
  // @ts-expect-error: পয়সা সংখ্যা, লেখা নয়
  cartTotals([{ sku: 'x', unitPaisa: '420', qty: 1 }]);
});

সত্যিকারের run-এ Vitest প্রথমেই জানাল: Testing types with tsc and vue-tsc is an experimental feature. তারপর Type Errors no errors. ইচ্ছা করে toBeNumber-কে toBeString বানালে এলো TypeCheckError: This expression is not callable. Type 'ExpectString<number>' has no call signatures. বার্তাটি একটু অদ্ভুত, কারণ type test-এর ব্যর্থতা আসলে একটি compile error. Feature-টি এখনো পরীক্ষামূলক, তাই version pin করে ব্যবহার করুন। Type-এর মূল কথা আছে পর্ব ০৬-এ।

গতি — ২০০টি test, একবারের মাপ

Unit test-এর বড় শক্তি গতি। আমরা test.each দিয়ে ২০০টি সারির একটি ফাইল বানিয়েছি: প্রতিটিতে একটি পয়সার মান লেখায় বদলে আবার parseTaka দিয়ে ফেরানো, আর formatTaka-র শেষে ৳ আছে কি না দেখা।

$ npx vitest run src/shop/bulk.test.ts
 Test Files  1 passed (1)
      Tests  200 passed (200)
   Duration  153ms (import 40%, transform 36%, tests 18%, worker 6%)

$ npx vitest run
 Test Files  6 passed (6)
      Tests  229 passed (229)
   Duration  165ms (import 55%, tests 18%, transform 16%, worker 10%)

153 ms-এর ১৮% প্রায় 27.5 ms গেছে test-এর নিজের কাজে, মানে প্রতি test-এ গড়ে প্রায় 0.14 ms. বাকিটা ফাইল import আর অনুবাদ। npx চালু হওয়াসহ পুরো কমান্ডের দেয়াল-ঘড়ির সময় মেপে পেয়েছি 0.86 s. এগুলো এক কম্পিউটারে একবারের মাপ, কোনো সাধারণ benchmark নয়। আসল শিক্ষাটি অনুপাতে: শুদ্ধ হিসাবের test-এ খরচ মূলত শুরুর, তাই test বাড়লেও সময় প্রায় একই থাকে। এর উল্টো ছবি পাবেন পর্ব ৭৬-এ, যেখানে একটি browser test-ই সেকেন্ড নেয়।

Jest থেকে আসা — সতর্ক একটি নোট

Vitest-এর API ইচ্ছা করেই Jest-এর কাছাকাছি: describe, it, expect, snapshot প্রায় একই। তবে Vitest-এর migration পাতায় কিছু পার্থক্য লেখা আছে, আর আপনার প্রজেক্টে আরও থাকতে পারে:

  • Global নেই: Jest-এ describe নিজে থেকে পাওয়া যায়। Vitest-এ import করতে হয়, অথবা config-এ globals: true দিতে হয়।
  • Module mock: jest.mock-এর factory যা ফেরত দেয় তা default export হয়। vi.mock-এ প্রতিটি export নাম ধরে দিতে হয়, যেমন { default: ... }. jest.requireActual-এর জায়গায় await vi.importActual.
  • done callback নেই: Async test async ফাংশন বা promise দিয়ে লিখতে হয়। Jest-এর legacy timer-ও নেই।
  • mockReset-এর আচরণ আলাদা: অংশ ৬-এ দেখেছেন।

ছোট প্রজেক্টে পরিবর্তন প্রায়ই নাম বদলের কাজ, কিন্তু বড় প্রজেক্টে custom matcher, setup ফাইল আর snapshot serializer মিলিয়ে কাজ বাড়ে। Jest-ও সক্রিয়ভাবে চলছে, লেখার সময়ে npm-এ তার version 30.5.2. Vite-এর বাইরের পুরোনো স্থির প্রজেক্টে শুধু “নতুন” বলে বদলানোর যুক্তি দুর্বল। Vite প্রজেক্টে নতুন করে শুরু করলে Vitest স্বাভাবিক পছন্দ।

আরও গভীরে: vi.mock কাজ করে কারণ Vitest import-এর আগেই module খোঁজার পথে বসে থাকে, এটি Vite-এর module runner-এর সুবিধা। তাই docs বলছে, নকল শুধু বাইরের access-এ খাটে: একই module-এর ভেতরের এক ফাংশন আরেকটিকে ডাকলে আসলটিই চলে। Coverage-এর v8 provider V8 ইঞ্জিনের নিজের গণনা থেকে তথ্য নিয়ে source map দিয়ে আপনার TypeScript লাইনে ফেরায়, আলাদা করে কোড instrument করে না। আরেক provider istanbul কোডে গণনার লাইন বসিয়ে দেয়, যা ধীর কিন্তু V8 ছাড়া অন্য পরিবেশেও চলে।

পরীক্ষা ও ইন্টারভিউতে:

  • toBe আর toEqual-এর পার্থক্য? toBe Object.is দিয়ে একই মান বা একই object দেখে, toEqual ভেতরের মান গভীর পর্যন্ত মেলায়।
  • Mock আর spy-র পার্থক্য? vi.fn নতুন নকল বানায়, vi.spyOn আসল method-এর ওপর নজর বসায় আর পরে ফেরানো যায়।
  • vi.mock ফাইলের মাঝে লিখলেও কাজ করে কেন? এটি hoist হয়ে সব import-এর আগে চলে।
  • Fake timer কেন? সময়-নির্ভর কোড অপেক্ষা ছাড়া, নির্দিষ্টভাবে আর বারবার একই ফলে test করতে।
  • ১০০% coverage মানে কি bug নেই? না। লাইন চলেছে মানে যাচাই হয়েছে এমন নয়, assertion-ই আসল।

১০ · AI-এর যুগে — AI-এর লেখা test যাচাই

AI-কে “এই ফাংশনের test লিখে দাও” বললে সে দ্রুত অনেকগুলো test দেয়, আর সাধারণত সব সবুজ। বিপদ ঠিক সেখানেই। সবুজ test প্রমাণ করে না যে test কিছু যাচাই করছে।

AI-এর সাধারণ ভুলকীভাবে ধরবেন
পুরোনো API: jest.fn, done callback, বা Vitest 5-এ সরানো test.sequentialআপনার version-এর vitest.dev docs আর migration পাতা মিলিয়ে নিন
কোডের হিসাবটাই test-এ কপি করা: expect(total).toBe(price * qty * 0.9)প্রত্যাশিত মান হাতে হিসাব করা সংখ্যায় লিখুন, যেমন 81600
নিজের হিসাবের কোডও mock করা, ফলে test শুধু নকলকে test করেনকল কোথায় বসেছে দেখুন, সীমানার বাইরে হলে সরান
পুরো object-এর বিশাল snapshot, আর “-u চালান” পরামর্শমূল নিয়মে স্পষ্ট assertion চান, snapshot ছোট রাখুন
Async assertion-এ await নেই, বা .only/.skip রেখে দেওয়াDiff-এ খুঁজুন, CI-তে চালান
নেই এমন option বা matcher বানিয়ে লেখাচালিয়ে দেখুন, TypeScript-এর error পড়ুন
Test-এ আসল API key বা ফোন নম্বরexample ধরনের নকল মান আর .invalid ঠিকানা দিন

AI-কে test লিখতে দিলে নিয়ম আর সীমানা বলে দিন, আর প্রমাণ চান যে test ভুল ধরতে পারে। নিচের prompt-টি আপনার ফাংশনের জন্য বদলে নিন:

আমি Vitest 5.1 আর TypeScript ব্যবহার করছি। নিচের ফাংশনের unit test লিখতে সাহায্য করো। [এখানে ফাংশন পেস্ট করুন, কোনো আসল secret বা গ্রাহকের তথ্য দেবেন না] নিয়ম: ১. টাকা পূর্ণসংখ্যা পয়সায়। প্রত্যাশিত মান হাতে হিসাব করা সংখ্যায় লেখো, কোডের সূত্র কপি করবে না। ২. সীমানার মানের জন্য test.each দাও: ঠিক সীমা, এক পয়সা কম, এক পয়সা বেশি। ৩. শুধু network বা SMS-এর মতো বাইরের module vi.mock করবে। নিজের হিসাবের কোড আসল থাকবে। ৪. সময় লাগলে vi.useFakeTimers আর vi.setSystemTime, Asia/Dhaka-র মধ্যরাতের দুই পাশসহ। ৫. বড় snapshot দেবে না। নতুন package যোগ করবে না। ৬. প্রতিটি test-এর জন্য বলো, কোডে কোন এক লাইনের ভুল হলে সেটি লাল হবে।

AI-এর লেখা test হাতে পেলে — যাচাইয়ের চেকলিস্ট

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

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

প্র ০১Jest তো ছিলই। তাহলে Vitest কেন তৈরি হলো, আর একটি “নতুন test runner” আসলে কী সমস্যা মেটায়?

২০১৪ সালে Facebook Jest open source করার পর কয়েক বছরে এটি JavaScript-এর প্রধান test runner হয়ে ওঠে। Snapshot, নিজে থেকে mock, সমান্তরালে চালানো আর একটি পরিচিত expect API, সব এক জায়গায়। সেই সময় JavaScript-এর বেশিরভাগ কোড CommonJS-এ লেখা হতো, আর Jest-এর ভেতরের নকশাও সেই জগতের।

তারপর দুটি বদল আসে। প্রথমত, ES module ভাষার মান হয়, আর browser ও Node দুই জায়গাতেই এটি চলতে শুরু করে। দ্বিতীয়ত, TypeScript প্রায় সাধারণ নিয়ম হয়ে যায়। Jest-এ এগুলো চালাতে আলাদা transform-এর ব্যবস্থা লাগত: Babel বা ts-jest, আর ESM-এর জন্য পরীক্ষামূলক flag. ২০২০ সালে Vite এলে আরেকটি সমস্যা দেখা দেয়। App চলে Vite-এর config, alias আর plugin দিয়ে, কিন্তু test চলে আরেকটি আলাদা ব্যবস্থায়। একই কোড দুই জায়গায় দুইভাবে অনুবাদ হয়, আর দুই config মিলিয়ে রাখতে হয়।

Vitest-এর মূল চিন্তা ছিল: test-ও Vite-এর একই pipeline দিয়ে চলুক। ফলে আলাদা transform config লাগে না, watch mode Vite-এর module graph জানে, আর API Jest-এর কাছাকাছি রাখা হয় যাতে আসা সহজ হয়। এই পর্বে আপনি তার ফল দেখেছেন: একটি source ফাইল বদলালে শুধু তার ওপর নির্ভরশীল test ফাইলটি আবার চলেছে।

তবে সৎ থাকা দরকার। “নতুন মানেই দ্রুত” সবসময় সত্য নয়। গতি নির্ভর করে isolation, পরিবেশ (jsdom না Node) আর আপনার কোডের ওপর। Jest-ও থেমে নেই, সক্রিয়ভাবে নতুন version আসছে। আর একটি বড়, স্থির প্রজেক্ট বদলাতে খরচ আছে।

মূল উপলব্ধি: Test runner বাছাইয়ের প্রশ্ন আসলে “আমার app যে পথে চলে, test কি সেই পথেই চলে?” App আর test-এর অনুবাদ এক হলে অবাক করা পার্থক্য কমে। এটিই Vitest-এর আসল যুক্তি।

প্র ০২Snapshot test কি সহায়ক, না ক্ষতিকর? কোন অবস্থায় কোনটি?

Snapshot জনপ্রিয় হয় মূলত React component-এর render করা ফল সংরক্ষণের জন্য। লিখতে এক লাইন, আর অনেক কিছু একসাথে ধরা পড়ে। এই সহজতাই তার দুর্বলতা। Snapshot বলে না কী ঠিক, শুধু বলে “আগের মতো আছে কি না”। প্রথমবার যা ছিল, ভুল থাকলেও সেটিই মানদণ্ড হয়ে যায়।

এই পর্বের সত্যিকারের run-এ দেখেছেন, একটি ভুল linePaisa এক কমান্ডে “প্রত্যাশিত” হয়ে গেল। এটি কাল্পনিক ভয় নয়। Jest-এর docs-ও snapshot-কে কোডের মতো commit আর review করতে বলে, আর snapshot ছোট ও কেন্দ্রীভূত রাখতে পরামর্শ দেয়। Kent C. Dodds-এর মতো পরিচিত লেখকেরা সতর্ক করেছেন যে বড় snapshot-এর diff মানুষ পড়ে না, শুধু update চাপে। কয়েকশো লাইনের diff-এ একটি বদলানো সংখ্যা চোখে পড়া কঠিন।

কোথায় snapshot ভালো কাজ করে? যেখানে ফলটি লম্বা, কিন্তু মানুষ পড়ে বুঝতে পারে, আর বদল হলে মানুষই বিচার করবেন ঠিক কি না। যেমন একটি error বার্তা, CLI-এর আউটপুট, একটি ছোট SQL query, বা আমাদের পাঁচ লাইনের রসিদ, inline snapshot-এ। Inline হলে review-এর সময় প্রত্যাশাটি test-এর পাশেই থাকে।

কোথায় খারাপ? যেখানে ফল বিশাল, বারবার বদলায়, বা এতে সময়, এলোমেলো id বা পরিবেশ-নির্ভর লেখা থাকে। Node আপগ্রেডে Intl-এর লেখা বদলালেও snapshot ভাঙতে পারে, যদিও আপনার কোড ঠিক আছে। তখন test এমন শব্দ করে যা কেউ আর শোনে না। সেরা মিশ্রণ হলো মূল নিয়মে স্পষ্ট assertion, আর পাশে ছোট snapshot.

মূল উপলব্ধি: Snapshot পরিবর্তন ধরে, সঠিকতা নয়। যে diff আপনি পড়বেন না, সেই snapshot আপনাকে রক্ষা করে না, শুধু নিরাপত্তার ভুল অনুভূতি দেয়।

প্র ০৩“যা আপনার নয়, তা mock করবেন না” কথাটির মানে কী, আর SMS gateway-র ক্ষেত্রে এটি কীভাবে খাটে?

Test double নিয়ে দুটি পরিচিত ধারা আছে। Martin Fowler-এর Mocks Aren't Stubs লেখায় (শেষ পরিমার্জন ২০০৭) একটিকে classicist, অন্যটিকে mockist বলা হয়েছে। Classicist ধারা যতটা সম্ভব আসল object ব্যবহার করে আর শেষের অবস্থা যাচাই করে। Mockist ধারা প্রতিটি সহযোগীকে নকল করে আর ডাকের আচরণ যাচাই করে। Mockist ধারা থেকেই Steve Freeman আর Nat Pryce-এর Growing Object-Oriented Software, Guided by Tests (২০০৯) বইয়ে একটি পরামর্শ জনপ্রিয় হয়: “Only mock types that you own.”

কারণটি বাস্তব। আপনি যদি একটি বাইরের SMS SDK-র method সরাসরি mock করেন, তাহলে আপনার নকল আপনার ধারণা অনুযায়ী আচরণ করে, SDK-র আসল আচরণ অনুযায়ী নয়। SDK আপডেট হয়ে argument-এর ক্রম বদলালে, বা error-এর ধরন বদলালে, আপনার test সবুজই থাকবে আর production ভাঙবে। আর SDK-র জটিল API নকল করতে test-এ অনেক কোড লাগে।

সমাধান হলো নিজের একটি পাতলা adapter: আমাদের sms.ts, যার একটিই ফাংশন sendSms(to, text). Adapter-টি আপনার নিজের, তার চুক্তিও আপনার। order.ts-এর test-এ এই adapter mock হয়। আর adapter নিজে বাইরের HTTP-র সাথে ঠিক কথা বলছে কি না, সেটি আলাদা test-এ যাচাই হয়, নকল HTTP server বা gateway-র sandbox দিয়ে (পর্ব ৭৭)। এই পর্বের coverage-এ sms.ts ০% দেখেছেন, এটিই সেই ফাঁকা জায়গা, যা জেনেশুনে রাখা।

আরেকটি দিক: নিজের হিসাবের কোড, যেমন cartTotals বা formatTaka, mock করলে test শুধু নকলকে test করে। placeOrder-এর test তখন প্রমাণ করবে যে সে একটি ফাংশন ডাকে, কিন্তু মোট ঠিক কি না নয়।

মূল উপলব্ধি: নকল বসান সীমানায়, আর সেই সীমানা নিজের মালিকানার একটি ছোট চুক্তি দিয়ে বানান। ভেতরের হিসাব আসল রাখুন, বাইরের জগৎ adapter-এর পেছনে রাখুন।

প্র ০৪তারিখ আর সময়ের bug test-এ এত সহজে লুকায় কেন? Fake timer নাকি সময়কে parameter বানানো, কোনটি ভালো?

সময়ের bug লুকায় কারণ test সাধারণত দিনের মাঝখানে, একটি time zone-এ, আর একই কম্পিউটারে চলে। আমাদের শুক্রবারের bug-টি শুধু ঢাকার রাত ১২টা থেকে সকাল ৬টার মধ্যে প্রকাশ পায়, কারণ তখন ঢাকায় শুক্রবার আর UTC-তে বৃহস্পতিবার। দুপুরে চালানো test সবসময় সবুজ।

বাংলাদেশের ইতিহাসেও এর উদাহরণ আছে। ২০০৯ সালে বিদ্যুৎ সাশ্রয়ের জন্য কয়েক মাস daylight saving time চালু ছিল, ঘড়ি এক ঘণ্টা এগিয়ে। পরে সেটি বাতিল হয়। এমন বদলের পরে সব কম্পিউটারের time zone database হালনাগাদ না হলে একই মুহূর্তে দুই যন্ত্র দুই সময় দেখায়। এজন্য time zone-এর নিয়ম কোডে নিজে না লিখে IANA database-এর Asia/Dhaka নাম ব্যবহার করা নিরাপদ, যা Intl নিজে জানে।

Test-এর দুটি পথ। প্রথম পথ, সময়কে parameter বানানো: cartTotals(lines, coupon, now). এটি সবচেয়ে স্পষ্ট। কোনো জাদু নেই, test-এ শুধু একটি Date দেন। এটি পর্ব ২৯-এর dependency injection-এরই রূপ, ঘড়িও একটি নির্ভরতা। দ্বিতীয় পথ, fake timer আর vi.setSystemTime. যেখানে কোড ভেতরে setTimeout বা Date.now() ডাকে, আর তা বদলানো সহজ নয়, যেমন library-র ভেতরে, সেখানে এটি অপরিহার্য। আমাদের reservation-এর timer এর উদাহরণ।

Fake timer-এর দাম আছে। এটি global জিনিস বদলায়, তাই afterEach-এ না ফেরালে পরের test-এ ছড়ায়। Microtask আর promise-এর সাথে মিশলে ক্রম বোঝা কঠিন হয়, আর docs বলছে nextTick ডিফল্টে নকল হয় না। নতুন Temporal API-ও এখন Vitest 5-এ নকল হয়।

মূল উপলব্ধি: সময় একটি input. সম্ভব হলে তাকে parameter হিসেবে দিন, না হলে fake timer দিয়ে নিয়ন্ত্রণ করুন। আর test করুন সীমার মুহূর্তে: মধ্যরাতের এক মিনিট আগে আর পরে, মেয়াদের এক মিলিসেকেন্ড আগে আর পরে।

প্র ০৫Coverage ৯০% বা ১০০% লক্ষ্য রাখা কি ভালো নিয়ম? Coverage-এর চেয়ে ভালো মাপ আছে কি?

Coverage মাপে কোন কোড চলেছে, কোন কোড যাচাই হয়েছে তা নয়। একটি test যদি cartTotals ডাকে কিন্তু কোনো expect না লেখে, তবু coverage বাড়বে। এজন্য coverage-কে লক্ষ্য বানালে Goodhart-এর সূত্রের কথা মনে আসে: কোনো মাপ লক্ষ্য হয়ে গেলে সেটি আর ভালো মাপ থাকে না। মানুষ তখন সংখ্যা বাড়ানোর test লেখে, ভুল ধরার test নয়।

তবু coverage অকেজো নয়। এটি সবচেয়ে ভালো কাজ করে উল্টো দিকে: কোথায় একেবারেই test নেই, তা দেখাতে। আমাদের রিপোর্টে reservation.ts-এর branch 62.5% দেখে বোঝা গেল, callback না দেওয়ার পথ আর অজানা পণ্যের পথ কখনো চলেনি। Google Testing Blog-এ ২০২০ সালের একটি লেখায় (Code Coverage Best Practices) মোটামুটি এমন কথা আছে: কম coverage একটি সতর্ক সংকেত, কিন্তু বেশি coverage মানেই ভালো test নয়, আর একটি সর্বজনীন সংখ্যা নেই।

Coverage-এর চেয়ে কঠোর একটি মাপ হলো mutation testing. একটি টুল কোডে ছোট ছোট ভুল ঢোকায়, যেমন >=-কে >, +-কে -, তারপর দেখে test লাল হয় কি না। যে ভুল টিকে যায়, সেটি দেখায় কোথায় assertion দুর্বল। JavaScript-এ Stryker এমন একটি পরিচিত টুল। এটি ধীর, তাই সাধারণত শুধু গুরুত্বপূর্ণ অংশে, যেমন টাকার হিসাবে, চালানো হয়। এই পর্বের চেকলিস্টের “ইচ্ছা করে ভুল ঢোকানো” আসলে হাতে করা ছোট mutation testing.

বাস্তব পরামর্শ: coverage রিপোর্ট দেখুন, ফাঁকা জায়গা খুঁজুন, আর CI-তে একটি নিচের সীমা রাখতে পারেন যাতে হঠাৎ পতন ধরা পড়ে। কিন্তু টাকা, stock, অনুমতির মতো জায়গায় সংখ্যার বদলে প্রশ্ন করুন, “এখানে কোন ভুল হলে কোন test লাল হবে?”

মূল উপলব্ধি: Coverage বলে কোথায় অন্ধকার, আলো কতটা উজ্জ্বল তা বলে না। Test-এর মান মাপুন তার ভুল ধরার ক্ষমতায়।

অনুশীলন

দাম: চাল 42000, ডাল 14000, তেল 95000 পয়সা। EID10-এ ১০% ছাড়, ঢাকার শুক্রবারে বাড়তি ৫%, ছাড় নিচে গোল করা। ছাড়ের পরে 100000 পয়সা বা বেশি হলে delivery ফ্রি, নইলে 6000.

  1. হিসাব করুন: একটি coverage রিপোর্টে লেখা Branches : ( 41/52 ) আর Lines : ( 118/121 ). রিপোর্ট দুই দশমিকে কেটে দেখায়। দুটি শতাংশ কত দেখাবে? CI-তে branch-এর সীমা ৮০% হলে কী হবে? আর ঢাকার শুক্রবারে ১টি তেল আর ১টি ডাল, কুপন EID10 হলে মোট কত পয়সা?

    Branches: 41 ÷ 52 = 0.788461…, তাই 78.84%. Lines: 118 ÷ 121 = 0.975206…, তাই 97.52%. Branch ৮০%-এর নিচে, তাই CI ফেল করবে, যদিও প্রায় সব লাইন চলেছে। আরও ২টি branch ঢাকলে 43 ÷ 52 = 82.69%, তখন পাস।

    অর্ডার: subtotal = 95000 + 14000 = 109000. শুক্রবারে ছাড় ১৫%: 109000 × 15 ÷ 100 = 16350, floor-এর পরেও 16350. ছাড়ের পরে 92650, যা 100000-এর কম, তাই delivery 6000. মোট 98650 পয়সা। বৃহস্পতিবার হলে ছাড় 10900, বাকি 98100, delivery 6000, মোট 104100. শুক্রবারে ক্রেতা 5450 পয়সা কম দেন।

  2. একটি test লিখে চালান: cartTotals-এর delivery-র সীমার জন্য একটি test.each লিখুন, তিনটি সারিতে: ছাড়ের পরে ঠিক 100000, 99999 আর 100001 পয়সা (কুপন ছাড়া, বুধবারে)। তারপর কোডে >= বদলে > করে npx vitest run চালান। কয়টি সারি লাল হওয়ার কথা?
    test.each([
      { unit: 100000, delivery: 0 },
      { unit: 99999, delivery: 6000 },
      { unit: 100001, delivery: 0 },
    ])('subtotal $unit -> delivery $delivery', ({ unit, delivery }) => {
      expect(cartTotals([{ sku: 'x', unitPaisa: unit, qty: 1 }]).delivery).toBe(delivery);
    });

    beforeEach-এ বুধবারের সময় বসানো describe-এর ভেতরে রাখুন, নইলে শুক্রবারে চালালে ছাড় ঢুকে ফল বদলাবে। > করার পরে শুধু প্রথম সারিটি লাল হওয়ার কথা, কারণ 100000 > 100000 মিথ্যা, তাই delivery 6000 আসবে। বাকি দুটি সারি দুই নিয়মেই একই ফল দেয়। এজন্যই সীমার ঠিক মানটি ছকে রাখা জরুরি। Test design-এর এই কৌশলের তত্ত্ব আছে Test design পর্বে।

  3. নকশা সমালোচনা করুন: একটি pull request-এ এই test এলো, সব সবুজ। অন্তত পাঁচটি সমস্যা বের করুন।
    import { expect, it, vi } from 'vitest';
    import { cartTotals } from './cart';
    import * as money from './money';
    
    it.only('cart works', () => {
      vi.spyOn(money, 'percentOf').mockReturnValue(8400);
      const lines = [{ sku: 'chal', unitPaisa: 42000, qty: 2 }];
      const t = cartTotals(lines, 'EID10');
      expect(t.total).toBe(lines[0].unitPaisa * lines[0].qty * 0.9 + 6000);
      expect(t).toMatchSnapshot();
      expect(new Date().getDay()).not.toBe(5);
    });
    • it.only: ফাইলের বাকি সব test বাদ পড়বে, আর CI-তে Vitest ফেল করাবে। মুছতে হবে।
    • নিজের হিসাব mock করা: percentOf বাজারঘরের নিজের ছাড়ের হিসাব। আমরা চালিয়ে দেখেছি, spy সত্যিই cart.ts-এর ডাকে বসে, আর ছাড় হয় স্থির 8400. ফলে কুপনের নিয়মই test হয় না: কেউ ছাড় ২০% করে দিলেও এই test সবুজ থাকবে।
    • সূত্র কপি করা: প্রত্যাশিত মান কোডের সূত্রের মতোই লেখা, আর 0.9 গুণে float ঢুকেছে। এবার ভাগ্যক্রমে ঠিক 81600 এসেছে, অন্য দামে না-ও আসতে পারে। হাতে হিসাব করা 81600 লিখুন।
    • সময় নিয়ন্ত্রণ নেই: শুক্রবারে চালালে ছাড় বদলাবে। আর শেষের লাইনটি test নয়, শুধু “আজ শুক্রবার নয়” দাবি, তাই শুক্রবারে test ভাঙবে। vi.setSystemTime দিয়ে সময় বসাতে হবে।
    • অকারণ snapshot: মূল নিয়ম স্পষ্ট assertion-এ থাকলে পুরো object-এর snapshot বাড়তি শব্দ, আর পরে অন্ধ অনুমোদনের ঝুঁকি।
    • অস্পষ্ট নাম: “cart works” লাল হলে কিছুই বলে না। “coupon EID10 gives 10% off below free delivery” ধরনের নাম দিন।
  4. আউটপুট বলুন: Vitest-এ নিচের test চালালে কোন কোন লাইন ছাপা হবে, আর শেষে stock কত? (HOLD_MS = 900000, reservation-এর কোড এই পর্বের মতো।)
    it('predict', () => {
      vi.useFakeTimers();
      const r = createReservations({ dal: 5 }, (id) => console.log('expired', id));
      const a = r.reserve('dal', 2);
      vi.advanceTimersByTime(10 * 60 * 1000);
      const b = r.reserve('dal', 3);
      console.log('now', r.available('dal'));
      vi.advanceTimersByTime(5 * 60 * 1000);
      console.log('after 15', r.available('dal'));
      console.log('confirm b', r.confirm(b), 'confirm a', r.confirm(a));
      vi.runAllTimers();
      console.log('end', r.available('dal'));
      vi.useRealTimers();
    });

    Vitest 5.1.0-এ --reporter=verbose দিয়ে চালানো সত্যিকারের আউটপুট (stdout অংশ):

    now 0
    expired r1
    after 15 2
    confirm b true confirm a false
    end 2

    শুরুতে 5, a (r1) নিল 2, stock 3. ১০ মিনিট পরে b (r2) নিল 3, stock 0. আরও ৫ মিনিটে মোট ১৫ মিনিট, তাই r1-এর মেয়াদ শেষ: “expired r1” ছাপা হলো, 2 ফেরত এসে stock 2. r2-এর মেয়াদ শেষ হবে ২৫ মিনিটে, এখনো বাকি। confirm(b) সত্য, কারণ r2 এখনো ধরা আছে, আর তার timer মুছে গেল। confirm(a) মিথ্যা, কারণ r1 আগেই মেয়াদোত্তীর্ণ। runAllTimers চালানোর মতো কোনো timer নেই, তাই stock 2-ই থাকে। ক্রেতা A দেরিতে টাকা দিলে তাঁর পণ্য আর ধরা নেই, এই আচরণও একটি test-এ লিখে রাখার মতো।

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

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

ক্রমnpx vitest run চালালে ঘটনাগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. ⚙️ Config পড়ে include-এর নিয়ম জানা
  2. 🔎 নিয়ম মিলিয়ে test ফাইল খুঁজে বের করা
  3. 🔁 ফাইল অনুবাদ, vi.mock সবার ওপরে তোলা
  4. 📥 describe আর it দিয়ে test-এর তালিকা জমা
  5. ▶️ প্রতিটি test-এর আগে beforeEach, তারপর test, তারপর afterEach
  6. 📋 Reporter-এ পাস, ফেল আর সময় দেখানো

Vitest-এ const a = {x: 1}; const b = {x: 1}; হলে কোনটি পাস করবে?

  • expect(a).toBe(b)
  • expect(a).not.toEqual(b)
  • expect(a).toEqual(b) আর expect(a).not.toBe(b) দুটিই
  • কোনোটিই পাস করবে না

একটি test ফাইলে import { sendSms } from './sms'-এর পরের লাইনে vi.mock('./sms', ...) লেখা। order.ts কোন sendSms পাবে?

  • আসলটি, কারণ import আগে লেখা
  • নকলটি, কারণ vi.mock hoist হয়ে import-এর আগে চলে
  • Vitest একটি ক্রমের error দেবে
  • Test ফাইল নকল পাবে, order.ts আসলটি

একটি ফাংশন ১৫ মিনিট পরে setTimeout দিয়ে stock ফেরত দেয়। Test-এ ঠিক ১৫ মিনিটের সীমা যাচাইয়ের সবচেয়ে ভালো উপায় কোনটি?

  • আসল সময়ে ১৫ মিনিট অপেক্ষা করা
  • vi.runAllTimers() একবার চালিয়ে শেষ অবস্থা দেখা
  • শুধু vi.setSystemTime দিয়ে ১৫ মিনিট পরের সময় বসানো
  • vi.useFakeTimers, তারপর সীমার ১ ms আগে পর্যন্ত আর তারপর ১ ms এগিয়ে দুবার যাচাই

একটি snapshot test লাল হলো। Diff-এ দেখা গেল "linePaisa": 84000 বদলে 42000 হয়েছে। কোনটি সঠিক পদক্ষেপ?

  • আগে কারণ খোঁজা, কারণ ২টি পণ্যের লাইনের দাম অর্ধেক হওয়া সম্ভবত bug
  • --update চালিয়ে সবুজ করা
  • Snapshot test মুছে ফেলা
  • Test-টিকে it.skip করা

Vitest 5-এ ডিফল্ট config-এ একটি vi.fn প্রথম test-এ একবার ডাকা হলো। দ্বিতীয় test-এ expect(fn).not.toHaveBeenCalled() লিখলে কী হবে, যদি দ্বিতীয় test-এ fn ডাকা না হয়?

  • ফেল করবে, কারণ আগের ডাক রয়ে গেছে
  • Test-এর ক্রমের ওপর নির্ভর করবে
  • পাস করবে, কারণ প্রতিটি test-এর আগে ডাকের হিসাব নিজে থেকে মুছে যায়
  • Error হবে, কারণ নকলটি মুছে যায়

একটি ফাইলের সব লাইন test-এ চলেছে (Lines 100%), কিন্তু Branches 62.5%. এর মানে কী?

  • Coverage টুলে ভুল আছে
  • কিছু শর্তের একটি দিক কখনো চলেনি, যেমন ?? বা ?.-এর বিকল্প পথ
  • ফাইলে নিশ্চিতভাবে bug আছে
  • ৬২.৫% test পাস করেছে

নিজের কম্পিউটারে Tests 24 passed | 5 skipped (29) দেখা গেল, কোনো লাল নেই। Vitest-এর ডিফল্টে CI-তে একই কোড কী করবে, যদি একটি test-এ it.only থাকে?

  • একই রকম সবুজ দেখাবে
  • শুধু .only-ওয়ালা test চলবে, অন্য সব ফাইল বাদ
  • .only নিজে থেকে মুছে সব চালাবে
  • “Unexpected .only modifier” বলে ফেল করবে

শব্দকোষ

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

মনে রাখুন

  1. vitest watch করে, vitest run একবার চালায়। .only commit করবেন না, CI তা ধরবে।
  2. সংখ্যায় toBe, object-এ toEqual. Async-এ await expect(...).resolves বা .rejects.
  3. টাকা পয়সায়, আর সীমার মানসহ test.each-এর ছক। প্রত্যাশিত মান হাতে হিসাব করা।
  4. নকল শুধু সীমানায়: vi.mock বাইরের module-এ, vi.spyOn নজরে, নিজের হিসাব আসল। Fake timer আর setSystemTime শেষে ফেরাতে হয়।
  5. Snapshot ছোট রাখুন, diff না পড়ে update নয়। Coverage অন্ধকার দেখায়, test-এর মান নয়।

পরের পর্বে: পর্ব ৭৪ — Component test: Testing Library আর ব্যবহারকারীর চোখে। এবার হিসাবের ফাংশন নয়, বাজারঘরের কার্টের React component: বোতাম চাপা, লেখা পড়া, আর ব্যবহারকারী যা দেখেন সেটিই যাচাই।

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

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