تست‌نویسی در javascript/typescript — بخش ۵: تست DOM و Testing Library

تست‌نویسی در javascript/typescript — بخش ۵: تست DOM و Testing Library

دی ۱۲, ۱۴۰۴

توی بخش ۴، با Playwright یاد گرفتیم چطور کل برنامه رو توی مرورگر واقعی تست کنیم. E2E عالیه — ولی کنده. نمی‌شه برای هر کلیک و هر input یه تست Playwright نوشت.

پس لایه میانی کجاست؟

جواب در تست‌نویسی اینه: تست DOM در Vitest — با JSDOM (یا Happy DOM) و Testing Library. این تست‌ها سریع‌تر از E2E هستن، ولی واقعی‌تر از unit testهای pure JavaScript — چون DOM و تعامل کاربر رو درگیر می‌کنن.

مرز integration test کمی مبهم است؛ تست‌های مرورگر (Playwright، Cypress) را اغلب integration می‌دانند. ما در این سری E2E را جدا کردیم (بخش ۴). حالا می‌رویم سراغ تست DOM در Node.

خب بزن بریم!

Node و DOM — چرا به شبیه‌سازی نیاز داریم؟

Node.js جاوااسکریپت رو اجرا می‌کنه — مثل مرورگر. ولی یه چیز مهم نداره: DOM.

وقتی توی Vitest تست می‌نویسیم، به صورت پیش‌فرض توی محیط Node هستیم. document، window، localStorage — هیچ‌کدوم وجود ندارن. برای تست کدی که با DOM کار می‌کنه، باید محیط مرورگر رو شبیه‌سازی کنیم.

توی بخش ۴ دیدیم Playwright مرورگر واقعی باز می‌کنه. اینجا مسیر سبک‌تر رو می‌ریم: کتابخونه‌هایی که DOM رو توی Node emulate می‌کنن.


راه‌اندازی JSDOM و Happy DOM

دو گزینه اصلی برای شبیه‌سازی DOM داریم.

نصب و پیکربندی JSDOM

npm install -D vitest jsdom
// vitest.config.js
import { defineConfig } from 'vitest/config';

export default defineConfig({
 test: {
 environment: 'jsdom',
 },
});

نصب و پیکربندی Happy DOM

npm install -D vitest happy-dom
// vitest.config.js
import { defineConfig } from 'vitest/config';

export default defineConfig({
 test: {
 environment: 'happy-dom',
 },
});

تنظیم per-file

می‌تونید فقط برای یه فایل تست، محیط DOM فعال کنید:

// @vitest-environment jsdom

import { describe, it, expect } from 'vitest';

describe('DOM tests', () => {
 it('has document', () => {
 expect(document).toBeDefined();
 });
});

JSDOM در برابر Happy DOM

ویژگی JSDOM Happy DOM
تمرکز دقت و شباهت به مرورگر سرعت
Performance کندتر، حافظه بیشتر سریع‌تر، سبک‌تر
پوشش API جامع‌تر زیرمجموعه ضروری‌ها
بهترین برای اپ‌های پیچیده، رفتار واقعی‌تر تست سریع، DOM ساده
اکوسیستم Jest، Mocha، Vitest Vitest

به عنوان یه rule of thumb:

  • JSDOM → وقتی دقت و پوشش API مهمه
  • Happy DOM → وقتی سرعت و feedback سریع مهمه

سه نکته مهم (Gotchas)

سه نکته مهم داره:

  1. هنوز مرورگر واقعی نیست. JSDOM طوری طراحی شده که مثل مرورگر رفتار کنه، نه اینکه باشه مرورگر. subtletyهای Chrome یا Safari رو نداره. (برای اون Playwright داریم — بخش ۴.)

  2. Performance. تست با jsdom از تست pure Node کندتره — هزینه emulate کردن DOM.

  3. مشکلات browser-specific. اگر چیزی توی Vitest + jsdom کار کنه، تضمینی نیست توی همه مرورگرها کار کنه.


اولین تست DOM — دکمه کلیک‌شونده

در مثال اول با ساده‌ترین مثال شروع می‌کنه: یه دکمه که با کلیک، متنش عوض می‌شه.

کد — src/button.js

// src/button.js
export function createButton() {
 const button = document.createElement('button');
 button.textContent = 'Click Me';

 button.addEventListener('click', () => {
 button.textContent = 'Clicked!';
 });

 return button;
}

چیز fancy نیست — دکمه‌ای که می‌گه «Click Me» و با کلیک می‌شه «Clicked!». مثل tutorial level مایکروویو برای DOM testing!

تست — src/button.test.js

import { it, expect, describe } from 'vitest';
import { createButton } from './button.js';

describe('createButton', () => {
 it('should create a button element', () => {
 const button = createButton();
 expect(button.tagName).toBe('BUTTON');
 });

 it('should have the text "Click Me"', () => {
 const button = createButton();
 expect(button.textContent).toBe('Click Me');
 });

 it('should change the text to "Clicked!" when clicked', () => {
 const button = createButton();
 button.click();
 expect(button.textContent).toBe('Clicked!');
 });
});

نکته مهم

