10.6 Vitest הרצאה
קונספט בדיקות ו-Vitest - Testing Concepts and Vitest¶
בשיעור זה נכנס לעולם הבדיקות. נלמד למה בדיקות חשובות, מה הם סוגי הבדיקות השונים, ונתחיל לכתוב בדיקות יחידה עם Vitest.
למה לבדוק - Why Test¶
- בדיקות מוודאות שהקוד עובד כמצופה
- מאפשרות שינויים בביטחון - אם משהו נשבר, הבדיקה תתפוס
- משמשות כתיעוד חי - מראות איך הקוד אמור לעבוד
- חוסכות זמן בטווח הארוך - מציאת באגים מוקדם זולה יותר
- משפרות את איכות הקוד - קוד שקל לבדוק הוא בדרך כלל קוד מעוצב היטב
פירמידת הבדיקות - Testing Pyramid¶
/\
/ \
/ E2E \ ← מעט בדיקות, יקרות, איטיות
/--------\
/Integration\ ← כמות בינונית
/--------------\
/ Unit Tests \ ← הרבה בדיקות, זולות, מהירות
/-------------------\
בדיקות יחידה - Unit Tests¶
- בודקות פונקציה או מודול בודד בבידוד
- מהירות מאוד (מילישניות)
- קל לכתוב ולתחזק
- דוגמאות: פונקציות utility, לוגיקה עסקית, helpers
בדיקות אינטגרציה - Integration Tests¶
- בודקות שמספר חלקים עובדים יחד
- בודקות קומפוננטות React עם ה-DOM
- יותר איטיות מבדיקות יחידה, אבל עדיין מהירות
- דוגמאות: קומפוננטה שמשתמשת ב-API, טופס עם validation
בדיקות מקצה לקצה - E2E Tests¶
- בודקות את האפליקציה כמו שמשתמש אמיתי היה משתמש
- רצות בדפדפן אמיתי
- איטיות יחסית
- דוגמאות: תהליך הרשמה מלא, רכישה בחנות
פיתוח מונחה בדיקות - TDD¶
TDD הוא גישת פיתוח שבה כותבים את הבדיקה לפני הקוד:
- אדום - כתוב בדיקה שנכשלת (הקוד עדיין לא קיים)
- ירוק - כתוב את הקוד המינימלי שגורם לבדיקה לעבור
- שיפור - שפר את הקוד (refactor) תוך שמירה על בדיקות עוברות
// שלב 1: כתיבת בדיקה (אדום)
test('should calculate total with tax', () => {
expect(calculateTotal(100, 0.17)).toBe(117);
});
// שלב 2: קוד מינימלי (ירוק)
function calculateTotal(price: number, taxRate: number): number {
return price + price * taxRate;
}
// שלב 3: שיפור (refactor)
function calculateTotal(price: number, taxRate: number): number {
const tax = price * taxRate;
return Math.round((price + tax) * 100) / 100; // עיגול ל-2 ספרות
}
הגדרת Vitest בפרויקט Vite¶
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true, // מאפשר describe, it, expect ללא import
environment: 'jsdom', // סביבת DOM לבדיקות React
setupFiles: './src/test/setup.ts',
css: true, // תמיכה בייבוא CSS
},
});
// package.json - סקריפטים
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}
כתיבת בדיקות יחידה - Unit Tests¶
מבנה בסיסי¶
// math.ts
export function add(a: number, b: number): number {
return a + b;
}
export function multiply(a: number, b: number): number {
return a * b;
}
export function divide(a: number, b: number): number {
if (b === 0) throw new Error('Cannot divide by zero');
return a / b;
}
// math.test.ts
import { describe, it, expect } from 'vitest';
import { add, multiply, divide } from './math';
describe('Math utilities', () => {
describe('add', () => {
it('should add two positive numbers', () => {
expect(add(2, 3)).toBe(5);
});
it('should handle negative numbers', () => {
expect(add(-1, -2)).toBe(-3);
});
it('should handle zero', () => {
expect(add(5, 0)).toBe(5);
});
});
describe('multiply', () => {
it('should multiply two numbers', () => {
expect(multiply(3, 4)).toBe(12);
});
it('should return 0 when multiplied by 0', () => {
expect(multiply(5, 0)).toBe(0);
});
});
describe('divide', () => {
it('should divide two numbers', () => {
expect(divide(10, 2)).toBe(5);
});
it('should throw when dividing by zero', () => {
expect(() => divide(10, 0)).toThrow('Cannot divide by zero');
});
});
});
Matchers - פונקציות השוואה¶
השוואות בסיסיות¶
// שוויון מדויק (===)
expect(2 + 2).toBe(4);
expect('hello').toBe('hello');
// שוויון עמוק (לאובייקטים ומערכים)
expect({ name: 'דני' }).toEqual({ name: 'דני' });
expect([1, 2, 3]).toEqual([1, 2, 3]);
// לא שווה
expect(2 + 2).not.toBe(5);
בדיקות truthiness¶
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect(value).toBeDefined();
expect(true).toBeTruthy();
expect(0).toBeFalsy();
מספרים¶
expect(10).toBeGreaterThan(5);
expect(5).toBeGreaterThanOrEqual(5);
expect(3).toBeLessThan(5);
expect(0.1 + 0.2).toBeCloseTo(0.3); // לבעיות floating point
מחרוזות¶
expect('Hello World').toContain('World');
expect('team@example.com').toMatch(/^\S+@\S+\.\S+$/); // regex
מערכים ואובייקטים¶
// מערך מכיל ערך
expect([1, 2, 3]).toContain(2);
// מערך מכיל אובייקט
expect([{ id: 1 }, { id: 2 }]).toContainEqual({ id: 1 });
// אובייקט מכיל מפתחות
expect({ name: 'דני', age: 25 }).toHaveProperty('name');
expect({ name: 'דני', age: 25 }).toHaveProperty('age', 25);
// אובייקט מכיל חלק מהמפתחות
expect({ name: 'דני', age: 25, city: 'תל אביב' }).toMatchObject({
name: 'דני',
age: 25,
});
חריגות¶
// בדיקה שפונקציה זורקת שגיאה
expect(() => throwingFunction()).toThrow();
expect(() => throwingFunction()).toThrow('error message');
expect(() => throwingFunction()).toThrow(TypeError);
Setup ו-Teardown¶
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
describe('Database operations', () => {
// רץ פעם אחת לפני כל הבדיקות בבלוק
beforeAll(async () => {
await connectToDatabase();
});
// רץ פעם אחת אחרי כל הבדיקות בבלוק
afterAll(async () => {
await disconnectFromDatabase();
});
// רץ לפני כל בדיקה בודדת
beforeEach(async () => {
await clearDatabase();
await seedDatabase();
});
// רץ אחרי כל בדיקה בודדת
afterEach(() => {
vi.restoreAllMocks(); // שחזור mocks
});
it('should create a user', async () => {
const user = await createUser({ name: 'דני' });
expect(user.name).toBe('דני');
});
it('should find a user by id', async () => {
const user = await findUser(1);
expect(user).toBeDefined();
});
});
בדיקת קוד אסינכרוני¶
Promises¶
// async/await - הדרך המומלצת
it('should fetch user data', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('דני');
});
// resolves / rejects
it('should resolve with user data', () => {
return expect(fetchUser(1)).resolves.toEqual({ id: 1, name: 'דני' });
});
it('should reject when user not found', () => {
return expect(fetchUser(999)).rejects.toThrow('User not found');
});
Callbacks (פחות נפוץ)¶
it('should call callback with data', (done) => {
fetchUserCallback(1, (error, user) => {
expect(error).toBeNull();
expect(user.name).toBe('דני');
done(); // סימון שהבדיקה הסתיימה
});
});
Timers¶
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
describe('Timer functions', () => {
beforeEach(() => {
vi.useFakeTimers(); // שימוש בטיימרים מזויפים
});
afterEach(() => {
vi.useRealTimers(); // חזרה לטיימרים אמיתיים
});
it('should call function after delay', () => {
const callback = vi.fn();
setTimeout(callback, 1000);
expect(callback).not.toHaveBeenCalled();
vi.advanceTimersByTime(1000); // קפיצה קדימה ב-1000ms
expect(callback).toHaveBeenCalledTimes(1);
});
it('should debounce function calls', () => {
const fn = vi.fn();
const debouncedFn = debounce(fn, 300);
debouncedFn();
debouncedFn();
debouncedFn();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledTimes(1);
});
});
דוגמה מלאה - בדיקות לפונקציות Utility¶
// utils/validation.ts
export function validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
export function validatePassword(password: string): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];
if (password.length < 8) {
errors.push('Password must be at least 8 characters');
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain an uppercase letter');
}
if (!/[a-z]/.test(password)) {
errors.push('Password must contain a lowercase letter');
}
if (!/[0-9]/.test(password)) {
errors.push('Password must contain a number');
}
return { valid: errors.length === 0, errors };
}
export function formatPrice(price: number, currency: string = 'ILS'): string {
return new Intl.NumberFormat('he-IL', {
style: 'currency',
currency,
}).format(price);
}
// utils/validation.test.ts
import { describe, it, expect } from 'vitest';
import { validateEmail, validatePassword, formatPrice } from './validation';
describe('validateEmail', () => {
it('should return true for valid emails', () => {
expect(validateEmail('user@example.com')).toBe(true);
expect(validateEmail('user.name@domain.co.il')).toBe(true);
expect(validateEmail('user+tag@example.com')).toBe(true);
});
it('should return false for invalid emails', () => {
expect(validateEmail('')).toBe(false);
expect(validateEmail('user')).toBe(false);
expect(validateEmail('user@')).toBe(false);
expect(validateEmail('@domain.com')).toBe(false);
expect(validateEmail('user @domain.com')).toBe(false);
});
});
describe('validatePassword', () => {
it('should accept a valid password', () => {
const result = validatePassword('MyPass123');
expect(result.valid).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('should reject short password', () => {
const result = validatePassword('Ab1');
expect(result.valid).toBe(false);
expect(result.errors).toContain('Password must be at least 8 characters');
});
it('should require uppercase letter', () => {
const result = validatePassword('mypass123');
expect(result.valid).toBe(false);
expect(result.errors).toContain('Password must contain an uppercase letter');
});
it('should require lowercase letter', () => {
const result = validatePassword('MYPASS123');
expect(result.valid).toBe(false);
expect(result.errors).toContain('Password must contain a lowercase letter');
});
it('should require a number', () => {
const result = validatePassword('MyPassword');
expect(result.valid).toBe(false);
expect(result.errors).toContain('Password must contain a number');
});
it('should return multiple errors', () => {
const result = validatePassword('abc');
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(1);
});
});
describe('formatPrice', () => {
it('should format price in ILS', () => {
const formatted = formatPrice(1299);
expect(formatted).toContain('1,299');
});
it('should handle zero', () => {
const formatted = formatPrice(0);
expect(formatted).toContain('0');
});
it('should handle decimal prices', () => {
const formatted = formatPrice(49.99);
expect(formatted).toContain('49.99');
});
});
הרצת בדיקות¶
# הרצת כל הבדיקות
npx vitest
# הרצת בדיקות במצב watch (מריץ מחדש בכל שינוי)
npx vitest --watch
# הרצת בדיקות פעם אחת (CI)
npx vitest run
# הרצת בדיקות עם coverage
npx vitest run --coverage
# הרצת בדיקות של קובץ ספציפי
npx vitest math.test.ts
# הרצת בדיקות שמתאימות לתבנית
npx vitest --grep "validateEmail"
סיכום¶
- בדיקות מוודאות שהקוד עובד כמצופה ומאפשרות שינויים בביטחון
- פירמידת הבדיקות: הרבה בדיקות יחידה, כמות בינונית של אינטגרציה, מעט E2E
- TDD הוא שיטת עבודה: אדום (בדיקה נכשלת) > ירוק (קוד מינימלי) > שיפור (refactor)
- Vitest מתאים מצוין לפרויקטי Vite - מהיר, תואם Jest, ותומך ב-TypeScript
- מבנה בסיסי: describe לקבוצות, it/test לבדיקות בודדות, expect להשוואות
- Matchers נפוצים: toBe, toEqual, toContain, toThrow, toBeTruthy
- Setup/Teardown: beforeEach, afterEach, beforeAll, afterAll
- קוד אסינכרוני: async/await, resolves/rejects, fake timers