Testing Strategies
Build confidence through comprehensive testing - Master unit testing, integration testing, and mocking strategies for robust CQL applications
Testing is essential for building reliable applications. This guide covers comprehensive testing strategies for CQL applications, from unit tests to integration tests, with practical examples and best practices.
Testing Fundamentals
Testing Pyramid
CQL applications benefit from a well-structured testing pyramid:
Test Types Overview
Unit
Fast
High
Mocked
Business logic, validations
Integration
Medium
Medium
Test DB
Database operations, queries
End-to-End
Slow
Low
Test DB
Full application flows
Test Environment Setup
Test Database Configuration
# spec/spec_helper.cr
require "spec"
require "../src/myapp"
# Configure test database
TestDB = CQL::Schema.define(
:test_db,
adapter: CQL::Adapter::SQLite,
uri: "sqlite3://:memory:") do
# Define your schema here
table :users do
primary :id, Int64, auto_increment: true
column :name, String
column :email, String
column :active, Bool, default: true
column :role, String, default: "user"
timestamps
end
table :posts do
primary :id, Int64, auto_increment: true
column :user_id, Int64
column :title, String
column :content, String
column :published, Bool, default: false
timestamps
foreign_key :user_id, references: :users
end
end
# Build test database schema
TestDB.build
# Test cleanup helpers
module TestHelpers
# Clean database between tests
def cleanup_database
TestDB.exec("DELETE FROM posts")
TestDB.exec("DELETE FROM users")
# Reset auto-increment counters
TestDB.exec("DELETE FROM sqlite_sequence") if TestDB.adapter == CQL::Adapter::SQLite
end
# Transaction rollback for faster cleanup
def with_rollback(&block)
TestDB.transaction do |tx|
begin
yield
ensure
tx.rollback
end
end
end
end
# Configure Spec hooks
Spec.before_each do
TestHelpers.cleanup_database
end
Spec.after_suite do
TestDB.close
end
Environment-Specific Configuration
# config/test.cr
module TestConfig
# Test-specific database settings
DATABASE_CONFIG = {
adapter: CQL::Adapter::SQLite,
uri: "sqlite3://:memory:"
}
# Use different configs for different test types
def self.unit_test_db
CQL::Schema.define(:unit_test, **DATABASE_CONFIG) do
# Define minimal schema for unit tests
end
end
def self.integration_test_db
CQL::Schema.define(:integration_test, adapter: CQL::Adapter::SQLite, uri: "sqlite3://./tmp/integration_test.db") do
# Define full schema for integration tests
end
end
# Fast test data creation
def self.enable_fast_tests
# Reduce bcrypt rounds for password hashing
ENV["BCRYPT_COST"] = "1"
end
end
Unit Testing
Testing Model Logic
# spec/models/user_spec.cr
require "../spec_helper"
describe User do
describe "validations" do
it "validates presence of name" do
user = User.new(name: "", email: "test@example.com")
user.valid?.should be_false
user.errors.map(&.message).should contain("name must be present")
end
it "validates email format" do
user = User.new(name: "Test", email: "invalid-email")
user.valid?.should be_false
user.errors.map(&.field).should contain(:email)
end
it "validates email uniqueness in database context" do
# This requires database integration
existing_user = User.new(name: "First", email: "test@example.com")
existing_user.save!
duplicate_user = User.new(name: "Second", email: "test@example.com")
# Would need database-level unique constraint validation
# This is typically tested in integration tests
end
end
describe "business logic" do
it "has correct full name" do
user = User.new(name: "John Doe", email: "john@example.com")
user.full_name.should eq("John Doe")
end
it "can be activated" do
user = User.new(name: "Test", email: "test@example.com", active: false)
user.activate!
user.active?.should be_true
user.activated_at.should_not be_nil
end
it "can be deactivated with reason" do
user = User.new(name: "Test", email: "test@example.com", active: true)
user.deactivate!("Terms violation")
user.active?.should be_false
user.deactivation_reason.should eq("Terms violation")
end
end
end
Testing Callbacks
# spec/models/user_callbacks_spec.cr
require "../spec_helper"
describe User do
describe "callbacks" do
it "normalizes email before saving" do
user = User.new(name: "Test", email: "TEST@EXAMPLE.COM")
user.save!
user.email.should eq("test@example.com")
end
it "tracks callback execution order" do
# Using the actual callback tracking from the codebase
CallbackTracker.clear
user = User.new(name: "Test", email: "test@example.com")
user.save!
expected_order = [
"before_validation",
"after_validation",
"before_save",
"before_create",
"after_create",
"after_save"
]
CallbackTracker.order.should eq(expected_order)
end
it "updates timestamps on save" do
user = User.new(name: "Test", email: "test@example.com")
user.save!
original_updated_at = user.updated_at
sleep 0.1 # Ensure time difference
user.touch
user.updated_at.should be > original_updated_at
end
end
end
Testing Custom Validators
# spec/validators/password_validator_spec.cr
require "../spec_helper"
describe PasswordValidator do
it "validates password complexity" do
user = User.new(name: "Test", email: "test@example.com", password: "weak")
validator = PasswordValidator.new(user)
errors = validator.valid?
errors.should_not be_empty
errors.map(&.message).should contain("Password must be at least 8 characters")
end
it "accepts strong passwords" do
user = User.new(name: "Test", email: "test@example.com", password: "StrongP@ss123")
validator = PasswordValidator.new(user)
errors = validator.valid?
errors.should be_empty
end
end
Integration Testing
Database Integration Tests
# spec/integration/user_persistence_spec.cr
require "../spec_helper"
describe "User Persistence" do
before_each do
TestDB.users.create!
end
after_each do
TestDB.users.drop!
end
describe "CRUD operations" do
it "creates and retrieves users" do
user = User.new(
name: "John Doe",
email: "john@example.com",
role: "admin"
)
user.save!
# Verify database persistence
user.id.should_not be_nil
user.persisted?.should be_true
# Retrieve from database
found_user = User.find!(user.id!)
found_user.name.should eq("John Doe")
found_user.email.should eq("john@example.com")
found_user.role.should eq("admin")
end
it "updates user attributes" do
user = User.new(name: "Original", email: "original@example.com")
user.save!
user.update!(name: "Updated", email: "updated@example.com")
# Verify changes persisted
reloaded_user = User.find!(user.id!)
reloaded_user.name.should eq("Updated")
reloaded_user.email.should eq("updated@example.com")
end
it "deletes users" do
user = User.new(name: "To Delete", email: "delete@example.com")
user.save!
user_id = user.id!
user.delete!
# Verify deletion
User.find?(user_id).should be_nil
end
end
describe "complex queries" do
before_each do
# Create test data
active_user = User.new(name: "Active", email: "active@example.com", active: true)
active_user.save!
inactive_user = User.new(name: "Inactive", email: "inactive@example.com", active: false)
inactive_user.save!
admin = User.new(name: "Admin", email: "admin@example.com", role: "admin")
admin.save!
# Create posts if posts table exists
if TestDB.tables.has_key?(:posts)
TestDB.posts.create!
active_user.posts.create!(title: "Active Post", content: "Content")
admin.posts.create!(title: "Admin Post", content: "Admin content", published: true)
end
end
it "finds users with specific criteria" do
active_users = User.where(active: true).all
active_users.size.should be >= 2 # Active user + Admin
active_users.all?(&.active?).should be_true
end
it "aggregates user statistics" do
total_users = User.count
active_users = User.where(active: true).count
admin_users = User.where(role: "admin").count
total_users.should eq(3)
active_users.should eq(2) # Active user + Admin
admin_users.should eq(1)
end
end
end
Transaction Testing
# spec/integration/transaction_spec.cr
require "../spec_helper"
describe "Transactions" do
before_each do
TestDB.users.create!
end
after_each do
TestDB.users.drop!
end
it "commits successful transactions" do
User.transaction do
User.new(name: "User 1", email: "user1@example.com").save!
User.new(name: "User 2", email: "user2@example.com").save!
end
User.count.should eq(2)
end
it "rolls back failed transactions" do
expect_raises(Exception) do
User.transaction do
User.new(name: "Valid User", email: "valid@example.com").save!
raise "Intentional error"
User.new(name: "Second User", email: "second@example.com").save!
end
end
# No users should be created due to rollback
User.count.should eq(0)
end
it "handles nested transactions" do
User.transaction do |tx|
user = User.new(name: "Outer", email: "outer@example.com")
user.save!
User.transaction(tx) do
if TestDB.tables.has_key?(:posts)
user.posts.create!(title: "Inner Post", content: "Content")
end
user.update!(name: "Updated in Inner")
end
user.reload!
user.name.should eq("Updated in Inner")
end
end
end
Mocking and Stubbing
Database Mocking
# spec/mocks/mock_database.cr
class MockDatabase
getter queries : Array(String)
getter results : Hash(String, Array(NamedTuple))
def initialize
@queries = [] of String
@results = {} of String => Array(NamedTuple)
end
def expect_query(sql : String, result : Array(NamedTuple))
@results[sql] = result
end
def exec(sql : String)
@queries << sql
@results[sql]? || [] of NamedTuple
end
def reset
@queries.clear
@results.clear
end
end
# Usage in tests
describe "User finder" do
it "executes correct SQL for finding by email" do
mock_db = MockDatabase.new
mock_db.expect_query(
"SELECT * FROM users WHERE email = ?",
[{id: 1, name: "Test", email: "test@example.com"}]
)
# Test your finder logic with mock
# This would require dependency injection in the actual implementation
mock_db.queries.should contain("SELECT * FROM users WHERE email = ?")
end
end
Service Mocking
# spec/mocks/mock_services.cr
class MockEmailService
getter sent_emails : Array(NamedTuple)
def initialize
@sent_emails = [] of NamedTuple
end
def send_email(to : String, subject : String, body : String)
@sent_emails << {to: to, subject: subject, body: body}
true
end
def reset
@sent_emails.clear
end
end
# Integration with models
describe "User email notifications" do
it "sends welcome email on registration" do
mock_email = MockEmailService.new
# This would require dependency injection in the actual callback
user = User.new(name: "Test", email: "test@example.com")
user.save!
# Verify email was sent (if callback system supports mocking)
# This is a conceptual example
end
end
Test Data Factories
Simple Factory Pattern
# spec/factories/user_factory.cr
class UserFactory
@@counter = 0
def self.build(attributes = {} of Symbol => String | Bool | Int32)
@@counter += 1
default_attributes = {
name: "User #{@@counter}",
email: "user#{@@counter}@example.com",
active: true,
role: "user",
age: 25
}
merged_attributes = default_attributes.merge(attributes)
User.new(
name: merged_attributes[:name].as(String),
email: merged_attributes[:email].as(String),
active: merged_attributes[:active].as(Bool),
role: merged_attributes[:role].as(String),
age: merged_attributes[:age].as(Int32)
)
end
def self.create(attributes = {} of Symbol => String | Bool | Int32)
user = build(attributes)
user.save!
user
end
def self.create_with_posts(post_count = 3, user_attributes = {} of Symbol => String | Bool | Int32)
user = create(user_attributes)
if TestDB.tables.has_key?(:posts)
post_count.times do |i|
user.posts.create!(
title: "Post #{i + 1} by #{user.name}",
content: "Content for post #{i + 1}",
published: i.even? # Alternate published status
)
end
end
user
end
end
class PostFactory
@@counter = 0
def self.build(user : User? = nil, attributes = {} of Symbol => String | Bool)
@@counter += 1
default_attributes = {
title: "Post #{@@counter}",
content: "Content for post #{@@counter}",
published: false
}
merged_attributes = default_attributes.merge(attributes)
Post.new(
title: merged_attributes[:title].as(String),
content: merged_attributes[:content].as(String),
published: merged_attributes[:published].as(Bool),
user_id: user.try(&.id)
)
end
def self.create(user : User? = nil, attributes = {} of Symbol => String | Bool)
post = build(user, attributes)
post.save!
post
end
end
Database Testing Patterns
Shared Examples
# spec/shared/crud_examples.cr
shared_examples "CRUD operations" do |factory_class|
describe "CRUD operations" do
it "creates records" do
record = factory_class.create
record.persisted?.should be_true
record.id.should_not be_nil
end
it "reads records" do
record = factory_class.create
found = record.class.find?(record.id!)
found.should_not be_nil
end
it "updates records" do
record = factory_class.create
original_updated_at = record.updated_at
sleep 0.1
record.touch
record.updated_at.should be > original_updated_at
end
it "deletes records" do
record = factory_class.create
record_id = record.id!
record.delete!
record.class.find?(record_id).should be_nil
end
end
end
# Usage
describe User do
include_examples "CRUD operations", UserFactory
end
describe Post do
include_examples "CRUD operations", PostFactory
end
Database Cleaning Strategies
# spec/support/database_cleaner.cr
module DatabaseCleaner
extend self
# Strategy 1: Truncation (fastest for small datasets)
def truncate_all_tables
TestDB.tables.each do |table_name, _|
TestDB.exec("DELETE FROM #{table_name}")
end
end
# Strategy 2: Transaction rollback (fastest overall)
def with_clean_database(&block)
TestDB.transaction do |tx|
begin
yield
ensure
tx.rollback
end
end
end
# Strategy 3: Selective cleanup (for specific test isolation)
def clean_tables(*table_names)
table_names.each do |table_name|
TestDB.exec("DELETE FROM #{table_name}")
end
end
end
# Configure for different test types
module TestConfig
def self.configure_database_cleaning
case ENV["TEST_TYPE"]?
when "unit"
# Mock everything - no database cleaning needed
when "integration"
# Use transaction rollback for speed
Spec.around_each { |example| DatabaseCleaner.with_clean_database { example.run } }
when "system"
# Use truncation for full cleanup
Spec.after_each { DatabaseCleaner.truncate_all_tables }
end
end
end
Performance Testing
Query Performance Tests
# spec/performance/query_performance_spec.cr
require "../spec_helper"
require "benchmark"
describe "Query Performance" do
before_all do
TestDB.users.create!
# Create test data
1000.times do |i|
user = UserFactory.create(name: "User #{i}")
if TestDB.tables.has_key?(:posts)
rand(0..5).times { PostFactory.create(user) }
end
end
end
after_all do
TestDB.users.drop!
end
it "finds users efficiently" do
result = Benchmark.measure do
100.times { User.where(active: true).limit(10).all }
end
# Should complete within reasonable time
result.total.should be < 1.0 # 1 second
end
it "handles large result sets efficiently" do
memory_before = GC.stats.heap_size
# Process in batches using limit/offset
offset = 0
batch_size = 100
loop do
batch = User.limit(batch_size).offset(offset).all
break if batch.empty?
batch.each { |user| user.name.upcase }
offset += batch_size
end
memory_after = GC.stats.heap_size
memory_used = memory_after - memory_before
# Should not use excessive memory
memory_used.should be < 10_000_000 # 10MB limit
end
end
Testing Relationships
Association Testing
# spec/models/associations_spec.cr
require "../spec_helper"
describe "User associations" do
before_each do
TestDB.users.create!
TestDB.posts.create! if TestDB.tables.has_key?(:posts)
end
after_each do
TestDB.posts.drop! if TestDB.tables.has_key?(:posts)
TestDB.users.drop!
end
describe "has_many :posts" do
it "returns user's posts" do
user = UserFactory.create
post1 = PostFactory.create(user)
post2 = PostFactory.create(user)
other_post = PostFactory.create # Different user
user_posts = user.posts.all
user_posts.should contain(post1)
user_posts.should contain(post2)
user_posts.should_not contain(other_post)
end
it "creates posts through association" do
user = UserFactory.create
post = user.posts.create!(title: "New Post", content: "Content")
post.user_id.should eq(user.id)
user.posts.all.should contain(post)
end
end
describe "belongs_to :user" do
it "returns the associated user" do
user = UserFactory.create
post = PostFactory.create(user)
post.user.should eq(user)
end
end
end
Testing Best Practices
Do This:
Test Organization:
# Group related tests logically
describe User do
describe "validations" do
# Test all validations together
end
describe "business logic" do
# Test domain-specific methods
end
describe "associations" do
# Test relationships
end
end
Clear Test Names:
# Good - Descriptive test names
it "sends welcome email after successful registration"
it "prevents duplicate email addresses across users"
it "calculates subscription expiry based on plan duration"
# Bad - Vague test names
it "works correctly"
it "tests email"
it "validates user"
Test Data Management:
# Good - Use factories for consistent test data
user = UserFactory.create(name: "Custom Name", role: "admin")
# Good - Create minimal data for each test
it "validates email format" do
user = User.new(name: "Test", email: "invalid")
# Only test what's needed
end
# Bad - Don't rely on external data
user = User.find(1) # Brittle - user might not exist
Assertions:
# Good - Specific assertions
user.errors.map(&.field).should contain(:email)
users.map(&.name).should eq(["Alice", "Bob", "Charlie"])
# Bad - Vague assertions
user.valid?.should be_false # Why is it invalid?
users.empty?.should be_false # How many users?
Avoid This:
Database Pollution:
# Bad - Don't let tests affect each other
it "creates user" do
User.new(name: "Test", email: "test@example.com").save!
end
it "finds user by email" do
User.where(email: "test@example.com").first # Depends on previous test
end
Slow Tests:
# Bad - Don't create unnecessary data
it "validates email format" do
user = UserFactory.create_with_posts(100) # Overkill for email validation
end
Brittle Tests:
# Bad - Don't test implementation details
it "calls save method" do
user = User.new(name: "Test", email: "test@example.com")
user.should receive(:save)
user.register!
end
# Good - Test behavior instead
it "persists user on registration" do
user = User.new(name: "Test", email: "test@example.com")
user.register!
user.persisted?.should be_true
end
Testing Checklist
Model Testing Checklist
Integration Testing Checklist
Test Quality Checklist
Testing is not about finding bugs, it's about preventing them - Comprehensive testing strategies help you build confidence in your code and catch issues before they reach production.
Next Steps:
Security Guide → - Secure your tested code
Performance Guide → - Test performance optimizations
Best Practices → - Apply testing best practices
Last updated
Was this helpful?