এই পর্বে যা শিখবেন
- 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
actwarning,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
লেখার সময়ে 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-domimport { 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%, …)
| jsdom | happy-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();
| output পরিমাণ | |
| + বোতাম disabled | |
| সীমার বার্তা | |
| plus |
- Render. Component নকল DOM-এ বসল। পরিমাণ ১, স্টক ৩, তাই বোতাম খোলা আর সীমার বার্তা নেই।
- খোঁজা।
getByRoleসব element-এর role আর accessible name হিসাব করল। ঠিক একটিbutton-এর নাম “পরিমাণ বাড়ান”, তাই সেটি ফেরত এল। শূন্য বা একাধিক মিল হলে সাথে সাথে ভুল হতো। - প্রথম ক্লিক।
userEventpointer আর mouse-এর ঘটনা পাঠাল, শেষেclick. React state বদলাল, পরিমাণ ২।awaitকরার ফলে React-এর update শেষ হওয়ার পরেই পরের লাইন চলল। - দ্বিতীয় ক্লিক। পরিমাণ ৩, যা স্টকের সমান।
atMaxtrue, তাই বোতামdisabledহলো আর “সর্বোচ্চ ৩টি” বার্তা এল। - তৃতীয় ক্লিক। বন্ধ বোতাম চাপলে আসল browser-এ কিছু হয় না,
userEvent-ও তাই করল। পরিমাণ ৩-এই রইল। এখানে আসল নিয়মটি যাচাই হলো: সীমা পার হওয়া যায় না। - যাচাই। লেবেল “পরিমাণ” দিয়ে 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 field | getByRole('button', { name: 'অর্ডার করুন' }) |
| ২ | getByLabelText | Form-এর ঘর, লেবেল দিয়ে | getByLabelText('মোবাইল') |
| ৩ | getByPlaceholderText | লেবেল নেই এমন ঘর (লেবেল না থাকাটাই সমস্যা) | খোঁজার ঘর |
| ৪ | getByText | Interactive নয় এমন লেখা | “সর্বোচ্চ ৩টি”, দামের লেখা |
| ৫ | getByDisplayValue, getByAltText, getByTitle | ঘরের বর্তমান মান, ছবি, title | পণ্যের ছবির alt |
| শেষে | getByTestId | অন্য কোনো পথ নেই | Chart-এর canvas |
Test id (data-testid) ব্যবহারকারী দেখেন না, screen reader-ও পড়ে না। তাই এটি দিয়ে খুঁজলে বোতামের নাম মুছে গেলেও test পাস করবে, অথচ আসল মানুষ বোতামটি চিনতে পারবেন না। তবে এটি নিষিদ্ধ নয়: chart-এর ছবি, বা একই লেখার অনেক কার্ডের একটিকে আলাদা করতে কখনো কাজে লাগে।
- Role + নাম
getByRoleপ্রথম পছন্দ - লেবেল
getByLabelTextform-এর ঘর - লেখা
getByTextসাধারণ লেখা - Test id
getByTestIdশেষ ভরসা
<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') শেষ ভরসা
- Role আর নাম আগে। বোতাম, লিংক, heading, তালিকা, এমনকি form-এর ঘরও role দিয়ে খোঁজা যায়। নাম না মিললে ভাবুন: ব্যবহারকারী এটিকে কী নামে চেনেন?
- Form-এর ঘরে লেবেল।
getByLabelTextনিশ্চিত করে যে লেবেল আর ঘর সত্যিই জোড়া। লেবেল জোড়া না থাকলে test ব্যর্থ হয়, আর সেটিই ঠিক, কারণ screen reader-ও তখন ঘরের নাম বলতে পারে না। - সাধারণ লেখা। বার্তা, দাম, বিবরণ: এদের role থাকলেও সাধারণত নাম নেই।
getByText-এ পুরো লেখা বা regex দিন। - Test id শেষে। ব্যবহারকারীর কাছে অদৃশ্য, তাই accessibility-র কোনো যাচাই হয় না। ব্যবহার করলে কারণটি মন্তব্যে লিখে রাখুন।
তিন রূপ: get, query, find
| রূপ | ০টি মিল | ১টি মিল | একাধিক মিল | অপেক্ষা? | কখন |
|---|---|---|---|---|---|
getBy… | ভুল ছোড়ে | element | ভুল ছোড়ে | না | যা এখনই থাকার কথা |
queryBy… | null | element | ভুল ছোড়ে | না | “নেই” প্রমাণ করতে |
findBy… | অপেক্ষার শেষে ভুল | Promise → element | ভুল | হ্যাঁ, default 1000 ms | যা একটু পরে আসবে |
getAllBy… ইত্যাদি | ভুল (queryAll: []) | সবগুলোর array | findAll-এ হ্যাঁ | তালিকার সব সারি | |
একটি সাধারণ ভুল: 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-এ যাচাই করাই নিরাপদ।
userEvent | fireEvent | |
|---|---|---|
| কী পাঠায় | মানুষের পুরো কাজের ধারা | একটি ঘটনা |
| বন্ধ বা ঢাকা element | Browser-এর মতো আটকায় | আটকায় না |
| 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-এclearMocksdefault-এ চালু, যা প্রতিটি 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 দিয়ে) আবার চেষ্টা করে, তাই উত্তর এলে প্রায় সাথে সাথে ধরা পড়ে।
| সময় | |
| fetch-এর অবস্থা | |
| পর্দায় | |
| চেষ্টার কারণ | |
| ফল |
- শুরুতে একবার। Render-এর পরে পর্দায় শুধু “পণ্য আসছে…”।
findByসাথে সাথে একবার খুঁজল, তালিকা নেই, তাই অপেক্ষা শুরু। - উত্তর এল, DOM বদলাল। নকল
fetchউত্তর দিল, React তালিকা বসাল। MutationObserver-এর সংকেতে আবার খোঁজা হলো, এবার মিলল। ৫০ ms-এর timer-এর জন্য অপেক্ষা লাগেনি। - দ্বিতীয় দৃশ্য: উত্তর আসে না। একই test, কিন্তু
fetchএকটি কখনো-শেষ-না-হওয়া Promise ফেরত দেয়। শুরুতে একবার খোঁজা, ব্যর্থ। - Timer-এ বারবার। DOM আর বদলায় না, তাই প্রতি ৫০ ms-এ timer আবার খোঁজায়। প্রতিবার ব্যর্থ।
- সময় শেষ। ১০০০ 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.querySelector | Accessibility-র ভুল ধরা পড়ে না | 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 সত্যিই কিছু ধরে।
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-এ একটি সত্যিকারের ফাঁকের খবর দেয়।
অনুশীলন
প্রথমে নিজে করুন, তারপর উত্তর মিলিয়ে নিন।
-
হিসাব আর command: (ক)
findBy-এর default সময়সীমা ১০০০ ms আর বিরতি ৫০ ms. DOM একবারও না বদলালে timer থেকে সর্বোচ্চ কতবার খোঁজা হয়, শুরুর একবারসহ? (খ) একটি suite-এ ১২টি test ভুল করে এমন element-এর জন্যfindByকরছে যা কখনো আসে না। এক worker-এ শুধু এই অপেক্ষায় কমপক্ষে কত সময় যায়? (গ) এক run-এdelay: nullদিয়ে একটি form test ৯১৬ থেকে ১৮৫ ms-এ নেমেছে। একই রকম ৫০টি test-এ এক worker-এ মোট কত সময় বাঁচবে? (ঘ) শুধুsrc/AddToCart.test.tsxhappy-dom-এ, প্রতিটি test-এর নামসহ চালানোর command লিখুন।(ক) ১০০০ ÷ ৫০ = ২০টি timer-এর চেষ্টা, সাথে শুরুর ১টি, মোট প্রায় ২১ বার। আসল সংখ্যা timer-এর দেরিতে একটু কম হতে পারে।
(খ) প্রতিটি অন্তত ১ সেকেন্ড, তাই ১২ × ১ = ১২ সেকেন্ড; সাথে প্রতিটির ভুল ছাপার সময়। এক run-এ আমাদের একটি এমন test নিয়েছিল ১০২৪ ms.
(গ) প্রতি test-এ ৯১৬ − ১৮৫ = ৭৩১ ms. ৫০ × ৭৩১ = ৩৬,৫৫০ ms, অর্থাৎ প্রায় ৩৬.৬ সেকেন্ড। এটি একটি মাপ থেকে অনুমান; ফর্মের অক্ষরের সংখ্যা ভিন্ন হলে বদলাবে।
(ঘ)
npx vitest run --environment happy-dom --reporter=verbose src/AddToCart.test.tsx -
নকশা ও সমালোচনা: একজন সহকর্মী (বা 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ডাকা হয়নি, সেটিও যাচাই করুন।
- ভাগ করা
-
ফল অনুমান করুন: একটি 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. -
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-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- নতুন
QueryClientআর store reset দিয়ে পরিবেশ তৈরি - Component
renderকরা getByRoleদিয়ে নাম ধরে বোতাম খোঁজাawait user.clickদিয়ে ব্যবহারকারীর কাজawait findByRoleদিয়ে নতুন পর্দার জন্য অপেক্ষা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-এ ভুল আছে
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব শব্দ এখানে একসাথে:
মনে রাখুন
- Component test দেখে ব্যবহারকারী যা দেখেন: role, নাম, লেবেল, লেখা। State, prop আর CSS নয়।
- Query-র ক্রম:
getByRole+ নাম,getByLabelText,getByText, শেষেgetByTestId. “নেই” প্রমাণেqueryBy, অপেক্ষায়findBy. - নামহীন বোতামে test ব্যর্থ হলে component-এ নাম দিন, test বদলাবেন না।
userEventমানুষের পুরো কাজ নকল করে; সব কাজawait.actwarning মানে assertion আর React-এর কাজ একসাথে নেই।- প্রতিটি test আলাদা: নতুন
QueryClient,retry: false, store reset,globals: trueবা নিজে cleanup.
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৭৫ · API integration test — Supertest আর test databaseপরবর্তী পর্বHTTP দিয়ে ঢুকে আসল database পর্যন্ত, Supertest আর আলাদা test database.
- ফুল-স্ট্যাক ওয়েব ফ্রেমওয়ার্ক · কোর্স হোম৯৩টি পর্বপুরো কোর্সের রোডম্যাপ; পরের পর্ব ৭৫-এ API integration test আসছে।
- পর্ব ১৯ · Server state — TanStack Queryফিরে দেখুনQueryClient, retry আর loading ও error-এর অবস্থা।
- পর্ব ২২ · Styling আর accessibilityফিরে দেখুনFocus, contrast আর বাংলা UI.
- সফটওয়্যার ইঞ্জিনিয়ারিং · Test doubleতত্ত্বMock, stub, spy আর fake-এর পার্থক্য।
- HTML ও CSS · Accessibilityগভীরে যানRole, label, ARIA আর screen reader.