چه از jsdom استفاده کنید چه happy-dom، متدهای DOM براتون پر شدن. کد هنوز توی Node اجرا می‌شه — ولی به document.createElement، addEventListener و click() دسترسی دارید.

این تست‌ها integration test محسوب می‌شن: DOM + event listener + state المان با هم کار می‌کنن — نه فقط یه تابع pure.


تست localStorage

برای localStorage: فرض کنید کدی دارید که localStorage لمس می‌کنه. توی Node، localStorage نیست. می‌تونید mock بنویسید (بخش ۶ می‌آد)، ولی با jsdom/happy-dom می‌تونید محیط مرورگر رو emulate کنید.

تست ساده localStorage

it('should properly assign to localStorage', () => {
 const key = 'secret';
 const message = "It's a secret to everybody.";

 localStorage.setItem(key, message);
 expect(localStorage.getItem(key)).toBe(message);
});

مثال با DOM — createSecretInput

// src/secret-input.js
export function createSecretInput() {
 const id = 'secret-input';

 const container = document.createElement('div');
 const input = document.createElement('input');
 const label = document.createElement('label');
 const button = document.createElement('button');

 input.id = id;
 input.type = 'password';
 input.placeholder = 'Enter your secret…';

 label.htmlFor = id;
 label.textContent = 'Secret';

 button.textContent = 'Store Secret';
 button.addEventListener('click', () => {
 localStorage.setItem('secret', input.value);
 input.value = '';
 });

 container.appendChild(label);
 container.appendChild(input);
 container.appendChild(button);

 return container;
}

تست اولیه (بدون Testing Library)

import { describe, expect, it, beforeEach } from 'vitest';
import { createSecretInput } from './secret-input.js';

describe('createSecretInput', () => {
 beforeEach(() => {
 localStorage.clear();
 });

 it('should store the value in localStorage', () => {
 const secretInput = createSecretInput();
 const input = secretInput.querySelector('input');
 const button = secretInput.querySelector('button');

 input.value = 'my secret';
 button.click();

 expect(localStorage.getItem('secret')).toBe('my secret');
 });
});

تست pass می‌شه. ولی دو مشکل داره:

  1. مقدار input رو دستی set می‌کنیم — نه مثل تایپ کاربر
  2. روی button متد صدا می‌زنیم — نه کلیک واقعی

اینجاست که Testing Library وارد می‌شه.


معرفی Testing Library

Testing Library کمک می‌کنه تست‌هایی بنویسیم که روی رفتار کاربر تمرکز دارن، نه جزئیات implementation.

چرا Testing Library؟

  • User-Centric Testing — تست از دید کاربر
  • Avoids Implementation Details — وابستگی کمتر به ساختار داخلی
  • Improved Test Reliability — مقاوم‌تر در برابر refactor
  • Accessible Queries — queryها شبیه screen reader عمل می‌کنن

دو بخش اصلی

  1. فریمورک-specific flavors: @testing-library/dom، @testing-library/react، @testing-library/vue و...
  2. User Event (@testing-library/user-event): شبیه‌سازی تعامل کاربر

نصب

npm install -D @testing-library/dom @testing-library/user-event @testing-library/jest-dom jsdom

راه‌اندازی Vitest

// vitest.config.js
import { defineConfig } from 'vitest/config';

export default defineConfig({
 test: {
 environment: 'jsdom',
 setupFiles: './tests/setupTests.js',
 },
});
// tests/setupTests.js
import '@testing-library/jest-dom/vitest';

Refactor کردن Secret Input با Testing Library

در Testing Library همون مثال secret-input رو با Testing Library بازنویسی می‌کنه.

importها

import { screen } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, beforeEach } from 'vitest';
import { createSecretInput } from './secret-input.js';

setup در beforeEach

describe('createSecretInput', () => {
 beforeEach(() => {
 document.innerHTML = '';
 localStorage.clear();
 document.body.appendChild(createSecretInput());
 });

 it('should store the value in localStorage', async () => {
 const user = userEvent.setup();
 const input = screen.getByLabelText('Secret');
 const button = screen.getByRole('button', { name: 'Store Secret' });

 await user.type(input, 'my secret');
 await user.click(button);

 expect(localStorage.getItem('secret')).toBe('my secret');
 });
});

تفاوت‌های کلیدی

قبل (querySelector) بعد (Testing Library)
secretInput.querySelector('input') screen.getByLabelText('Secret')
input.value = 'my secret' await user.type(input, 'my secret')
button.click() await user.click(button)
sync async — حتماً await

screen چیست؟

screen basically پنجره مرورگر شماست. یعنی id می‌تونه عوض بشه یا random generate بشه — مهم نیست. ما از همون queryهایی استفاده می‌کنیم که screen reader استفاده می‌کنه.

[!danger] حتماً await کنید! یه آزمایش بکنید: await رو از user.type و user.click حذف کنید. Spoiler: خوب پیش نمی‌ره.

Queryهای پرکاربرد Testing Library

// با label (فرم‌ها)
screen.getByLabelText('Secret');

// با role (دکمه، heading، textbox)
screen.getByRole('button', { name: 'Store Secret' });
screen.getByRole('heading', { name: 'Tic Tac Toe' });
screen.getByRole('textbox', { name: 'Email' });

