Table of Contents
- Repository Testing Strategy
- Database Operations Testing
- Transaction Testing
- Migration and Schema Testing
Repository Testing Strategy
Repository tests validate database operations and data integrity:Repository Scope: Test database repositories with real database connections while maintaining test isolation and performance.
Repository Testing Principles:Key Principles:
// src/db/repositories/__tests__/user-repository.test.ts
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { db } from '@/db'
import { users, userSettings } from '@/db/models'
import { eq } from 'drizzle-orm'
import {
getUserByIdDao,
createUserDao,
updateUserDao,
deleteUserDao,
searchUsersDao,
} from '../user-repository'
import { createTestUser } from '@/test/factories/user-factory'
describe('User Repository', () => {
const testUserId = 'test-user-' + crypto.randomUUID()
beforeEach(async () => {
// Clean test data before each test
await db.delete(userSettings).where(eq(userSettings.userId, testUserId))
await db.delete(users).where(eq(users.id, testUserId))
})
afterEach(async () => {
// Clean up after each test
await db.delete(userSettings).where(eq(userSettings.userId, testUserId))
await db.delete(users).where(eq(users.id, testUserId))
})
describe('getUserByIdDao', () => {
it('should return user with relations', async () => {
// Insert test user
const testUser = createTestUser({ id: testUserId })
await db.insert(users).values(testUser)
// Insert related data
await db.insert(userSettings).values({
userId: testUserId,
theme: 'dark',
language: 'en',
timezone: 'UTC',
})
// Test repository function
const result = await getUserByIdDao(testUserId)
expect(result).toBeDefined()
expect(result?.id).toBe(testUserId)
expect(result?.name).toBe(testUser.name)
expect(result?.settings).toBeDefined()
expect(result?.settings?.theme).toBe('dark')
})
it('should return null for non-existent user', async () => {
const result = await getUserByIdDao('non-existent-id')
expect(result).toBeNull()
})
})
})- Real Database: Test against actual database, not mocks
- Test Isolation: Each test is independent and isolated
- Data Cleanup: Proper setup and teardown of test data
- Realistic Data: Use factories to create realistic test data
Database Operations Testing
Test CRUD operations and complex database queries:Create, Read, Update, Delete Testing:
// src/db/repositories/__tests__/user-repository.test.ts
describe('User Repository CRUD Operations', () => {
describe('createUserDao', () => {
it('should create user with UUID', async () => {
const userData = {
name: 'New User',
email: 'new@example.com',
emailVerified: false,
}
const result = await createUserDao(userData)
expect(result.id).toBeDefined()
expect(result.id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
expect(result.name).toBe(userData.name)
expect(result.email).toBe(userData.email)
expect(result.createdAt).toBeInstanceOf(Date)
expect(result.updatedAt).toBeInstanceOf(Date)
})
it('should handle email uniqueness constraint', async () => {
const userData = {
name: 'User One',
email: 'duplicate@example.com',
}
await createUserDao(userData)
// Attempt to create duplicate
await expect(createUserDao({
name: 'User Two',
email: 'duplicate@example.com',
})).rejects.toThrow(/unique constraint/i)
})
it('should set default values', async () => {
const userData = {
name: 'Test User',
email: 'test@example.com',
}
const result = await createUserDao(userData)
expect(result.emailVerified).toBe(false) // Default value
expect(result.role).toBe('user') // Default role
expect(result.visibility).toBe('private') // Default visibility
})
})
describe('updateUserDao', () => {
it('should update user fields', async () => {
// Create user first
const user = await createUserDao({
name: 'Original Name',
email: 'original@example.com',
})
// Update user
const updated = await updateUserDao(user.id, {
name: 'Updated Name',
emailVerified: true,
})
expect(updated.name).toBe('Updated Name')
expect(updated.email).toBe('original@example.com') // Unchanged
expect(updated.emailVerified).toBe(true)
expect(updated.updatedAt.getTime()).toBeGreaterThan(user.updatedAt.getTime())
})
it('should handle partial updates', async () => {
const user = await createUserDao({
name: 'Test User',
email: 'test@example.com',
})
const updated = await updateUserDao(user.id, {
emailVerified: true,
})
expect(updated.name).toBe('Test User') // Unchanged
expect(updated.emailVerified).toBe(true) // Updated
})
it('should return null for non-existent user', async () => {
const result = await updateUserDao('non-existent-id', {
name: 'New Name',
})
expect(result).toBeNull()
})
})
describe('deleteUserDao', () => {
it('should delete user and return deleted data', async () => {
const user = await createUserDao({
name: 'To Delete',
email: 'delete@example.com',
})
const deleted = await deleteUserDao(user.id)
expect(deleted).toBeDefined()
expect(deleted?.id).toBe(user.id)
// Verify deletion
const found = await getUserByIdDao(user.id)
expect(found).toBeNull()
})
it('should handle cascading deletes', async () => {
const user = await createUserDao({
name: 'User with Settings',
email: 'withsettings@example.com',
})
// Create related data
await db.insert(userSettings).values({
userId: user.id,
theme: 'dark',
language: 'en',
})
await deleteUserDao(user.id)
// Verify cascading delete
const settings = await db.select()
.from(userSettings)
.where(eq(userSettings.userId, user.id))
expect(settings).toHaveLength(0)
})
})
})Transaction Testing
Test database transactions and atomic operations:Basic Transaction Testing:
// src/db/__tests__/transactions.test.ts
import { describe, it, expect } from 'vitest'
import { db } from '@/db'
import { users, organizations, members } from '@/db/models'
describe('Database Transactions', () => {
describe('Basic Transaction Operations', () => {
it('should commit transaction on success', async () => {
const result = await db.transaction(async (tx) => {
// Create user
const [user] = await tx.insert(users).values({
name: 'Transaction User',
email: 'transaction@example.com',
}).returning()
// Create organization
const [org] = await tx.insert(organizations).values({
name: 'Transaction Org',
ownerId: user.id,
}).returning()
// Create membership
const [member] = await tx.insert(members).values({
userId: user.id,
organizationId: org.id,
role: 'owner',
}).returning()
return { user, org, member }
})
// Verify all data exists after commit
expect(result.user.id).toBeDefined()
expect(result.org.id).toBeDefined()
expect(result.member.id).toBeDefined()
const savedUser = await db.query.users.findFirst({
where: eq(users.id, result.user.id),
})
expect(savedUser).toBeDefined()
})
it('should rollback transaction on error', async () => {
const initialUserCount = await db.select({ count: count() }).from(users)
try {
await db.transaction(async (tx) => {
// Create user (this should succeed)
const [user] = await tx.insert(users).values({
name: 'Rollback User',
email: 'rollback@example.com',
}).returning()
// Create organization (this should succeed)
await tx.insert(organizations).values({
name: 'Rollback Org',
ownerId: user.id,
})
// Simulate business logic error
throw new Error('Business logic error')
})
} catch (error) {
// Expected error
expect(error.message).toBe('Business logic error')
}
// Verify rollback - no new users should exist
const finalUserCount = await db.select({ count: count() }).from(users)
expect(finalUserCount[0].count).toBe(initialUserCount[0].count)
})
it('should handle nested transactions', async () => {
const result = await db.transaction(async (tx) => {
const [user] = await tx.insert(users).values({
name: 'Nested User',
email: 'nested@example.com',
}).returning()
// Nested transaction (savepoint)
const orgResult = await tx.transaction(async (nestedTx) => {
return await nestedTx.insert(organizations).values({
name: 'Nested Org',
ownerId: user.id,
}).returning()
})
return { user, organization: orgResult[0] }
})
expect(result.user.id).toBeDefined()
expect(result.organization.id).toBeDefined()
})
})
describe('Transaction Isolation', () => {
it('should maintain read committed isolation', async () => {
const testEmail = 'isolation@example.com'
// Start first transaction
const tx1Promise = db.transaction(async (tx1) => {
await tx1.insert(users).values({
name: 'Isolation User',
email: testEmail,
})
// Simulate long operation
await new Promise(resolve => setTimeout(resolve, 100))
return 'tx1-complete'
})
// Start second transaction after a delay
await new Promise(resolve => setTimeout(resolve, 50))
const tx2Promise = db.transaction(async (tx2) => {
// Should not see uncommitted data from tx1
const user = await tx2.query.users.findFirst({
where: eq(users.email, testEmail),
})
return user
})
const [tx1Result, tx2Result] = await Promise.all([tx1Promise, tx2Promise])
expect(tx1Result).toBe('tx1-complete')
expect(tx2Result).toBeUndefined() // Shouldn't see uncommitted data
})
})
})Migration and Schema Testing
Test database migrations, schema changes, and data integrity:Database Migration Testing:
// src/db/__tests__/migrations.test.ts
import { describe, it, expect, beforeAll } from 'vitest'
import { migrate } from 'drizzle-orm/neon-http/migrator'
import { sql } from 'drizzle-orm'
import { db } from '@/db'
describe('Database Migrations', () => {
beforeAll(async () => {
// Ensure test database is up to date
await migrate(db, { migrationsFolder: './drizzle/migrations' })
})
describe('Schema Creation', () => {
it('should have all required tables', async () => {
const tables = await db.execute(sql`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
`)
const tableNames = tables.rows.map(row => row.table_name)
// Verify critical tables exist
const expectedTables = [
'users',
'organizations',
'members',
'projects',
'tasks',
'subscriptions',
'notifications',
'user_settings',
]
expectedTables.forEach(tableName => {
expect(tableNames).toContain(tableName)
})
})
it('should have UUID extension enabled', async () => {
const extensions = await db.execute(sql`
SELECT extname FROM pg_extension WHERE extname = 'uuid-ossp'
`)
expect(extensions.rows).toHaveLength(1)
expect(extensions.rows[0].extname).toBe('uuid-ossp')
})
it('should generate UUIDs correctly', async () => {
const result = await db.execute(sql`SELECT uuid_generate_v4() as uuid`)
const uuid = result.rows[0].uuid
expect(uuid).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
})
})
describe('Foreign Key Constraints', () => {
it('should have proper foreign key relationships', async () => {
const constraints = await db.execute(sql`
SELECT
tc.constraint_name,
tc.table_name,
kcu.column_name,
ccu.table_name AS foreign_table_name,
ccu.column_name AS foreign_column_name
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name
JOIN information_schema.constraint_column_usage AS ccu
ON ccu.constraint_name = tc.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = 'public'
`)
const constraintData = constraints.rows
// Verify key relationships
const expectedConstraints = [
{ table: 'organizations', column: 'owner_id', foreignTable: 'users' },
{ table: 'members', column: 'user_id', foreignTable: 'users' },
{ table: 'members', column: 'organization_id', foreignTable: 'organizations' },
{ table: 'projects', column: 'organization_id', foreignTable: 'organizations' },
{ table: 'projects', column: 'owner_id', foreignTable: 'users' },
]
expectedConstraints.forEach(({ table, column, foreignTable }) => {
const constraint = constraintData.find(
c => c.table_name === table && c.column_name === column
)
expect(constraint).toBeDefined()
expect(constraint?.foreign_table_name).toBe(foreignTable)
})
})
})
describe('Indexes', () => {
it('should have performance indexes', async () => {
const indexes = await db.execute(sql`
SELECT
indexname,
tablename,
indexdef
FROM pg_indexes
WHERE schemaname = 'public'
AND indexname NOT LIKE '%_pkey'
`)
const indexNames = indexes.rows.map(row => row.indexname)
// Verify critical indexes exist
const expectedIndexes = [
'users_email_unique',
'organizations_slug_unique',
'idx_members_organization_id',
'idx_projects_organization_id',
]
expectedIndexes.forEach(indexName => {
expect(indexNames).toContain(indexName)
})
})
it('should have unique constraints', async () => {
const uniqueConstraints = await db.execute(sql`
SELECT
tc.table_name,
tc.constraint_name,
kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
WHERE tc.constraint_type = 'UNIQUE'
AND tc.table_schema = 'public'
`)
const constraints = uniqueConstraints.rows
// Verify unique constraints
const expectedUnique = [
{ table: 'users', column: 'email' },
{ table: 'organizations', column: 'slug' },
]
expectedUnique.forEach(({ table, column }) => {
const constraint = constraints.find(
c => c.table_name === table && c.column_name === column
)
expect(constraint).toBeDefined()
})
})
})
describe('Column Definitions', () => {
it('should have correct column types and constraints', async () => {
const columns = await db.execute(sql`
SELECT
column_name,
data_type,
is_nullable,
column_default
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'users'
ORDER BY ordinal_position
`)
const columnData = columns.rows.reduce((acc, row) => {
acc[row.column_name] = {
type: row.data_type,
nullable: row.is_nullable === 'YES',
default: row.column_default,
}
return acc
}, {})
// Verify critical columns
expect(columnData.id.type).toBe('text')
expect(columnData.id.nullable).toBe(false)
expect(columnData.email.type).toBe('character varying')
expect(columnData.email.nullable).toBe(false)
expect(columnData.created_at.type).toBe('timestamp without time zone')
expect(columnData.created_at.nullable).toBe(false)
expect(columnData.created_at.default).toContain('now()')
})
it('should have proper enum types', async () => {
const enums = await db.execute(sql`
SELECT
t.typname as enum_name,
e.enumlabel as enum_value
FROM pg_type t
JOIN pg_enum e ON t.oid = e.enumtypid
WHERE t.typname LIKE '%_enum'
ORDER BY t.typname, e.enumsortorder
`)
const enumData = enums.rows.reduce((acc, row) => {
if (!acc[row.enum_name]) acc[row.enum_name] = []
acc[row.enum_name].push(row.enum_value)
return acc
}, {})
// Verify enums exist and have correct values
if (enumData.role_enum) {
expect(enumData.role_enum).toContain('user')
expect(enumData.role_enum).toContain('admin')
}
if (enumData.member_role_enum) {
expect(enumData.member_role_enum).toContain('member')
expect(enumData.member_role_enum).toContain('admin')
expect(enumData.member_role_enum).toContain('owner')
}
})
})
})Best Practices Summary
Repository Testing Excellence:
- Real Database: Test against actual database for realistic validation
- Data Isolation: Ensure tests don't interfere with each other
- Transaction Testing: Validate atomic operations and rollback behavior
- Performance Awareness: Monitor query performance and connection usage
Key Principles:
- Test with Real Data: Use actual database connections, not mocks
- Isolation: Each test should be completely independent
- Comprehensive Coverage: Test CRUD operations, relationships, and constraints
- Performance Monitoring: Track query performance and optimize slow operations
- Migration Validation: Ensure schema changes work correctly and can be rolled back
Common Patterns:
// ✅ Good repository test structure
describe('Repository Function', () => {
beforeEach(async () => {
await cleanTestData()
})
afterEach(async () => {
await cleanTestData()
})
it('should handle database operation correctly', async () => {
const testData = createTestData()
const result = await repositoryFunction(testData)
expect(result).toMatchExpectedShape()
})
})