10.9 - Lighthouse - פתרון
פתרון - לייטהאוס ואסטרטגיית בדיקות - Lighthouse and Testing Strategy¶
פתרון תרגיל 1¶
// lighthouserc.json
{
"ci": {
"collect": {
"url": [
"http://localhost:3000/",
"http://localhost:3000/products",
"http://localhost:3000/about"
],
"numberOfRuns": 5,
"startServerCommand": "npm run start",
"startServerReadyPattern": "ready on"
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"categories:accessibility": ["error", { "minScore": 0.95 }],
"categories:seo": ["error", { "minScore": 0.9 }],
"categories:best-practices": ["error", { "minScore": 0.9 }]
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}
# .github/workflows/lighthouse.yml
name: Lighthouse CI
on:
pull_request:
branches: [main]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Run Lighthouse CI
run: |
npm install -g @lhci/cli
lhci autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
- name: Upload Lighthouse report
uses: actions/upload-artifact@v4
if: always()
with:
name: lighthouse-report
path: .lighthouseci/
retention-days: 14
// package.json
{
"scripts": {
"lighthouse": "lhci autorun",
"lighthouse:local": "lighthouse http://localhost:3000 --output html --output-path ./reports/lighthouse.html --view"
}
}
פתרון תרגיל 2¶
תוכנית תיקון לפי סדר עדיפויות:
עדיפות 1 - נגישות ו-SEO בסיסי (שעה):
<!-- Fix lang attribute -->
<html lang="he" dir="rtl">
<!-- Fix viewport -->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Fix meta description -->
<meta name="description" content="Relevant description of the page, up to 155 characters" />
<!-- Fix alt text -->
<img src="hero.jpg" alt="Precise description of the image" />
<!-- Fix labels -->
<label htmlFor="email">אימייל</label>
<input id="email" type="email" />
<!-- Fix anchor text -->
<!-- Bad: <a href="/products">click here</a> -->
<a href="/products">צפו בכל המוצרים שלנו</a>
עדיפות 2 - ניגודיות צבעים (30 דקות):
/* Bad - contrast 2.5:1 */
.text-light {
color: #aaa; /* on a white background */
}
/* Good - contrast 4.5:1+ */
.text-light {
color: #595959; /* on a white background */
}
/* Button - sufficient contrast */
.button {
background-color: #2563eb;
color: #ffffff; /* contrast 7.4:1 */
}
עדיפות 3 - ביצועים - תמונות (שעה):
// hero image: 3MB JPEG -> next/image with WebP
import Image from 'next/image';
<Image
src="/hero.jpg"
alt="תמונת גיבור"
width={1920}
height={1080}
priority
sizes="100vw"
quality={80}
/>
// next/image will convert to WebP and shrink to ~200KB
עדיפות 4 - ביצועים - JavaScript (2 שעות):
// code splitting - dynamic loading of heavy modules
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('./HeavyChart'), {
ssr: false,
loading: () => <div style={{ height: '400px' }}>טוען...</div>,
});
// Replacing heavy libraries
// moment.js -> date-fns
// lodash -> native methods
// any unused library -> remove
עדיפות 5 - CLS (שעה):
// Images with dimensions
<Image src="/photo.jpg" alt="..." width={600} height={400} />
// Fonts without FOUT
import { Rubik } from 'next/font/google';
const rubik = Rubik({ subsets: ['hebrew'], display: 'swap' });
פתרון תרגיל 3¶
בדיקות יחידה (10+):
- validateTask - בדיקת validation של שם משימה
- filterTasks - סינון לפי סטטוס
- sortTasks - מיון לפי תאריך/שם
- calculateStats - חישוב סטטיסטיקות
- formatDueDate - עיצוב תאריך יעד
- isOverdue - בדיקה אם משימה באיחור
- parseNotification - ניתוח הודעת תזכורת
- mergeTaskLists - מיזוג רשימות משימות
- searchTasks - חיפוש לפי טקסט
- getTaskPriority - חישוב עדיפות
- useAuth hook - ניהול אימות
- useTaskFilters hook - ניהול פילטרים
בדיקות אינטגרציה (6+):
- TaskItem - רינדור, toggle, מחיקה, עריכה
- TaskForm - יצירת משימה חדשה עם validation
- TaskList - הצגת רשימה, סינון, מיון
- LoginForm - התחברות עם שגיאות
- ShareDialog - שיתוף משימה עם משתמש
- StatsPanel - הצגת סטטיסטיקות
בדיקות E2E (4+):
- תהליך הרשמה והתחברות מלא
- יצירת משימה, סימון כהושלמה, מחיקה
- שיתוף משימה עם משתמש אחר
- סינון וחיפוש משימות
הגדרות coverage:
// vite.config.ts
test: {
coverage: {
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
}
CI Pipeline:
lint + typecheck (30s)
|
unit tests + coverage (1-2min) ← fails if coverage is below 80%
|
build (1-2min)
|
+---+---+
| |
E2E Lighthouse
(3-5min) (2-3min)
פתרון תרגיל 4¶
// utils/date.test.ts
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { formatDate, isOverdue, daysUntil, getRelativeTime } from './date';
describe('formatDate', () => {
it('should format date in Hebrew', () => {
const date = new Date('2026-03-08');
const formatted = formatDate(date);
expect(formatted).toContain('2026');
expect(formatted).toContain('8');
});
it('should handle different dates', () => {
const date = new Date('2025-12-25');
const formatted = formatDate(date);
expect(formatted).toContain('2025');
});
});
describe('isOverdue', () => {
it('should return true for past date', () => {
const pastDate = new Date('2020-01-01');
expect(isOverdue(pastDate)).toBe(true);
});
it('should return false for future date', () => {
const futureDate = new Date('2030-01-01');
expect(isOverdue(futureDate)).toBe(false);
});
});
describe('daysUntil', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-08'));
});
afterEach(() => {
vi.useRealTimers();
});
it('should return positive for future date', () => {
const future = new Date('2026-03-18');
expect(daysUntil(future)).toBe(10);
});
it('should return negative for past date', () => {
const past = new Date('2026-03-03');
expect(daysUntil(past)).toBeLessThan(0);
});
it('should return 0 or 1 for today', () => {
const today = new Date('2026-03-08');
expect(daysUntil(today)).toBeLessThanOrEqual(1);
});
});
describe('getRelativeTime', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-08T12:00:00'));
});
afterEach(() => {
vi.useRealTimers();
});
it('should return "הרגע" for less than a minute', () => {
const date = new Date('2026-03-08T11:59:30');
expect(getRelativeTime(date)).toBe('הרגע');
});
it('should return minutes for less than an hour', () => {
const date = new Date('2026-03-08T11:45:00');
expect(getRelativeTime(date)).toBe('לפני 15 דקות');
});
it('should return hours for less than a day', () => {
const date = new Date('2026-03-08T09:00:00');
expect(getRelativeTime(date)).toBe('לפני 3 שעות');
});
it('should return days for less than a month', () => {
const date = new Date('2026-03-03T12:00:00');
expect(getRelativeTime(date)).toBe('לפני 5 ימים');
});
it('should return formatted date for more than a month', () => {
const date = new Date('2026-01-01T12:00:00');
const result = getRelativeTime(date);
expect(result).toContain('2026');
});
});
// components/TaskItem.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { TaskItem } from './TaskItem';
const defaultTask = {
id: '1',
title: 'משימה לדוגמה',
completed: false,
dueDate: null,
};
const defaultProps = {
task: defaultTask,
onToggle: vi.fn(),
onDelete: vi.fn(),
onEdit: vi.fn(),
};
describe('TaskItem', () => {
it('should render task title', () => {
render(<TaskItem {...defaultProps} />);
expect(screen.getByText('משימה לדוגמה')).toBeInTheDocument();
});
it('should render unchecked checkbox for incomplete task', () => {
render(<TaskItem {...defaultProps} />);
expect(screen.getByRole('checkbox')).not.toBeChecked();
});
it('should render checked checkbox for completed task', () => {
render(<TaskItem {...defaultProps} task={{ ...defaultTask, completed: true }} />);
expect(screen.getByRole('checkbox')).toBeChecked();
});
it('should call onToggle when checkbox clicked', async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
render(<TaskItem {...defaultProps} onToggle={onToggle} />);
await user.click(screen.getByRole('checkbox'));
expect(onToggle).toHaveBeenCalledWith('1');
});
it('should call onDelete when delete button clicked', async () => {
const user = userEvent.setup();
const onDelete = vi.fn();
render(<TaskItem {...defaultProps} onDelete={onDelete} />);
await user.click(screen.getByRole('button', { name: /מחק/ }));
expect(onDelete).toHaveBeenCalledWith('1');
});
it('should enter edit mode on double click', async () => {
const user = userEvent.setup();
render(<TaskItem {...defaultProps} />);
await user.dblClick(screen.getByText('משימה לדוגמה'));
expect(screen.getByRole('textbox')).toHaveValue('משימה לדוגמה');
});
it('should save on Enter in edit mode', async () => {
const user = userEvent.setup();
const onEdit = vi.fn();
render(<TaskItem {...defaultProps} onEdit={onEdit} />);
await user.dblClick(screen.getByText('משימה לדוגמה'));
await user.clear(screen.getByRole('textbox'));
await user.type(screen.getByRole('textbox'), 'כותרת חדשה{Enter}');
expect(onEdit).toHaveBeenCalledWith('1', 'כותרת חדשה');
});
it('should show overdue style for overdue task', () => {
const overdueTask = {
...defaultTask,
dueDate: new Date('2020-01-01'),
};
const { container } = render(<TaskItem {...defaultProps} task={overdueTask} />);
expect(container.querySelector('.overdue')).toBeInTheDocument();
});
});
פתרון תרגיל 5¶
# .github/workflows/ci.yml
name: CI Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npx tsc --noEmit
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx vitest run --coverage
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/
retention-days: 7
build:
runs-on: ubuntu-latest
needs: [lint, unit-tests]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Upload build
uses: actions/upload-artifact@v4
with:
name: build
path: .next/
retention-days: 1
e2e:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
needs: [build]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npx playwright install chromium --with-deps
- name: Download build
uses: actions/download-artifact@v4
with:
name: build
path: .next/
- run: npx playwright test --project=chromium
- name: Upload Playwright report
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 14
lighthouse:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
needs: [build]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Download build
uses: actions/download-artifact@v4
with:
name: build
path: .next/
- run: npm install -g @lhci/cli
- run: lhci autorun
- name: Upload Lighthouse report
uses: actions/upload-artifact@v4
if: always()
with:
name: lighthouse-report
path: .lighthouseci/
retention-days: 14
פתרון תרגיל 6¶
// scripts/generateReport.mjs
import { execSync } from 'child_process';
import { writeFileSync } from 'fs';
function runCommand(cmd) {
try {
return { output: execSync(cmd, { encoding: 'utf-8' }), success: true };
} catch (error) {
return { output: error.stdout || error.message, success: false };
}
}
function generateReport() {
const timestamp = new Date().toLocaleString('he-IL');
console.log('Running unit tests...');
const unitResult = runCommand('npx vitest run --reporter=json --coverage 2>&1');
console.log('Running E2E tests...');
const e2eResult = runCommand('npx playwright test --reporter=json 2>&1');
let unitData = { passed: 0, failed: 0, skipped: 0 };
try {
const parsed = JSON.parse(unitResult.output);
unitData = {
passed: parsed.numPassedTests || 0,
failed: parsed.numFailedTests || 0,
skipped: parsed.numPendingTests || 0,
};
} catch {}
const html = `
<!DOCTYPE html>
<html lang="he" dir="rtl">
<head>
<meta charset="UTF-8" />
<title>דוח בדיקות</title>
<style>
body { font-family: Arial, sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; }
h1 { color: #1f2937; }
.card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 20px; margin: 16px 0; }
.pass { color: #16a34a; }
.fail { color: #dc2626; }
.metric { display: inline-block; margin: 0 20px; text-align: center; }
.metric .value { font-size: 32px; font-weight: bold; }
.metric .label { font-size: 14px; color: #6b7280; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 8px 12px; border: 1px solid #e5e7eb; text-align: right; }
th { background: #f9fafb; }
</style>
</head>
<body>
<h1>דוח בדיקות</h1>
<p>תאריך: ${timestamp}</p>
<div class="card">
<h2>בדיקות יחידה</h2>
<div>
<div class="metric">
<div class="value pass">${unitData.passed}</div>
<div class="label">עברו</div>
</div>
<div class="metric">
<div class="value fail">${unitData.failed}</div>
<div class="label">נכשלו</div>
</div>
<div class="metric">
<div class="value">${unitData.skipped}</div>
<div class="label">דולגו</div>
</div>
</div>
<p class="${unitResult.success ? 'pass' : 'fail'}">
סטטוס: ${unitResult.success ? 'עברו בהצלחה' : 'נכשלו'}
</p>
</div>
<div class="card">
<h2>בדיקות E2E</h2>
<p class="${e2eResult.success ? 'pass' : 'fail'}">
סטטוס: ${e2eResult.success ? 'עברו בהצלחה' : 'נכשלו'}
</p>
</div>
<footer>
<p>נוצר אוטומטית בתאריך ${timestamp}</p>
</footer>
</body>
</html>
`;
writeFileSync('reports/test-report.html', html);
console.log('Report saved to reports/test-report.html');
}
generateReport();
תשובות לשאלות¶
1. Lab Data נמדד בסביבה מבוקרת (מכשיר סימולציה, רשת מוגדרת) ומספק תוצאות עקביות אבל לא מייצגות את כל המשתמשים. Field Data נמדד ממשתמשים אמיתיים (CrUX) ומשקף ביצועים אמיתיים עם מגוון מכשירים ורשתות. הציונים עשויים להיות שונים כי Lab לא כולל שונות של מכשירים ותנאי רשת.
2. 100% coverage אינו יעד מומלץ כי: (1) הוא מוביל לכתיבת בדיקות "למען ה-coverage" ולא למען ערך. (2) קוד פשוט שלא שווה לבדוק (getters, types) מנפח את הבדיקות. (3) coverage לא מבטיח שהבדיקות בודקות את הדבר הנכון. יעד 80% statements ו-75% branches טוב - ומתמקדים בכיסוי של לוגיקה קריטית.
3. E2E: תהליכים שחוצים כמה דפים/שירותים ומייצגים flow של משתמש אמיתי (התחברות, רכישה). יחידה: פונקציות ולוגיקה בבידוד - validation, חישובים, helpers. כלל אצבע: אם ניתן לבדוק בבדיקת יחידה - תעדיפו אותה (מהירה יותר ואמינה יותר).
4. הרצת Lighthouse ב-CI מבטיחה שכל שינוי קוד נבדק אוטומטית - אם ביצועים או נגישות ירדו, ה-build נכשל. בדיקה ידנית תלויה בזיכרון של המפתח, לא עקבית, ולא מכסה כל PR. בנוסף, CI מספק היסטוריה ומגמות לאורך זמן.
5. מצב שבו בדיקות עוברות אבל האפליקציה שבורה יכול לקרות כש: (1) הבדיקות בודקות מימוש ולא התנהגות. (2) אין מספיק בדיקות אינטגרציה/E2E. (3) ה-mocks לא מייצגים את המציאות. (4) אין בדיקות למקרי קצה. מניעה: שילוב של כל סוגי הבדיקות, עדכון mocks כשה-API משתנה, בדיקות smoke ב-staging.