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

Component test — Testing Library আর ব্যবহারকারীর চোখে

Component Testing — Testing Library and the User's Point of View

Taught in Bangla, this lesson tests React components the way users see them, sets up Vitest with Testing Library and jsdom or happy-dom, writes a first render, find, click and assert test, follows the query priority from getByRole down to getByTestId, explains get, query and find, shows how an unnamed icon button fails both tests and accessibility, uses userEvent over fireEvent on a Zod checkout form, async UI with findBy and waitFor, and act warnings.

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

  • Vitest, React Testing Library আর jsdom বা happy-dom দিয়ে component test-এর পরিবেশ বানানো, আর দুই DOM-এর গতি মাপা
  • Query-র অগ্রাধিকার, get/query/find-এর পার্থক্য, আর screen.debug দিয়ে ভুল খোঁজা
  • userEvent বনাম fireEvent, আর findBy ও waitFor দিয়ে async UI test
  • বাজারঘরের ঝুড়ির বোতাম, Zod-এর checkout form, TanStack Query-র তালিকা আর Zustand store-এর test
  • act warning, renderHook, Vitest browser mode, আর কী test করবেন না

১ · Component test কী, আর “ব্যবহারকারীর চোখে” কেন

বাজারঘরের পণ্যের পাতায় একটি ছোট অংশ আছে: “−” আর “+” বোতাম, মাঝে পরিমাণ, নিচে “ঝুড়িতে যোগ করুন”। নিয়ম হলো, স্টকে যত আছে তার বেশি নেওয়া যাবে না, আর একবারে সর্বোচ্চ ১০টি। এই নিয়ম ভাঙলে গুদামে টান পড়ে, ক্রেতা রেগে যান। প্রশ্ন হলো: প্রতিবার কোড বদলানোর পরে কে নিশ্চিত করবে যে নিয়মটি এখনো ঠিক আছে?

Unit test একটি ফাংশন যাচাই করে: useQuantity(3) চতুর্থবার বাড়ালে ৩-এই থামে কি না। পর্ব ৭৩-এ Vitest দিয়ে এমন test লিখেছেন। কিন্তু ফাংশন ঠিক থাকলেও বোতাম ভুল ফাংশনে জোড়া থাকতে পারে, বা বোতাম বন্ধ না হয়ে শুধু ধূসর দেখাতে পারে। Component test এই ফাঁকটি ধরে। এটি একটি component আসলে render করে, DOM-এ বোতাম খোঁজে, চাপে, আর পর্দায় কী এল তা দেখে। ব্যবহারকারীর কাজের ধরনটি নকল করা হয়, তবে পুরো অ্যাপ, সার্ভার বা আসল browser ছাড়াই।

রেস্তোরাঁর মালিক নতুন রাঁধুনির বিরিয়ানি যাচাই করবেন। দুটি পথ আছে। এক, রান্নাঘরে ঢুকে দেখা কোন হাঁড়িতে কত চামচ লবণ গেল। দুই, অতিথির মতো এক প্লেট নিয়ে চেখে দেখা। প্রথম পথে রাঁধুনি হাঁড়ি বদলালেই “ভুল” ধরা পড়ে, অথচ বিরিয়ানি হয়তো আগের মতোই ভালো। দ্বিতীয় পথে শুধু সেই বদলই ধরা পড়ে যা অতিথি টের পান। ভালো component test দ্বিতীয় পথের।

এই চিন্তা জনপ্রিয় করেছেন Kent C. Dodds. তিনি ২০১৮ সালের দিকে Testing Library তৈরি করেন, আর তাঁর নামে একটি বাক্য বহুল প্রচলিত, মোটামুটি এরকম: “আপনার test যত বেশি software-এর ব্যবহারের ধরনের মতো হবে, তত বেশি আস্থা দেবে।” এটি প্রমাণিত সূত্র নয়, একটি নকশার দর্শন। তবে এর ব্যবহারিক ফল স্পষ্ট: test ভাঙবে যখন ব্যবহারকারীর অভিজ্ঞতা ভাঙবে, আর ভিতরের কোড গুছিয়ে লেখার সময় (refactor) অকারণে ভাঙবে না।

মূল ধারণা

যা ব্যবহারকারী দেখতে বা করতে পারেন না, তা test-এ খুঁজবেন না। Component-এর state-এর নাম, ভেতরের ফাংশন, CSS class: এগুলো implementation detail. Test দেখবে role, নাম, লেবেল, লেখা আর ব্যবহারকারীর কাজের ফল।

Test-এর তত্ত্ব, যেমন test double, pyramid আর integration-এর ভাগ, আছে testing fundamentals আর test pyramid পর্বে। কোন স্তরে কতটি test রাখবেন, সেই কৌশল পর্ব ৭২-এ। এই পর্ব শুধু component স্তরে হাতে-কলমে কাজ। আগে পর্ব ১৭-এ reducer আর পর্ব ২৯-এ service-এর test লিখেছেন; এবার পর্দার অংশটি।

২ · কাজের পরিবেশ: Vitest, Testing Library আর jsdom বনাম happy-dom

Component test-এ চারটি অংশ একসাথে কাজ করে। Vitest test চালায় আর ফল জানায়। একটি নকল DOM, যেমন jsdom বা happy-dom, Node.js-এর ভেতরে document আর window বানায়, কারণ Node-এ browser নেই। React সেই DOM-এ render করে। আর Testing Library দেয় খোঁজার টুল (screen.getByRole), ব্যবহারকারীর কাজের নকল (userEvent), আর jest-dom-এর সহজ assertion, যেমন toBeDisabled, দেয়।

একটি component test-এর স্তর

The layers of a component test, and what the test is allowed to look at

Vitest থেকে নকল DOM পর্যন্ত স্তর, আর test যা দেখে না Vitest · AddToCart.test.tsx test চালায়, ফল জানায় screen getByRole … userEvent click, type jest-dom toBeDisabled React render component → DOM state · prop CSS class test দেখে না নকল DOM — jsdom বা happy-dom role · accessible name · লেখা layout নেই, আসল CSS হিসাব নেই ব্যবহারকারী আর screen reader যা পান, test-ও ঠিক সেটুকুই পায়
ছবি ৭৪.১ — Test-এর তীরগুলো নিচের DOM-এ নামে। ডানের ধূসর বাক্সে state আর class; সেখানে তীর কাটা, কারণ ব্যবহারকারী সেগুলো দেখেন না।

লেখার সময়ে version (npm-এ দেখা): vitest 5.1.0, @testing-library/react 16.3.3, @testing-library/dom 10.4.2, @testing-library/user-event 14.6.8, @testing-library/jest-dom 7.0.1, jsdom 30.1.3, happy-dom 21.0.4, React 19.3.1, Node 24. Version ১৬ থেকে @testing-library/dom আলাদা করে install করতে হয়, কারণ এটি এখন peer dependency.

npm install -D vitest jsdom @vitejs/plugin-react \
  @testing-library/react @testing-library/dom \
  @testing-library/user-event @testing-library/jest-dom
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/setup.ts'],
    include: ['src/**/*.test.{ts,tsx}'],
  },
});
import '@testing-library/jest-dom/vitest';

globals: true কেন, আমাদের নিজের ভুল থেকে: প্রথম run-এ এই লাইন ছিল না। Testing Library-র React অংশ নিজে থেকে দুটি কাজ করে: প্রতিটি test-এর পরে DOM পরিষ্কার (cleanup), আর React-কে জানানো “এটি test-এর পরিবেশ”। দুটিই সে করে global afterEach আর beforeAll খুঁজে পেলে। Vitest-এ এগুলো default-এ global নয়। ফলে আমাদের act ছাড়া লেখা ভুল test-টি কোনো warning ছাড়াই শুধু পুরোনো মান দেখাল, আর ভুলের কারণ বোঝা গেল না। globals: true দেওয়ার পরে আসল warning এল (অংশ ৮)। বিকল্প হলো setup ফাইলে নিজে cleanup ডাকা আর IS_REACT_ACT_ENVIRONMENT চালু করা।

jsdom বনাম happy-dom: একই ১৪টি test, দুই DOM

দুটিই browser-এর DOM-কে JavaScript-এ নকল করে। jsdom পুরোনো আর বেশি সম্পূর্ণ; happy-dom নতুন, হালকা আর সাধারণত দ্রুত বলে পরিচিত। নিজে মেপে দেখুন। এই পর্বের পুরো suite (৯টি ফাইল, ১৪টি test) একই কম্পিউটারে দুই DOM-এ তিনবার করে চালানো হলো। --environment দিয়ে config না বদলেই DOM বদলানো যায়।

সত্যিকারের run · Vitest 5.1.0 · jsdom 30.1.3 · happy-dom 21.0.4 · Node 24 · সারাংশ, ছাঁটা · ~/bazarghar

~/bazarghar$ npx vitest run --environment jsdom        # তিনবার
 Tests  14 passed (14)   Duration  3.33s (environment 59%, …)
 Tests  14 passed (14)   Duration  3.22s (environment 62%, …)
 Tests  14 passed (14)   Duration  3.19s (environment 60%, …)

~/bazarghar$ npx vitest run --environment happy-dom    # তিনবার
 Tests  14 passed (14)   Duration  2.90s (environment 50%, …)
 Tests  14 passed (14)   Duration  2.56s (environment 46%, …)
 Tests  14 passed (14)   Duration  2.56s (environment 46%, …)
jsdomhappy-dom
তিন run-এর গড় সময়৩.২৫ s২.৬৭ s
পার্থক্যপ্রায় ০.৫৭ s, অর্থাৎ jsdom-এর তুলনায় প্রায় ১৮% কম
পরিবেশ বানানোর ভাগ৫৯–৬২%৪৬–৫০%
এই ১৪টি test-এর ফলসব পাসসব পাস
শক্তিবেশি সম্পূর্ণ, বহুল ব্যবহৃতহালকা, শুরু দ্রুত
ঝুঁকিধীরকিছু browser API-তে অমিল থাকতে পারে

হিসাব: jsdom-এর গড় (৩.৩৩ + ৩.২২ + ৩.১৯) ÷ ৩ = ৩.২৫ s, happy-dom-এর (২.৯০ + ২.৫৬ + ২.৫৬) ÷ ৩ = ২.৬৭ s. পার্থক্য ০.৫৭ ÷ ৩.২৫ ≈ ১৮%. এটি এক কম্পিউটারের একবারের মাপ, কোনো সাধারণ ফল নয়। লক্ষ করুন, সময়ের বড় অংশ গেছে “environment”-এ, অর্থাৎ প্রতিটি ফাইলের জন্য নকল DOM বানাতে; test নিজে কম সময় নিয়েছে। তাই ছোট suite-এ পার্থক্য চোখে পড়ে, আর বড় suite-এ test-এর নিজের কাজ বাড়লে অনুপাত বদলাতে পারে। সিদ্ধান্তের নিয়ম সহজ: jsdom দিয়ে শুরু করুন; গতি সমস্যা হলে happy-dom-এ চালিয়ে দেখুন সব test একই ফল দেয় কি না।

৩ · প্রথম test: render, খোঁজা, ক্লিক আর যাচাই

প্রথমে component. পরিমাণের হিসাব রাখে একটি ছোট custom hook, আর ঝুড়ি থাকে পর্ব ১৮-এর মতো একটি Zustand store-এ। আইকন বোতামের নাম দেওয়া আছে aria-label দিয়ে, কারণ “+” চিহ্নটি screen reader-এ অর্থবহ নয়।

import { useCart } from './cartStore';
import { useQuantity } from './useQuantity';

type Product = { id: string; name: string; price: number; stock: number };

export function AddToCart({ product }: { product: Product }) {
  const max = Math.min(product.stock, 10);
  const { qty, atMax, inc, dec } = useQuantity(max);
  const add = useCart((s) => s.add);
  return (
    <div>
      <button aria-label="পরিমাণ কমান" onClick={dec}>−</button>
      <output aria-label="পরিমাণ">{qty}</output>
      <button aria-label="পরিমাণ বাড়ান" onClick={inc} disabled={atMax}>+</button>
      {atMax && <p role="status">এই পণ্য সর্বোচ্চ {max}টি নেওয়া যাবে</p>}
      <button onClick={() => add(product, qty)}>ঝুড়িতে যোগ করুন</button>
    </div>
  );
}

