اختبار تايب سكريبت
اختبار كود تايب سكريبت يتطلب اعتبارات خاصة تتجاوز اختبار جافا سكريبت العادي. تحتاج إلى التعامل مع فحص الأنواع، وتكوين منفذي الاختبار لتايب سكريبت، وكتابة mocks الخاصة بك وأدوات الاختبار، والتأكد من أن اختباراتك تستفيد من أمان نوع تايب سكريبت. في هذا الدرس، سنستكشف اختبار تايب سكريبت مع Jest، وتكوين ts-jest، وكتابة mocks، وإنشاء أدوات اختبار آمنة من حيث النوع، وحتى اختبار الأنواع نفسها.
إعداد Jest مع تايب سكريبت
Jest هو إطار الاختبار الأكثر شيوعاً لجافا سكريبت وتايب سكريبت. إليك كيفية تكوينه بشكل صحيح:
// تثبيت التبعيات
npm install --save-dev jest @types/jest ts-jest typescript
// إنشاء jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
transform: {
'^.+\.ts$': 'ts-jest'
},
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/__tests__/**'
],
globals: {
'ts-jest': {
tsconfig: {
esModuleInterop: true,
allowSyntheticDefaultImports: true
}
}
}
};
// إضافة إلى scripts في package.json
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
}
}
// tsconfig.json للاختبار
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["jest", "node"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
ملاحظة: الإعداد المسبق ts-jest يتعامل مع ترجمة تايب سكريبت تلقائياً، لذا يمكنك كتابة الاختبارات في تايب سكريبت مع فحص كامل للأنواع.
كتابة اختبارات آمنة من حيث النوع
تايب سكريبت تمكنك من كتابة اختبارات مع أمان نوع كامل:
// src/math.ts
export function add(a: number, b: number): number {
return a + b;
}
export function divide(a: number, b: number): number {
if (b === 0) {
throw new Error('القسمة على صفر');
}
return a / b;
}
export class Calculator {
private result: number = 0;
add(value: number): this {
this.result += value;
return this;
}
subtract(value: number): this {
this.result -= value;
return this;
}
getResult(): number {
return this.result;
}
reset(): void {
this.result = 0;
}
}
// src/__tests__/math.test.ts
import { add, divide, Calculator } from '../math';
describe('دوال الرياضيات', () => {
describe('add', () => {
it('يجب أن تجمع رقمين موجبين', () => {
const result = add(2, 3);
expect(result).toBe(5);
// تايب سكريبت تعرف أن result من نوع number
expect(typeof result).toBe('number');
});
it('يجب أن تجمع أرقاماً سالبة', () => {
expect(add(-5, -3)).toBe(-8);
});
// تايب سكريبت تلتقط أخطاء النوع في وقت الترجمة
// it('يجب أن تفشل مع strings', () => {
// add('2', '3'); // خطأ: نوع الوسيط 'string' غير قابل للتعيين
// });
});
describe('divide', () => {
it('يجب أن تقسم رقمين', () => {
expect(divide(10, 2)).toBe(5);
});
it('يجب أن ترمي خطأ عند القسمة على صفر', () => {
expect(() => divide(10, 0)).toThrow('القسمة على صفر');
});
});
});
describe('Calculator', () => {
let calculator: Calculator;
beforeEach(() => {
calculator = new Calculator();
});
it('يجب أن تبدأ بصفر', () => {
expect(calculator.getResult()).toBe(0);
});
it('يجب أن تدعم ربط الطرق', () => {
const result = calculator
.add(10)
.subtract(3)
.add(5)
.getResult();
expect(result).toBe(12);
});
it('يجب أن تعيد التعيين إلى صفر', () => {
calculator.add(100);
calculator.reset();
expect(calculator.getResult()).toBe(0);
});
});
Mocking مع تايب سكريبت
توفر تايب سكريبت دعماً ممتازاً لإنشاء mocks آمنة من حيث النوع:
// src/api.ts
export interface User {
id: number;
name: string;
email: string;
}
export interface ApiClient {
getUser(id: number): Promise<User>;
createUser(data: Omit<User, 'id'>): Promise<User>;
deleteUser(id: number): Promise<void>;
}
export class HttpApiClient implements ApiClient {
constructor(private readonly baseUrl: string) {}
async getUser(id: number): Promise<User> {
const response = await fetch(\`${this.baseUrl}/users/${id}\`);
return response.json();
}
async createUser(data: Omit<User, 'id'>): Promise<User> {
const response = await fetch(\`${this.baseUrl}/users\`, {
method: 'POST',
body: JSON.stringify(data)
});
return response.json();
}
async deleteUser(id: number): Promise<void> {
await fetch(\`${this.baseUrl}/users/${id}\`, { method: 'DELETE' });
}
}
// src/userService.ts
import { ApiClient, User } from './api';
export class UserService {
constructor(private readonly api: ApiClient) {}
async getUserById(id: number): Promise<User | null> {
try {
return await this.api.getUser(id);
} catch (error) {
console.error('فشل في جلب المستخدم:', error);
return null;
}
}
async registerUser(name: string, email: string): Promise<User> {
const user = await this.api.createUser({ name, email });
console.log(\`تم تسجيل المستخدم: ${user.id}\`);
return user;
}
}
// src/__tests__/userService.test.ts
import { UserService } from '../userService';
import { ApiClient, User } from '../api';
// إنشاء mock آمن من حيث النوع
const createMockApiClient = (): jest.Mocked<ApiClient> => ({
getUser: jest.fn(),
createUser: jest.fn(),
deleteUser: jest.fn()
});
describe('UserService', () => {
let apiClient: jest.Mocked<ApiClient>;
let userService: UserService;
beforeEach(() => {
apiClient = createMockApiClient();
userService = new UserService(apiClient);
});
describe('getUserById', () => {
it('يجب أن تعيد المستخدم عند العثور عليه', async () => {
const mockUser: User = {
id: 1,
name: 'أحمد محمد',
email: 'ahmad@example.com'
};
apiClient.getUser.mockResolvedValue(mockUser);
const user = await userService.getUserById(1);
expect(user).toEqual(mockUser);
expect(apiClient.getUser).toHaveBeenCalledWith(1);
expect(apiClient.getUser).toHaveBeenCalledTimes(1);
});
it('يجب أن تعيد null عند فشل API', async () => {
apiClient.getUser.mockRejectedValue(new Error('خطأ في الشبكة'));
const user = await userService.getUserById(1);
expect(user).toBeNull();
});
});
describe('registerUser', () => {
it('يجب أن تنشئ وتعيد المستخدم', async () => {
const mockUser: User = {
id: 1,
name: 'فاطمة علي',
email: 'fatima@example.com'
};
apiClient.createUser.mockResolvedValue(mockUser);
const user = await userService.registerUser('فاطمة علي', 'fatima@example.com');
expect(user).toEqual(mockUser);
expect(apiClient.createUser).toHaveBeenCalledWith({
name: 'فاطمة علي',
email: 'fatima@example.com'
});
});
});
});
نصيحة: استخدم jest.Mocked<T> لإنشاء mocks آمنة من حيث النوع تحافظ على جميع معلومات النوع الخاصة بالواجهة الأصلية.
اختبار الكود غير المتزامن
تايب سكريبت تجعل اختبار الكود غير المتزامن آمناً من حيث النوع وواضحاً:
// src/dataService.ts
export interface DataService {
fetchData(): Promise<string>;
}
export async function processData(service: DataService): Promise<string> {
const data = await service.fetchData();
return data.toUpperCase();
}
export async function fetchWithRetry(
fn: () => Promise<string>,
retries: number = 3
): Promise<string> {
for (let i = 0; i < retries; i++) {
try {
return await fn();
} catch (error) {
if (i === retries - 1) throw error;
await new Promise(resolve => setTimeout(resolve, 100));
}
}
throw new Error('فشلت جميع المحاولات');
}
// src/__tests__/dataService.test.ts
import { processData, fetchWithRetry, DataService } from '../dataService';
describe('العمليات غير المتزامنة', () => {
describe('processData', () => {
it('يجب أن تعالج البيانات بشكل صحيح', async () => {
const mockService: DataService = {
fetchData: jest.fn().mockResolvedValue('مرحباً')
};
const result = await processData(mockService);
expect(result).toBe('مرحباً'.toUpperCase());
});
it('يجب أن تتعامل مع الأخطاء', async () => {
const mockService: DataService = {
fetchData: jest.fn().mockRejectedValue(new Error('فشل الجلب'))
};
await expect(processData(mockService)).rejects.toThrow('فشل الجلب');
});
});
describe('fetchWithRetry', () => {
it('يجب أن تنجح في المحاولة الأولى', async () => {
const mockFn = jest.fn().mockResolvedValue('نجاح');
const result = await fetchWithRetry(mockFn);
expect(result).toBe('نجاح');
expect(mockFn).toHaveBeenCalledTimes(1);
});
it('يجب أن تعيد المحاولة عند الفشل', async () => {
const mockFn = jest
.fn()
.mockRejectedValueOnce(new Error('فشل 1'))
.mockRejectedValueOnce(new Error('فشل 2'))
.mockResolvedValue('نجاح');
const result = await fetchWithRetry(mockFn, 3);
expect(result).toBe('نجاح');
expect(mockFn).toHaveBeenCalledTimes(3);
});
it('يجب أن ترمي بعد فشل جميع المحاولات', async () => {
const mockFn = jest.fn().mockRejectedValue(new Error('يفشل دائماً'));
await expect(fetchWithRetry(mockFn, 3)).rejects.toThrow('يفشل دائماً');
expect(mockFn).toHaveBeenCalledTimes(3);
});
});
});
أدوات اختبار آمنة من حيث النوع
إنشاء أدوات قابلة لإعادة الاستخدام وآمنة من حيث النوع لاختباراتك:
// src/__tests__/utils/testHelpers.ts
// باني mock عام
export function createMock<T>(overrides?: Partial<T>): jest.Mocked<T> {
const mock = {} as jest.Mocked<T>;
if (overrides) {
Object.assign(mock, overrides);
}
return mock;
}
// مساعدات تأكيد آمنة من حيث النوع
export function assertDefined<T>(value: T | undefined | null): asserts value is T {
expect(value).toBeDefined();
expect(value).not.toBeNull();
}
export function assertType<T>(value: unknown): asserts value is T {
// سيذهب الفحص في وقت التشغيل هنا
}
// matchers مخصصة مع أمان النوع
export const customMatchers = {
toBeWithinRange(received: number, floor: number, ceiling: number) {
const pass = received >= floor && received <= ceiling;
return {
pass,
message: () =>
pass
? \`متوقع ${received} ألا يكون ضمن النطاق ${floor} - ${ceiling}\`
: \`متوقع ${received} أن يكون ضمن النطاق ${floor} - ${ceiling}\`
};
}
};
// توسيع matchers في Jest
declare global {
namespace jest {
interface Matchers<R> {
toBeWithinRange(floor: number, ceiling: number): R;
}
}
}
// دوال المصنع لبيانات الاختبار
export function createTestUser(overrides?: Partial<User>): User {
return {
id: 1,
name: 'مستخدم الاختبار',
email: 'test@example.com',
...overrides
};
}
export function createTestUsers(count: number): User[] {
return Array.from({ length: count }, (_, i) =>
createTestUser({ id: i + 1, name: \`مستخدم ${i + 1}\` })
);
}
// مساعدات الاختبار غير المتزامنة
export async function waitFor<T>(
fn: () => T | Promise<T>,
options: { timeout?: number; interval?: number } = {}
): Promise<T> {
const { timeout = 5000, interval = 50 } = options;
const startTime = Date.now();
while (true) {
try {
return await fn();
} catch (error) {
if (Date.now() - startTime >= timeout) {
throw error;
}
await new Promise(resolve => setTimeout(resolve, interval));
}
}
}
// الاستخدام في الاختبارات
import { createMock, assertDefined, createTestUser, customMatchers } from './utils/testHelpers';
expect.extend(customMatchers);
describe('أدوات الاختبار', () => {
it('يجب أن تنشئ mocks آمنة من حيث النوع', () => {
const mockApi = createMock<ApiClient>({
getUser: jest.fn().mockResolvedValue(createTestUser())
});
expect(mockApi.getUser).toBeDefined();
});
it('يجب أن تستخدم matchers مخصصة', () => {
expect(15).toBeWithinRange(10, 20);
expect(25).not.toBeWithinRange(10, 20);
});
it('يجب أن تنشئ بيانات الاختبار', () => {
const user = createTestUser({ name: 'اسم مخصص' });
expect(user.name).toBe('اسم مخصص');
expect(user.email).toBe('test@example.com');
});
});
ملاحظة: أدوات الاختبار الآمنة من حيث النوع تقلل من الكود المتكرر وتضمن أن كود اختبارك يتبع نفس مبادئ أمان النوع الخاصة بكود الإنتاج الخاص بك.
اختبار الأنواع
تايب سكريبت تسمح لك باختبار أن أنواعك صحيحة باستخدام مكتبات متخصصة:
// تثبيت مكتبة اختبار الأنواع
npm install --save-dev tsd
// src/types.ts
export type Optional<T> = T | undefined;
export type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
export type Prettify<T> = {
[K in keyof T]: T[K];
} & {};
export function identity<T>(value: T): T {
return value;
}
// src/__tests__/types.test-d.ts (استخدام tsd)
import { expectType, expectError, expectAssignable } from 'tsd';
import { Optional, DeepReadonly, identity } from '../types';
// اختبار نوع Optional
expectType<Optional<string>>('مرحباً');
expectType<Optional<string>>(undefined);
expectError<Optional<string>>(null);
expectError<Optional<string>>(123);
// اختبار نوع DeepReadonly
type Person = {
name: string;
address: {
street: string;
city: string;
};
};
const person: DeepReadonly<Person> = {
name: 'أحمد',
address: { street: 'الشارع الرئيسي', city: 'الرياض' }
};
expectError(person.name = 'محمد');
expectError(person.address.city = 'جدة');
// اختبار الدالة العامة
expectType<string>(identity('مرحباً'));
expectType<number>(identity(42));
expectType<boolean>(identity(true));
// البديل: استخدام @ts-expect-error لاختبارات الأنواع
// src/__tests__/typeTests.ts
// يجب أن يترجم هذا
const validString: Optional<string> = 'مرحباً';
const validUndefined: Optional<string> = undefined;
// @ts-expect-error - null غير صالح
const invalidNull: Optional<string> = null;
// @ts-expect-error - number غير صالح
const invalidNumber: Optional<string> = 123;
// اختبار أن readonly يمنع التعديلات
const readonlyPerson: DeepReadonly<Person> = {
name: 'أحمد',
address: { street: 'الشارع الرئيسي', city: 'الرياض' }
};
// @ts-expect-error - لا يمكن تعديل الخاصية readonly
readonlyPerson.name = 'محمد';
// @ts-expect-error - لا يمكن تعديل الخاصية readonly المتداخلة
readonlyPerson.address.city = 'جدة';
تحذير: اختبارات الأنواع يتم فحصها في وقت الترجمة، وليس في وقت التشغيل. استخدم tsd أو تعليقات @ts-expect-error للتأكد من أن تعريفات الأنواع الخاصة بك تعمل كما هو متوقع.
الاختبار مع React وتايب سكريبت
اختبار مكونات React مع تايب سكريبت يتطلب تكويناً إضافياً:
// تثبيت التبعيات
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
// src/components/Button.tsx
import React from 'react';
export interface ButtonProps {
label: string;
onClick: () => void;
disabled?: boolean;
variant?: 'primary' | 'secondary';
}
export const Button: React.FC<ButtonProps> = ({
label,
onClick,
disabled = false,
variant = 'primary'
}) => {
return (
<button
onClick={onClick}
disabled={disabled}
className={\`btn btn-${variant}\`}
>
{label}
</button>
);
};
// src/components/__tests__/Button.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { Button, ButtonProps } from '../Button';
describe('Button', () => {
const defaultProps: ButtonProps = {
label: 'انقر هنا',
onClick: jest.fn()
};
it('يجب أن يعرض مع التسمية', () => {
render(<Button {...defaultProps} />);
expect(screen.getByText('انقر هنا')).toBeInTheDocument();
});
it('يجب أن يستدعي onClick عند النقر', () => {
const handleClick = jest.fn();
render(<Button {...defaultProps} onClick={handleClick} />);
fireEvent.click(screen.getByText('انقر هنا'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('يجب أن يكون معطلاً عندما يكون disabled true', () => {
render(<Button {...defaultProps} disabled />);
expect(screen.getByText('انقر هنا')).toBeDisabled();
});
it('يجب أن يطبق فئة variant الصحيحة', () => {
const { rerender } = render(<Button {...defaultProps} variant="primary" />);
expect(screen.getByText('انقر هنا')).toHaveClass('btn-primary');
rerender(<Button {...defaultProps} variant="secondary" />);
expect(screen.getByText('انقر هنا')).toHaveClass('btn-secondary');
});
});
تمرين:
- أعد مشروع تايب سكريبت مع Jest وts-jest، بما في ذلك التكوين الصحيح لتغطية الكود.
- اكتب اختبارات شاملة لفئة UserRepository تنفذ عمليات CRUD، باستخدام mocks آمنة من حيث النوع لطبقة قاعدة البيانات.
- أنشئ مجموعة من أدوات الاختبار الآمنة من حيث النوع بما في ذلك دوال المصنع وmatchers المخصصة ومساعدات التأكيد.
- نفذ اختبارات الأنواع لمكتبة أنواع مساعدة عامة معقدة باستخدام tsd أو تعليقات @ts-expect-error.
- ابنِ مكون نموذج React مختبر مع تايب سكريبت، بما في ذلك منطق التحقق ومعالجة الإرسال غير المتزامن.
الخلاصة
اختبار كود تايب سكريبت يجمع أفضل ما في العالمين: التحقق في وقت التشغيل من الاختبارات مع أمان وقت الترجمة للأنواع. من خلال استخدام ts-jest، وإنشاء mocks آمنة من حيث النوع، وبناء أدوات اختبار قابلة لإعادة الاستخدام، وحتى اختبار الأنواع نفسها، يمكنك التأكد من أن كودك يعمل بشكل صحيح على مستوى النوع ووقت التشغيل. هذا النهج الشامل للاختبار يلتقط المزيد من الأخطاء مبكراً ويجعل قاعدة الكود الخاصة بك أكثر قابلية للصيانة وموثوقية.