// با متن
screen.getByText('Player X wins!');

// با test id (fallback)
screen.getByTestId('counter-count');

// همه المان‌های matching
screen.getAllByRole('button');

اولویت queryها (Testing Library philosophy)

Testing Library این ترتیب رو توصیه می‌کنن:

  1. getByRole
  2. getByLabelText
  3. getByPlaceholderText
  4. getByText
  5. getByDisplayValue
  6. getByAltText
  7. getByTitle
  8. getByTestId (آخرین راه)

نکته مهم: این اولویت‌ها دقیقاً همون فلسفه‌ایه که توی بخش ۴ با getByRole در Playwright داشتیم — در هر دو لایه همین رویکرد توصیه می‌شود.


fireEvent در برابر userEvent

در user-event تفاوت این دو رو توضیح می‌ده.

مشکل fireEvent

fireEvent یه event خام رو روی DOM node شلیک می‌کنه. ولی تعامل واقعی کاربر یک event نیست. وقتی کاربر توی input تایپ می‌کنه:

  • ممکنه اول روی field کلیک کنه (click, focus)
  • یه کلید فشار بده (keydown, keypress)
  • کلید رو رها کنه (keyup)
  • change event روی input trigger بشه

fireEvent فقط یکی از این‌ها رو می‌زنه — نه کل زنجیره.

مثال با fireEvent (کار می‌کنه، ولی ideal نیست)

import { render, screen, fireEvent } from '@testing-library/react';
import { Counter } from './counter';

test('increment with fireEvent', () => {
 render(<Counter />);

 const currentCount = screen.getByTestId('counter-count');
 const incrementButton = screen.getByRole('button', { name: 'Increment' });

 fireEvent.click(incrementButton);

 expect(currentCount).toHaveTextContent('1');
});

مثال با userEvent (توصیه شده)

test('increment with userEvent', async () => {
 const user = userEvent.setup();
 render(<Counter />);

 const currentCount = screen.getByTestId('counter-count');
 const incrementButton = screen.getByRole('button', { name: 'Increment' });

 await user.click(incrementButton);

 expect(currentCount).toHaveTextContent('1');
});

اشتباه رایج — فراموش کردن await

// ❌ fail می‌شه
test('bad test', () => {
 const user = userEvent.setup();
 user.click(incrementButton); // Promise برمی‌گردونه!
 expect(currentCount).toHaveTextContent('1');
});

// ✅ درست
test('good test', async () => {
 const user = userEvent.setup();
 await user.click(incrementButton);
 expect(currentCount).toHaveTextContent('1');
});

userEvent.click() یه Promise برمی‌گردونه — تست باید async باشه و حتماً await بزنید.

قابلیت‌های اضافی userEvent

مثال: شبیه‌سازی نگه داشتن Shift:

const user = userEvent.setup();

await user.keyboard('[ShiftLeft>]'); // Shift رو نگه دار
await user.click(element); // کلیک با shiftKey: true

چرا userEvent بهتر از fireEvent است؟

fireEvent userEvent
واقع‌گرایی یه event خام زنجیره کامل تعامل کاربر
توصیه Testing Library fallback پیش‌فرض
Async معمولاً sync async — نیاز به await
آینده محدود پشتیبانی بهتر از featureهای جدید

به عنوان یه rule of thumb: همیشه userEvent — مگر اینکه واقعاً به fireEvent خام نیاز داشته باشید.


Refactor دکمه با Testing Library

در exercise دکمه exercise دکمه رو با Testing Library حل می‌کنه.

نسخه ۱ — کلیک مستقیم روی element

import { screen } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { createButton } from './button.js';

it('should change the text to "Clicked!" when clicked', async () => {
 const user = userEvent.setup();
 const button = createButton();

 await user.click(button);

 expect(button.textContent).toBe('Clicked!');
});

نسخه ۲ — render به document و query با role

it('should change the text to "Clicked!" when clicked', async () => {
 const user = userEvent.setup();
 document.body.appendChild(createButton());
 const button = screen.getByRole('button', { name: 'Click Me' });

 await user.click(button);

 expect(button.textContent).toBe('Clicked!');
});

نسخه ۲ بهتره چون:

  • تست «دکمه label درست داره» دیگه لازم نیست — getByRole همون رو چک می‌کنه
  • تست «tagName برابر BUTTON است» هم obsolete می‌شه

یه تست خوب Testing Library، چند تست جزئی رو با هم پوشش می‌ده.


Matcherهای jest-dom

در matchers matcherهای @testing-library/jest-dom رو معرفی می‌کنه. با import کردن @testing-library/jest-dom/vitest، expect extend می‌شه.

Matcherهای مهم

// متن
expect(element).toHaveTextContent('0');
expect(element).toContainHTML('<span>hello</span>');

// وضعیت
expect(button).toBeDisabled();
expect(button).toBeEnabled();
expect(element).toBeVisible();
expect(element).toBeInTheDocument();

// فرم
expect(input).toHaveValue('my secret');
expect(input).toBeRequired();
expect(input).toBeValid();
expect(checkbox).toBeChecked();