এবার test. প্রতিটি লাইনে ভাবুন, একজন ক্রেতা ঠিক এই কাজটিই করেন কি না।

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, expect, test } from 'vitest';
import { AddToCart } from './AddToCart';
import { useCart } from './cartStore';

const initial = useCart.getInitialState();
beforeEach(() => useCart.setState(initial, true));

const dal = { id: 'dal', name: 'মসুর ডাল', price: 140, stock: 3 };

test('স্টকের বেশি বাড়ানো যায় না', async () => {
  const user = userEvent.setup();
  render(<AddToCart product={dal} />);
  const plus = screen.getByRole('button', { name: 'পরিমাণ বাড়ান' });

  await user.click(plus);
  await user.click(plus);
  await user.click(plus); // তৃতীয়বার: বোতাম ততক্ষণে বন্ধ

  expect(screen.getByLabelText('পরিমাণ')).toHaveTextContent('3');
  expect(plus).toBeDisabled();
  expect(screen.getByText(/সর্বোচ্চ 3টি/)).toBeInTheDocument();
});

test('যোগ করলে store-এ ঠিক পরিমাণ যায়', async () => {
  const user = userEvent.setup();
  render(<AddToCart product={dal} />);
  await user.click(screen.getByRole('button', { name: 'পরিমাণ বাড়ান' }));
  await user.click(screen.getByRole('button', { name: 'ঝুড়িতে যোগ করুন' }));
  expect(useCart.getState().lines).toEqual([{ id: 'dal', name: 'মসুর ডাল', price: 140, stock: 3, qty: 2 }]);
});
  • render: component-টি নকল DOM-এর document.body-তে বসায়।
  • screen: পুরো document.body-তে খোঁজার টুলের থলে। Render-এর ফেরত দেওয়া query-র চেয়ে এটি সহজ, কারণ সব test-এ একই নাম।
  • userEvent.setup(): একজন “ব্যবহারকারী” তৈরি করে, যিনি ক্লিক বা টাইপ করলে আসল browser-এর মতো পুরো ঘটনার ধারা ঘটে। সব কাজ await করতে হয়।
  • beforeEach-এ store reset: Zustand store একটি module-এ থাকে, তাই test শেষ হলেও মুছে যায় না। অংশ ৮-এ এর ব্যর্থতাটি দেখবেন।

নিচের ধাপগুলোতে প্রথম test-টি ধীরে চালান। মানগুলো আসল run-এর আচরণ থেকে নেওয়া।

1render(<AddToCart product={dal} />);2const plus = screen.getByRole('button',3  { name: 'পরিমাণ বাড়ান' });4await user.click(plus);5await user.click(plus);6await user.click(plus);7expect(getByLabelText('পরিমাণ')).toHaveTextContent('3');8expect(plus).toBeDisabled();
নকল DOM-এ যা আছে
output পরিমাণ
+ বোতাম disabled
সীমার বার্তা
plus
  1. Render. Component নকল DOM-এ বসল। পরিমাণ ১, স্টক ৩, তাই বোতাম খোলা আর সীমার বার্তা নেই।
  2. খোঁজা। getByRole সব element-এর role আর accessible name হিসাব করল। ঠিক একটি button-এর নাম “পরিমাণ বাড়ান”, তাই সেটি ফেরত এল। শূন্য বা একাধিক মিল হলে সাথে সাথে ভুল হতো।
  3. প্রথম ক্লিক। userEvent pointer আর mouse-এর ঘটনা পাঠাল, শেষে click. React state বদলাল, পরিমাণ ২। await করার ফলে React-এর update শেষ হওয়ার পরেই পরের লাইন চলল।
  4. দ্বিতীয় ক্লিক। পরিমাণ ৩, যা স্টকের সমান। atMax true, তাই বোতাম disabled হলো আর “সর্বোচ্চ ৩টি” বার্তা এল।
  5. তৃতীয় ক্লিক। বন্ধ বোতাম চাপলে আসল browser-এ কিছু হয় না, userEvent-ও তাই করল। পরিমাণ ৩-এই রইল। এখানে আসল নিয়মটি যাচাই হলো: সীমা পার হওয়া যায় না।
  6. যাচাই। লেবেল “পরিমাণ” দিয়ে output খুঁজে লেখা ৩ পাওয়া গেল, বোতাম বন্ধ পাওয়া গেল। Test-এর কোথাও state বা ফাংশনের নাম নেই।

যখন খোঁজা ব্যর্থ হয়: ভুলের বার্তা পড়া

শুরুতে শেষ লাইনটি ছিল screen.getByRole('status'). Test ব্যর্থ হলো, আর কারণটি শিক্ষণীয়:

সত্যিকারের run · Vitest 5.1.0 · ছাঁটা

 FAIL  src/AddToCart.test.tsx > স্টকের বেশি বাড়ানো যায় না
TestingLibraryElementError: Found multiple elements with the role "status"

Here are the matching elements:

<output
  aria-label="পরিমাণ"
>
  3
</output>

<p
  role="status"
>
  এই পণ্য সর্বোচ্চ 
  3
  টি নেওয়া যাবে
</p>

(If this is intentional, then use the `*AllBy*` variant of the query (like `queryAllByText`, `getAllByText`, or `findAllByText`)).
…
 ❯ src/AddToCart.test.tsx:23:17
     23|   expect(screen.getByRole('status')).toHaveTextContent('সর্বোচ্চ 3টি');
       |                 ^

HTML-এর <output> element-এর নিজস্ব (implicit) role হলো status. তাই পাতায় দুটি status ছিল, আর getBy একাধিক মিল পেলে ভুল দেয়, অনুমান করে একটি বেছে নেয় না। সমাধান ছিল বার্তাটিকে তার লেখা দিয়ে খোঁজা: getByText(/সর্বোচ্চ 3টি/). এই কড়া নিয়মই Testing Library-র শক্তি: অস্পষ্ট খোঁজা চুপচাপ ভুল element ধরে না।

কোনো element কেন মিলছে না বুঝতে screen.debug() ডাকুন। এটি DOM-এর বর্তমান রূপ সুন্দর করে ছাপে। একটি element দিলে শুধু সেটিই ছাপে:

সত্যিকারের run · screen.debug(plusButton)

~/bazarghar$ npx vitest run --reporter=verbose demos/debug.test.tsx

stdout | demos/debug.test.tsx > debug
<button
  aria-label="পরিমাণ বাড়ান"
>
  +
</button>

 ✓ demos/debug.test.tsx > debug 81ms

Vitest 5-এর default reporter-এ পাস করা test-এর console লেখা হয়তো দেখা যাবে না; আমাদের run-এ --reporter=verbose দিলে দেখা গেছে। খুব বড় DOM হলে ছাপা ছেঁটে যায়; সেক্ষেত্রে DEBUG_PRINT_LIMIT environment variable দিয়ে সীমা বাড়ানো যায়।

৪ · Query-র অগ্রাধিকার, আর get, query, find

Testing Library-র নথিতে query-গুলোর একটি পছন্দের ক্রম দেওয়া আছে। মূল কথা: যে পথে সবাই, screen reader ব্যবহারকারীসহ, পাতাটি চেনেন, সেই পথ আগে।

ক্রমQueryকখনবাজারঘরের উদাহরণ
১getByRole + nameপ্রায় সবকিছু: বোতাম, লিংক, heading, তালিকা, form fieldgetByRole('button', { name: 'অর্ডার করুন' })
২getByLabelTextForm-এর ঘর, লেবেল দিয়েgetByLabelText('মোবাইল')
৩getByPlaceholderTextলেবেল নেই এমন ঘর (লেবেল না থাকাটাই সমস্যা)খোঁজার ঘর
৪getByTextInteractive নয় এমন লেখা“সর্বোচ্চ ৩টি”, দামের লেখা
৫getByDisplayValue, getByAltText, getByTitleঘরের বর্তমান মান, ছবি, titleপণ্যের ছবির alt
শেষেgetByTestIdঅন্য কোনো পথ নেইChart-এর canvas

Test id (data-testid) ব্যবহারকারী দেখেন না, screen reader-ও পড়ে না। তাই এটি দিয়ে খুঁজলে বোতামের নাম মুছে গেলেও test পাস করবে, অথচ আসল মানুষ বোতামটি চিনতে পারবেন না। তবে এটি নিষিদ্ধ নয়: chart-এর ছবি, বা একই লেখার অনেক কার্ডের একটিকে আলাদা করতে কখনো কাজে লাগে।

  1. Role + নামgetByRoleপ্রথম পছন্দ
  2. লেবেলgetByLabelTextform-এর ঘর
  3. লেখাgetByTextসাধারণ লেখা
  4. Test idgetByTestIdশেষ ভরসা
প্রশ্নএর কি role আর নাম আছে?
<button>অর্ডার করুন</button>
role: button   name: "অর্ডার করুন"
getByRole('button', { name: 'অর্ডার করুন' })  ✓
প্রশ্নএটি কি লেবেলওয়ালা ঘর?
<label for="phone">মোবাইল</label><input id="phone">
getByLabelText('মোবাইল')                      ✓
একই জিনিস: getByRole('textbox', { name: 'মোবাইল' })
প্রশ্নএটি কি শুধু পড়ার লেখা?
<p>এই পণ্য সর্বোচ্চ 3টি নেওয়া যাবে</p>
কোনো role-এর নাম নেই, তাই
getByText(/সর্বোচ্চ 3টি/)                      ✓
প্রশ্নআর কোনো পথ নেই?
<canvas data-testid="sales-chart"></canvas>
লেখা নেই, নাম নেই
getByTestId('sales-chart')                    শেষ ভরসা
  1. Role আর নাম আগে। বোতাম, লিংক, heading, তালিকা, এমনকি form-এর ঘরও role দিয়ে খোঁজা যায়। নাম না মিললে ভাবুন: ব্যবহারকারী এটিকে কী নামে চেনেন?
  2. Form-এর ঘরে লেবেল। getByLabelText নিশ্চিত করে যে লেবেল আর ঘর সত্যিই জোড়া। লেবেল জোড়া না থাকলে test ব্যর্থ হয়, আর সেটিই ঠিক, কারণ screen reader-ও তখন ঘরের নাম বলতে পারে না।
  3. সাধারণ লেখা। বার্তা, দাম, বিবরণ: এদের role থাকলেও সাধারণত নাম নেই। getByText-এ পুরো লেখা বা regex দিন।
  4. Test id শেষে। ব্যবহারকারীর কাছে অদৃশ্য, তাই accessibility-র কোনো যাচাই হয় না। ব্যবহার করলে কারণটি মন্তব্যে লিখে রাখুন।

তিন রূপ: get, query, find

রূপ০টি মিল১টি মিলএকাধিক মিলঅপেক্ষা?কখন
getBy…ভুল ছোড়েelementভুল ছোড়েনাযা এখনই থাকার কথা
queryBy…nullelementভুল ছোড়েনা“নেই” প্রমাণ করতে
findBy…অপেক্ষার শেষে ভুলPromise → elementভুলহ্যাঁ, default 1000 msযা একটু পরে আসবে
getAllBy… ইত্যাদিভুল (queryAll: [])সবগুলোর arrayfindAll-এ হ্যাঁতালিকার সব সারি

একটি সাধারণ ভুল: expect(screen.getByRole('alert')).not.toBeInTheDocument(). Alert না থাকলে getBy নিজেই ভুল ছোড়ে, তাই assertion পর্যন্ত পৌঁছায় না। “নেই” প্রমাণে queryBy লাগে।

