Skip to main content

Testing

Strategies and utilities for testing Kysera applications.

Transaction-Based Testing

The fastest approach - each test runs in a transaction that automatically rolls back.

import { testInTransaction } from '@kysera/testing'

describe('User Repository', () => {
it('should create user', async () => {
await testInTransaction(db, async (trx) => {
const repos = createRepos(trx)

const user = await repos.users.create({
email: 'test@example.com',
name: 'Test User'
})

expect(user.id).toBeDefined()
expect(user.email).toBe('test@example.com')

// No cleanup needed - transaction rolls back!
})
})

it('should find user by ID', async () => {
await testInTransaction(db, async (trx) => {
const repos = createRepos(trx)

const created = await repos.users.create({ ... })
const found = await repos.users.findById(created.id)

expect(found).toEqual(created)
})
})
})

Savepoints for Nested Transactions

When the code under test opens its own nested transactions, use testWithSavepoints — it creates a savepoint before the test body and rolls back to it afterwards (an optional third logger argument reports unexpected rollback errors):

import { testWithSavepoints } from '@kysera/testing'

it('handles nested operations', async () => {
await testWithSavepoints(db, async (trx) => {
await createUserWithProfile(trx, userData)

const user = await trx.selectFrom('users').selectAll().executeTakeFirst()
expect(user).toBeDefined()
})
})

Testing Under a Specific Isolation Level

testWithIsolation runs the rollback-transaction with an explicit isolation level ('read uncommitted', 'read committed', 'repeatable read', 'serializable'), using kysely's dialect-aware setIsolationLevel():

import { testWithIsolation } from '@kysera/testing'

it('detects write skew under serializable', async () => {
await testWithIsolation(db, 'serializable', async (trx) => {
// Test behavior under serializable isolation
})
})

Test Data Factories

Create consistent test data with factories:

import { createFactory, createSequenceFactory, createMany } from '@kysera/testing'

// createFactory value functions take NO arguments - they're re-invoked on each build
const userFactory = createFactory({
email: () => `user-${Date.now()}@example.com`,
name: 'Test User',
status: 'active'
})

// Override specific fields
const admin = userFactory({ status: 'admin' })

// For sequential data, use createSequenceFactory - it passes a sequence number
const sequencedUser = createSequenceFactory(seq => ({
email: `user${seq}@example.com`,
name: `User ${seq}`,
status: 'active'
}))

const user1 = sequencedUser() // { email: 'user1@...', name: 'User 1', ... }
const user2 = sequencedUser() // { email: 'user2@...', name: 'User 2', ... }

// Generate multiple
const users = createMany(sequencedUser, 10)

Testing Services

Test services with dependency injection:

class UserService {
constructor(private repos = createRepos(db)) {}

async createUserWithProfile(data: CreateUserInput) {
// Use repository's transaction method
return this.repos.users.transaction(async trx => {
const user = await trx
.insertInto('users')
.values(data)
.returningAll()
.executeTakeFirstOrThrow()

await trx.insertInto('profiles').values({ userId: user.id }).execute()

return user
})
}
}

describe('UserService', () => {
it('should create user with profile', async () => {
await testInTransaction(db, async trx => {
const service = new UserService(createRepos(trx))

const user = await service.createUserWithProfile({
email: 'test@example.com',
name: 'Test'
})

expect(user.id).toBeDefined()

const profile = await trx
.selectFrom('profiles')
.where('user_id', '=', user.id)
.executeTakeFirst()

expect(profile).toBeDefined()
})
})
})

Testing Transactions

Verify transaction rollback behavior:

it('should rollback on error', async () => {
const initialCount = await countRows(db, 'users')

await expect(
db.transaction().execute(async trx => {
const repos = createRepos(trx)
await repos.users.create({ email: 'test@test.com', name: 'Test' })
throw new Error('Force rollback')
})
).rejects.toThrow('Force rollback')

// Verify rollback
const finalCount = await countRows(db, 'users')
expect(finalCount).toBe(initialCount)
})

Testing Plugins

Test plugin behavior with soft delete:

import { createORM, createRepositoryFactory, nativeAdapter } from '@kysera/repository'
import { softDeletePlugin } from '@kysera/soft-delete'

describe('Soft Delete Plugin', () => {
it('should soft delete user', async () => {
await testInTransaction(db, async trx => {
// Create executor with soft delete plugin using createORM
const orm = await createORM(trx, [softDeletePlugin()])

// Create repository using orm's createRepository
const userRepo = orm.createRepository(executor => {
const factory = createRepositoryFactory(executor)
return factory.create({
tableName: 'users',
mapRow: row => row,
schemas: {
create: nativeAdapter()
}
})
})

// Create and soft delete user
const user = await userRepo.create({
email: 'test@example.com',
name: 'Test User'
})
await userRepo.softDelete(user.id)

// Should not find with regular query
const found = await userRepo.findById(user.id)
expect(found).toBeNull()

// Should find with findWithDeleted
const foundDeleted = await userRepo.findWithDeleted(user.id)
expect(foundDeleted).toBeDefined()
expect(foundDeleted?.deleted_at).toBeDefined()
})
})
})

