React: اختبار الوحدات (Vitest + React Testing Library)
آخر تحديث: 2026-08-26
أثناء إعادة هيكلة مكون قديم، قام توم «عن غير قصد» بتغيير منطق الحالة الداخلية، مما تسبب في حدوث مشكلات في واجهة المستخدم في ثلاث صفحات تعتمد على ذلك المكون. ونظرًا لعدم وجود اختبارات وحدة، لم يتم اكتشاف هذه المشكلة إلا خلال اختبار ضمان الجودة، مما أدى إلى إهدار وقت الدورة الكاملة للفريق. قرر توم إدخال Vitest وReact Testing Library إلى المشروع للاستفادة من الاختبارات الآلية لضمان ألا يؤدي أي تغيير إلى تعطيل الوظائف الحالية.
1. ما ستتعلمه
- تثبيت Vitest وتهيئته (بيئة jsdom، setupFiles)
- الاستخدام المنسق لواجهات برمجة التطبيقات (API) الثلاثة الرئيسية الخاصة بالاختبار: render و screen و userEvent
- اختبار التحقق من صحة خصائص المكونات ووظائف الاستدعاء عند وقوع الأحداث
- اختبار حالة التحميل للمكونات غير المتزامنة (findBy / waitFor)
- محاكاة التبعيات الخارجية (طلبات واجهة برمجة التطبيقات، الوحدات النمطية)
2. المخططات المفاهيمية
يوضح الرسم البياني التالي دور الاختبارات الوحدوية وعلاقاتها في تطوير مكونات React:
flowchart LR
A[Creating Components] --> B[Writing Tests]
B --> C{Run Test}
C -->|Through| D[Submit Code]
C -->|Failure| E[Positioning Bug]
E --> F{Error Type}
F -->|Rendering Issues| G[getByText / getByRole]
F -->|Interaction Issues| H[userEvent.click]
F -->|Asynchronous Issues| I[findByText / waitFor]
F -->|External Dependencies| J[vi.mock / vi.fn]
G --> A
H --> A
I --> A
J --> A
style B fill:#e3f2fd,stroke:#1565c0
style D fill:#e8f5e9,stroke:#2e7d32
style E fill:#fff3e0,stroke:#e65100
3. سيناريو واقعي
يحتوي فريق توم على مكون UserCard يعرض معلومات المستخدم. ويستقبل هذا المكون كائنًا من نوع user ووظيفة استدعاء مرتدة من نوع onFollow، ويقوم بتغيير نمط الزر بناءً على الحالة isFollowing.
أثناء عملية إعادة هيكلة الكود، قام توم بتغيير القيم الأولية للحالة الداخلية، مما أدى إلى تعيين isFollowing على القيمة الافتراضية true — ونتيجة لذلك، تم ضبط جميع بطاقات المستخدمين بحيث تعرض كلمة «متابعة» بشكل افتراضي. ولم يتم اكتشاف هذا الخطأ إلا خلال اختبار القبول الذي أجراه مدير المنتج.
لو كانت الاختبارات مطبقة في ذلك الوقت، لكان قد تم اكتشاف مشكلة التراجع هذه في غضون ثوانٍ خلال مرحلة npm test. قرر توم إضافة اختبارات وحدة لجميع المكونات الأساسية.
(1) إعداد البيئة — إعداد البنية التحتية للاختبار
أولاً، قم بتثبيت جميع المكونات التبعية:
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
تكوين Vitest (إضافة الحقل test إلى vite.config.ts):
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
// Usage jsdom Simulate a browser environment
environment: 'jsdom',
// Global Registration describe / test / expect,No manual import required
globals: true,
// Configuration files that run before the test starts
setupFiles: './src/test/setup.ts',
},
})
إنشاء ملف إعداد:
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
// This line allows toBeInTheDocument()、toHaveTextContent() Assertions are available
أضف نصوصًا برمجية للاختبار إلى package.json:
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}
(2) ثلاث واجهات برمجة تطبيقات أساسية
فلسفة الاختبار في مكتبة React Testing Library: لا تختبر تفاصيل التنفيذ؛ بل اختبر فقط ما يمكن للمستخدمين رؤيته والتفاعل معه.
| واجهة برمجة التطبيقات (API) | الغرض | الميزات |
|---|---|---|
render(component) |
عرض المكونات في DOM الافتراضي | إرجاع مراجع الحاويات والأساليب المساعدة |
screen |
نقطة الدخول للبحث عن العناصر العالمية | توفر ثلاثة أنواع من الطرق: getBy و findBy و queryBy |
userEvent |
محاكاة تصرفات المستخدم | أكثر واقعية من fireEvent |
المبدأ الأساسي: استخدم
getByRoleأولاً (الدلالة)، ثمgetByTextثانيًا، وأخيرًاgetByTestId.
▶ المثال 1: اختبار عرض مكون «Counter» وتفاعليته
أولاً، دعونا نكتب مكون «Counter» بسيطًا:
// Counter.tsx
import { useState } from 'react'
interface CounterProps {
initialCount?: number
step?: number
label?: string
}
export function Counter({
initialCount = 0,
step = 1,
label = 'Count',
}: CounterProps) {
const [count, setCount] = useState(initialCount)
return (
<div>
<p>
{label}:{count}
</p>
<button onClick={() => setCount(c => c + step)}>+{step}</button>
<button onClick={() => setCount(c => c - step)} disabled={count <= 0}>
-{step}
</button>
{count >= 10 && (
<p role="alert">Maximum Value Reached Alert</p>
)}
</div>
)
}
اختبارات الكتابة:
// Counter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from './Counter'
describe('Counter Components', () => {
// Test 1:Initial Rendering
test('Display the initial counter value', () => {
render(<Counter initialCount={5} />)
// getByText — Search by text content
expect(screen.getByText('Count:5')).toBeInTheDocument()
})
// Test 2:Click the "Add" button
test('Click +1 The count increases after the button is pressed', async () => {
const user = userEvent.setup()
render(<Counter initialCount={0} step={1} />)
const incrementBtn = screen.getByRole('button', { name: '+1' })
await user.click(incrementBtn)
expect(screen.getByText('Count:1')).toBeInTheDocument()
})
// Test 3:The count is 0 "Time Decrease" Button Disabled
test('The count is 0 "Time Decrease" Button Disabled', () => {
render(<Counter initialCount={0} />)
const decrementBtn = screen.getByRole('button', { name: '-1' })
expect(decrementBtn).toBeDisabled()
})
// Test 4:Display a reminder when the threshold is reached
test('Count reached 10 Display reminders', async () => {
const user = userEvent.setup()
render(<Counter initialCount={9} step={1} />)
// No reminder at the beginning
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
// Click to add 1
await user.click(screen.getByRole('button', { name: '+1' }))
// Now there's a reminder
expect(screen.getByRole('alert')).toHaveTextContent('Maximum Value Reached Alert')
})
// Test 5:Custom label and step
test('Supports customization label and step', async () => {
const user = userEvent.setup()
render(<Counter initialCount={0} step={5} label="Number of steps" />)
expect(screen.getByText('Number of steps:0')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '+5' }))
expect(screen.getByText('Number of steps:5')).toBeInTheDocument()
})
})
يوضح هذا الاختبار أربعة أوضاع:
getByText— البحث عن العناصر التي تحتوي على النص المحدد (الطريقة الأبسط والأكثر مباشرة)getByRole— البحث حسب دور ARIA؛ يوفر الخيارnameمطابقة تامة لنص الزرqueryByRole— يبحث عن عنصر قد لا يكون موجودًا، ويعيدnullبدلاً من إثارة استثناءtoBeDisabled()/toHaveTextContent()— تأكيدات دلالية مقدمة من jest-dom
(3) اختبار الدعائم ووظائف الاستدعاء عند وقوع الأحداث
تتلقى المكونات عادةً البيانات ووظائف الاستدعاء المرتد عبر «props». والهدف من الاختبار هو التحقق من أن وظائف الاستدعاء المرتد يتم استدعاؤها بشكل صحيح، وأن المعلمات صحيحة.
▶ المثال 2: اختبار الخصائص والأحداث لمكون TodoItem
// TodoItem.tsx
interface Todo {
id: number
text: string
completed: boolean
}
interface TodoItemProps {
todo: Todo
onToggle: (id: number) => void
onDelete: (id: number) => void
}
export function TodoItem({ todo, onToggle, onDelete }: TodoItemProps) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 12px',
background: todo.completed ? '#f6ffed' : '#fff',
borderRadius: 6,
border: '1px solid #f0f0f0',
}}
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
aria-label={`Mark ${todo.text}`}
/>
<span
style={{
flex: 1,
textDecoration: todo.completed ? 'line-through' : 'none',
color: todo.completed ? '#999' : '#333',
}}
>
{todo.text}
</span>
<button
onClick={() => onDelete(todo.id)}
aria-label={`Delete ${todo.text}`}
style={{
border: 'none',
background: '#ff4d4f',
color: '#fff',
borderRadius: 4,
padding: '2px 8px',
cursor: 'pointer',
fontSize: 12,
}}
>
Delete
</button>
</div>
)
}
// TodoItem.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TodoItem } from './TodoItem'
describe('TodoItem Components', () => {
const mockTodo = {
id: 42,
text: 'Study React Test',
completed: false,
}
test('Format the to-do list text', () => {
render(
<TodoItem
todo={mockTodo}
onToggle={vi.fn()}
onDelete={vi.fn()}
/>
)
expect(screen.getByText('Study React Test')).toBeInTheDocument()
})
test('Completed items are displayed with a strikethrough', () => {
render(
<TodoItem
todo={{ ...mockTodo, completed: true }}
onToggle={vi.fn()}
onDelete={vi.fn()}
/>
)
const text = screen.getByText('Study React Test')
expect(text).toHaveStyle('text-decoration: line-through')
})
test('Triggered by clicking the checkbox onToggle', async () => {
const onToggle = vi.fn()
const user = userEvent.setup()
render(
<TodoItem
todo={mockTodo}
onToggle={onToggle}
onDelete={vi.fn()}
/>
)
await user.click(screen.getByRole('checkbox'))
expect(onToggle).toHaveBeenCalledTimes(1)
expect(onToggle).toHaveBeenCalledWith(42) // The validation parameters are todo.id
})
test('Triggered by clicking the Delete button onDelete', async () => {
const onDelete = vi.fn()
const user = userEvent.setup()
render(
<TodoItem
todo={mockTodo}
onToggle={vi.fn()}
onDelete={onDelete}
/>
)
await user.click(screen.getByRole('button', { name: /Delete/ }))
expect(onDelete).toHaveBeenCalledWith(42)
})
test('Unfinished items are not struck through', () => {
render(
<TodoItem
todo={mockTodo}
onToggle={vi.fn()}
onDelete={vi.fn()}
/>
)
const text = screen.getByText('Study React Test')
// Note:Inline styles text-decoration as 'none',rather than not having this property
expect(text).not.toHaveStyle('text-decoration: line-through')
})
})
الاستخدامات الرئيسية لدالة Mock vi.fn():
| واجهة برمجة التطبيقات (API) | الدالة |
|---|---|
vi.fn() |
إنشاء دالة وهمية فارغة |
toHaveBeenCalledTimes(n) |
تم التحقق منه n مرات |
toHaveBeenCalledWith(...) |
التحقق من المعلمات أثناء المكالمة |
vi.fn().mockResolvedValue(x) |
محاكاة استجابة نجاح غير متزامنة |
vi.fn().mockRejectedValue(e) |
محاكاة استجابات الفشل غير المتزامنة |
(4) اختبار المكونات غير المتزامنة
تعرض العديد من المكونات في البداية عبارة «جاري التحميل...» أثناء التحميل، ثم تعرض المحتوى بمجرد وصول البيانات. يتعين عليك استخدام findBy (الانتظار غير المتزامن) لاختبار هذه الأنواع من السيناريوهات.
▶ المثال 3: اختبار مكون تحميل البيانات غير المتزامن
// UserProfile.tsx
interface UserProfileProps {
userId: number
}
interface UserData {
id: number
name: string
email: string
}
// Simulation API Call
async function fetchUser(id: number): Promise<UserData> {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error('Failed to load')
return res.json()
}
export function UserProfile({ userId }: UserProfileProps) {
const [user, setUser] = useState<UserData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
setError(null)
try {
const data = await fetchUser(userId)
if (!cancelled) setUser(data)
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Unknown error')
}
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => { cancelled = true }
}, [userId])
if (loading) return <div aria-label="Loading...">Loading......</div>
if (error) return <div role="alert">Error:{error}</div>
if (!user) return <div>No data</div>
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
)
}
// UserProfile.test.tsx
import { render, screen } from '@testing-library/react'
import { UserProfile } from './UserProfile'
// Mock out fetchUser module
vi.mock('./UserProfile', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
// Rewrite fetchUser Implementation
fetchUser: vi.fn(),
}
})
// A Better Approach:Separately mock API Module
// vi.mock('../api', () => ({
// fetchUser: vi.fn()
// }))
describe('UserProfile Asynchronous Components', () => {
beforeEach(() => {
vi.clearAllMocks()
})
test('Display "Loading" while loading', () => {
// let fetch All along pending
vi.spyOn(global, 'fetch').mockImplementation(
() => new Promise(() => {}) // Never resolve
)
render(<UserProfile userId={1} />)
expect(screen.getByLabelText('Loading...')).toBeInTheDocument()
})
test('Display user information after successful loading', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' }
// Mock fetch Return successful data
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => mockUser,
} as Response)
render(<UserProfile userId={1} />)
// findByText — Waiting Asynchronously for an Element to Appear(Default Timeout 1000ms)
expect(await screen.findByText('Alice')).toBeInTheDocument()
expect(screen.getByText('alice@example.com')).toBeInTheDocument()
})
test('Display an error message when loading fails', async () => {
// Mock fetch Back 500
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
} as Response)
render(<UserProfile userId={1} />)
// Wait for an error message to appear
expect(await screen.findByRole('alert')).toHaveTextContent('Error:')
})
test('The state is not updated when the component is unmounted(Preventing Memory Leaks)', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' }
let resolvePromise!: (value: any) => void
vi.spyOn(global, 'fetch').mockReturnValue(
new Promise((resolve) => {
resolvePromise = resolve
})
)
const { unmount } = render(<UserProfile userId={1} />)
// in fetch Uninstall Components Before Completion
unmount()
// At this moment resolve,But the component has been uninstalled,Probably not. setState
resolvePromise({
ok: true,
json: async () => mockUser,
} as Response)
// I didn't make a mistake = Test Passed
})
})
مقارنة بين الطرق الثلاث الأساسية للاختبار غير المتزامن:
| الطريقة | متزامنة/غير متزامنة | في حالة عدم وجود العنصر | المهلة الافتراضية | حالة الاستخدام |
|---|---|---|---|---|
getByText |
متزامن | إصدار خطأ فوري | - | يجب أن يكون العنصر موجودًا |
queryByText |
مزامنة | العودة إلى null |
- | العنصر غير موجود |
findByText |
غير متزامن (Promise) | إصدار خطأ بعد انتهاء المهلة | 1000 مللي ثانية | انتظار العرض غير المتزامن |
(5) أفضل الممارسات في محاكاة التبعيات الخارجية
في المشاريع العملية، غالبًا ما تعتمد المكونات على واجهات برمجة التطبيقات (APIs) والتوجيه وإدارة الحالة. ويُعد محاكاة هذه التبعيات أمرًا أساسيًّا في عملية الاختبار.
الطريقة الأولى: محاكاة المتغير العام fetch
// Replace the global variable before each test fetch
beforeEach(() => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ data: 'mock' }),
} as Response)
})
afterEach(() => {
vi.restoreAllMocks() // Restore to Original fetch
})
الطريقة الثانية: الوحدة النمطية الوهمية
// api.ts — Real Module
export async function fetchUsers() {
const res = await fetch('/api/users')
return res.json()
}
// Testing... Mock
vi.mock('../api', () => ({
fetchUsers: vi.fn().mockResolvedValue([
{ id: 1, name: 'Mock User' },
])
}))
الطريقة الثالثة: محاكاة React Router
import { MemoryRouter } from 'react-router-dom'
test('Rendering in the route context', () => {
render(
<MemoryRouter initialEntries={['/users/1']}>
<UserDetailPage />
</MemoryRouter>
)
})
▶ المثال 4: اختبار «هوك» مخصص
import { renderHook, act } from '@testing-library/react'
import { useState, useCallback } from 'react'
function useCounter(initial = 0) {
const [count, setCount] = useState(initial)
const increment = useCallback(() => setCount(c => c + 1), [])
const decrement = useCallback(() => setCount(c => c - 1), [])
const reset = useCallback(() => setCount(initial), [initial])
return { count, increment, decrement, reset }
}
describe('useCounter', () => {
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
test('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
test('increments counter', () => {
const { result } = renderHook(() => useCounter())
act(() => result.current.increment())
expect(result.current.count).toBe(1)
})
test('decrements counter', () => {
const { result } = renderHook(() => useCounter(5))
act(() => result.current.decrement())
expect(result.current.count).toBe(4)
})
test('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10))
act(() => result.current.increment())
act(() => result.current.reset())
expect(result.current.count).toBe(10)
})
})
▶ المثال 5: اختبار التكامل — عملية إرسال النموذج
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
async function handleSubmit(e) {
e.preventDefault()
if (!email.includes('@')) { setError('Invalid email'); return }
if (password.length < 6) { setError('Password too short'); return }
setError('')
await onSubmit({ email, password })
}
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="Email" data-testid="email" />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Password" data-testid="password" />
{error && <p data-testid="error">{error}</p>}
<button type="submit">Login</button>
</form>
)
}
describe('LoginForm integration', () => {
test('shows error for invalid email', async () => {
render(<LoginForm onSubmit={jest.fn()} />)
await userEvent.type(screen.getByTestId('email'), 'invalid')
await userEvent.type(screen.getByTestId('password'), 'password123')
await userEvent.click(screen.getByRole('button', { name: /login/i }))
expect(screen.getByTestId('error')).toHaveTextContent('Invalid email')
})
test('calls onSubmit with valid data', async () => {
const onSubmit = jest.fn().mockResolvedValue(undefined)
render(<LoginForm onSubmit={onSubmit} />)
await userEvent.type(screen.getByTestId('email'), 'alice@test.com')
await userEvent.type(screen.getByTestId('password'), 'secure123')
await userEvent.click(screen.getByRole('button', { name: /login/i }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({ email: 'alice@test.com', password: 'secure123' })
})
})
})
❓ أسئلة شائعة
getBy عندما يكون وجود العنصر مؤكدًا (يتم إلقاء خطأ في حالة عدم العثور عليه)؛ واستخدم queryBy عندما يكون من المحتمل ألا يكون العنصر موجودًا (تُرجع القيمة null)؛ استخدم findBy عندما يتم عرض العنصر بشكل غير متزامن (تُرجع Promise؛ ويتم إصدار خطأ بعد انتهاء المهلة). لكل طريقة متغيرات Role/Text/TestId المقابلة، مثل getByRole وfindByText وqueryByTestId.userEvent أم fireEvent؟userEvent كلما أمكن ذلك. fireEvent هي واجهة برمجة تطبيقات (API) منخفضة المستوى تُطلق أحداث DOM مباشرةً. userEvent تحاكي تسلسلًا كاملاً من إجراءات المستخدم فوق fireEvent (على سبيل المثال، «النقر» يشمل mousedown → mouseup → click)، وهو ما يتطابق بشكل أوثق مع سلوك المتصفح الفعلي. لا يجب اللجوء إلى fireEvent إلا في حالة العمليات التي لا تدعمها userEvent.vi.mock('./ExpensiveChart', () => () => <div>Mock Chart</div>).beforeEach؟afterEach(() => { vi.clearAllMocks() }). يقوم Vitest تلقائيًا بإلغاء تحميل المكونات بعد كل عملية عرض، ولكن يجب عليك تنظيف حالة النموذج الوهمي يدويًّا. إذا كنت تستخدم نموذجًا وهميًّا عالميًّا للجلب، فاستخدم vi.restoreAllMocks() لاستعادة التنفيذ الأصلي.--coverage في Vitest إنشاء تقرير تغطية Istanbul.📖 ملخص
- تُعد مكتبة Vitest + React Testing Library المزيج القياسي لاختبار الوحدات في React
- تغطي واجهات برمجة التطبيقات (API) الثلاث الرئيسية — render و screen و userEvent — العملية الكاملة لعرض المكونات والبحث عنها والتفاعل معها.
- تخدم طرق الاستعلام الثلاث — getBy (متزامنة، مضمونة الوجود)، وqueryBy (متزامنة، قد لا تكون موجودة)، وfindBy (غير متزامنة، قيد الانتظار) — غرضًا محددًا لكل منها.
- تُنشئ الدالة vi.fn() دالة وهمية للتحقق من استدعاءات الدوال المرتدة؛ بينما تُحاكي الدالة vi.spyOn() الدوال العالمية
- عند اختبار المكونات غير المتزامنة، استخدم
findByأوwaitForللانتظار حتى يتم عرض واجهة المستخدم بشكل غير متزامن - لا تركز الاختبارات الجيدة على تفاصيل التنفيذ؛ بل تقتصر على التحقق من السلوك الذي يلاحظه المستخدم.
📝 تمارين
- اكتب اختبارات شاملة لمكون
Button: تحقق من أن النقر عليه يؤدي إلى تشغيلonClickوdisabled، وأنه يصبح غير قابل للنقر، وأن النص الصحيح يُعرض، وأن اسم الفئة المخصص (className) يسري مفعوله. قم بتغطية 4 حالات اختبار على الأقل. - اكتب اختبارات لمكون
UserProfile: يجب أن يتحقق الاختبار من أن حالة التحميل تعرض عبارة «جاري التحميل»، وأن حالة النجاح تعرض معلومات المستخدم (مع المعالجة غير المتزامنة)، وأن حالة الفشل تعرض رسالة خطأ. استخدمvi.spyOnلمحاكاة واجهة برمجة التطبيقات (API) الخاصة بالاسترجاع. - اكتب اختبارات التكامل لمكون
TodoApp(بما في ذلك TodoList و TodoItem ونموذج AddTodo): اختبر الوظائف الثلاث — إضافة مهمة جديدة، ووضع علامة «مكتمل» على مهمة، وحذف مهمة — للتحقق من أن واجهة المستخدم الخاصة بالقائمة يتم تحديثها بشكل صحيح.