// attribute و class
expect(element).toHaveAttribute('href', '/login');
expect(element).toHaveClass('active');

// accessibility
expect(element).toHaveAccessibleName('Submit');
expect(element).toHaveRole('button');

لیست کامل در jest-dom documentation.


مثال Counter با React

در matchers یک کامپوننت Counter رو با React تست می‌کنیم — تا نشون بده تفاوت Testing Library بین vanilla JS و React کمه.

کامپوننت — src/counter.jsx

import { useState, useEffect } from 'react';

export function Counter() {
 const [count, setCount] = useState(0);

 useEffect(() => {
 const unit = count === 1 ? 'day' : 'days';
 document.title = `${count} ${unit}`;
 }, [count]);

 const unit = count === 1 ? 'day' : 'days';
 const isAtZero = count === 0;

 return (
 <div>
 <span data-testid="counter-count">{count}</span>
 <span data-testid="counter-unit">{unit}</span>
 <button type="button" onClick={() => setCount((c) => c + 1)}>
 Increment
 </button>
 <button
 type="button"
 onClick={() => setCount((c) => Math.max(0, c - 1))}
 disabled={isAtZero}
 >
 Decrement
 </button>
 <button type="button" onClick={() => setCount(0)} disabled={isAtZero}>
 Reset
 </button>
 </div>
 );
}

نصب React Testing Library

npm install -D @testing-library/react @vitejs/plugin-react
// vitest.config.js
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

export default defineConfig({
 plugins: [react()],
 test: {
 environment: 'jsdom',
 setupFiles: './tests/setupTests.js',
 },
});

تست‌های Counter — src/counter.test.jsx

در مثال Counter این تست‌ها رو پیشنهاد می‌ده:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, beforeEach } from 'vitest';
import { Counter } from './counter';
import '@testing-library/jest-dom/vitest';

describe('Counter Component', () => {
 beforeEach(() => {
 render(<Counter />);
 });

 it('renders with an initial count of 0', () => {
 const countElement = screen.getByTestId('counter-count');
 expect(countElement).toHaveTextContent('0');
 });

 it('displays "days" when the count is 0', () => {
 const unitElement = screen.getByTestId('counter-unit');
 expect(unitElement).toHaveTextContent('days');
 });

 it('increments the count when the "Increment" button is clicked', async () => {
 const user = userEvent.setup();
 const incrementButton = screen.getByRole('button', { name: 'Increment' });

 await user.click(incrementButton);

 expect(screen.getByTestId('counter-count')).toHaveTextContent('1');
 });

 it('displays "day" when the count is 1', async () => {
 const user = userEvent.setup();
 await user.click(screen.getByRole('button', { name: 'Increment' }));

 expect(screen.getByTestId('counter-unit')).toHaveTextContent('day');
 });

 it('decrements the count when the "Decrement" button is clicked', async () => {
 const user = userEvent.setup();
 await user.click(screen.getByRole('button', { name: 'Increment' }));
 await user.click(screen.getByRole('button', { name: 'Decrement' }));

 expect(screen.getByTestId('counter-count')).toHaveTextContent('0');
 });

 it('does not allow decrementing below 0', async () => {
 const user = userEvent.setup();
 await user.click(screen.getByRole('button', { name: 'Decrement' }));

 expect(screen.getByTestId('counter-count')).toHaveTextContent('0');
 });

 it('resets the count when the "Reset" button is clicked', async () => {
 const user = userEvent.setup();
 await user.click(screen.getByRole('button', { name: 'Increment' }));
 await user.click(screen.getByRole('button', { name: 'Reset' }));

 expect(screen.getByTestId('counter-count')).toHaveTextContent('0');
 });

 it('disables the "Decrement" and "Reset" buttons when the count is 0', () => {
 expect(screen.getByRole('button', { name: 'Decrement' })).toBeDisabled();
 expect(screen.getByRole('button', { name: 'Reset' })).toBeDisabled();
 });

 it('updates the document title based on the count', async () => {
 const user = userEvent.setup();
 await user.click(screen.getByRole('button', { name: 'Increment' }));

 expect(document.title).toBe('1 day');

 await user.click(screen.getByRole('button', { name: 'Increment' }));
 expect(document.title).toBe('2 days');
 });
});

نکات از تست Counter

render قبل از هر تست

beforeEach(() => {
 render(<Counter />);
});

هر تست با state تازه شروع می‌شه — مثل beforeEach در بخش ۳، ولی برای کامپوننت React.

toBeDisabled — matcher جدید

expect(decrementButton).toBeDisabled();

این از @testing-library/jest-dom می‌آد — توی Vitest خام نداریم.

تست document.title

Counter با useEffect عنوان صفحه رو آپدیت می‌کنه — تست DOM می‌تونه side effectهای document رو هم چک کنه.

تفاوت کم React و Vanilla JS

نکته: «فقط برای نشون دادن اینکه چقدر کم فرق می‌کنه.»

Vanilla JS React
document.body.appendChild(createSecretInput()) render(<Component />)
screen.getByLabelText('Secret') screen.getByLabelText('Secret')همون
await user.type(input, 'text') await user.type(input, 'text')همون

