Database Testing Fundamentals
Database testing is critical for applications that interact with persistent storage. Unlike unit tests that run in isolation, database tests verify that your application correctly reads, writes, updates, and deletes data. Well-designed database tests ensure data integrity, validate complex queries, and catch schema-related bugs early in development.
Key Principle: Database tests should be isolated, repeatable, and fast. Each test should start with a known state, execute operations, verify results, and clean up afterward. Tests should never depend on each other or leave behind data that affects subsequent tests.
Database Testing Strategies
There are several approaches to testing code that interacts with databases:
1. In-Memory Database
Use an in-memory database (like SQLite :memory:) for fast, isolated tests:
- Pros: Extremely fast, no cleanup needed, perfect isolation
- Cons: May have different SQL dialect than production, limited feature parity
- Best for: Unit tests, CI/CD pipelines, quick feedback loops
2. Test Database
Use a separate database instance with the same engine as production:
- Pros: Production-like behavior, full feature support, accurate query testing
- Cons: Slower than in-memory, requires cleanup, needs infrastructure
- Best for: Integration tests, complex queries, production validation
3. Database Transactions
Wrap each test in a transaction that rolls back after completion:
- Pros: Fast cleanup, maintains consistency, no leftover data
- Cons: Can't test transaction logic itself, some operations can't be rolled back
- Best for: Most database tests, especially with Laravel/Rails
Laravel Database Testing
Laravel provides excellent tools for database testing with factories, seeders, and transaction management:
Database Migrations in Tests
<?php
namespace Tests\Feature;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class UserTest extends TestCase
{
use RefreshDatabase; // Migrates and rolls back database
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 Trait: This trait migrates your database before each test and rolls back changes after. It's faster than fully migrating between tests because it uses transactions when possible. Use RefreshDatabase for most tests, and DatabaseMigrations only when you need full migrations (e.g., testing migrations themselves).
Database Factories
Factories generate test data with realistic, customizable attributes:
<?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',
]);
}
}
// Using factories in tests
$user = User::factory()->create(); // Creates and saves to database
$unverified = User::factory()->unverified()->create();
$admin = User::factory()->admin()->create();
// Create multiple users
$users = User::factory()->count(10)->create();
// Make without saving
$user = User::factory()->make(); // In-memory only, not persisted
Factory Relationships
<?php
// database/factories/PostFactory.php
class PostFactory extends Factory
{
public function definition()
{
return [
'user_id' => User::factory(), // Creates related user
'title' => fake()->sentence(),
'content' => fake()->paragraphs(3, true),
'published_at' => now(),
];
}
}
// Creating related models
$post = Post::factory()->create(); // Creates post AND user
$user = User::factory()->create();
$post = Post::factory()->for($user)->create(); // Post for existing user
// Has many relationships
$user = User::factory()
->has(Post::factory()->count(5))
->create(); // User with 5 posts
// Many-to-many relationships
$post = Post::factory()
->hasAttached(Tag::factory()->count(3))
->create(); // Post with 3 tags
Database Assertions
Laravel provides powerful assertions for verifying database state:
Basic Database Assertions
<?php
// Assert record exists
$this->assertDatabaseHas('users', [
'email' => 'john@example.com',
'name' => 'John Doe'
]);
// Assert record doesn't exist
$this->assertDatabaseMissing('users', [
'email' => 'deleted@example.com'
]);
// Assert record count
$this->assertDatabaseCount('posts', 10);
// Soft deletes
$this->assertSoftDeleted('posts', [
'id' => $post->id
]);
// Not soft deleted
$this->assertNotSoftDeleted('posts', [
'id' => $activePost->id
]);
// Model assertions
$this->assertModelExists($user);
$this->assertModelMissing($deletedUser);
Advanced Assertions
<?php
public function test_order_creates_invoice()
{
$user = User::factory()->create();
$order = Order::factory()->for($user)->create();
// Multiple related assertions
$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]);
}
}
Database Seeders in Tests
Seeders populate the database with predefined data for tests:
<?php
// database/seeders/TestSeeder.php
namespace Database\Seeders;
class TestSeeder extends Seeder
{
public function run()
{
// Create admin user
User::factory()->admin()->create([
'email' => 'admin@example.com'
]);
// Create regular users with posts
User::factory()
->count(10)
->has(Post::factory()->count(3))
->create();
// Create categories
Category::factory()->count(5)->create();
}
}
// Using seeders in tests
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);
}
}
Transaction Management
Transactions ensure tests don't leave behind data and run faster:
Automatic Transactions (Laravel)
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
class OrderTest extends TestCase
{
use RefreshDatabase; // Automatically uses transactions
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);
// Transaction automatically rolls back after test
}
}
Manual Transactions
<?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(); // Manual cleanup
} catch (\Exception $e) {
DB::rollBack();
throw $e;
}
}
Transaction Limitations: Some operations cannot be rolled back:
- DDL statements (CREATE, ALTER, DROP tables)
- Truncate operations in some databases
- Operations that commit implicitly
- External system calls (emails, API requests)
For these cases, use
DatabaseMigrations trait instead or mock external dependencies.
JavaScript Database Testing
JavaScript applications often test databases using ORMs like Sequelize, Prisma, or TypeORM:
Prisma Testing Example
// 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 Testing Example
// tests/models/user.test.js
const { sequelize, User, Post } = require('../../models');
describe('User Model', () => {
beforeAll(async () => {
await sequelize.sync({ force: true }); // Drop and recreate tables
});
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);
});
});
Testing Complex Queries
Test queries that involve joins, aggregations, and subqueries:
<?php
public function test_finds_top_authors_by_views()
{
// Create users with posts
$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]);
// Query: Top authors by total views
$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']);
// Search posts by tag
$laravelPosts = Post::whereHas('tags', function ($query) {
$query->where('name', 'laravel');
})->get();
$this->assertCount(1, $laravelPosts);
$this->assertEquals($post1->id, $laravelPosts->first()->id);
}
Best Practices for Database Testing
1. Isolate Each Test
Use transactions or migrations to ensure each test starts with a clean slate. Tests should never depend on data from previous tests.
2. Use Factories Over Hard-coded Data
Factories make tests more maintainable and realistic. Avoid hard-coding user data in every test.
3. Test Business Logic, Not ORM Features
Don't test that Eloquent can save records—that's Laravel's job. Test your application's queries, relationships, and data transformations.
4. Keep Tests Fast
Use in-memory databases for unit tests. Reserve full database tests for integration tests. Minimize factory usage to only necessary data.
5. Assert on Multiple Levels
Check both the model state and database state. Verify relationships, constraints, and side effects.
Practical Exercise
Exercise 1: Test an e-commerce order system
<?php
// Create factories and tests for:
// Order model with:
// - belongsTo User
// - hasMany OrderItem
// - total() method that sums item prices
// OrderItem model with:
// - belongsTo Order
// - belongsTo Product
// - subtotal property (price * quantity)
// Write tests for:
// 1. Creating an order with multiple items
// 2. Calculating correct order total
// 3. Updating item quantity updates total
// 4. Deleting order cascades to items
// 5. Order belongs to correct user
Exercise 2: Test a blog post publishing workflow
// Tests to write:
test('draft post is not visible to guests', () => {
// Create draft post
// Query published posts
// Assert draft not included
});
test('publishing post sets published_at', () => {
// Create draft
// Call publish()
// Assert published_at is set
// Assert status is 'published'
});
test('author can see their drafts', () => {
// Create author with drafts
// Query author.posts()
// Assert includes drafts
});
test('guest sees only published posts', () => {
// Create mix of draft/published
// Query Post::published()
// Assert only published returned
});
Exercise 3: Test a tagging system
// Many-to-many: posts <-> tags
test('attaches tags to post', () => {
// Create post and tags
// Attach tags
// Assert relationship exists
});
test('finds posts by tag', () => {
// Create posts with different tags
// Query by specific tag
// Assert correct posts returned
});
test('detaching tag doesn\'t delete post', () => {
// Create post with tag
// Detach tag
// Assert post still exists
// Assert tag still exists
});