تستنویسی در 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)
سه نکته مهم داره:
هنوز مرورگر واقعی نیست. JSDOM طوری طراحی شده که مثل مرورگر رفتار کنه، نه اینکه باشه مرورگر. subtletyهای Chrome یا Safari رو نداره. (برای اون Playwright داریم — بخش ۴.)
Performance. تست با jsdom از تست pure Node کندتره — هزینه emulate کردن DOM.
مشکلات 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 میشه. ولی دو مشکل داره:
- مقدار input رو دستی set میکنیم — نه مثل تایپ کاربر
- روی 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 عمل میکنن
دو بخش اصلی
- فریمورک-specific flavors:
@testing-library/dom،@testing-library/react،@testing-library/vueو... - 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 این ترتیب رو توصیه میکنن:
getByRolegetByLabelTextgetByPlaceholderTextgetByTextgetByDisplayValuegetByAltTextgetByTitlegetByTestId(آخرین راه)
نکته مهم: این اولویتها دقیقاً همون فلسفهایه که توی بخش ۴ با
getByRoleدر Playwright داشتیم — در هر دو لایه همین رویکرد توصیه میشود.
fireEvent در برابر userEvent
در user-event تفاوت این دو رو توضیح میده.
مشکل fireEvent
fireEvent یه event خام رو روی DOM node شلیک میکنه. ولی تعامل واقعی کاربر یک event نیست. وقتی کاربر توی input تایپ میکنه:
- ممکنه اول روی field کلیک کنه (
click,focus) - یه کلید فشار بده (
keydown,keypress) - کلید رو رها کنه (
keyup) changeevent روی 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 درست انجام میشه
خلاصه آنچه یاد گرفتیم
- JSDOM / Happy DOM — شبیهسازی DOM در Node برای Vitest
- اولین تست DOM —
createButtonبا click - localStorage — emulate مرورگر بدون mock
- Testing Library — query از دید کاربر (
getByRole,getByLabelText) - userEvent — شبیهسازی واقعیتر از
fireEvent - jest-dom matchers —
toHaveTextContent,toBeDisabled,... - Counter React — مثال کامل با userEvent
- Tic-Tac-Toe UI — همان بازی بخش ۴، لایه سریعتر
چکلیست تست DOM
قبل از merge:
-
environment: 'jsdom'تنظیم شده؟ -
@testing-library/jest-dom/vitestimport شده؟ - تستهای userEvent
asyncهستن؟ -
beforeEachDOM و 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 پیشنهاد میشود:
- محیط DOM رو در Vitest راه بنداز (jsdom)
- با DOM خام شروع کن (
createButton) - localStorage و تعامل DOM (
createSecretInput) - به Testing Library مهاجرت کن (queryهای accessible)
- userEvent برای تعامل واقعیتر
- jest-dom برای assertionهای DOM
- React — تفاوت کم با vanilla JS
- پروژه کامل — Tic-Tac-Toe UI
ما همین مسیر رو توی این مقاله طی کردیم. قدم بعدی: mock و test doubles — وقتی کد به API، زمان، یا dependency خارجی وابسته است و باید mock کنیم.
خب دوستان، تا بخش بعدی!