Queryها و userEvent یکسان هستن — فقط نحوه render فرق می‌کنه.


مثال کامل: Tic-Tac-Toe UI با Testing Library

توی بخش ۴، همین بازی Tic-Tac-Toe رو با Playwright در مرورگر واقعی تست کردیم. در مثال tic-tac-toe همون بازی رو با Testing Library در jsdom تست می‌کنه — لایه سریع‌تر integration.

تفاوت بخش ۴ و بخش ۵ برای همین بازی

بخش ۴ (Playwright) بخش ۵ (Testing Library)
محیط مرورگر واقعی jsdom در Node
سرعت کندتر سریع
سرور نیاز به webServer نیاز نداره
Query page.getByRole screen.getByRole
وقتی استفاده کنیم سفر کامل کاربر، cross-browser تعامل UI، منطق DOM

راه‌اندازی

npm install -D jsdom @testing-library/dom @testing-library/user-event @testing-library/jest-dom
// vitest.config.js
import { defineConfig } from 'vitest/config';

export default defineConfig({
 test: {
 environment: 'jsdom',
 setupFiles: './tests/setupTests.js',
 },
});
// tests/setupTests.js
import '@testing-library/jest-dom/vitest';

HTML بازی — src/index.html

<!doctype html>
<html lang="en">
 <head>
 <meta charset="UTF-8" />
 <title>Tic Tac Toe</title>
 <style>
 .board {
 display: grid;
 grid-template-columns: repeat(3, 100px);
 gap: 5px;
 }
 .cell {
 width: 100px;
 height: 100px;
 font-size: 2em;
 text-align: center;
 line-height: 100px;
 border: 1px solid #000;
 cursor: pointer;
 }
 .disabled {
 pointer-events: none;
 background-color: #f0f0f0;
 }
 #message {
 margin-top: 20px;
 font-size: 1.2em;
 }
 </style>
 </head>
 <body>
 <h1>Tic Tac Toe</h1>
 <div class="board" id="board"></div>
 <div id="message" role="status"></div>
 <button id="reset" type="button">Reset Game</button>
 <script type="module" src="ui.js"></script>
 </body>
</html>

UI — src/ui.js

import { createGame } from './game.js';

const game = createGame();
const boardElement = document.getElementById('board');
const messageElement = document.getElementById('message');
const resetButton = document.getElementById('reset');

for (let row = 0; row < 3; row++) {
 for (let col = 0; col < 3; col++) {
 const cell = document.createElement('button');
 cell.classList.add('cell');
 cell.dataset.row = row;
 cell.dataset.col = col;
 cell.setAttribute('aria-label', `Cell ${row},${col}`);
 boardElement.appendChild(cell);
 }
}

function updateBoard() {
 document.querySelectorAll('.cell').forEach((cell) => {
 const row = cell.dataset.row;
 const col = cell.dataset.col;
 cell.textContent = game.board[row][col];
 });
}

function handleClick(event) {
 const cell = event.target;
 const row = Number(cell.dataset.row);
 const col = Number(cell.dataset.col);
 try {
 game.placeMove(row, col);
 updateBoard();
 const winner = game.checkWinner();
 if (winner) {
 messageElement.textContent = `Player ${winner} wins!`;
 disableBoard();
 } else if (game.isDraw()) {
 messageElement.textContent = "It's a draw!";
 disableBoard();
 }
 } catch {
 // خانه پر است
 }
}

function disableBoard() {
 document.querySelectorAll('.cell').forEach((cell) => {
 cell.disabled = true;
 cell.classList.add('disabled');
 });
}

function resetGame() {
 game.board = [['', '', ''], ['', '', ''], ['', '', '']];
 game.currentPlayer = 'X';
 messageElement.textContent = '';
 document.querySelectorAll('.cell').forEach((cell) => {
 cell.disabled = false;
 cell.classList.remove('disabled');
 cell.textContent = '';
 });
}

document.querySelectorAll('.cell').forEach((cell) => {
 cell.addEventListener('click', handleClick);
});

resetButton.addEventListener('click', resetGame);

منطق بازی — src/game.js

export function createGame() {
 return {
 board: [['', '', ''], ['', '', ''], ['', '', '']],
 currentPlayer: 'X',

 placeMove(row, col) {
 if (this.board[row][col] !== '') {
 throw new Error('Spot already taken');
 }
 this.board[row][col] = this.currentPlayer;
 this.currentPlayer = this.currentPlayer === 'X' ? 'O' : 'X';
 },

 checkWinner() {
 const b = this.board;
 const lines = [
 [b[0][0], b[0][1], b[0][2]],
 [b[1][0], b[1][1], b[1][2]],
 [b[2][0], b[2][1], b[2][2]],
 [b[0][0], b[1][0], b[2][0]],
 [b[0][1], b[1][1], b[2][1]],
 [b[0][2], b[1][2], b[2][2]],
 [b[0][0], b[1][1], b[2][2]],
 [b[0][2], b[1][1], b[2][0]],
 ];
 for (const line of lines) {
 if (line[0] && line[0] === line[1] && line[1] === line[2]) {
 return line[0];
 }
 }
 return null;
 },

 isDraw() {
 if (this.checkWinner()) return false;
 for (const row of this.board) {
 if (row.includes('')) return false;
 }
 return true;
 },
 };
}

