Introduction to Testing in Crystal Applications
Crystal is a statically typed, compiled programming language with a syntax heavily inspired by Ruby. One of the key advantages of Crystal is its built-in testing framework called Spec, which provides a clean and expressive DSL for writing tests. In this tutorial, we will explore various testing strategies for Crystal applications, from basic unit tests to more advanced techniques like mocking and integration testing.
What Is Testing in Crystal?
Testing in Crystal revolves around the Spec module, which is included in the standard library. It follows a behavior-driven development (BDD) style, similar to RSpec in Ruby, using describe, context, and it blocks to organize and define test cases. The framework also provides a rich set of matchers for assertions.
Why Testing Matters
Testing is a critical part of the software development lifecycle. Here are some key reasons why testing matters in Crystal applications:
- Type Safety Is Not Enough: While Crystal's type system catches many errors at compile time, it cannot catch logical errors or incorrect business logic.
- Refactoring Confidence: A solid test suite allows you to refactor code with confidence, knowing that existing behavior is verified.
- Documentation: Well-written tests serve as living documentation, showing how different parts of your application are expected to behave.
- Regression Prevention: Tests catch regressions when new features are added or dependencies are updated.
- Design Feedback: Writing tests often reveals design flaws, such as tightly coupled components or functions that do too much.
Getting Started with Crystal Spec
Crystal's Spec module requires no external dependencies. To use it, simply add require "spec" at the top of your test file. By convention, test files are placed in a spec directory and end with _spec.cr.
Project Structure
A typical Crystal project structure looks like this:
my_app/
βββ shard.yml
βββ src/
β βββ my_app.cr
β βββ models/
β βββ user.cr
βββ spec/
βββ spec_helper.cr
βββ models/
βββ user_spec.cr
Writing Your First Test
Let's start with a simple example. Suppose we have a Calculator class in our source code:
# src/calculator.cr
class Calculator
def add(a : Int32, b : Int32) : Int32
a + b
end
def divide(a : Int32, b : Int32) : Float64
raise "Division by zero" if b == 0
a.to_f64 / b.to_f64
end
end
Now, let's write tests for this class:
# spec/calculator_spec.cr
require "spec"
require "../src/calculator"
describe Calculator do
describe "#add" do
it "adds two positive numbers" do
calc = Calculator.new
calc.add(2, 3).should eq(5)
end
it "handles negative numbers" do
calc = Calculator.new
calc.add(-1, -4).should eq(-5)
end
end
describe "#divide" do
it "divides two numbers correctly" do
calc = Calculator.new
calc.divide(10, 2).should eq(5.0)
end
it "raises an error when dividing by zero" do
calc = Calculator.new
expect_raises(Exception, "Division by zero") do
calc.divide(5, 0)
end
end
end
end
To run the tests, execute the following command in your terminal:
crystal spec
You can also run a specific spec file or a specific test by providing the file path or using the -e flag with a pattern:
crystal spec spec/calculator_spec.cr
crystal spec -e "adds two positive numbers"
Testing Strategies
Unit Testing
Unit testing focuses on testing individual components in isolation. In Crystal, this means testing individual classes, methods, or functions without relying on external systems like databases or APIs. Unit tests should be fast, deterministic, and easy to understand.
Here is an example of a unit test for a User model:
# src/models/user.cr
class User
property name : String
property email : String
property age : Int32
def initialize(@name : String, @email : String, @age : Int32)
end
def adult?
@age >= 18
end
def valid_email?
@email.matches?(/^[^@]+@[^@]+\.[^@]+$/)
end
end
# spec/models/user_spec.cr
require "spec"
require "../../src/models/user"
describe User do
describe "#adult?" do
it "returns true when age is 18 or above" do
user = User.new("Alice", "alice@example.com", 18)
user.adult?.should be_true
end
it "returns false when age is below 18" do
user = User.new("Bob", "bob@example.com", 17)
user.adult?.should be_false
end
end
describe "#valid_email?" do
it "returns true for a valid email" do
user = User.new("Alice", "alice@example.com", 25)
user.valid_email?.should be_true
end
it "returns false for an invalid email" do
user = User.new("Bob", "not-an-email", 25)
user.valid_email?.should be_false
end
end
end
Using Context Blocks for Organization
The context block is used to group tests under a specific scenario or state. This is especially useful when testing the same method under different conditions:
describe User do
describe "#adult?" do
context "when the user is exactly 18" do
it "returns true" do
user = User.new("Alice", "alice@example.com", 18)
user.adult?.should be_true
end
end
context "when the user is a minor" do
it "returns false" do
user = User.new("Bob", "bob@example.com", 15)
user.adult?.should be_false
end
end
end
end
Setup and Teardown with Before and After
Crystal's Spec module provides before_each, before_all, after_each, and after_all hooks for setup and teardown operations:
describe Calculator do
before_each do
@calc = Calculator.new
end
describe "#add" do
it "adds two numbers" do
@calc.not_nil!.add(1, 2).should eq(3)
end
it "adds negative numbers" do
@calc.not_nil!.add(-1, -2).should eq(-3)
end
end
end
Note that instance variables in spec blocks are nilable, so you may need to use not_nil! or a helper method to access them safely.
Integration Testing
Integration tests verify that multiple components work together correctly. For web applications built with frameworks like Kemal or Lucky, integration tests often involve making HTTP requests to your application and asserting on the responses.
Here is an example using Kemal's built-in test helpers:
# src/app.cr
require "kemal"
get "/" do
"Hello, World!"
end
get "/users/:id" do |env|
"User #{env.params.url["id"]}"
end
Kemal.run
# spec/app_spec.cr
require "spec"
require "kemal"
require "../src/app"
describe "Web Application" do
it "returns Hello World at root" do
get "/"
response.body.should eq("Hello, World!")
response.status_code.should eq(200)
end
it "returns user id in the response" do
get "/users/42"
response.body.should eq("User 42")
response.status_code.should eq(200)
end
end
Mocking and Stubs
Crystal does not include a built-in mocking library, but you can use the mocks shard or implement simple mocks manually. A common approach is to use dependency injection and create stub implementations of interfaces (abstract classes).
Here is an example using dependency injection to make code testable:
# src/repositories/user_repository.cr
abstract class UserRepository
abstract def find(id : Int32) : User?
abstract def save(user : User) : User
end
# src/repositories/database_user_repository.cr
class DatabaseUserRepository < UserRepository
def find(id : Int32) : User?
# Real database query logic here
end
def save(user : User) : User
# Real database save logic here
end
end
# src/services/user_service.cr
class UserService
def initialize(@repository : UserRepository)
end
def get_user(id : Int32) : User
@repository.find(id) || raise "User not found"
end
end
Now, in your tests, you can create a fake repository:
# spec/services/user_service_spec.cr
require "spec"
require "../../src/repositories/user_repository"
require "../../src/services/user_service"
require "../../src/models/user"
class FakeUserRepository < UserRepository
property users : Hash(Int32, User)
def initialize
@users = {} of Int32 => User
end
def find(id : Int32) : User?
@users[id]?
end
def save(user : User) : User
@users[@users.size + 1] = user
user
end
end
describe UserService do
describe "#get_user" do
it "returns the user when found" do
repo = FakeUserRepository.new
user = User.new("Alice", "alice@example.com", 30)
repo.users[1] = user
service = UserService.new(repo)
service.get_user(1).should eq(user)
end
it "raises when user is not found" do
repo = FakeUserRepository.new
service = UserService.new(repo)
expect_raises(Exception, "User not found") do
service.get_user(99)
end
end
end
end
This approach keeps your tests fast and isolated from external dependencies like databases, while still testing the real logic of your service layer.
Using the Mocks Shard
For more advanced mocking capabilities, you can use the mocks shard. Add it to your shard.yml:
development_dependencies:
mocks:
github: waterlink/mocks.cr
Then run shards install. Here is how you can use it:
require "spec"
require "mocks"
require "../src/services/email_service"
class EmailGateway
def send(to : String, subject : String, body : String)
# Real email sending logic
end
end
describe EmailService do
it "sends a welcome email" do
Mocks.create_mock(EmailGateway) do |mock|
mock.expect(:send) do |to, subject, body|
to.should eq("user@example.com")
subject.should eq("Welcome!")
end
end
gateway = EmailGateway.new
service = EmailService.new(gateway)
service.send_welcome("user@example.com")
mock.verify
end
end
Property-Based Testing
Property-based testing is a strategy where you test that certain properties hold true for a wide range of randomly generated inputs. While Crystal does not have a built-in property-based testing library, you can simulate it using random data generation:
require "spec"
require "random"
require "../src/calculator"
describe Calculator do
describe "#add" do
it "is commutative for random integers" do
calc = Calculator.new
100.times do
a = Random.new.rand(Int32)
b = Random.new.rand(Int32)
calc.add(a, b).should eq(calc.add(b, a))
end
end
it "returns the identity when adding zero" do
calc = Calculator.new
100.times do
a = Random.new.rand(Int32)
calc.add(a, 0).should eq(a)
end
end
end
end
Best Practices
Organize Tests to Mirror Source Structure
Keep your spec directory structure mirroring your source directory structure. This makes it easy to find the tests for any given source file. If you have src/models/user.cr, the corresponding test should be at spec/models/user_spec.cr.
Use a Spec Helper File
Create a spec/spec_helper.cr file to centralize common requires and configuration. This keeps your individual spec files clean:
# spec/spec_helper.cr
require "spec"
require "../src/my_app"
# Configure spec output format
Spec.before_suite do
puts "Running test suite..."
end
# Add any custom helpers here
def create_test_user(name = "Test User", email = "test@example.com", age = 25)
User.new(name, email, age)
end
Then in your spec files, simply require the helper:
require "../spec_helper"
describe User do
it "creates a test user" do
user = create_test_user
user.name.should eq("Test User")
end
end
Write Descriptive Test Names
Test names should clearly describe the behavior being tested. When a test fails, the name should help you understand what went wrong without needing to read the test body. Use the it block description as a specification of behavior:
# Good
it "returns true when the password contains at least 8 characters" do
...
end
# Bad
it "works" do
...
end
Test One Thing at a Time
Each it block should test a single behavior. This makes failures easier to diagnose and tests easier to understand. If you find yourself writing multiple assertions that test different behaviors, consider splitting them into separate it blocks.
Keep Tests Fast
Slow tests discourage developers from running them frequently. Avoid hitting databases, making network calls, or performing expensive operations in unit tests. Use mocks, stubs, and fakes to keep tests fast and deterministic.
Use Tags for Categorization
Crystal's Spec supports tags, which allow you to categorize and selectively run tests. You can tag tests using the tags option:
describe "Slow Database Tests", tags: "slow" do
it "fetches all users from the database" do
# This test hits a real database
end
end
describe "Fast Unit Tests", tags: "fast" do
it "validates a user's email" do
# This test is fast
end
end
You can then run only fast tests during development:
crystal spec --tag fast
Test Edge Cases
Always test edge cases and boundary conditions. For numeric inputs, test zero, negative numbers, and maximum values. For strings, test empty strings, very long strings, and special characters. For collections, test empty collections and single-element collections.
describe Calculator do
describe "#divide" do
it "handles division resulting in a repeating decimal" do
calc = Calculator.new
calc.divide(1, 3).should be_close(0.333333, 0.0001)
end
it "handles division of zero by a number" do
calc = Calculator.new
calc.divide(0, 5).should eq(0.0)
end
end
end
Use Available Matchers Effectively
Crystal's Spec provides a variety of matchers. Using the right matcher makes your tests more readable and expressive:
# Equality
result.should eq(42)
# Truthiness
result.should be_true
result.should be_false
result.should be_nil
# Comparison
result.should be > 10
result.should be <= 100
# Type checking
result.should be_a(Int32)
# String matching
result.should match(/pattern/)
result.should start_with("Hello")
result.should end_with("World")
result.should contain("substring")
# Collection checks
array.should be_empty
array.should contain(42)
array.should have_size(3)
# Floating point comparison
result.should be_close(3.14, 0.01)
# Exception testing
expect_raises(ArgumentError) do
some_method_that_raises
end
Continuous Integration
Always run your test suite in a CI pipeline. This ensures that tests pass on clean environments and catches issues that might not appear on your local machine. Here is a simple GitHub Actions workflow for a Crystal project:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Install Crystal
uses: crystal-lang/install-crystal@v1
- name: Install dependencies
run: shards install
- name: Run tests
run: crystal spec
Conclusion
Testing is an essential practice for building reliable and maintainable Crystal applications. The built-in Spec module provides a powerful and expressive framework for writing tests, from simple unit tests to complex integration tests. By following the strategies and best practices outlined in this tutorialβorganizing tests effectively, using dependency injection for mocking, testing edge cases, leveraging the full range of matchers, and integrating tests into your CI pipelineβyou can build a robust test suite that gives you confidence in your code. Remember that good tests are an investment: they pay dividends every time you refactor, add features, or fix bugs, ensuring that your application continues to behave as expected throughout its lifecycle.