এবার getByRole-এর ভেতরের ধারণাটি নিজে চালিয়ে দেখুন। Live block-এ Testing Library আনা যায় না, তাই এটি একটি শিক্ষণ মডেল: একটি ছোট getByRole যা DOM ঘুরে role আর নাম মেলায়। আসল React 19 চলছে। ReactDOM.flushSync React-কে update সাথে সাথে শেষ করতে বলে, যা test-এ act-এর কাজের কাছাকাছি।

<div id="root"></div>
<script src="../course-kit/vendor/react-lab.js"></script>
<script type="text/jsx">
function AddToCart({ stock }) {
  const [qty, setQty] = useState(1);
  return (<div><button aria-label="পরিমাণ বাড়ান" disabled={qty >= stock}
    onClick={() => setQty(q => q + 1)}>+</button>
    <output aria-label="পরিমাণ">{qty}</output></div>);
}
// শিক্ষণ মডেল: getByRole-এর খুব ছোট রূপ, আসল Testing Library নয়
const ROLE = { button: 'button', output: 'status', a: 'link' };
const nameOf = el => el.getAttribute('aria-label') ?? el.textContent.trim();
function getByRole(role, { name }) {
  const hits = [...document.querySelectorAll('#root *')]
    .filter(el => ROLE[el.localName] === role && nameOf(el) === name);
  if (hits.length !== 1) throw new Error(`${hits.length}টি ${role} "${name}"`);
  return hits[0];
}
const root = createRoot(document.getElementById('root'));
ReactDOM.flushSync(() => root.render(<AddToCart stock={3} />));
(() => {
  const plus = getByRole('button', { name: 'পরিমাণ বাড়ান' });
  for (let i = 0; i < 3; i++) ReactDOM.flushSync(() => plus.click()); // act-এর মতো
  console.log('পরিমাণ:', getByRole('status', { name: 'পরিমাণ' }).textContent);
  console.log('বন্ধ?', plus.disabled);
  try { getByRole('button', { name: '+' }); } catch (e) { console.log('পাওয়া যায়নি:', e.message); }
})();
</script>

আউটপুট (Edge-এ চালানো): পরিমাণ: 3, বন্ধ? true, পাওয়া যায়নি: 0টি button "+". তিনবার ক্লিকের পরেও পরিমাণ ৩, কারণ দ্বিতীয় ক্লিকের পরে বোতাম বন্ধ হয়েছে আর বন্ধ বোতামের click() কিছু করে না। শেষ লাইনটি বেশি গুরুত্বপূর্ণ: বোতামের ভেতরের লেখা “+” হলেও aria-label থাকায় তার নাম “পরিমাণ বাড়ান”, তাই “+” নামে খুঁজলে শূন্য মিল। আসল getByRole-এর হিসাব অনেক জটিল (role-এর পূর্ণ তালিকা, লুকানো element বাদ, aria-labelledby), কিন্তু ভাবনাটি এটিই। ক্লিকের চারপাশ থেকে flushSync সরালে (Edge-এ চালিয়ে দেখা) প্রথম লাইনে 1 আসে, কারণ React-এর update তখনো শেষ হয়নি; এই অবস্থাটিই অংশ ৮-এর act-এর গল্প।

৫ · নামহীন বোতাম: test-এর ব্যর্থতা আসলে accessibility-র ব্যর্থতা

বাজারঘরের পণ্যের কার্ডে নকশাকার একটি সুন্দর ঝুড়ির আইকন চাইলেন, কোনো লেখা ছাড়া। প্রথম রূপটি এরকম ছিল:

export function IconAddToCart({ onAdd }: { onAdd: () => void }) {
  return (
    <button onClick={onAdd}>
      <svg width="20" height="20" aria-hidden="true"><path d="M2 2h16v16H2z" /></svg>
    </button>
  );
}

চোখে বোতামটি স্পষ্ট। Test লেখা হলো ব্যবহারকারীর ভাষায়: getByRole('button', { name: 'ঝুড়িতে যোগ করুন' }). ফল:

সত্যিকারের run · Vitest 5.1.0 · @testing-library/react 16.3.3 · ছাঁটা

~/bazarghar$ npx vitest run src/IconAddToCart.test.tsx

 FAIL  src/IconAddToCart.test.tsx > আইকন বোতাম নাম দিয়ে খোঁজা
TestingLibraryElementError: Unable to find an accessible element with the role "button" and name "ঝুড়িতে যোগ করুন"

Here are the accessible roles:

  button:

  Name "":
  <button />

  --------------------------------------------------

Ignored nodes: comments, script, style
<body>
  <div>
    <button>
      <svg
        aria-hidden="true"
        height="20"
        width="20"
      >
        <path
          d="M2 2h16v16H2z"
        />
      </svg>
    </button>
  </div>