تست UI — tests/ui.test.js

در مثال tic-tac-toe این تست‌ها رو می‌نویسه:

import { describe, it, expect, beforeEach } from 'vitest';
import { screen } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const html = fs.readFileSync(path.resolve(__dirname, '../src/index.html'), 'utf8');

describe('Tic Tac Toe UI', () => {
 beforeEach(async () => {
 document.body.innerHTML = html;
 await import('../src/ui.js');
 });

 it('renders a 3x3 grid', () => {
 const cells = document.querySelectorAll('.cell');
 expect(cells.length).toBe(9);
 });

 it('allows players to take turns placing marks', async () => {
 const user = userEvent.setup();
 const cells = screen.getAllByRole('button', { name: /Cell/ });

 await user.click(cells[0]);
 expect(cells[0]).toHaveTextContent('X');

 await user.click(cells[1]);
 expect(cells[1]).toHaveTextContent('O');

 await user.click(cells[2]);
 expect(cells[2]).toHaveTextContent('X');
 });

 it('declares a winner when a player wins', async () => {
 const user = userEvent.setup();
 const cells = screen.getAllByRole('button', { name: /Cell/ });
 const message = document.getElementById('message');

 await user.click(cells[0]); // X
 await user.click(cells[3]); // O
 await user.click(cells[1]); // X
 await user.click(cells[4]); // O
 await user.click(cells[2]); // X wins

 expect(message).toHaveTextContent('Player X wins!');
 });

 it('declares a draw when the game ends without a winner', async () => {
 const user = userEvent.setup();
 const cells = screen.getAllByRole('button', { name: /Cell/ });
 const message = document.getElementById('message');

 const moves = [0, 1, 2, 4, 3, 5, 7, 6, 8];
 for (const index of moves) {
 await user.click(cells[index]);
 }

 expect(message).toHaveTextContent("It's a draw!");
 });
});

نکات تست Tic-Tac-Toe

بارگذاری HTML در beforeEach

beforeEach(async () => {
 document.body.innerHTML = html;
 await import('../src/ui.js');
});

هر تست با DOM تازه شروع می‌شه — مهم برای ایزوله بودن تست‌ها (همون فلسفه هوک‌های بخش ۳).

getAllByRole برای سلول‌های بازی

const cells = screen.getAllByRole('button', { name: /Cell/ });

از aria-label که توی ui.js گذاشتیم (Cell 0,0 و...) استفاده می‌کنه — accessible و پایدار.

اجرا

npx vitest tests/ui.test.js

خیلی سریع‌تر از Playwright — چون مرورگر واقعی باز نمی‌شه.


کی Testing Library و کی Playwright؟

هر دو روش — ولی برای هدف‌های مختلف:

Testing Library + jsdom (این مقاله)

استفاده کنید وقتی:

  • می‌خواید تعامل UI رو سریع تست کنید
  • کامپوننت React/Vue/Svelte تست می‌کنید
  • منطق DOM و event handler مهمه
  • توی CI می‌خواید هزاران تست در ثانیه‌ها اجرا بشه

Playwright (بخش ۴)

استفاده کنید وقتی:

  • کل flow کاربر از ابتدا تا انتها مهمه
  • routing واقعی، چند صفحه، authentication
  • cross-browser (Safari، Firefox)
  • می‌خواید CSS layout واقعی رو ببینید

هر دو با هم

Unit tests (بخش ۲-۳) → منطق pure
Testing Library (بخش ۵) → UI و DOM سریع
Playwright (بخش ۴) → سفر کامل کاربر

نکته: «گاهی یه integration test به اندازه ۶۰ unit test اعتماد می‌ده.» — Testing Library همون integration test سریع برای UI است.


خطاهای رایج در تست DOM

۱. فراموش کردن environment

ReferenceError: document is not defined

راه‌حل: environment: 'jsdom' در vitest.config یا // @vitest-environment jsdom بالای فایل.

۲. فراموش کردن await با userEvent

// ❌ flaky یا fail
user.click(button);
expect(count).toHaveTextContent('1');

// ✅
await user.click(button);
expect(count).toHaveTextContent('1');

۳. querySelector به جای Testing Library

// ❌ وابسته به ساختار DOM
container.querySelector('div > button.submit');

// ✅
screen.getByRole('button', { name: 'Submit' });

۴. فراموش کردن پاکسازی DOM

beforeEach(() => {
 document.innerHTML = ''; // حتماً!
 localStorage.clear();
 document.body.appendChild(createSecretInput());
});

بدون پاکسازی، تست‌ها روی هم اثر می‌ذارن.

۵. فراموش کردن jest-dom

expect(element).toHaveTextContent is not a function

راه‌حل: import '@testing-library/jest-dom/vitest' در setupFiles.

۶. تست implementation به جای behavior

// ❌ تست implementation
expect(component.state.count).toBe(1);

// ✅ تست behavior
expect(screen.getByTestId('counter-count')).toHaveTextContent('1');

