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
endEnvironment-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
endUnit 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
endTesting 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
endTesting 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
endIntegration 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
endTransaction 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
endMocking 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
endService 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
endTest 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
endDatabase 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
endDatabase 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
endPerformance 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
endTesting 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
endTesting 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
endClear 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 existAssertions:
# 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
endSlow Tests:
# Bad - Don't create unnecessary data
it "validates email format" do
  user = UserFactory.create_with_posts(100)  # Overkill for email validation
endBrittle 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
endTesting 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?