</body>
 ❯ src/IconAddToCart.test.tsx:9:32
      8|   render(<IconAddToCart onAdd={onAdd} />);
      9|   await userEvent.click(screen.getByRole('button', { name: 'ঝুড়িতে যো…
       |                                ^

ভুলের বার্তাটি নিজেই রোগ নির্ণয় করে দিয়েছে: পাতায় একটি button আছে, কিন্তু তার accessible name "", অর্থাৎ ফাঁকা। SVG-তে aria-hidden="true", আর ভেতরে কোনো লেখা নেই। Screen reader এই বোতামে এসে শুধু “button” বলবে। দৃষ্টিপ্রতিবন্ধী একজন ক্রেতা জানবেন না বোতামটি কী করে। Test ঠিক সেই জায়গায় আটকেছে যেখানে মানুষটি আটকাবেন।

সমাধান component-এ, test-এ নয়: <button onClick={onAdd} aria-label="ঝুড়িতে যোগ করুন">. এই এক লাইনের পরে একই test পাস করেছে (উপরের সবুজ তালিকায় আইকন বোতাম নাম দিয়ে খোঁজা)। দেখার মতো জিনিস হলো, test বদলাতে হয়নি। Accessibility ঠিক করায় test ঠিক হয়েছে। বিকল্প হলো চোখে লুকানো কিন্তু screen reader-এ পড়া যায় এমন লেখা, যাকে “visually hidden” লেখা বলা হয়। Focus, contrast আর বাংলা UI-এর accessibility দেখেছেন পর্ব ২২-এ, আর বিস্তারিত নিয়ম আছে HTML ও CSS কোর্সের accessibility পর্বে।

Role আর নাম কোথা থেকে আসে: ARIA আর HTML-এর নিয়মে প্রতিটি element-এর একটি role থাকে, নিজস্ব (<button> → button, <output> → status, <ul> → list) অথবা role="…" দিয়ে দেওয়া। নাম হিসাব হয় একটি ক্রমে: aria-labelledby, তারপর aria-label, তারপর <label> বা alt-এর মতো HTML-এর নিজস্ব উৎস, তারপর ভেতরের লেখা, শেষে title. পুরো নিয়মটি W3C-এর “Accessible Name and Description Computation” নথিতে।

নিচের block-এ এই ক্রমের একটি সরল শিক্ষণ মডেল চলছে। এটি পূর্ণ W3C নিয়ম নয়; role-এর বিচার, লুকানো element আর title বাদ দেওয়া হয়েছে।

// শিক্ষণ মডেল: accessible name-এর সরল হিসাব (W3C accname-এর পূর্ণ নিয়ম নয়)
function textOf(node) {
  if (node.nodeType === 3) return node.textContent;
  if (node.getAttribute('aria-hidden') === 'true') return '';
  if (node.localName === 'img') return node.getAttribute('alt') ?? '';
  return [...node.childNodes].map(textOf).join('');
}
function accName(el, doc) {
  const ids = el.getAttribute('aria-labelledby');               // ১. aria-labelledby
  if (ids) return ids.split(' ').map(id => textOf(doc.getElementById(id))).join(' ');
  const label = el.getAttribute('aria-label');                   // ২. aria-label
  if (label && label.trim()) return label;
  const forLabel = el.id && doc.querySelector(`label[for="${el.id}"]`);
  if (forLabel) return textOf(forLabel);                         // ৩. <label for>
  return textOf(el);                                             // ৪. ভেতরের লেখা
}
const html = `
  <button id="a"><svg aria-hidden="true"></svg></button>
  <button id="b" aria-label="ঝুড়িতে যোগ করুন"><svg aria-hidden="true"></svg></button>
  <button id="c" aria-label="পরিমাণ বাড়ান">+</button>
  <h3 id="p1">মসুর ডাল</h3><span id="s1">ঝুড়িতে নিন</span>
  <button id="d" aria-labelledby="p1 s1" aria-label="যোগ">+</button>
  <label for="e">মোবাইল</label><input id="e">
  <button id="f">ঝুড়িতে <b>যোগ</b>   করুন</button>
  <button id="g"><img src="x.svg" alt="মুছুন"></button>`;
const doc = new DOMParser().parseFromString(html, 'text/html');
for (const el of doc.querySelectorAll('button, input')) {
  const name = accName(el, doc).replace(/\s+/g, ' ').trim();
  console.log(el.id, el.localName.padEnd(6), JSON.stringify(name));
}

আউটপুট সাত লাইনের। a button "": শুধু লুকানো আইকন, নাম ফাঁকা, ঠিক আগের ব্যর্থ test-এর মতো। b aria-label থেকে “ঝুড়িতে যোগ করুন”। c-তে ভেতরে “+” থাকলেও aria-label জিতেছে: “পরিমাণ বাড়ান”। d-তে aria-label="যোগ" থাকা সত্ত্বেও নাম “মসুর ডাল ঝুড়িতে নিন”, কারণ aria-labelledby সবার আগে, আর দুটি id-র লেখা পরপর জোড়া লাগে। একই পাতায় দশটি “যোগ” বোতাম থাকলে এভাবেই প্রতিটিকে পণ্যের নামসহ আলাদা করা যায়। e input "মোবাইল" এসেছে <label for> থেকে। f-এ ভেতরের <b>-সহ সব লেখা জুড়ে, বাড়তি ফাঁকা জায়গা এক করে “ঝুড়িতে যোগ করুন”। g-তে ছবির alt থেকে “মুছুন”।

৬ · userEvent বনাম fireEvent, আর Zod-এর checkout form

Testing Library-তে ঘটনা পাঠানোর দুটি পথ আছে। fireEvent একটি DOM ঘটনা সরাসরি পাঠায়। userEvent একজন মানুষের পুরো কাজটি নকল করে: focus, কী চাপা, প্রতিটি অক্ষরে input, আর browser যা যা যাচাই করে, যেমন বন্ধ বোতাম বা pointer-events: none, সেগুলোও মানে। পার্থক্যটি একটি search ঘরে মেপে দেখা হলো, যেখানে প্রতিটি ঘটনা একটি তালিকায় লেখা হয়:

fireEvent.change(screen.getByRole('textbox'), { target: { value: 'চাল' } });
// …আবার render করে
await userEvent.setup().type(screen.getByRole('textbox'), 'চাল');

সত্যিকারের run · @testing-library/user-event 14.6.8

stdout | src/events.test.tsx > fireEvent বনাম userEvent
fireEvent: change:চাল

stdout | src/events.test.tsx > fireEvent বনাম userEvent
userEvent: focus | keydown | change:চ | keydown | change:চা | keydown | change:চাল

fireEvent-এ একটিমাত্র change, focus নেই, কী চাপা নেই। userEvent-এ আগে focus, তারপর তিনবার keydown আর change: “চ”, “চা”, “চাল”। লক্ষ করুন, “চাল” তিনটি Unicode অক্ষর (চ, া, ল), তাই তিনটি ধাপ। আসল কীবোর্ডে বাংলা লেখা হয় IME দিয়ে, যেখানে composition-এর আলাদা ঘটনা আছে; userEvent সেটি নকল করে না। তাই বাংলা IME-নির্ভর আচরণ (যেমন composition চলাকালীন search না পাঠানো) আসল browser-এ পর্ব ৭৬-এর মতো E2E test-এ যাচাই করাই নিরাপদ।

userEventfireEvent
কী পাঠায়মানুষের পুরো কাজের ধারাএকটি ঘটনা
বন্ধ বা ঢাকা elementBrowser-এর মতো আটকায়আটকায় না
Asyncহ্যাঁ, await লাগেনা
কখনপ্রায় সব সময়, প্রথম পছন্দযে ঘটনা মানুষ সরাসরি ঘটান না: scroll, media-র ended

Checkout form: Zod-এর ভুল পর্দায় আসে কি না

পর্ব ২১-এর checkout form-এর একটি ছোট রূপ: React Hook Form আর Zod, তিনটি ঘর। ভুল বার্তা role="alert"-এ, আর ঘরের সাথে aria-describedby দিয়ে জোড়া, যাতে screen reader ঘরে এলে ভুলটিও পড়ে।

test('ভুল ফোনে Zod-এর বার্তা দেখায়, submit হয় না', async () => {
  const user = userEvent.setup();
  const onSubmit = vi.fn();
  render(<CheckoutForm onSubmit={onSubmit} />);

  await user.type(screen.getByLabelText('নাম'), 'রহিমা খাতুন');
  await user.type(screen.getByLabelText('মোবাইল'), '0171');
  await user.type(screen.getByLabelText('ঠিকানা'), 'বাড়ি ১২, রোড ৫, ধানমন্ডি');
  await user.click(screen.getByRole('button', { name: 'অর্ডার করুন' }));

  expect(await screen.findByRole('alert')).toHaveTextContent('১১ সংখ্যার মোবাইল নম্বর দিন');
  expect(screen.getByLabelText('মোবাইল')).toHaveAccessibleDescription('১১ সংখ্যার মোবাইল নম্বর দিন');
  expect(onSubmit).not.toHaveBeenCalled();
});

তিনটি জিনিস লক্ষ করুন। এক, validation async (resolver Promise ফেরত দেয়), তাই বার্তা খোঁজা হয়েছে findByRole দিয়ে। দুই, toHaveAccessibleDescription যাচাই করে বার্তাটি ঘরের সাথে সত্যিই জোড়া, শুধু পাশে বসানো নয়। তিন, onSubmit একটি mock ফাংশন; Test শুধু দেখে সেটি ডাকা হয়নি। Zod schema-র প্রতিটি নিয়ম এখানে আবার test করার দরকার নেই, সেটি schema-র unit test-এর কাজ। Component test দেখে schema আর পর্দা ঠিকমতো জোড়া কি না।

সবুজ run-এ এই দুটি test সবচেয়ে ধীর ছিল, প্রায় ৯১০ ms করে। কারণ userEvent default-এ প্রতিটি অক্ষরের পরে একটি ছোট বিরতি (delay: 0, অর্থাৎ একটি timer) নেয়, আর এখানে ৪০টির মতো অক্ষর। userEvent.setup({ delay: null }) দিলে বিরতি বাদ যায়:

সত্যিকারের run · একই কম্পিউটার, এক run

~/bazarghar$ npx vitest run --reporter=verbose src/CheckoutForm.test.tsx   # userEvent.setup({ delay: null })
 ✓ src/CheckoutForm.test.tsx > ভুল ফোনে Zod-এর বার্তা দেখায়, submit হয় না 185ms
 ✓ src/CheckoutForm.test.tsx > ঠিক ইনপুটে একবার submit হয় 65ms

৯১৬ থেকে ১৮৫ ms, আর ৯১২ থেকে ৬৫ ms. তবে বিরতি বাদ দিলে অক্ষরের মাঝে চলা কোনো timer-নির্ভর আচরণ (যেমন debounce) আর বাস্তবের মতো থাকে না। তাই এটি বেছে বেছে ব্যবহার করুন।

৭ · Async UI: findBy, waitFor আর TanStack Query-র তালিকা

বাজারঘরের পণ্যের তালিকা আসে পর্ব ১৯-এর TanStack Query দিয়ে। পর্দায় তিনটি অবস্থা: “পণ্য আসছে…”, তালিকা, অথবা ভুলের বার্তা। তিনটিই test করার মতো, কারণ ধীর network-এ ক্রেতা প্রথমটি আর সার্ভার পড়ে গেলে শেষটি দেখেন।

import { render, screen } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { afterEach, expect, test, vi } from 'vitest';
import type { ReactNode } from 'react';
import { ProductList } from './ProductList';

function renderWithClient(ui: ReactNode) {
  const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>);
}
afterEach(() => vi.restoreAllMocks());

test('আগে loading, তারপর তালিকা', async () => {
  vi.spyOn(globalThis, 'fetch').mockResolvedValue(
    Response.json([{ id: 'rice', name: 'মিনিকেট চাল', price: 420 }]));
  renderWithClient(<ProductList />);
  expect(screen.getByRole('status')).toHaveTextContent('পণ্য আসছে');
  const list = await screen.findByRole('list', { name: 'পণ্যের তালিকা' });
  expect(list).toHaveTextContent('মিনিকেট চাল — ৳420');
  expect(screen.queryByRole('status')).not.toBeInTheDocument();
});

test('সার্ভার 500 দিলে error বার্তা', async () => {
  vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('oops', { status: 500 }));
  renderWithClient(<ProductList />);
  expect(await screen.findByRole('alert')).toHaveTextContent('পণ্য আনা যায়নি');
});
  • প্রতি test-এ নতুন QueryClient: QueryClient-এর ভেতরে cache থাকে। ভাগ করলে এক test-এর ডেটা পরের test-এ চলে যায়, নিচে আসল ব্যর্থতা দেখুন।
  • retry: false: Default-এ ব্যর্থ query ৩ বার retry হয়, মাঝে বাড়তে থাকা অপেক্ষাসহ। Test-এ তা হলে error-এর পর্দা আসতে findBy-এর ১ সেকেন্ড পার হয়ে যায়।
  • fetch-এর নকল: vi.spyOn(globalThis, 'fetch') দিয়ে উত্তর বানানো হয়েছে, আর afterEach-এ vi.restoreAllMocks() দিয়ে আসল fetch ফিরিয়ে দেওয়া হয়েছে। Vitest 5-এ clearMocks default-এ চালু, যা প্রতিটি test-এর আগে mock-এর ডাকার হিসাব মোছে; কিন্তু spy-এর জায়গায় আসল ফাংশন ফেরানো আলাদা কাজ। Network স্তরে নকল করার ভালো পথ MSW, যা পর্ব ৭৭-এ আসবে; সেখানে component-এর fetch কোড অক্ষত থাকে।

একটি QueryClient দুটি test-এ ভাগ করলে কী হয়, তা মেপে দেখা হলো। প্রথম test তালিকা আনল। দ্বিতীয় test-এর fetch কখনো উত্তর দেয় না, তাই সেখানে “পণ্য আসছে…” দেখার কথা:

সত্যিকারের run · @tanstack/react-query 5.105.2 · ছাঁটা

~/bazarghar$ npx vitest run demos/shared.test.tsx     # একটি QueryClient দুই test-এ

 ❯ demos/shared.test.tsx (2 tests | 1 failed) 133ms
   × loading দেখায় 11ms

 FAIL  demos/shared.test.tsx > loading দেখায়
TestingLibraryElementError: Unable to find an accessible element with the role "status"

Here are the accessible roles:

  list:

  Name "পণ্যের তালিকা":
  <ul
    aria-label="পণ্যের তালিকা"
  />
…

দ্বিতীয় test আগের test-এর cache থেকে তালিকা পেয়ে গেছে, তাই loading-এর পর্দা দেখাই যায়নি। ক্রমের ওপর নির্ভর করা এমন test একা চালালে পাস করে, সবার সাথে চালালে ব্যর্থ হয়; এই ধরনের ভুল খুঁজতে অনেক সময় যায়।

findBy কীভাবে অপেক্ষা করে

findBy… আসলে getBy… আর waitFor-এর জোড়া। waitFor একটি ফাংশন বারবার চালায় যতক্ষণ না সেটি ভুল ছোড়া বন্ধ করে, অথবা সময় শেষ হয়। Default সময়সীমা ১০০০ ms, বিরতি ৫০ ms. সাথে DOM-এ কিছু বদলালেও (MutationObserver দিয়ে) আবার চেষ্টা করে, তাই উত্তর এলে প্রায় সাথে সাথে ধরা পড়ে।

findByRole('list', { name: 'পণ্যের তালিকা' })
সময়
fetch-এর অবস্থা
পর্দায়
চেষ্টার কারণ
ফল
  1. শুরুতে একবার। Render-এর পরে পর্দায় শুধু “পণ্য আসছে…”। findBy সাথে সাথে একবার খুঁজল, তালিকা নেই, তাই অপেক্ষা শুরু।
  2. উত্তর এল, DOM বদলাল। নকল fetch উত্তর দিল, React তালিকা বসাল। MutationObserver-এর সংকেতে আবার খোঁজা হলো, এবার মিলল। ৫০ ms-এর timer-এর জন্য অপেক্ষা লাগেনি।
  3. দ্বিতীয় দৃশ্য: উত্তর আসে না। একই test, কিন্তু fetch একটি কখনো-শেষ-না-হওয়া Promise ফেরত দেয়। শুরুতে একবার খোঁজা, ব্যর্থ।
  4. Timer-এ বারবার। DOM আর বদলায় না, তাই প্রতি ৫০ ms-এ timer আবার খোঁজায়। প্রতিবার ব্যর্থ।
  5. সময় শেষ। ১০০০ ms পার হলে শেষ ভুলটি ছোড়া হয়, সাথে DOM ছাপা। আসল run-এ test-টি নিয়েছে ১০২৪ ms; নিচে বার্তাটি দেখুন।

সত্যিকারের run · fetch কখনো উত্তর দেয় না · ছাঁটা

~/bazarghar$ npx vitest run demos/findfail.test.tsx

 ❯ demos/findfail.test.tsx (1 test | 1 failed) 1026ms
   × উত্তর আসে না 1024ms

 FAIL  demos/findfail.test.tsx > উত্তর আসে না
TestingLibraryElementError: Unable to find role="list" and name "পণ্যের তালিকা"

Ignored nodes: comments, script, style
<body>
  <div>
    <p
      role="status"
    >
      পণ্য আসছে…
    </p>
  </div>
</body>
…
 ❯ demos/findfail.test.tsx:10:16
     10|   await screen.findByRole('list', { name: 'পণ্যের তালিকা' });
       |                ^

waitFor সরাসরিও ব্যবহার করা যায়, যখন অপেক্ষার বিষয়টি কোনো element নয়: await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)). দুটি নিয়ম মানুন। এক, waitFor-এর ভেতরে শুধু যাচাই রাখুন, ক্লিক বা অন্য পার্শ্বপ্রতিক্রিয়া নয়, কারণ ভেতরের ফাংশন অনেকবার চলে। দুই, এক waitFor-এ একটিই যাচাই রাখুন, যাতে ব্যর্থ হলে বোঝা যায় কোনটি আটকেছে। আমাদের checkout test-এ Vitest-এর নিজের vi.waitFor ব্যবহার করা হয়েছে, যা একই কাজ করে।

Live block-এ এই অপেক্ষার শিক্ষণ মডেল চালান। Component ২৪০ ms পরে তালিকা দেখায়। মডেলের findBy প্রতি চেষ্টায় একটি ছোট query চালায়, ব্যর্থ হলে বিরতি নিয়ে আবার। সময় না মেপে চেষ্টা গোনা হয়েছে, যাতে ফল স্থির থাকে। আসল waitFor-এর মতো DOM-এর বদল এখানে শোনা হয় না।