Plugin Testing Utilities

@kysera/testing ships dedicated helpers for testing plugins in isolation.

createMockPlugin

Records every intercepted operation — useful for verifying plugin composition and execution order:

import { createMockPlugin } from '@kysera/testing'
import { createExecutor } from '@kysera/executor'

const mockPlugin = createMockPlugin('test-plugin', {
onIntercept: (qb, ctx) => qb // Optional: transform the query or return it unmodified
})

const executor = await createExecutor(db, [mockPlugin, softDeletePlugin()])
await executor.selectFrom('users').selectAll().execute()

expect(mockPlugin.operations).toHaveLength(1)
expect(mockPlugin.operations[0].operation).toBe('select')
expect(mockPlugin.operations[0].table).toBe('users')

mockPlugin.reset() // Clear recorded operations

spyOnPlugin

Wraps a real plugin, recording calls while preserving its behavior:

import { spyOnPlugin } from '@kysera/testing'

const spiedPlugin = spyOnPlugin(softDeletePlugin())
const executor = await createExecutor(db, [spiedPlugin])

await executor.deleteFrom('users').where('id', '=', 1).execute()

expect(spiedPlugin.calls).toHaveLength(1)
expect(spiedPlugin.calls[0].operation).toBe('delete')

createInMemoryDatabase

Creates an in-memory SQLite database from a SQL schema string (requires better-sqlite3 as a dev dependency):

import { createInMemoryDatabase } from '@kysera/testing'

const db = await createInMemoryDatabase<Database>(`
CREATE TABLE users (
id INTEGER PRIMARY KEY,
email TEXT NOT NULL,
deleted_at TEXT
)
`)

const executor = await createExecutor(db, [softDeletePlugin()])
await executor.insertInto('users').values({ email: 'test@example.com' }).execute()

createPluginTestHarness

Structured setup/execute/verify/teardown for plugin integration tests, backed by an in-memory database:

import { createPluginTestHarness } from '@kysera/testing'

const harness = createPluginTestHarness<Database>({
plugins: [softDeletePlugin(), timestampsPlugin()],
schema: `
CREATE TABLE posts (
id INTEGER PRIMARY KEY,
title TEXT,
deleted_at TEXT,
created_at TEXT,
updated_at TEXT
)
`
// Optional: seedData: async executor => { ... }
})

await harness.setup()

const result = await harness.execute(executor =>
executor.insertInto('posts').values({ title: 'Test Post' }).returningAll().executeTakeFirst()
)

harness.verify(result, r => {
expect(r?.created_at).toBeDefined()
expect(r?.updated_at).toBeDefined()
})

await harness.teardown()

For unit-testing a plugin's interceptQuery against a mock query builder, assertPluginBehavior(plugin, mockQb, context, assertions) reports whether the plugin intercepted and modified the query.

Testing Security (SQL Injection Prevention)

Kysera uses parameterized queries by default. Test that user input is safely handled:

import { sql } from 'kysely'

describe('SQL Injection Prevention', () => {
it('should safely handle malicious input in where clause', async () => {
await testInTransaction(db, async trx => {
const repos = createRepos(trx)

// Create test user
await repos.users.create({ email: 'legit@example.com', name: 'Legit User' })

// Attempt SQL injection
const maliciousEmail = "' OR '1'='1"

// Query builder uses parameterized queries - safe by default
const result = await trx
.selectFrom('users')
.selectAll()
.where('email', '=', maliciousEmail)
.execute()

// Should return empty (no match), not all users
expect(result).toHaveLength(0)
})
})

it('should safely handle user input in dynamic column names', async () => {
await testInTransaction(db, async trx => {
// Use sql.ref() for dynamic column names
const userColumn = 'email' // Could be from user input (after validation!)

const result = await trx
.selectFrom('users')
.select([sql.ref(userColumn)])
.limit(1)
.execute()

expect(result[0]).toHaveProperty('email')
})
})

it('should validate column names against allowlist', async () => {
const ALLOWED_COLUMNS = ['email', 'name', 'created_at'] as const

function getSortedUsers(sortBy: string) {
// Validate against allowlist before using
if (!ALLOWED_COLUMNS.includes(sortBy as any)) {
throw new Error('Invalid sort column')
}

return db.selectFrom('users').selectAll().orderBy(sql.ref(sortBy)).execute()
}

await testInTransaction(db, async trx => {
// Valid column - should work
await expect(getSortedUsers('email')).resolves.toBeDefined()

// Invalid column - should throw
await expect(getSortedUsers('DROP TABLE users')).rejects.toThrow('Invalid sort column')
})
})
})

