10.6 Vitest פתרון
פתרון - קונספט בדיקות ו-Vitest - Testing Concepts and Vitest¶
פתרון תרגיל 1¶
// utils/string.ts
export function capitalize(str: string): string {
if (!str) return '';
return str.charAt(0).toUpperCase() + str.slice(1);
}
export function slugify(str: string): string {
return str
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export function truncate(str: string, maxLength: number): string {
if (!str) return '';
if (str.length <= maxLength) return str;
return str.slice(0, maxLength - 3) + '...';
}
export function countWords(str: string): number {
if (!str || !str.trim()) return 0;
return str.trim().split(/\s+/).length;
}
// utils/string.test.ts
import { describe, it, expect } from 'vitest';
import { capitalize, slugify, truncate, countWords } from './string';
describe('capitalize', () => {
it('should capitalize the first letter', () => {
expect(capitalize('hello')).toBe('Hello');
});
it('should handle already capitalized string', () => {
expect(capitalize('Hello')).toBe('Hello');
});
it('should handle empty string', () => {
expect(capitalize('')).toBe('');
});
it('should handle single character', () => {
expect(capitalize('a')).toBe('A');
});
it('should not change other characters', () => {
expect(capitalize('hELLO wORLD')).toBe('HELLO wORLD');
});
});
describe('slugify', () => {
it('should convert spaces to hyphens', () => {
expect(slugify('Hello World')).toBe('hello-world');
});
it('should convert to lowercase', () => {
expect(slugify('My Blog Post')).toBe('my-blog-post');
});
it('should remove special characters', () => {
expect(slugify('Hello! World?')).toBe('hello-world');
});
it('should handle multiple spaces', () => {
expect(slugify('hello world')).toBe('hello-world');
});
it('should trim leading and trailing hyphens', () => {
expect(slugify(' -hello world- ')).toBe('hello-world');
});
it('should handle empty string', () => {
expect(slugify('')).toBe('');
});
});
describe('truncate', () => {
it('should not truncate short strings', () => {
expect(truncate('hello', 10)).toBe('hello');
});
it('should truncate and add ellipsis', () => {
expect(truncate('Hello World, this is a long text', 15)).toBe('Hello World,...');
});
it('should handle exact length', () => {
expect(truncate('hello', 5)).toBe('hello');
});
it('should handle empty string', () => {
expect(truncate('', 10)).toBe('');
});
it('should handle maxLength less than 3', () => {
expect(truncate('hello', 3)).toBe('...');
});
});
describe('countWords', () => {
it('should count words in a sentence', () => {
expect(countWords('hello world')).toBe(2);
});
it('should handle multiple spaces', () => {
expect(countWords('hello world test')).toBe(3);
});
it('should return 0 for empty string', () => {
expect(countWords('')).toBe(0);
});
it('should return 0 for whitespace only', () => {
expect(countWords(' ')).toBe(0);
});
it('should count single word', () => {
expect(countWords('hello')).toBe(1);
});
});
פתרון תרגיל 2¶
// utils/array.ts
export function unique<T>(arr: T[]): T[] {
return [...new Set(arr)];
}
export function groupBy<T>(arr: T[], key: keyof T): Record<string, T[]> {
return arr.reduce((groups, item) => {
const groupKey = String(item[key]);
if (!groups[groupKey]) groups[groupKey] = [];
groups[groupKey].push(item);
return groups;
}, {} as Record<string, T[]>);
}
export function sortBy<T>(arr: T[], ...keys: (keyof T)[]): T[] {
return [...arr].sort((a, b) => {
for (const key of keys) {
if (a[key] < b[key]) return -1;
if (a[key] > b[key]) return 1;
}
return 0;
});
}
export function flatten<T>(arr: (T | T[])[]): T[] {
return arr.flat() as T[];
}
export function chunk<T>(arr: T[], size: number): T[][] {
if (size <= 0) throw new Error('Chunk size must be positive');
const result: T[][] = [];
for (let i = 0; i < arr.length; i += size) {
result.push(arr.slice(i, i + size));
}
return result;
}
// utils/array.test.ts
import { describe, it, expect } from 'vitest';
import { unique, groupBy, sortBy, flatten, chunk } from './array';
describe('unique', () => {
it('should remove duplicates from numbers', () => {
expect(unique([1, 2, 2, 3, 3, 3])).toEqual([1, 2, 3]);
});
it('should remove duplicates from strings', () => {
expect(unique(['a', 'b', 'a'])).toEqual(['a', 'b']);
});
it('should handle empty array', () => {
expect(unique([])).toEqual([]);
});
});
describe('groupBy', () => {
const items = [
{ name: 'A', category: 'x' },
{ name: 'B', category: 'y' },
{ name: 'C', category: 'x' },
];
it('should group items by key', () => {
const result = groupBy(items, 'category');
expect(result['x']).toHaveLength(2);
expect(result['y']).toHaveLength(1);
});
it('should handle empty array', () => {
expect(groupBy([], 'category' as any)).toEqual({});
});
it('should include all items in groups', () => {
const result = groupBy(items, 'category');
const totalItems = Object.values(result).flat().length;
expect(totalItems).toBe(items.length);
});
});
describe('sortBy', () => {
const items = [
{ name: 'C', age: 30 },
{ name: 'A', age: 25 },
{ name: 'B', age: 25 },
];
it('should sort by single key', () => {
const result = sortBy(items, 'name');
expect(result[0].name).toBe('A');
expect(result[2].name).toBe('C');
});
it('should sort by multiple keys', () => {
const result = sortBy(items, 'age', 'name');
expect(result[0].name).toBe('A');
expect(result[1].name).toBe('B');
});
it('should not mutate original array', () => {
const original = [...items];
sortBy(items, 'name');
expect(items).toEqual(original);
});
});
describe('flatten', () => {
it('should flatten nested arrays', () => {
expect(flatten([1, [2, 3], 4, [5]])).toEqual([1, 2, 3, 4, 5]);
});
it('should handle already flat array', () => {
expect(flatten([1, 2, 3])).toEqual([1, 2, 3]);
});
it('should handle empty array', () => {
expect(flatten([])).toEqual([]);
});
});
describe('chunk', () => {
it('should split array into chunks', () => {
expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
});
it('should handle exact division', () => {
expect(chunk([1, 2, 3, 4], 2)).toEqual([[1, 2], [3, 4]]);
});
it('should handle size larger than array', () => {
expect(chunk([1, 2], 5)).toEqual([[1, 2]]);
});
it('should throw for non-positive size', () => {
expect(() => chunk([1, 2], 0)).toThrow();
});
});
פתרון תרגיל 3¶
// cart.test.ts - בדיקות נכתבו קודם (TDD)
import { describe, it, expect, beforeEach } from 'vitest';
import { createCart, Cart } from './cart';
describe('Cart', () => {
let cart: Cart;
beforeEach(() => {
cart = createCart();
});
describe('addItem', () => {
it('should add a new item', () => {
cart.addItem({ id: '1', name: 'מוצר א', price: 100 });
expect(cart.items).toHaveLength(1);
expect(cart.items[0].quantity).toBe(1);
});
it('should increase quantity for existing item', () => {
cart.addItem({ id: '1', name: 'מוצר א', price: 100 });
cart.addItem({ id: '1', name: 'מוצר א', price: 100 });
expect(cart.items).toHaveLength(1);
expect(cart.items[0].quantity).toBe(2);
});
it('should add with custom quantity', () => {
cart.addItem({ id: '1', name: 'מוצר א', price: 100 }, 5);
expect(cart.items[0].quantity).toBe(5);
});
});
describe('removeItem', () => {
it('should remove an existing item', () => {
cart.addItem({ id: '1', name: 'מוצר א', price: 100 });
cart.removeItem('1');
expect(cart.items).toHaveLength(0);
});
it('should not throw when removing non-existing item', () => {
expect(() => cart.removeItem('999')).not.toThrow();
});
});
describe('updateQuantity', () => {
it('should update quantity', () => {
cart.addItem({ id: '1', name: 'מוצר א', price: 100 });
cart.updateQuantity('1', 5);
expect(cart.items[0].quantity).toBe(5);
});
it('should remove item when quantity is 0', () => {
cart.addItem({ id: '1', name: 'מוצר א', price: 100 });
cart.updateQuantity('1', 0);
expect(cart.items).toHaveLength(0);
});
it('should not allow negative quantity', () => {
cart.addItem({ id: '1', name: 'מוצר א', price: 100 });
expect(() => cart.updateQuantity('1', -1)).toThrow();
});
});
describe('getTotal', () => {
it('should return 0 for empty cart', () => {
expect(cart.getTotal()).toBe(0);
});
it('should calculate total correctly', () => {
cart.addItem({ id: '1', name: 'מוצר א', price: 100 }, 2);
cart.addItem({ id: '2', name: 'מוצר ב', price: 50 }, 3);
expect(cart.getTotal()).toBe(350); // 200 + 150
});
});
describe('getItemCount', () => {
it('should return total item count', () => {
cart.addItem({ id: '1', name: 'מוצר א', price: 100 }, 2);
cart.addItem({ id: '2', name: 'מוצר ב', price: 50 }, 3);
expect(cart.getItemCount()).toBe(5);
});
});
describe('clear', () => {
it('should remove all items', () => {
cart.addItem({ id: '1', name: 'מוצר א', price: 100 });
cart.addItem({ id: '2', name: 'מוצר ב', price: 50 });
cart.clear();
expect(cart.items).toHaveLength(0);
expect(cart.getTotal()).toBe(0);
});
});
describe('getItem', () => {
it('should return item by id', () => {
cart.addItem({ id: '1', name: 'מוצר א', price: 100 });
expect(cart.getItem('1')?.name).toBe('מוצר א');
});
it('should return undefined for non-existing item', () => {
expect(cart.getItem('999')).toBeUndefined();
});
});
});
// cart.ts - קוד שנכתב אחרי הבדיקות
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
export interface Cart {
items: CartItem[];
addItem(item: Omit<CartItem, 'quantity'>, quantity?: number): void;
removeItem(id: string): void;
updateQuantity(id: string, quantity: number): void;
getTotal(): number;
getItemCount(): number;
clear(): void;
getItem(id: string): CartItem | undefined;
}
export function createCart(): Cart {
const items: CartItem[] = [];
return {
get items() {
return [...items];
},
addItem(item, quantity = 1) {
const existing = items.find((i) => i.id === item.id);
if (existing) {
existing.quantity += quantity;
} else {
items.push({ ...item, quantity });
}
},
removeItem(id) {
const index = items.findIndex((i) => i.id === id);
if (index !== -1) items.splice(index, 1);
},
updateQuantity(id, quantity) {
if (quantity < 0) throw new Error('Quantity cannot be negative');
if (quantity === 0) {
this.removeItem(id);
return;
}
const item = items.find((i) => i.id === id);
if (item) item.quantity = quantity;
},
getTotal() {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
},
getItemCount() {
return items.reduce((sum, item) => sum + item.quantity, 0);
},
clear() {
items.length = 0;
},
getItem(id) {
return items.find((i) => i.id === id);
},
};
}
פתרון תרגיל 4¶
// utils/api.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { fetchUser, fetchWithRetry, debounce } from './api';
// Mock של fetch
const mockFetch = vi.fn();
global.fetch = mockFetch;
describe('fetchUser', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should return user data on success', async () => {
const userData = { id: 1, name: 'דני' };
mockFetch.mockResolvedValue({
ok: true,
json: () => Promise.resolve(userData),
});
const result = await fetchUser(1);
expect(result).toEqual(userData);
expect(mockFetch).toHaveBeenCalledWith('/api/users/1');
});
it('should throw on 404', async () => {
mockFetch.mockResolvedValue({ ok: false, status: 404 });
await expect(fetchUser(999)).rejects.toThrow('User 999 not found');
});
it('should throw on network error', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));
await expect(fetchUser(1)).rejects.toThrow('Network error');
});
});
describe('fetchWithRetry', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should succeed on first try', async () => {
mockFetch.mockResolvedValue({ ok: true });
const result = await fetchWithRetry('/api/data');
expect(result.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it('should retry and succeed', async () => {
mockFetch
.mockRejectedValueOnce(new Error('fail'))
.mockResolvedValueOnce({ ok: true });
const promise = fetchWithRetry('/api/data', 3, 100);
await vi.advanceTimersByTimeAsync(100);
const result = await promise;
expect(result.ok).toBe(true);
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it('should throw after all retries fail', async () => {
mockFetch.mockRejectedValue(new Error('fail'));
const promise = fetchWithRetry('/api/data', 3, 100);
await vi.advanceTimersByTimeAsync(300);
await expect(promise).rejects.toThrow('fail');
expect(mockFetch).toHaveBeenCalledTimes(3);
});
});
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should call function after delay', () => {
const fn = vi.fn();
const debouncedFn = debounce(fn, 300);
debouncedFn();
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledTimes(1);
});
it('should only call once for multiple rapid calls', () => {
const fn = vi.fn();
const debouncedFn = debounce(fn, 300);
debouncedFn();
debouncedFn();
debouncedFn();
vi.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledTimes(1);
});
it('should pass arguments to the function', () => {
const fn = vi.fn();
const debouncedFn = debounce(fn, 300);
debouncedFn('hello', 42);
vi.advanceTimersByTime(300);
expect(fn).toHaveBeenCalledWith('hello', 42);
});
it('should reset timer on new call', () => {
const fn = vi.fn();
const debouncedFn = debounce(fn, 300);
debouncedFn();
vi.advanceTimersByTime(200);
debouncedFn(); // מתחיל מחדש
vi.advanceTimersByTime(200);
expect(fn).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(fn).toHaveBeenCalledTimes(1);
});
});
פתרון תרגיל 5¶
// utils/validation.ts
interface RegistrationForm {
username: string;
email: string;
password: string;
confirmPassword: string;
age: number;
agreeToTerms: boolean;
}
interface ValidationResult {
valid: boolean;
errors: Record<string, string>;
}
export function validateRegistration(form: RegistrationForm): ValidationResult {
const errors: Record<string, string> = {};
// username
if (!form.username) {
errors.username = 'Username is required';
} else if (form.username.length < 3 || form.username.length > 20) {
errors.username = 'Username must be 3-20 characters';
} else if (!/^[a-zA-Z0-9_]+$/.test(form.username)) {
errors.username = 'Username can only contain letters, numbers, and underscores';
}
// email
if (!form.email) {
errors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) {
errors.email = 'Invalid email format';
}
// password
if (!form.password) {
errors.password = 'Password is required';
} else if (form.password.length < 8) {
errors.password = 'Password must be at least 8 characters';
} else if (!/[A-Z]/.test(form.password) || !/[a-z]/.test(form.password) || !/[0-9]/.test(form.password)) {
errors.password = 'Password must contain uppercase, lowercase, and number';
}
// confirmPassword
if (form.password !== form.confirmPassword) {
errors.confirmPassword = 'Passwords do not match';
}
// age
if (form.age < 13 || form.age > 120) {
errors.age = 'Age must be between 13 and 120';
}
// agreeToTerms
if (!form.agreeToTerms) {
errors.agreeToTerms = 'You must agree to the terms';
}
return { valid: Object.keys(errors).length === 0, errors };
}
// utils/validation.test.ts
import { describe, it, expect } from 'vitest';
import { validateRegistration } from './validation';
const validForm = {
username: 'john_doe',
email: 'john@example.com',
password: 'MyPass123',
confirmPassword: 'MyPass123',
age: 25,
agreeToTerms: true,
};
describe('validateRegistration', () => {
it('should pass with valid form', () => {
const result = validateRegistration(validForm);
expect(result.valid).toBe(true);
expect(Object.keys(result.errors)).toHaveLength(0);
});
describe('username validation', () => {
it('should reject empty username', () => {
const result = validateRegistration({ ...validForm, username: '' });
expect(result.errors.username).toBeDefined();
});
it('should reject short username', () => {
const result = validateRegistration({ ...validForm, username: 'ab' });
expect(result.errors.username).toContain('3-20');
});
it('should reject special characters', () => {
const result = validateRegistration({ ...validForm, username: 'user@name' });
expect(result.errors.username).toBeDefined();
});
it('should accept valid username with underscore', () => {
const result = validateRegistration({ ...validForm, username: 'user_123' });
expect(result.errors.username).toBeUndefined();
});
});
describe('email validation', () => {
it('should reject empty email', () => {
const result = validateRegistration({ ...validForm, email: '' });
expect(result.errors.email).toBeDefined();
});
it('should reject invalid email format', () => {
const result = validateRegistration({ ...validForm, email: 'notanemail' });
expect(result.errors.email).toBeDefined();
});
it('should accept valid email', () => {
const result = validateRegistration({ ...validForm, email: 'user@domain.co.il' });
expect(result.errors.email).toBeUndefined();
});
});
describe('password validation', () => {
it('should reject short password', () => {
const form = { ...validForm, password: 'Ab1', confirmPassword: 'Ab1' };
const result = validateRegistration(form);
expect(result.errors.password).toBeDefined();
});
it('should reject password without uppercase', () => {
const form = { ...validForm, password: 'mypass123', confirmPassword: 'mypass123' };
const result = validateRegistration(form);
expect(result.errors.password).toBeDefined();
});
it('should reject mismatched passwords', () => {
const form = { ...validForm, confirmPassword: 'DifferentPass1' };
const result = validateRegistration(form);
expect(result.errors.confirmPassword).toBeDefined();
});
});
describe('age validation', () => {
it('should reject age under 13', () => {
const result = validateRegistration({ ...validForm, age: 10 });
expect(result.errors.age).toBeDefined();
});
it('should reject age over 120', () => {
const result = validateRegistration({ ...validForm, age: 150 });
expect(result.errors.age).toBeDefined();
});
it('should accept age of 13', () => {
const result = validateRegistration({ ...validForm, age: 13 });
expect(result.errors.age).toBeUndefined();
});
});
describe('terms agreement', () => {
it('should reject when terms not agreed', () => {
const result = validateRegistration({ ...validForm, agreeToTerms: false });
expect(result.errors.agreeToTerms).toBeDefined();
});
});
it('should return multiple errors', () => {
const form = {
username: '',
email: '',
password: '',
confirmPassword: 'x',
age: 5,
agreeToTerms: false,
};
const result = validateRegistration(form);
expect(result.valid).toBe(false);
expect(Object.keys(result.errors).length).toBeGreaterThan(3);
});
});
פתרון תרגיל 6¶
// storage.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { Storage } from './storage';
describe('Storage', () => {
let storage: Storage;
beforeEach(() => {
localStorage.clear();
storage = new Storage('test');
});
afterEach(() => {
localStorage.clear();
});
describe('set and get', () => {
it('should store and retrieve a string', () => {
storage.set('name', 'דני');
expect(storage.get('name')).toBe('דני');
});
it('should store and retrieve a number', () => {
storage.set('age', 25);
expect(storage.get('age')).toBe(25);
});
it('should store and retrieve an object', () => {
const user = { name: 'דני', age: 25 };
storage.set('user', user);
expect(storage.get('user')).toEqual(user);
});
it('should store and retrieve an array', () => {
storage.set('items', [1, 2, 3]);
expect(storage.get('items')).toEqual([1, 2, 3]);
});
it('should return default value for missing key', () => {
expect(storage.get('missing', 'default')).toBe('default');
});
it('should return undefined for missing key without default', () => {
expect(storage.get('missing')).toBeUndefined();
});
});
describe('prefix', () => {
it('should use prefix in storage key', () => {
storage.set('key', 'value');
expect(localStorage.getItem('test:key')).toBe('"value"');
});
it('should not interfere with other prefixes', () => {
const otherStorage = new Storage('other');
storage.set('key', 'value1');
otherStorage.set('key', 'value2');
expect(storage.get('key')).toBe('value1');
expect(otherStorage.get('key')).toBe('value2');
});
});
describe('remove', () => {
it('should remove an item', () => {
storage.set('key', 'value');
storage.remove('key');
expect(storage.get('key')).toBeUndefined();
});
it('should not throw when removing non-existing key', () => {
expect(() => storage.remove('missing')).not.toThrow();
});
});
describe('clear', () => {
it('should clear only prefixed items', () => {
storage.set('key1', 'value1');
storage.set('key2', 'value2');
localStorage.setItem('other:key', 'should remain');
storage.clear();
expect(storage.get('key1')).toBeUndefined();
expect(storage.get('key2')).toBeUndefined();
expect(localStorage.getItem('other:key')).toBe('should remain');
});
});
describe('has', () => {
it('should return true for existing key', () => {
storage.set('key', 'value');
expect(storage.has('key')).toBe(true);
});
it('should return false for missing key', () => {
expect(storage.has('missing')).toBe(false);
});
});
});
תשובות לשאלות¶
1. בדיקת יחידה בודקת פונקציה בודדת בבידוד - למשל בדיקה שפונקציית validateEmail מחזירה true/false נכון. בדיקת אינטגרציה בודקת שמספר חלקים עובדים יחד - למשל בדיקה שקומפוננטת טופס מציגה שגיאות validation כשהמשתמש מגיש טופס לא תקין (שילוב של DOM, event handlers, ו-validation logic).
2. TDD: (1) אדום - כותבים בדיקה שנכשלת כי הקוד עוד לא קיים. (2) ירוק - כותבים את הקוד המינימלי שגורם לבדיקה לעבור. (3) שיפור - משפרים את הקוד בלי לשבור את הבדיקות. היתרון: הבדיקה מגדירה את הדרישה לפני הקוד, מה שמבטיח שכל קוד שנכתב באמת נבדק ושהממשק מעוצב מנקודת המבט של המשתמש בקוד.
3. toBe משתמש ב-=== (שוויון זהות) - מתאים לערכים פרימיטיביים (מספרים, מחרוזות, boolean). toEqual עושה השוואה עמוקה - מתאים לאובייקטים ומערכים. toBe({ a: 1 }) ייכשל כי שני אובייקטים שונים בזיכרון, אבל toEqual({ a: 1 }) יצליח.
4. בלי fake timers, בדיקות שמשתמשות ב-setTimeout או setInterval צריכות לחכות בזמן אמת (למשל 3 שניות). זה הופך את הבדיקות לאיטיות ולא אמינות. fake timers מאפשרים "לקפוץ קדימה" בזמן באופן מיידי, מה שהופך את הבדיקות למהירות ודטרמיניסטיות.
5. beforeEach רץ לפני כל בדיקה בודדת - מתאים לאתחול state שצריך להיות נקי לכל בדיקה (למשל ניקוי מסד נתונים). beforeAll רץ פעם אחת לפני כל הבדיקות בבלוק - מתאים לפעולות יקרות שלא צריך לחזור עליהן (למשל חיבור למסד נתונים).