<div id="root"></div>
<script src="../course-kit/vendor/react-lab.js"></script>
<script type="text/jsx">
function ProductList() {
  const [items, setItems] = useState(null);
  useEffect(() => { setTimeout(() => setItems(['মিনিকেট চাল', 'মসুর ডাল']), 240); }, []);
  if (!items) return <p role="status">পণ্য আসছে…</p>;
  return <ul aria-label="পণ্যের তালিকা">{items.map(i => <li key={i}>{i}</li>)}</ul>;
}
// শিক্ষণ মডেল: findBy মানে getBy বারবার, সীমা পর্যন্ত (আসল Testing Library নয়)
const byLabel = (tag, name) => () => {
  const el = document.querySelector(`${tag}[aria-label="${name}"]`);
  if (!el) throw new Error(`${tag} "${name}" পাওয়া যায়নি`);
  return el;
};
async function findBy(query, { timeout = 1000, interval = 50 } = {}) {
  for (let tries = 1; ; tries++) {
    try { return { el: query(), tries }; } catch (e) {
      if ((tries - 1) * interval >= timeout) throw new Error(`${tries} বার চেষ্টা: ${e.message}`);
      await new Promise(r => setTimeout(r, interval));
    }
  }
}
createRoot(document.getElementById('root')).render(<ProductList />);
(async () => {
  const { el, tries } = await findBy(byLabel('ul', 'পণ্যের তালিকা'), { interval: 100 });
  console.log(`চেষ্টা ${tries}: ${el.children.length}টি পণ্য`);
  try { await findBy(byLabel('ul', 'অফার'), { timeout: 200 }); } catch (e) { console.log(e.message); }
})();
</script>

আউটপুট (Edge-এ তিনবার চালিয়ে একই): চেষ্টা 4: 2টি পণ্য আর 5 বার চেষ্টা: ul "অফার" পাওয়া যায়নি. প্রথম findBy-এ বিরতি ১০০ ms: চেষ্টা হয় প্রায় ০, ১০০, ২০০ আর ৩০০ ms-এ। তালিকা আসে ২৪০ ms-এর পরে, তাই চতুর্থ চেষ্টায় মিল। দ্বিতীয়টিতে সময়সীমা ২০০ ms আর বিরতি ৫০ ms, তাই ০, ৫০, ১০০, ১৫০, ২০০-তে মোট ৫টি চেষ্টা, তারপর ভুল। আপনার যন্ত্রে timer-এর সামান্য দেরিতে প্রথম সংখ্যাটি কখনো একটু বদলাতে পারে। আসল waitFor DOM বদলালেও খোঁজে, তাই সেখানে তালিকা আসার প্রায় সাথে সাথেই মিল হতো।

৮ · act warning, store reset আর renderHook

React state বদলালে পর্দা সাথে সাথে বদলায় না। React update জমিয়ে রাখে, পরে একসাথে render করে। Browser-এ এটি ভালো, কিন্তু test-এ সমস্যা: আপনি যাচাই করছেন, অথচ React তখনো কাজ শেষ করেনি। act একটি মোড়ক, যা বলে “এর ভেতরের সব update শেষ করে তবে ফেরো।” Testing Library-র render, userEvent আর findBy ভেতরে ভেতরে act ব্যবহার করে, তাই সাধারণত আপনাকে লিখতে হয় না। সমস্যা হয় যখন update আসে Testing Library-র বাইরে থেকে।

উদাহরণ: “ঝুড়িতে যোগ হয়েছে” বার্তাটি ৩ সেকেন্ড পরে নিজে সরে যায়। Test-এ fake timer দিয়ে সময় এগিয়ে দেওয়া হলো, কিন্তু act ছাড়া:

test('৩ সেকেন্ড পরে বার্তা সরে (act ছাড়া)', () => {
  render(<Toast text="ঝুড়িতে যোগ হয়েছে" />);
  vi.advanceTimersByTime(3000);
  expect(screen.queryByRole('status')).not.toBeInTheDocument();
});

সত্যিকারের run · React 19.3.1 · Vitest 5.1.0 · ছাঁটা

stderr | src/Toast.test.tsx > ৩ সেকেন্ড পরে বার্তা সরে (act ছাড়া)
An update to Toast inside a test was not wrapped in act(...).

When testing, code that causes React state updates should be wrapped into act(...):

act(() => {
  /* fire events that update state */
});
/* assert on the output */

This ensures that you're testing the behavior the user would see in the browser. Learn more at https://react.dev/link/wrap-tests-with-act

 FAIL  src/Toast.test.tsx > ৩ সেকেন্ড পরে বার্তা সরে (act ছাড়া)
Error: expect(element).not.toBeInTheDocument()

expected document not to contain element, found <p
  role="status"
>
  ঝুড়িতে যোগ হয়েছে
</p> instead
 ❯ src/Toast.test.tsx:11:44
     10|   vi.advanceTimersByTime(3000);
     11|   expect(screen.queryByRole('status')).not.toBeInTheDocument();
       |                                            ^

দুটি জিনিস একসাথে ঘটেছে। প্রথমে warning: setOpen(false) act-এর বাইরে ঘটেছে। তারপর ব্যর্থতা: বার্তাটি এখনো পর্দায়, কারণ React update জমিয়ে রেখেছে, এখনো render করেনি। Warning-টি তাই কোনো বিরক্তিকর আনুষ্ঠানিকতা নয়; এটি বলছে “আপনার assertion হয়তো পুরোনো পর্দা দেখছে।” সমাধান: act(() => vi.advanceTimersByTime(3000)). এতে test পাস করেছে। একই ঘটনা ঘটেছে Zustand store-এ সরাসরি useCart.getState().add(rice, 2) ডাকায়: একই warning, আর badge-এ ঝুড়ি (0), যদিও store-এ ২টি। act(() => useCart.getState().add(rice, 2)) দিলে ঠিক হয়।

Warning চুপ করাবেন না। AI প্রায়ই সব কিছু act-এ মুড়ে দেয়, বা console.error বন্ধ করে দেয়। আগে প্রশ্ন করুন: update-টি কোথা থেকে এল? ক্লিক বা টাইপ থেকে হলে userEvent ব্যবহার করুন। Fetch-এর পরে হলে findBy দিয়ে ফলের জন্য অপেক্ষা করুন। শুধু timer বা বাইরের store-এর মতো ক্ষেত্রে নিজে act লিখুন। Test শেষ হওয়ার পরে আসা warning সাধারণত বলে কোনো async কাজের ফল আপনি যাচাই করেননি।

Store আর Context: প্রতিটি test-এর আগে পরিষ্কার

Zustand store একটি module-এর ভেতরে থাকে, আর একই ফাইলের test-গুলো একই module ভাগ করে। পর্ব ১৮-এর store-এ Zustand 5-এর getInitialState() শুরুর অবস্থা দেয়, আর setState(initial, true)-এর দ্বিতীয় argument true পুরো state বদলে দেয়, শুধু merge নয়। এই reset বন্ধ করে চালালে:

সত্যিকারের run · zustand 5.0.16 · ছাঁটা

~/bazarghar$ npx vitest run src/CartBadge.test.tsx     # beforeEach-এর reset বন্ধ করে

 ❯ src/CartBadge.test.tsx (2 tests | 1 failed) 98ms
   × আগের test-এর ঝুড়ি এখানে নেই 8ms

 FAIL  src/CartBadge.test.tsx > আগের test-এর ঝুড়ি এখানে নেই
Error: expect(element).toHaveTextContent()

Expected element to have text content:
  ঝুড়ি (0)
Received:
  ঝুড়ি (2)

আগের test ঝুড়িতে ২টি চাল রেখে গেছে, পরের test সেটি পেয়েছে। Context-এর ক্ষেত্রে (পর্ব ১৭) সমস্যা কম, কারণ provider প্রতিটি render-এ নতুন করে তৈরি হয়। সেখানে একটি ছোট renderWithProviders helper লিখুন, যা দরকারি সব provider (cart, theme, QueryClient) নতুন করে মুড়ে দেয়, আর চাইলে শুরুর মান নেয়। এভাবে প্রতিটি test নিজের মতো শুরু হয়, ক্রম বদলালেও ফল বদলায় না।

renderHook: hook-কে একা test করা

পর্ব ১৪-এ custom hook লিখেছেন। Hook শুধু component-এর ভেতরে চলে, তাই renderHook একটি অদৃশ্য component বানিয়ে hook চালায় আর ফল result.current-এ রাখে:

import { act, renderHook } from '@testing-library/react';
import { expect, test } from 'vitest';
import { useQuantity } from './useQuantity';

test('useQuantity সীমা মানে', () => {
  const { result, rerender } = renderHook(({ max }) => useQuantity(max), { initialProps: { max: 2 } });
  act(() => result.current.inc());
  act(() => result.current.inc());
  expect(result.current).toMatchObject({ qty: 2, atMax: true });
  act(() => result.current.dec());
  rerender({ max: 5 });
  expect(result.current).toMatchObject({ qty: 1, atMax: false });
});

এখানে act নিজে লিখতে হয়েছে, কারণ hook-এর ফাংশন সরাসরি ডাকা হচ্ছে, কোনো userEvent নেই। rerender নতুন prop দিয়ে hook আবার চালায়: সীমা ৫ হলে atMax false. মনে রাখবেন, hook-টি যদি শুধু একটি component-এ ব্যবহৃত হয়, তবে component test-ই যথেষ্ট; renderHook সবচেয়ে কাজে লাগে যখন hook অনেক component-এ ভাগ করা, বা নিজেই একটি library.

সব ঠিক করার পরে পুরো suite:

সত্যিকারের run · Vitest 5.1.0 · jsdom 30.1.3 · Node 24

~/bazarghar$ npx vitest run --reporter=verbose --silent

 RUN  v5.1.0 ~/bazarghar

 ✓ src/useQuantity.test.ts > useQuantity সীমা মানে 12ms
 ✓ src/bangla.test.tsx > চোখে এক, code point-এ আলাদা 23ms
 ✓ src/bangla.test.tsx > ফাঁকা জায়গা আর ভাঙা লাইন 2ms
 ✓ src/Toast.test.tsx > ৩ সেকেন্ড পরে বার্তা সরে (act সহ) 26ms
 ✓ src/CartBadge.test.tsx > store বদলালে badge বদলায় (act সহ) 97ms
 ✓ src/CartBadge.test.tsx > আগের test-এর ঝুড়ি এখানে নেই 5ms
 ✓ src/ProductList.test.tsx > আগে loading, তারপর তালিকা 110ms
 ✓ src/ProductList.test.tsx > সার্ভার 500 দিলে error বার্তা 34ms
 ✓ src/IconAddToCart.test.tsx > আইকন বোতাম নাম দিয়ে খোঁজা 143ms
 ✓ src/events.test.tsx > fireEvent বনাম userEvent 192ms
 ✓ src/AddToCart.test.tsx > স্টকের বেশি বাড়ানো যায় না 279ms
 ✓ src/AddToCart.test.tsx > যোগ করলে store-এ ঠিক পরিমাণ যায় 106ms
 ✓ src/CheckoutForm.test.tsx > ভুল ফোনে Zod-এর বার্তা দেখায়, submit হয় না 916ms
 ✓ src/CheckoutForm.test.tsx > ঠিক ইনপুটে একবার submit হয় 912ms

 Test Files  9 passed (9)
      Tests  14 passed (14)
   Duration  3.17s (environment 60%, tests 22%, setup 9%, import 5%, transform 3%, worker 1%)

৯ · বাংলা লেখা, browser mode, আর কী test করবেন না

বাংলা লেখায় query: চোখে এক, code point-এ আলাদা

getByText default-এ লেখার দুই মাথার ফাঁকা জায়গা ছাঁটে আর ভেতরের একাধিক ফাঁকা বা নতুন লাইন একটি space-এ নামায়। তাই <p> মোট দাম:{'\n'} ৳৪২০ </p> “মোট দাম: ৳৪২০” দিয়ে খুঁজে পাওয়া গেছে। কিন্তু বাংলায় একটি সূক্ষ্ম ফাঁদ আছে। “য়” লেখা যায় একটি code point-এ (U+09DF), অথবা “য” আর নুকতা (U+09AF U+09BC) দুটিতে। চোখে হুবহু এক, কম্পিউটারে আলাদা। Unicode normalization-এর ধারণা দেখেছেন পর্ব ৫৯-এ।