Database Cleanup Strategies

Transaction (Fastest)

await testInTransaction(db, async trx => {
// Test code - auto rollback
})

Delete (Preserves Sequences)

beforeEach(async () => {
await cleanDatabase(db, 'delete', ['users', 'posts'])
})

Truncate (Most Thorough)

afterAll(async () => {
await cleanDatabase(db, 'truncate', ['posts', 'users'])
})

Both the 'delete' and 'truncate' strategies require the table list.

Integration Testing

Test with real database:

import { seedDatabase, cleanDatabase } from '@kysera/testing'

describe('Integration', () => {
beforeAll(async () => {
// seedDatabase takes a function, not raw data
await seedDatabase(db, async trx => {
await trx
.insertInto('users')
.values([
{ email: 'alice@example.com', name: 'Alice', status: 'active' },
{ email: 'bob@example.com', name: 'Bob', status: 'active' }
])
.execute()

await trx
.insertInto('posts')
.values([
{ user_id: 1, title: 'Post 1' },
{ user_id: 2, title: 'Post 2' }
])
.execute()
})
})

afterAll(async () => {
await cleanDatabase(db, 'truncate', ['posts', 'users'])
})

it('should handle complex query', async () => {
const result = await db
.selectFrom('users')
.innerJoin('posts', 'posts.user_id', 'users.id')
.where('users.status', '=', 'active')
.select(['users.id', 'users.name', db.fn.count('posts.id').as('post_count')])
.groupBy(['users.id', 'users.name'])
.execute()

expect(result.length).toBeGreaterThan(0)
})
})

Testing with Vitest

// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
globals: true,
setupFiles: ['./tests/setup.ts'],
pool: 'forks' // Isolated processes for DB tests
}
})
// tests/setup.ts
import { db } from './db'

beforeAll(async () => {
// Run migrations
await runMigrations(db, migrations)
})

afterAll(async () => {
await db.destroy()
})

Best Practices

1. Use Transaction Isolation

// Each test is isolated
await testInTransaction(db, async (trx) => { ... })

2. Create Fresh Data Per Test

it('test 1', async () => {
await testInTransaction(db, async trx => {
const user = await createTestUser(trx) // Fresh data
// Test...
})
})

3. Test Edge Cases

it('should handle not found', async () => {
await testInTransaction(db, async (trx) => {
const repos = createRepos(trx)
const found = await repos.users.findById(999999)
expect(found).toBeNull()
})
})

it('should handle duplicate', async () => {
await testInTransaction(db, async (trx) => {
const repos = createRepos(trx)
await repos.users.create({ email: 'test@test.com', ... })

await expect(
repos.users.create({ email: 'test@test.com', ... })
).rejects.toThrow(UniqueConstraintError)
})
})

4. Test Validation

it('should validate input', async () => {
await testInTransaction(db, async trx => {
const repos = createRepos(trx)

await expect(repos.users.create({ email: 'invalid', name: '' })).rejects.toThrow()
})
})

Testing Error Parsing

Kysera's parseDatabaseError() converts raw driver errors into typed errors. It does not detect the dialect — the second parameter selects the parser and defaults to 'postgres':

import { parseDatabaseError, UniqueConstraintError } from '@kysera/core'

describe('Database Error Parsing', () => {
it('should parse unique constraint error', async () => {
await testInTransaction(db, async trx => {
const repos = createRepos(trx)

// Create first user
await repos.users.create({ email: 'test@example.com', name: 'Test' })

try {
// Attempt duplicate
await repos.users.create({ email: 'test@example.com', name: 'Test2' })
expect.unreachable('Should have thrown')
} catch (err) {
// Pass your database's dialect (defaults to 'postgres' when omitted)
const error = parseDatabaseError(err, 'postgres') // or 'mysql', 'sqlite', 'mssql'

expect(error).toBeInstanceOf(UniqueConstraintError)
if (error instanceof UniqueConstraintError) {
expect(error.constraint).toBeDefined()
expect(error.columns).toContain('email')
expect(error.table).toBe('users')
}
}
})
})

it('should work across different databases', async () => {
// Test with PostgreSQL
const pgDb = new Kysely({ dialect: new PostgresDialect({ pool }) })
// Test with MySQL
const mysqlDb = new Kysely({ dialect: new MysqlDialect({ pool }) })
// Test with SQLite
const sqliteDb = new Kysely({ dialect: new SqliteDialect({ database }) })

// parseDatabaseError works with all dialects
// Just pass the correct dialect name
})
})