לדלג לתוכן

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 הוא גישת פיתוח שבה כותבים את הבדיקה לפני הקוד:

  1. אדום - כתוב בדיקה שנכשלת (הקוד עדיין לא קיים)
  2. ירוק - כתוב את הקוד המינימלי שגורם לבדיקה לעבור
  3. שיפור - שפר את הקוד (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

# התקנה
npm install --save-dev vitest
// 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
  },
});
// src/test/setup.ts
import '@testing-library/jest-dom'; // matchers נוספים כמו toBeInTheDocument
// 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