const oneCodePoint = 'দয়া করে অপেক্ষা করুন';       // য় একটি অক্ষরে
const twoCodePoints = 'দয়া করে অপেক্ষা করুন'; // য + নুকতা
console.log(oneCodePoint === twoCodePoints, oneCodePoint.length, twoCodePoints.length);
render(<p>{oneCodePoint}</p>);
expect(screen.queryByText(twoCodePoints)).toBeNull();          // মেলেনি
const nfc = (s: string) => getDefaultNormalizer()(s).normalize('NFC');
expect(screen.getByText(twoCodePoints.normalize('NFC'), { normalizer: nfc })).toBeInTheDocument();

সত্যিকারের run-এ console-এ এসেছে false 20 21: দুটি লেখা সমান নয়, দৈর্ঘ্য ২০ আর ২১। Default query মেলেনি। normalizer শুধু DOM-এর লেখায় চলে, তাই খোঁজার লেখাটিও নিজে normalize('NFC') করতে হয়েছে। একটি অবাক করা তথ্য: U+09DF Unicode-এর “composition exclusion” তালিকায় আছে, তাই NFC এটিকে জোড়া লাগায় না, বরং দুই অংশে ভাঙে। দুই দিকই একই রূপে আসায় মিল হয়েছে। বাস্তবে এই সমস্যা আসে যখন লেখা আসে ভিন্ন উৎস থেকে: CMS, পুরোনো database, বা অন্য কীবোর্ড। দাম Intl দিয়ে বাংলা অঙ্কে দেখালে (৪২০ বনাম 420) test-এও একই formatter ব্যবহার করুন, অথবা regex দিন।

jsdom-এর বাইরে: Vitest browser mode আর Storybook

jsdom-এ layout নেই: element-এর মাপ সব ০, CSS-এর media query বা :hover হিসাব হয় না, scroll নেই। যে আচরণ এসবের ওপর নির্ভর করে, তা নকল DOM-এ যাচাই করা যায় না। Vitest browser mode একই ধরনের test আসল browser-এ চালায়, Playwright বা WebdriverIO দিয়ে। Vitest 4.0-তে (অক্টোবর ২০২৫) এর “experimental” তকমা সরানো হয়, আর provider আলাদা package-এ যায়। আমাদের run-এ এই কম্পিউটারের Edge ব্যবহার হয়েছে:

import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import { playwright } from '@vitest/browser-playwright';

export default defineConfig({
  plugins: [react()],
  test: {
    include: ['browser/**/*.test.tsx'],
    browser: {
      enabled: true,
      headless: true,
      provider: playwright({ launchOptions: { channel: 'msedge' } }),
      instances: [{ browser: 'chromium' }],
    },
  },
});
import { render } from 'vitest-browser-react';
import { expect, test } from 'vitest';
import { AddToCart } from '../src/AddToCart';

test('আসল browser-এ স্টকের সীমা', async () => {
  const screen = await render(<AddToCart product={{ id: 'dal', name: 'ডাল', price: 140, stock: 3 }} />);
  const plus = screen.getByRole('button', { name: 'পরিমাণ বাড়ান' });
  await plus.click();
  await plus.click();
  await expect.element(screen.getByLabelText('পরিমাণ')).toHaveTextContent('3');
  await expect.element(plus).toBeDisabled();
  await expect.element(screen.getByText(/সর্বোচ্চ 3টি/)).toBeVisible();
});

সত্যিকারের run · @vitest/browser-playwright 5.1.0 · vitest-browser-react 2.3.0 · Playwright 1.64.1 · Edge

~/bazarghar$ npx vitest run --config vitest.browser.config.ts --reporter=verbose

 RUN  v5.1.0 ~/bazarghar

 ✓ |chromium| browser/AddToCart.browser.test.tsx > আসল browser-এ স্টকের সীমা 140ms

 Test Files  1 passed (1)
      Tests  1 passed (1)
   Duration  981ms (worker 62%, tests 22%, import 16%)

লক্ষ করুন: render এখানে await করতে হয়, query-র নাম একই রকম, আর assertion হয় await expect.element(...)-এ, যা নিজেই একটু অপেক্ষা করে। আসল browser-এ সত্যিকারের ক্লিক, layout আর CSS পাওয়া যায়; বিনিময়ে browser install আর চালানোর খরচ। একটি যুক্তিসঙ্গত ভাগ: বেশির ভাগ test jsdom-এ, আর যেগুলো layout, focus বা আসল browser API-র ওপর নির্ভর করে, সেগুলো browser mode-এ।

Storybook component-কে আলাদা করে দেখানোর একটি টুল; লেখার সময়ে এর version 10.6.5. এর story-তে একটি play ফাংশন লিখে ক্লিক আর যাচাই করা যায়, যাকে interaction test বলে। @storybook/addon-vitest দিয়ে এই story-গুলো Vitest-এর browser mode-এ test হিসেবে চলে। যে দল আগে থেকেই Storybook ব্যবহার করে, তাদের জন্য এটি একই story-কে নথি আর test দুই কাজে লাগানোর পথ। এটি এই পর্বে চালানো হয়নি।

কী test করবেন না

Implementation detail

State-এর নাম, hook-এর ভেতরের মান, কোন ফাংশন কতবার ডাকা হলো, child component-এর prop. এগুলো refactor-এ বদলায়, ব্যবহারকারী টের পান না।

CSS আর চেহারা

“বোতামে bg-green-600 class আছে” যাচাই কিছুই প্রমাণ করে না; jsdom রং হিসাবই করে না। চেহারার জন্য browser-এ visual test বা মানুষের চোখ।

Library-র কাজ

React state রাখে কি না, Zod regex মানে কি না, TanStack Query cache করে কি না: এগুলো তাদের নিজেদের test-এ আছে। আপনি দেখুন আপনার জোড়া ঠিক কি না।

বড় snapshot

পুরো পাতার HTML snapshot যেকোনো বদলে ভাঙে, আর মানুষ না পড়েই “update” চাপেন। ছোট, উদ্দেশ্যমূলক snapshot পর্ব ৭৩-এ।

নিরাপত্তার নিয়মও component test-এর ভরসায় রাখবেন না। বোতাম বন্ধ থাকলেও কেউ সরাসরি API ডাকতে পারে; ১০টির সীমা সার্ভারেও যাচাই হতে হবে, আর সেই test পর্ব ৭৫-এর API integration test-এর কাজ। পর্ব ৪৮-এ এই দুই দিকের যাচাই দেখেছেন।

আরও গভীরে: getByRole নাম হিসাব করে dom-accessibility-api নামের একটি package দিয়ে, যা W3C-এর accname নিয়ম মেনে চলার চেষ্টা করে, আর role আসে ARIA-র HTML mapping থেকে। এই হিসাব প্রতিটি element-এর জন্য চলে বলে বড় DOM-এ getByRole অন্য query-র চেয়ে ধীর হতে পারে; নথিতে hidden option আর ছোট container-এ খোঁজার পরামর্শ আছে। React-এর act কাজ করে একটি global flag (IS_REACT_ACT_ENVIRONMENT) আর একটি আলাদা কাজের সারি দিয়ে: act-এর ভেতরে থাকাকালীন React update-কে scheduler-এ না পাঠিয়ে act-এর সারিতে রাখে, আর act শেষে সব একসাথে চালায়; flag-টি মূলত ঠিক করে act-এর বাইরে update হলে warning দেখানো হবে কি না।

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

  • getBy, queryBy, findBy-এর পার্থক্য? না পেলে ভুল, null, আর অপেক্ষা করে Promise.
  • getByTestId কেন শেষে? ব্যবহারকারী আর screen reader test id দেখেন না, তাই accessibility-র কোনো যাচাই হয় না।
  • userEvent কেন fireEvent-এর চেয়ে ভালো? এটি focus, কী চাপা আর বন্ধ element-এর বাধাসহ মানুষের পুরো কাজ নকল করে।
  • “not wrapped in act” মানে? React state বদলেছে এমন জায়গা থেকে যা test অপেক্ষা করেনি; assertion পুরোনো পর্দা দেখতে পারে।
  • TanStack Query-র component test-এ কী লাগে? প্রতি test-এ নতুন QueryClient, retry: false, আর network-এর নকল।

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

AI খুব দ্রুত test লেখে, আর প্রায় সব test প্রথমবারেই সবুজ হয়। সেটিই বিপদ: সবুজ test মানেই ভালো test নয়। Testing Library-র API বছরের পর বছর বদলেছে, আর Enzyme-এর যুগের অনেক কোড ইন্টারনেটে রয়ে গেছে।

AI-এর ভুলকী হয়যাচাই
Enzyme-এর shallow, wrapper.state()React 18 থেকে কোনো অফিসিয়াল adapter নেই, আর state-এ নজরpackage.json দেখুন, Testing Library-তে লিখুন
react-test-renderer বা @testing-library/react-hooksপুরোনো বা বাতিল পথ; renderHook এখন মূল package-এnpm-এ package-এর অবস্থা দেখুন
সব জায়গায় getByTestId আর container.querySelectorAccessibility-র ভুল ধরা পড়ে নাQuery-র ক্রম মিলিয়ে নিন
সব কিছু act-এ মোড়া, console.error বন্ধআসল async ভুল লুকিয়ে যায়Warning-এর উৎস খুঁজুন
ভাগ করা QueryClient, store reset নেইক্রম-নির্ভর, মাঝে মাঝে ব্যর্থ test--sequence.shuffle দিয়ে এলোমেলো ক্রমে চালান
Test পাস করাতে component-এ data-testid যোগ, নাম নয়পর্দা আগের মতোই অগম্যDiff পড়ুন: নাম যোগ হয়েছে কি?
নতুন package, যেমন অচেনা “testing helper”অদরকারি dependency, কখনো বানানো নামnpm view দিয়ে অস্তিত্ব আর রক্ষণাবেক্ষণ দেখুন

AI-কে version, query-র নিয়ম আর ব্যর্থতার পথ, তিনটিই বলে দিন। শেষ অনুরোধটি AI-কে প্রমাণ দিতে বাধ্য করে যে test সত্যিই কিছু ধরে।

আমি Vitest 5, @testing-library/react 16, user-event 14, jest-dom 7 আর React 19 ব্যবহার করি, jsdom আর globals: true সহ। নিচের AddToCart component-এর জন্য component test লিখে দাও। নিয়ম: ১. Query-র ক্রম: getByRole সাথে name, তারপর getByLabelText, তারপর getByText দিয়ে খুঁজবে। getByTestId বা container.querySelector নয়। ২. ক্লিক আর টাইপ শুধু userEvent.setup() দিয়ে, await সহ। ৩. Zustand store প্রতিটি test-এর আগে getInitialState() দিয়ে reset করবে। ৪. State, prop বা CSS class যাচাই করবে না; শুধু পর্দায় যা দেখা যায়। ৫. act দিয়ে কিছু মুড়বে না, console বন্ধ করবে না, নতুন package যোগ করবে না। শেষে বলো: component-এর কোন এক লাইন ভাঙলে কোন test লাল হবে, আর aria-label মুছে দিলে কোন ভুলের বার্তা আসবে।

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

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

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

প্র ০১React component test-এর জগৎ Enzyme থেকে Testing Library-তে সরে গেল কেন? এই বদল কি শুধু রুচির?

২০১৫–১৬ সালের দিকে React test-এর প্রধান টুল ছিল Airbnb-র তৈরি Enzyme. এর জনপ্রিয় বৈশিষ্ট্য ছিল shallow rendering: একটি component-কে তার child-দের render না করেই দেখা। সাথে ছিল wrapper.state(), wrapper.instance(), আর component-এর নাম ধরে খোঁজা। দ্রুত আর সুবিধাজনক, কিন্তু test লেখা হতো কোডের গঠন ধরে।

সমস্যা ধরা পড়ল refactor-এ। Class component থেকে hook-এ গেলে state() আর কাজ করে না, যদিও পর্দায় কিছুই বদলায়নি। Child component ভাগ করলে shallow test ভাঙে। উল্টো দিকে, shallow test পাস করলেও child-এর সাথে জোড়ায় ভুল থাকতে পারে। দল সময় দিত test সারাতে, ভুল ধরতে নয়।