Testing Library می‌گه: «هرچقدر تست شبیه‌تر به نحوه استفاده کاربر باشه، باگ‌های بیشتری می‌گیره — با اطمینان بیشتر که تست‌ها نشکنن.»


بهترین روش‌ها

۱. از دید کاربر تست بنویسید

تأکید می‌شود: تست نباید به class name یا ساختار DOM وابسته باشه. کاربر getByRole نمی‌زنه — ولی screen reader می‌زنه. اگر screen reader پیدا کنه، کاربر هم پیدا می‌کنه.

۲. از userEvent استفاده کنید

fireEvent فقط برای edge case. پیش‌فرض: userEvent.setup() + await user.click().

۳. DOM رو در beforeEach تمیز کنید

beforeEach(() => {
 document.innerHTML = '';
 localStorage.clear();
});

۴. async را جدی بگیرید

هر تستی که userEvent داره باید async باشه.

۵. getByTestId را آخر بزنید

data-testid وقتی role/label/text جواب نمی‌ده — نه به عنوان اولین انتخاب.

۶. تست DOM جایگزین E2E نیست

برای critical path (login کامل، checkout) هنوز Playwright لازمه. Testing Library برای لایه سریع‌تر UI است.


چه زمانی از Testing Library استفاده کنیم؟

در Testing Library می‌گه:

  • Component Interaction Testing — وقتی باید ببینید کامپوننت به action کاربر چطور جواب می‌ده
  • Accessibility Compliance — مطمئن بشید کامپوننت accessible و درست کار می‌کنه
  • Behavior Verification — validate کنید render و update درست انجام می‌شه

خلاصه آنچه یاد گرفتیم

  1. JSDOM / Happy DOM — شبیه‌سازی DOM در Node برای Vitest
  2. اولین تست DOMcreateButton با click
  3. localStorage — emulate مرورگر بدون mock
  4. Testing Library — query از دید کاربر (getByRole, getByLabelText)
  5. userEvent — شبیه‌سازی واقعی‌تر از fireEvent
  6. jest-dom matcherstoHaveTextContent, toBeDisabled,...
  7. Counter React — مثال کامل با userEvent
  8. Tic-Tac-Toe UI — همان بازی بخش ۴، لایه سریع‌تر

چک‌لیست تست DOM

قبل از merge:

  • environment: 'jsdom' تنظیم شده؟
  • @testing-library/jest-dom/vitest import شده؟
  • تست‌های userEvent async هستن؟
  • beforeEach DOM و localStorage رو پاک می‌کنه؟
  • از getByRole / getByLabelText استفاده شده؟
  • تست behavior رو چک می‌کنه، نه implementation؟

تیزر بخش ۶: Mock و Stub

توی مقاله بعدی، وارد بخش mock و test doubles می‌شیم:

  • Test Doubles — mock، spy، stub
  • Mocking fetch و درخواست‌های شبکه
  • Mock Service Worker (MSW) — mock API در تست
  • Mocking time و environment variables
  • مثال Task List با React و MSW
  • Dependency Injection به عنوان جایگزین mock

این لایه به ما اجازه می‌ده کد وابسته به API، زمان، یا سرویس خارجی رو ایزوله تست کنیم — بدون نیاز به سرور واقعی.


تست‌نویسی یک سفر یادگیری مستمره. با unit test پایه ساختید (بخش ۲-۳)، با Playwright E2E لایه مرورگر واقعی اضافه کردید (بخش ۴)، و حالا با Testing Library لایه سریع integration UI رو هم دارید.

موفق باشید و به تست‌نویسی ادامه بدید!

خب دوستان، امیدوارم این مقاله براتون مفید بوده باشه. Testing Library ابزاریه که هر روز توی پروژه‌های React و frontend استفاده می‌شه — تمرین کنید با مثال‌های این مقاله. توی مقاله بعدی سراغ mocking و MSW می‌ریم. تا بعد!


Queryهای پیشرفته‌تر Testing Library

توی دوره بیشتر روی getByRole و getByLabelText تمرکز می‌کنه. چند query دیگه هم مفیدن:

getByPlaceholderText

const input = screen.getByPlaceholderText('Enter your secret…');
await user.type(input, 'my secret');

queryBy در برابر getBy

// getBy — اگر پیدا نکنه، error می‌ده
screen.getByText('Error'); // throw اگر نباشه

// queryBy — اگر پیدا نکنه، null برمی‌گردونه
expect(screen.queryByText('Error')).not.toBeInTheDocument();

// findBy — async، منتظر ظاهر شدن می‌مونه
const message = await screen.findByText('Loading complete');

برای assert کردن عدم وجود المان، از queryBy استفاده کنید — نه getBy.

within — محدود کردن جستجو

import { within } from '@testing-library/dom';

const board = document.getElementById('board');
const cells = within(board).getAllByRole('button');

وقتی فقط داخل یه بخش از صفحه می‌خواید جستجو کنید.


ساختار پروژه پیشنهادی

