أساسيات اختبار قاعدة البيانات
اختبار قاعدة البيانات أمر بالغ الأهمية للتطبيقات التي تتفاعل مع التخزين الدائم. على عكس اختبارات الوحدة التي تعمل بشكل منعزل، تتحقق اختبارات قاعدة البيانات من أن تطبيقك يقرأ ويكتب ويحدث ويحذف البيانات بشكل صحيح. تضمن اختبارات قاعدة البيانات المصممة جيداً سلامة البيانات، وتتحقق من الاستعلامات المعقدة، وتكتشف الأخطاء المتعلقة بالمخطط في وقت مبكر من التطوير.
مبدأ أساسي: يجب أن تكون اختبارات قاعدة البيانات معزولة وقابلة للتكرار وسريعة. يجب أن يبدأ كل اختبار بحالة معروفة، وينفذ العمليات، ويتحقق من النتائج، وينظف بعد ذلك. يجب ألا تعتمد الاختبارات على بعضها البعض أو تترك وراءها بيانات تؤثر على الاختبارات اللاحقة.
استراتيجيات اختبار قاعدة البيانات
هناك عدة طرق لاختبار الكود الذي يتفاعل مع قواعد البيانات:
1. قاعدة بيانات في الذاكرة
استخدم قاعدة بيانات في الذاكرة (مثل SQLite :memory:) للاختبارات السريعة والمعزولة:
- الإيجابيات: سريعة للغاية، لا حاجة للتنظيف، عزل مثالي
- السلبيات: قد يكون لها لهجة SQL مختلفة عن الإنتاج، تكافؤ محدود في الميزات
- الأفضل لـ: اختبارات الوحدة، خطوط CI/CD، حلقات التغذية الراجعة السريعة
2. قاعدة بيانات اختبار
استخدم مثيل قاعدة بيانات منفصل بنفس المحرك كالإنتاج:
- الإيجابيات: سلوك شبيه بالإنتاج، دعم كامل للميزات، اختبار دقيق للاستعلامات
- السلبيات: أبطأ من الذاكرة، يتطلب التنظيف، يحتاج إلى بنية تحتية
- الأفضل لـ: اختبارات التكامل، الاستعلامات المعقدة، التحقق من الإنتاج
3. معاملات قاعدة البيانات
لف كل اختبار في معاملة يتم التراجع عنها بعد الإكمال:
- الإيجابيات: تنظيف سريع، يحافظ على الاتساق، لا توجد بيانات متبقية
- السلبيات: لا يمكن اختبار منطق المعاملة نفسه، بعض العمليات لا يمكن التراجع عنها
- الأفضل لـ: معظم اختبارات قاعدة البيانات، خاصة مع Laravel/Rails
اختبار قاعدة بيانات Laravel
توفر Laravel أدوات ممتازة لاختبار قاعدة البيانات مع المصانع والموزعين وإدارة المعاملات:
ترحيلات قاعدة البيانات في الاختبارات
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class UserTest extends TestCase
{
use RefreshDatabase; // ترحيل وتراجع قاعدة البيانات
public function test_user_can_be_created()
{
$user = User::create([
'name' => 'John Doe',
'email' => 'john@example.com',
'password' => Hash::make('password')
]);
$this->assertDatabaseHas('users', [
'email' => 'john@example.com'
]);
}
}
سمة RefreshDatabase: تقوم هذه السمة بترحيل قاعدة البيانات قبل كل اختبار وتراجع التغييرات بعده. إنها أسرع من الترحيل الكامل بين الاختبارات لأنها تستخدم المعاملات عندما يكون ذلك ممكناً. استخدم RefreshDatabase لمعظم الاختبارات، و DatabaseMigrations فقط عندما تحتاج إلى ترحيلات كاملة (مثل اختبار الترحيلات نفسها).
مصانع قاعدة البيانات
تولد المصانع بيانات اختبار واقعية وقابلة للتخصيص:
<?php
// database/factories/UserFactory.php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
class UserFactory extends Factory
{
public function definition()
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => Hash::make('password'),
'remember_token' => Str::random(10),
];
}
public function unverified()
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
public function admin()
{
return $this->state(fn (array $attributes) => [
'role' => 'admin',
]);
}
}
// استخدام المصانع في الاختبارات
$user = User::factory()->create(); // ينشئ ويحفظ في قاعدة البيانات
$unverified = User::factory()->unverified()->create();
$admin = User::factory()->admin()->create();
// إنشاء مستخدمين متعددين
$users = User::factory()->count(10)->create();
// إنشاء بدون حفظ
$user = User::factory()->make(); // في الذاكرة فقط، غير محفوظ
علاقات المصنع
<?php
// database/factories/PostFactory.php
class PostFactory extends Factory
{
public function definition()
{
return [
'user_id' => User::factory(), // ينشئ مستخدم ذي صلة
'title' => fake()->sentence(),
'content' => fake()->paragraphs(3, true),
'published_at' => now(),
];
}
}
// إنشاء نماذج ذات صلة
$post = Post::factory()->create(); // ينشئ منشور ومستخدم
$user = User::factory()->create();
$post = Post::factory()->for($user)->create(); // منشور لمستخدم موجود
// علاقات له الكثير
$user = User::factory()
->has(Post::factory()->count(5))
->create(); // مستخدم مع 5 منشورات
// علاقات كثير لكثير
$post = Post::factory()
->hasAttached(Tag::factory()->count(3))
->create(); // منشور مع 3 علامات
تأكيدات قاعدة البيانات
توفر Laravel تأكيدات قوية للتحقق من حالة قاعدة البيانات:
تأكيدات قاعدة البيانات الأساسية
<?php
// تأكيد وجود السجل
$this->assertDatabaseHas('users', [
'email' => 'john@example.com',
'name' => 'John Doe'
]);
// تأكيد عدم وجود السجل
$this->assertDatabaseMissing('users', [
'email' => 'deleted@example.com'
]);
// تأكيد عدد السجلات
$this->assertDatabaseCount('posts', 10);
// الحذف الناعم
$this->assertSoftDeleted('posts', [
'id' => $post->id
]);
// غير محذوف ناعماً
$this->assertNotSoftDeleted('posts', [
'id' => $activePost->id
]);
// تأكيدات النموذج
$this->assertModelExists($user);
$this->assertModelMissing($deletedUser);
تأكيدات متقدمة
<?php
public function test_order_creates_invoice()
{
$user = User::factory()->create();
$order = Order::factory()->for($user)->create();
// تأكيدات متعددة ذات صلة
$this->assertDatabaseHas('orders', [
'id' => $order->id,
'user_id' => $user->id,
'status' => 'pending'
]);
$this->assertDatabaseHas('invoices', [
'order_id' => $order->id,
'total' => $order->total
]);
}
public function test_user_deletion_cascades()
{
$user = User::factory()
->has(Post::factory()->count(3))
->create();
$postIds = $user->posts->pluck('id');
$user->delete();
$this->assertModelMissing($user);
foreach ($postIds as $id) {
$this->assertDatabaseMissing('posts', ['id' => $id]);
}
}
موزعو قاعدة البيانات في الاختبارات
تملأ الموزعات قاعدة البيانات ببيانات محددة مسبقاً للاختبارات:
<?php
// database/seeders/TestSeeder.php
namespace Database\Seeders;
class TestSeeder extends Seeder
{
public function run()
{
// إنشاء مستخدم مسؤول
User::factory()->admin()->create([
'email' => 'admin@example.com'
]);
// إنشاء مستخدمين عاديين مع منشورات
User::factory()
->count(10)
->has(Post::factory()->count(3))
->create();
// إنشاء فئات
Category::factory()->count(5)->create();
}
}
// استخدام الموزعات في الاختبارات
class FeatureTest extends TestCase
{
use RefreshDatabase;
public function setUp(): void
{
parent::setUp();
$this->seed(TestSeeder::class);
}
public function test_admin_can_delete_any_post()
{
$admin = User::where('email', 'admin@example.com')->first();
$post = Post::first();
$this->actingAs($admin)
->delete("/posts/{$post->id}")
->assertStatus(204);
$this->assertModelMissing($post);
}
}
إدارة المعاملات
تضمن المعاملات أن الاختبارات لا تترك وراءها بيانات وتعمل بشكل أسرع:
المعاملات التلقائية (Laravel)
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
class OrderTest extends TestCase
{
use RefreshDatabase; // يستخدم المعاملات تلقائياً
public function test_order_calculates_total()
{
$order = Order::factory()->create();
$order->items()->create(['price' => 10, 'quantity' => 2]);
$order->items()->create(['price' => 15, 'quantity' => 1]);
$this->assertEquals(35, $order->fresh()->total);
// يتراجع المعاملة تلقائياً بعد الاختبار
}
}
المعاملات اليدوية
<?php
use Illuminate\Support\Facades\DB;
public function test_payment_processing()
{
DB::beginTransaction();
try {
$order = Order::factory()->create();
$payment = $order->processPayment(100);
$this->assertDatabaseHas('payments', [
'order_id' => $order->id,
'amount' => 100
]);
DB::rollBack(); // تنظيف يدوي
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
}
قيود المعاملة: لا يمكن التراجع عن بعض العمليات:
- بيانات DDL (CREATE، ALTER، DROP جداول)
- عمليات Truncate في بعض قواعد البيانات
- العمليات التي تلتزم ضمنياً
- استدعاءات النظام الخارجية (رسائل البريد الإلكتروني، طلبات API)
لهذه الحالات، استخدم سمة
DatabaseMigrations بدلاً من ذلك أو قم بمحاكاة التبعيات الخارجية.
اختبار قاعدة بيانات JavaScript
غالباً ما تختبر تطبيقات JavaScript قواعد البيانات باستخدام ORMs مثل Sequelize أو Prisma أو TypeORM:
مثال اختبار Prisma
// tests/setup.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export async function resetDatabase() {
await prisma.$transaction([
prisma.post.deleteMany(),
prisma.user.deleteMany(),
]);
}
export { prisma };
// tests/user.test.ts
import { prisma, resetDatabase } from './setup';
describe('User Model', () => {
beforeEach(async () => {
await resetDatabase();
});
afterAll(async () => {
await prisma.$disconnect();
});
test('creates user with posts', async () => {
const user = await prisma.user.create({
data: {
name: 'John Doe',
email: 'john@example.com',
posts: {
create: [
{ title: 'First Post', content: 'Content 1' },
{ title: 'Second Post', content: 'Content 2' }
]
}
},
include: { posts: true }
});
expect(user.posts).toHaveLength(2);
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
include: { posts: true }
});
expect(dbUser?.posts).toHaveLength(2);
});
test('deletes user cascades to posts', async () => {
const user = await prisma.user.create({
data: {
name: 'Jane Doe',
email: 'jane@example.com',
posts: {
create: [{ title: 'Post', content: 'Content' }]
}
}
});
await prisma.user.delete({ where: { id: user.id } });
const posts = await prisma.post.findMany({
where: { userId: user.id }
});
expect(posts).toHaveLength(0);
});
});
مثال اختبار Sequelize
// tests/models/user.test.js
const { sequelize, User, Post } = require('../../models');
describe('User Model', () => {
beforeAll(async () => {
await sequelize.sync({ force: true }); // إسقاط وإعادة إنشاء الجداول
});
beforeEach(async () => {
await User.destroy({ where: {}, truncate: true, cascade: true });
});
afterAll(async () => {
await sequelize.close();
});
test('creates user with validation', async () => {
const user = await User.create({
name: 'John Doe',
email: 'john@example.com',
password: 'password123'
});
expect(user.id).toBeDefined();
expect(user.email).toBe('john@example.com');
});
test('validates email uniqueness', async () => {
await User.create({
name: 'John',
email: 'john@example.com',
password: 'password'
});
await expect(
User.create({
name: 'Jane',
email: 'john@example.com',
password: 'password'
})
).rejects.toThrow();
});
test('has many posts', async () => {
const user = await User.create({
name: 'John',
email: 'john@example.com',
password: 'password'
});
await Post.bulkCreate([
{ userId: user.id, title: 'Post 1', content: 'Content 1' },
{ userId: user.id, title: 'Post 2', content: 'Content 2' }
]);
const posts = await user.getPosts();
expect(posts).toHaveLength(2);
});
});
اختبار الاستعلامات المعقدة
اختبر الاستعلامات التي تتضمن الصلات والتجميعات والاستعلامات الفرعية:
<?php
public function test_finds_top_authors_by_views()
{
// إنشاء مستخدمين مع منشورات
$author1 = User::factory()->create();
Post::factory()->for($author1)->create(['views' => 1000]);
Post::factory()->for($author1)->create(['views' => 500]);
$author2 = User::factory()->create();
Post::factory()->for($author2)->create(['views' => 2000]);
// استعلام: أفضل المؤلفين حسب إجمالي المشاهدات
$topAuthors = User::query()
->withCount('posts')
->withSum('posts', 'views')
->having('posts_sum_views', '>', 1000)
->orderByDesc('posts_sum_views')
->get();
$this->assertCount(2, $topAuthors);
$this->assertEquals($author2->id, $topAuthors->first()->id);
$this->assertEquals(2000, $topAuthors->first()->posts_sum_views);
}
public function test_searches_posts_with_tags()
{
$tag1 = Tag::factory()->create(['name' => 'laravel']);
$tag2 = Tag::factory()->create(['name' => 'php']);
$post1 = Post::factory()->create(['title' => 'Laravel Testing']);
$post1->tags()->attach([$tag1->id, $tag2->id]);
$post2 = Post::factory()->create(['title' => 'Vue.js Guide']);
// البحث عن المنشورات حسب العلامة
$laravelPosts = Post::whereHas('tags', function ($query) {
$query->where('name', 'laravel');
})->get();
$this->assertCount(1, $laravelPosts);
$this->assertEquals($post1->id, $laravelPosts->first()->id);
}
أفضل الممارسات لاختبار قاعدة البيانات
1. عزل كل اختبار
استخدم المعاملات أو الترحيلات للتأكد من أن كل اختبار يبدأ بلوح نظيف. يجب ألا تعتمد الاختبارات على بيانات من الاختبارات السابقة.
2. استخدم المصانع بدلاً من البيانات المشفرة
تجعل المصانع الاختبارات أكثر قابلية للصيانة وواقعية. تجنب ترميز بيانات المستخدم في كل اختبار.
3. اختبار منطق الأعمال، وليس ميزات ORM
لا تختبر أن Eloquent يمكنه حفظ السجلات—هذه وظيفة Laravel. اختبر استعلامات تطبيقك وعلاقاته وتحويلات البيانات.
4. حافظ على الاختبارات سريعة
استخدم قواعد بيانات في الذاكرة لاختبارات الوحدة. احتفظ باختبارات قاعدة البيانات الكاملة لاختبارات التكامل. قلل استخدام المصنع إلى البيانات الضرورية فقط.
5. تأكيد على مستويات متعددة
تحقق من حالة النموذج وحالة قاعدة البيانات. تحقق من العلاقات والقيود والآثار الجانبية.
تمرين عملي
التمرين 1: اختبار نظام طلب التجارة الإلكترونية
<?php
// إنشاء مصانع واختبارات لـ:
// نموذج الطلب مع:
// - belongsTo User
// - hasMany OrderItem
// - طريقة total() التي تجمع أسعار العناصر
// نموذج OrderItem مع:
// - belongsTo Order
// - belongsTo Product
// - خاصية subtotal (السعر * الكمية)
// اكتب اختبارات لـ:
// 1. إنشاء طلب مع عناصر متعددة
// 2. حساب إجمالي الطلب الصحيح
// 3. تحديث كمية العنصر يحدث الإجمالي
// 4. حذف الطلب يتسلسل إلى العناصر
// 5. الطلب ينتمي إلى المستخدم الصحيح
التمرين 2: اختبار سير عمل نشر منشور المدونة
// اختبارات للكتابة:
test('draft post is not visible to guests', () => {
// إنشاء منشور مسودة
// الاستعلام عن المنشورات المنشورة
// تأكيد عدم تضمين المسودة
});
test('publishing post sets published_at', () => {
// إنشاء مسودة
// استدعاء publish()
// تأكيد تعيين published_at
// تأكيد الحالة 'published'
});
test('author can see their drafts', () => {
// إنشاء مؤلف مع مسودات
// الاستعلام author.posts()
// تأكيد يتضمن المسودات
});
test('guest sees only published posts', () => {
// إنشاء مزيج من المسودة/المنشور
// الاستعلام Post::published()
// تأكيد إرجاع المنشور فقط
});
التمرين 3: اختبار نظام وضع العلامات
// كثير لكثير: posts <-> tags
test('attaches tags to post', () => {
// إنشاء منشور وعلامات
// إرفاق العلامات
// تأكيد وجود العلاقة
});
test('finds posts by tag', () => {
// إنشاء منشورات مع علامات مختلفة
// الاستعلام حسب علامة محددة
// تأكيد إرجاع المنشورات الصحيحة
});
test('detaching tag doesn\'t delete post', () => {
// إنشاء منشور مع علامة
// فصل العلامة
// تأكيد أن المنشور لا يزال موجوداً
// تأكيد أن العلامة لا تزال موجودة
});