Kent C. Dodds ২০১৮ সালের দিকে react-testing-library আনেন ইচ্ছা করে সীমিত একটি API নিয়ে: state বা instance-এ পৌঁছানোর পথই নেই, শুধু DOM আর ব্যবহারকারীর মতো query. পরে মূল অংশ framework-নিরপেক্ষ @testing-library/dom-এ যায়, আর Vue, Angular, Svelte-এর রূপ আসে। React-এর নিজস্ব নথিও একসময় এটিকে সুপারিশ করতে শুরু করে। Hook আর concurrent rendering আসার পরে Enzyme-এর জন্য React 18-এর কোনো অফিসিয়াল adapter আসেনি বলে জানা যায়, ফলে বদলটি অনেক দলের জন্য বাধ্যতামূলকও হয়ে যায়।

তবে বিতর্ক পুরো শেষ হয়নি। কেউ কেউ বলেন, সীমিত API কিছু বৈধ ক্ষেত্রকে কঠিন করে, যেমন জটিল component-এর ভেতরের একটি অবস্থা দ্রুত বসানো। তাঁদের উত্তর সাধারণত: সেই যুক্তিকে আলাদা ফাংশন বা hook-এ বের করে unit test করুন।

মূল উপলব্ধি: টুলের API ঠিক করে দেয় আপনি কী test করবেন। যে টুল ভেতরে দেখতে দেয় না, সে আপনাকে বাইরে থেকে, অর্থাৎ ব্যবহারকারীর জায়গা থেকে, ভাবতে বাধ্য করে।

প্র ০২সব test getByRole দিয়ে লিখলে কি অ্যাপটি accessible হয়ে গেল? সীমাটা কোথায়?

না, তবে বড় একটি অংশ পাহারায় আসে। getByRole দিয়ে খোঁজা মানে প্রতিটি interactive element-এর সঠিক role আর বোধগম্য নাম আছে, যা screen reader-এর ব্যবহারকারীর প্রথম প্রয়োজন। এই পর্বের নামহীন আইকন বোতাম তার প্রমাণ: test সেখানেই ব্যর্থ হয়েছে যেখানে মানুষ আটকাতেন। লেবেল ছাড়া form-এর ঘরও getByLabelText-এ ধরা পড়ে।

কিন্তু WCAG-এর (Web Content Accessibility Guidelines, সর্বশেষ বড় সংস্করণ ২.২, ২০২৩ সালে) অনেক শর্ত jsdom-এ যাচাই করাই যায় না। রঙের contrast: jsdom রং হিসাব করে না। Focus-এর দাগ দেখা যায় কি না: CSS নেই। Keyboard-এ Tab চাপলে focus যুক্তিসঙ্গত ক্রমে যায় কি না: userEvent.tab() কিছুটা দেখায়, পুরোটা নয়। Zoom করলে লেখা কেটে যায় কি না: layout নেই। আর সবচেয়ে বড় কথা, নাম থাকা আর নামটি বোধগম্য হওয়া এক নয়। “বোতাম ১” নামেও test পাস করবে।

তাই একটি স্তরভিত্তিক পথ যুক্তিসঙ্গত। Component test-এ role আর নাম। axe-core-এর মতো স্বয়ংক্রিয় যাচাই, যা jsdom বা browser-এ চালানো যায় (jsdom-এ contrast-এর মতো কিছু নিয়ম কাজ করে না)। Browser mode বা E2E-তে keyboard আর focus. আর অন্তত মাঝে মাঝে আসল screen reader দিয়ে হাতে পরীক্ষা, সম্ভব হলে এমন মানুষের সাথে যাঁরা প্রতিদিন এটি ব্যবহার করেন। বাংলা সাইটে screen reader-এর বাংলা উচ্চারণও আলাদা পরীক্ষার বিষয়।

একটি অপ্রত্যাশিত সুবিধাও আছে: যে দল test-এর জন্য role আর নাম নিয়ে ভাবে, তাদের নকশার আলোচনাতেই accessibility ঢুকে পড়ে। এটি আলাদা কোনো “audit”-এর অপেক্ষায় থাকে না।

মূল উপলব্ধি: getByRole accessibility-র মেঝে তৈরি করে, ছাদ নয়। এটি নিশ্চিত করে প্রতিটি নিয়ন্ত্রণের নাম আছে; বাকিটা যাচাই করতে আসল browser আর আসল মানুষ লাগে।

প্র ০৩নকল DOM-এ test যদি আসল browser-এর সব দেখতে না পায়, তবে সব component test browser mode-এ চালালেই তো হয়?

প্রশ্নটি যুক্তিসঙ্গত, আর Vitest 4.0-তে browser mode স্থিতিশীল ঘোষণার পরে অনেক দল এটি নিয়ে ভাবছে। আসল browser-এর সুবিধা স্পষ্ট: সত্যিকারের layout, CSS, focus, scroll, IntersectionObserver, Canvas. jsdom-এ এগুলোর কোনোটি নেই বা নকল, আর নকল জিনিস ভুল নিশ্চয়তা দিতে পারে।

তবে খরচও আছে। প্রথমত, CI-তে browser install করতে হয়, যা সময় আর জায়গা নেয় (পর্ব ৮১-তে CI দেখবেন)। দ্বিতীয়ত, ধীরতা: আমাদের এক run-এ একটি browser test-এর মোট সময় ৯৮১ ms, যেখানে jsdom-এ ৯টি ফাইলের পুরো suite ৩ সেকেন্ডের কিছু বেশি। এক কম্পিউটারের এক মাপ থেকে সাধারণ সিদ্ধান্ত টানা ঠিক নয়, কিন্তু browser চালু করার একটি স্থির খরচ যে আছে, তা বোঝা যায়। তৃতীয়ত, আসল browser-এ timing-এর অনিশ্চয়তা বেশি, ফলে মাঝে মাঝে ব্যর্থ (flaky) test-এর ঝুঁকি, যা পর্ব ৭৭-এর বিষয়।

আরেকটি বিকল্প হলো happy-dom: আমাদের মাপে jsdom-এর চেয়ে প্রায় ১৮% দ্রুত, কিন্তু সম্পূর্ণতায় পিছিয়ে থাকার সম্ভাবনা বেশি। কোনো test যদি শুধু happy-dom-এ পাস করে আর jsdom-এ ব্যর্থ হয়, বা উল্টো, তবে ধরে নিন সেটি DOM-এর নকলের ওপর নির্ভর করছে, ব্যবহারকারীর অভিজ্ঞতার ওপর নয়।

অনেক দল তাই মিশ্র পথ নেয়: যুক্তি আর form-এর বেশির ভাগ test নকল DOM-এ, কারণ সেগুলো DOM-এর উন্নত অংশ ব্যবহার করে না; layout বা browser API-নির্ভর অল্প কিছু component browser mode-এ; আর পুরো ক্রেতার যাত্রা Playwright-এর E2E-তে (পর্ব ৭৬)। কোথায় দাগ টানবেন, তা ঠিক হয় একটি প্রশ্নে: এই test-এর আচরণ কি সত্যিই browser-এর এমন কিছুর ওপর নির্ভর করে যা jsdom নকল করে না?

মূল উপলব্ধি: পরিবেশ বাছাই একটি খরচ আর নিশ্চয়তার হিসাব। প্রতিটি test-কে সবচেয়ে সস্তা সেই পরিবেশে চালান, যেখানে তার আচরণ সত্যি সত্যি দেখা যায়।

প্র ০৪Snapshot test কি component test-এর সহজ বিকল্প? বড় দল কেন এ নিয়ে দ্বিধায় থাকে?

Snapshot test component-এর render করা HTML একটি ফাইলে রেখে দেয়, আর পরের run-এ মেলায়। লিখতে এক লাইন, আর প্রথম দিন থেকেই “সব কিছু” ঢেকে দেয় বলে মনে হয়। Jest ২০১৬ সালের দিকে এটি জনপ্রিয় করে, Vitest-এও আছে।

সমস্যা হলো, snapshot জানে না কোন বদল ভুল আর কোনটি ইচ্ছাকৃত। একটি class-এর নাম বদলালেও ভাঙে, বোতামের লেখা বদলালেও ভাঙে। বড় snapshot-এর diff শত লাইনের হলে মানুষ পড়ে না, “update” চেপে দেয়। তখন snapshot আর পাহারাদার থাকে না, শুধু একটি ঝামেলা। আবার snapshot-এ থাকে শুধু প্রথম অবস্থা; ক্লিকের পরে সীমা মানা হলো কি না, তা দেখতে আলাদা কাজ লাগে।

অন্যদিকে, ছোট ও উদ্দেশ্যমূলক snapshot কাজে লাগে: একটি error বার্তার object, একটি serializer-এর ফল, বা একটি ছোট markup যার প্রতিটি অংশ গুরুত্বপূর্ণ। Inline snapshot (test ফাইলের ভেতরেই) review-এ চোখে পড়ে, তাই অন্ধ update-এর ঝুঁকি কমে। পর্ব ৭৩-এ এর ব্যবহার দেখবেন।

একটি ভালো পরীক্ষা: snapshot-টি ভাঙলে review-কারী কি দশ সেকেন্ডে বলতে পারবেন বদলটি ঠিক না ভুল? না পারলে সেটি ভুল ধরার টুল নয়। “স্টকের বেশি বাড়ানো যায় না”-এর মতো test নাম নিজেই বলে দেয় কী ভেঙেছে, যা snapshot পারে না।

এর পেছনে একটি গভীর ধারণা আছে: একটি test-এর মূল্য তার ব্যর্থতার বার্তায়। যে test ব্যর্থ হলে সমস্যাটি নাম ধরে বলে দেয়, সেটি দলের সময় বাঁচায়। যে test শুধু বলে “কিছু একটা বদলেছে”, সেটি সময় নেয়।

মূল উপলব্ধি: Snapshot পরিবর্তন ধরে, ভুল নয়। ব্যবহারকারীর আচরণ নিয়ে স্পষ্ট assertion ভুল ধরে, আর সাথে বলে দেয় কোন প্রতিশ্রুতি ভাঙল।

প্র ০৫React-কে test-এর জন্য আলাদা করে act কেন লাগে? এটি কি React-এর নকশার দুর্বলতা?

React-এর মূল প্রতিশ্রুতি হলো: আপনি state বলে দেন, React ঠিক করে কখন আর কীভাবে পর্দা বদলাবে। এই “কখন”-এর স্বাধীনতাই React-কে batching, অগ্রাধিকার আর concurrent rendering-এর সুযোগ দেয়। একটি ক্লিকে তিনটি setState হলে React একবারই render করে। React 18 থেকে এই batching সব জায়গায়, timer আর Promise-এর ভেতরেও।

Test-এর চাহিদা ঠিক উল্টো: “এই কাজের পরে পর্দা এখন কেমন?” প্রশ্নের নিশ্চিত উত্তর। React যদি কাজ পরে করে, test পুরোনো পর্দা দেখে। act এই দুই জগতের সেতু: এর ভেতরের সব update, effect আর জমে থাকা কাজ শেষ করে তবেই ফেরে। React 18-এ এটি একটি global flag-এর সাথে যুক্ত হয়, যাতে test-এর পরিবেশ চেনা যায় আর act-এর বাইরে update হলে warning দেওয়া যায়। এই পর্বেই দেখেছেন, flag না থাকলে warning আসে না, শুধু পুরোনো পর্দা; flag থাকলে warning আর পুরোনো পর্দা দুটোই।

এটিকে দুর্বলতা বলা যায় কি না, তা দৃষ্টিভঙ্গির বিষয়। সমালোচকেরা বলেন, test-এর জন্য বাড়তি ধারণা শেখা লাগে, আর warning-এর ভাষা নতুনদের বিভ্রান্ত করে। সমর্থকেরা বলেন, এটি async বাস্তবতাকে লুকায় না, বরং সামনে আনে: আসল browser-এও ব্যবহারকারী একটি মুহূর্তের জন্য পুরোনো পর্দা দেখেন। Svelte বা Solid-এর মতো fine-grained reactivity-র framework-এ (পর্ব ৬৭) update প্রায় সাথে সাথে DOM-এ যায়, তাই সেখানে এই সমস্যা ভিন্ন রূপে আসে; তবে তাদেরও async কাজের জন্য অপেক্ষার নিয়ম লাগে।