dom-testing-app/
├── package.json
├── vitest.config.js
├── tests/
│ └── setupTests.js
├── src/
│ ├── button.js
│ ├── button.test.js
│ ├── secret-input.js
│ ├── secret-input.test.js
│ ├── counter.jsx
│ ├── counter.test.jsx
│ ├── game.js
│ ├── game.test.js
│ ├── ui.js
│ └── index.html
└── tests/
 └── ui.test.js

package.json نمونه

{
 "name": "dom-testing-app",
 "type": "module",
 "scripts": {
 "test": "vitest",
 "test:run": "vitest run"
 },
 "devDependencies": {
 "@testing-library/dom": "^10.0.0",
 "@testing-library/jest-dom": "^6.0.0",
 "@testing-library/react": "^16.0.0",
 "@testing-library/user-event": "^14.0.0",
 "@vitejs/plugin-react": "^4.0.0",
 "jsdom": "^25.0.0",
 "react": "^18.0.0",
 "react-dom": "^18.0.0",
 "vitest": "^2.0.0"
 }
}

تست منطق بازی جدا از UI

در مثال tic-tac-toe اول game logic رو با unit test می‌نویسه، بعد UI. این جداسازی مهمه:

// tests/game.test.js — unit test (بدون DOM)
import { describe, it, expect } from 'vitest';
import { createGame } from '../src/game.js';

describe('Tic Tac Toe Game Logic', () => {
 it('initializes a 3x3 game board', () => {
 const game = createGame();
 expect(game.board).toEqual([
 ['', '', ''],
 ['', '', ''],
 ['', '', ''],
 ]);
 });

 it('starts with player X', () => {
 expect(createGame().currentPlayer).toBe('X');
 });

 it('places the current player mark on the board', () => {
 const game = createGame();
 game.placeMove(0, 0);
 expect(game.board[0][0]).toBe('X');
 });

 it('switches to the next player after a move', () => {
 const game = createGame();
 game.placeMove(0, 0);
 expect(game.currentPlayer).toBe('O');
 });

 it('does not allow placing a move on an occupied spot', () => {
 const game = createGame();
 game.placeMove(0, 0);
 expect(() => game.placeMove(0, 0)).toThrow('Spot already taken');
 });

 it('detects a winning row', () => {
 const game = createGame();
 game.board = [
 ['X', 'X', 'X'],
 ['', '', ''],
 ['', '', ''],
 ];
 expect(game.checkWinner()).toBe('X');
 });

 it('returns true when the board is full and there is no winner', () => {
 const game = createGame();
 game.board = [
 ['X', 'O', 'X'],
 ['X', 'O', 'O'],
 ['O', 'X', 'X'],
 ];
 expect(game.isDraw()).toBe(true);
 });
});

هرم تست برای Tic-Tac-Toe

لایه فایل چی تست می‌کنه
Unit game.test.js placeMove, checkWinner, isDraw
Integration DOM ui.test.js کلیک، پیام برد، draw
E2E (بخش ۴) tic-tac-toe.spec.ts کل بازی در مرورگر واقعی

سه لایه، یک بازی — هر کدوم نقش خودش رو داره.


تمرین‌های پیشنهادی

چند تمرین پیشنهادی که می‌تونید بعد از خوندن این مقاله انجام بدید:

Exercise ۱: دکمه با Testing Library

تمرین: دکمه createButton رو با Testing Library و userEvent refactor کنید.

Exercise ۲: Accident Counter

تمرین: تست‌های Counter رو کامل کنید:

  • نمایش "days" وقتی count صفر است
  • increment و decrement
  • نمایش "day" وقتی count یک است
  • reset
  • disable دکمه‌ها در صفر
  • آپدیت document.title

Exercise ۳: Tic-Tac-Toe UI

تمرین: تست UI بازی رو با Testing Library بنویسید (بخش بالای این مقاله).


مقایسه سریع ابزارهای این سری

ابزار بخش محیط سرعت
Vitest + pure Node ۲-۳ Node خیلی سریع
Vitest + jsdom + Testing Library ۵ شبیه‌سازی DOM سریع
Playwright ۴ مرورگر واقعی کند

همه با هم در یه پروژه واقعی:

{
 "scripts": {
 "test": "vitest",
 "test:unit": "vitest run --exclude '**/ui.test.js' --exclude '**/counter.test.jsx'",
 "test:dom": "vitest run tests/ui.test.js src/**/*.test.jsx",
 "test:e2e": "playwright test"
 }
}

جمع‌بندی نهایی

این مسیر برای یادگیری تست DOM پیشنهاد می‌شود:

  1. محیط DOM رو در Vitest راه بنداز (jsdom)
  2. با DOM خام شروع کن (createButton)
  3. localStorage و تعامل DOM (createSecretInput)
  4. به Testing Library مهاجرت کن (queryهای accessible)
  5. userEvent برای تعامل واقعی‌تر
  6. jest-dom برای assertionهای DOM
  7. React — تفاوت کم با vanilla JS
  8. پروژه کامل — Tic-Tac-Toe UI

ما همین مسیر رو توی این مقاله طی کردیم. قدم بعدی: mock و test doubles — وقتی کد به API، زمان، یا dependency خارجی وابسته است و باید mock کنیم.

خب دوستان، تا بخش بعدی!