Repository Testing

Test the database access layer, repository functions, and data integrity with real database operations in a controlled environment.

Table of Contents

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:
// 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()
    })
  })
})
Key Principles:
  • 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:

  1. Test with Real Data: Use actual database connections, not mocks
  2. Isolation: Each test should be completely independent
  3. Comprehensive Coverage: Test CRUD operations, relationships, and constraints
  4. Performance Monitoring: Track query performance and optimize slow operations
  5. 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()
  })
})
Ready to test your business logic? Continue with Service Testing to learn about testing business logic, authorization, and integration scenarios.
    Repository Testing | ShipSaaS Documentation | ShipSaaS - Launch your SaaS with AI in days