বাস্তবে Testing Library বেশির ভাগ act নিজে সামলায়, তাই নিজের হাতে act লেখা হলে সেটি একটি সংকেত: হয়তো আপনি ব্যবহারকারীর পথের বাইরে দিয়ে কিছু করছেন, যেমন store সরাসরি বদলানো বা timer চালানো। সেটি কখনো যুক্তিসঙ্গত, কিন্তু সচেতনভাবে হওয়া উচিত।

মূল উপলব্ধি: act React-এর async স্বাধীনতার দাম, যা test-এ দিতে হয়। Warning মানে “আপনার যাচাই আর React-এর কাজ একই সময়ে নেই”, আর সেটি সাধারণত test-এ একটি সত্যিকারের ফাঁকের খবর দেয়।

অনুশীলন

প্রথমে নিজে করুন, তারপর উত্তর মিলিয়ে নিন।

  1. হিসাব আর command: (ক) findBy-এর default সময়সীমা ১০০০ ms আর বিরতি ৫০ ms. DOM একবারও না বদলালে timer থেকে সর্বোচ্চ কতবার খোঁজা হয়, শুরুর একবারসহ? (খ) একটি suite-এ ১২টি test ভুল করে এমন element-এর জন্য findBy করছে যা কখনো আসে না। এক worker-এ শুধু এই অপেক্ষায় কমপক্ষে কত সময় যায়? (গ) এক run-এ delay: null দিয়ে একটি form test ৯১৬ থেকে ১৮৫ ms-এ নেমেছে। একই রকম ৫০টি test-এ এক worker-এ মোট কত সময় বাঁচবে? (ঘ) শুধু src/AddToCart.test.tsx happy-dom-এ, প্রতিটি test-এর নামসহ চালানোর command লিখুন।

    (ক) ১০০০ ÷ ৫০ = ২০টি timer-এর চেষ্টা, সাথে শুরুর ১টি, মোট প্রায় ২১ বার। আসল সংখ্যা timer-এর দেরিতে একটু কম হতে পারে।

    (খ) প্রতিটি অন্তত ১ সেকেন্ড, তাই ১২ × ১ = ১২ সেকেন্ড; সাথে প্রতিটির ভুল ছাপার সময়। এক run-এ আমাদের একটি এমন test নিয়েছিল ১০২৪ ms.

    (গ) প্রতি test-এ ৯১৬ − ১৮৫ = ৭৩১ ms. ৫০ × ৭৩১ = ৩৬,৫৫০ ms, অর্থাৎ প্রায় ৩৬.৬ সেকেন্ড। এটি একটি মাপ থেকে অনুমান; ফর্মের অক্ষরের সংখ্যা ভিন্ন হলে বদলাবে।

    (ঘ) npx vitest run --environment happy-dom --reporter=verbose src/AddToCart.test.tsx

  2. নকশা ও সমালোচনা: একজন সহকর্মী (বা AI) বাজারঘরের checkout-এর জন্য এই test দিয়েছেন। কী কী সমস্যা, আর কীভাবে লিখবেন?
    const qc = new QueryClient();
    test('checkout', () => {
      const { container } = render(<QueryClientProvider client={qc}><Checkout /></QueryClientProvider>);
      fireEvent.change(container.querySelector('#phone')!, { target: { value: '0171' } });
      fireEvent.click(screen.getByTestId('submit-btn'));
      expect(container.querySelector('.text-red-600')).toBeTruthy();
      expect(container.innerHTML).toMatchSnapshot();
    });
    • ভাগ করা QueryClient, retry চালু: পরের test-এ cache যায়, error-এর পথ ধীর। প্রতি test-এ নতুন client, retry: false.
    • #phone আর test id: লেবেল জোড়া আছে কি না যাচাই হয় না। getByLabelText('মোবাইল') আর getByRole('button', { name: 'অর্ডার করুন' }).
    • fireEvent: focus বা টাইপের ধারা নেই। await user.type আর await user.click.
    • Async নেই: Zod-এর validation async; সাথে সাথে যাচাই করলে বার্তা হয়তো আসেনি। await screen.findByRole('alert').
    • CSS class যাচাই: লাল রং বার্তার প্রমাণ নয়। বার্তার লেখা আর toHaveAccessibleDescription যাচাই করুন।
    • পুরো snapshot: যেকোনো বদলে ভাঙবে, কেউ পড়বে না। বাদ দিন। আর onSubmit ডাকা হয়নি, সেটিও যাচাই করুন।
  3. ফল অনুমান করুন: একটি component render করে: <output aria-label="পরিমাণ">2</output>, <p role="status">সর্বোচ্চ ৩টি</p>, <button aria-label="পরিমাণ বাড়ান">+</button>. পাতায় আর কিছু নেই। প্রতিটি লাইনের ফল কী: (১) screen.getByRole('status') (২) screen.getByRole('status', { name: 'পরিমাণ' }) (৩) screen.queryByRole('alert') (৪) screen.getByRole('button', { name: '+' }) (৫) screen.getAllByRole('status').length

    (১) ভুল: Found multiple elements with the role "status", কারণ <output>-এর নিজস্ব role-ও status. (২) <output> element, নাম দিয়ে একটিতে নামানো গেছে। (৩) null, কোনো ভুল নয়। (৪) ভুল: নাম “পরিমাণ বাড়ান”, কারণ aria-label ভেতরের “+”-কে ছাপিয়ে যায়; বার্তায় পাতার button-গুলোর নাম দেখাবে। (৫) 2.

  4. Test লিখুন ও ভাঙুন: বাজারঘরের CartBadge দেখায় ঝুড়ি (n), যেখানে n Zustand store-এর মোট পরিমাণ। দুটি test লিখুন: শুরুতে ঝুড়ি (0), আর add-এর পরে ঝুড়ি (2). তারপর reset-এর লাইনটি মুছে ক্রম উল্টে দেখুন কী হয়।
    import { act, render, screen } from '@testing-library/react';
    import { beforeEach, expect, test } from 'vitest';
    import { CartBadge } from './CartBadge';
    import { useCart } from './cartStore';
    
    const initial = useCart.getInitialState();
    beforeEach(() => useCart.setState(initial, true));
    const rice = { id: 'rice', name: 'চাল', price: 420 };
    
    test('store বদলালে badge বদলায় (act সহ)', () => {
      render(<CartBadge />);
      act(() => useCart.getState().add(rice, 2));
      expect(screen.getByRole('link', { name: 'ঝুড়ি (2)' })).toBeInTheDocument();
    });
    
    test('আগের test-এর ঝুড়ি এখানে নেই', () => {
      render(<CartBadge />);
      expect(screen.getByRole('link')).toHaveTextContent('ঝুড়ি (0)');
    });

    Reset মুছে দিলে শেষ test-টি Expected ঝুড়ি (0), Received ঝুড়ি (2) দিয়ে ব্যর্থ হয়, যা অংশ ৮-এ আসল run-এ দেখেছেন। ক্রম উল্টালে (০-এর test আগে) সব পাস করে, আর এটিই বিপদ: ভুলটি লুকিয়ে থাকে যতক্ষণ না কেউ ক্রম বদলায়। npx vitest run --sequence.shuffle এমন লুকানো নির্ভরতা খুঁজতে সাহায্য করে। act লাগছে কারণ store বদলানো হচ্ছে সরাসরি, ব্যবহারকারীর কোনো ক্লিক ছাড়া।

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

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

ক্রমTesting Library দিয়ে একটি async component test-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. নতুন QueryClient আর store reset দিয়ে পরিবেশ তৈরি
  2. Component render করা
  3. getByRole দিয়ে নাম ধরে বোতাম খোঁজা
  4. await user.click দিয়ে ব্যবহারকারীর কাজ
  5. await findByRole দিয়ে নতুন পর্দার জন্য অপেক্ষা
  6. expect দিয়ে পর্দায় যা এল তা যাচাই

Testing Library-র নথির পছন্দের ক্রম অনুযায়ী একটি “অর্ডার করুন” বোতাম খুঁজতে কোন query প্রথম পছন্দ?

  • getByTestId('order-btn')
  • getByRole('button', { name: 'অর্ডার করুন' })
  • container.querySelector('.btn-primary')
  • getByText('অর্ডার করুন')

একটি test প্রমাণ করতে চায় যে সফল submit-এর পরে পাতায় কোনো ভুলের বার্তা (role="alert") নেই। কোন লেখাটি ঠিক?

  • expect(screen.getByRole('alert')).not.toBeInTheDocument()
  • expect(await screen.findByRole('alert')).toBeNull()
  • expect(screen.queryByRole('alert')).not.toBeInTheDocument()
  • expect(screen.getAllByRole('alert')).toHaveLength(0)

শুধু একটি SVG আইকনওয়ালা বোতাম (SVG-তে aria-hidden="true", কোনো লেখা নেই) getByRole('button', { name: 'ঝুড়িতে যোগ করুন' })-এ পাওয়া যাচ্ছে না। সবচেয়ে ভালো সমাধান কী?

  • বোতামে aria-label="ঝুড়িতে যোগ করুন" যোগ করা
  • বোতামে data-testid দিয়ে getByTestId ব্যবহার করা
  • Query থেকে name বাদ দেওয়া
  • SVG থেকে aria-hidden সরিয়ে দেওয়া

Testing Library-র findByRole সম্পর্কে কোন কথাটি ঠিক?

  • এটি সবসময় পুরো ১০০০ ms অপেক্ষা করে তারপর খোঁজে
  • এটি সাথে সাথে element ফেরত দেয়, await লাগে না
  • না পেলে অপেক্ষার শেষে null দেয়
  • এটি getBy-কে waitFor-এ চালায়: বারবার খোঁজে, default সীমা ১০০০ ms

TanStack Query ব্যবহার করা একটি component-এর প্রতিটি test-এ নতুন QueryClient আর retry: false দেওয়ার মূল কারণ কী?

  • QueryClient তৈরি খুব ধীর, তাই আগেভাগে বানাতে হয়
  • Cache এক test থেকে আরেক test-এ যায় না, আর ব্যর্থ query সাথে সাথে error দেখায়
  • Retry বন্ধ করলে fetch-এর নকল লাগে না
  • React 19 প্রতি render-এ নতুন QueryClient চায়

একটি test-এ fake timer দিয়ে vi.advanceTimersByTime(3000) চালানোর পরে React বলল “An update to Toast inside a test was not wrapped in act(...)”, আর assertion পুরোনো লেখা দেখল। সবচেয়ে সঠিক ব্যাখ্যা কী?

  • Warning অর্থহীন, console বন্ধ করলেই চলবে
  • Fake timer timer চালাতে পারেনি
  • State update act-এর বাইরে ঘটেছে, তাই React render শেষ করার আগেই assertion চলেছে
  • Component-এর useEffect-এ ভুল আছে

শব্দকোষ

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

মনে রাখুন

  1. Component test দেখে ব্যবহারকারী যা দেখেন: role, নাম, লেবেল, লেখা। State, prop আর CSS নয়।
  2. Query-র ক্রম: getByRole + নাম, getByLabelText, getByText, শেষে getByTestId. “নেই” প্রমাণে queryBy, অপেক্ষায় findBy.
  3. নামহীন বোতামে test ব্যর্থ হলে component-এ নাম দিন, test বদলাবেন না।
  4. userEvent মানুষের পুরো কাজ নকল করে; সব কাজ await. act warning মানে assertion আর React-এর কাজ একসাথে নেই।
  5. প্রতিটি test আলাদা: নতুন QueryClient, retry: false, store reset, globals: true বা নিজে cleanup.

পরের পর্বে: পর্দা থেকে সার্ভারে। Supertest দিয়ে আসল Express API-কে আসল test database-এর ওপর চালাবেন, প্রতিটি test-এর আগে ডেটা পরিষ্কার করে।

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

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