Crystal Spec Framework: Testing in Crystal with describe, it, and expect
Crystal ships with a built-in testing framework: crystal spec. It uses an RSpec-like API with describe, it, context, before_each, after_each, and expect. No external library needed. This guide covers writing Crystal specs, using matchers, testing exceptions, and setting up CI.
Key Takeaways
Crystal's Spec framework is RSpec-inspired and built-in. crystal spec is part of the compiler toolchain — no installation needed. The API is deliberately RSpec-like for Ruby developers adopting Crystal.
expect(actual).to eq(expected) is the core assertion syntax. Crystal Spec uses matcher objects: eq, be_true, be_nil, contain, raise_error. They produce readable failure messages.
describe organizes by class/feature, context by scenario. describe User covers the class. context "when premium" covers a specific state. Both create nested test groups.
before_each and after_each run setup/teardown per example. before_all and after_all run once per describe block. Use before_each for most setups.
crystal spec spec/ runs all specs — no test runner needed. Crystal discovers all *_spec.cr files in the spec/ directory automatically.
Crystal's Built-in Spec Framework
Crystal includes crystal spec as part of its standard toolchain. It implements a subset of RSpec's API with Crystal's static typing:
# spec/calculator_spec.cr
require "spec"
require "../src/calculator"
describe Calculator do
describe "#add" do
it "adds two positive numbers" do
calc = Calculator.new
expect(calc.add(2, 3)).to eq(5)
end
it "handles negative numbers" do
calc = Calculator.new
expect(calc.add(-3, 2)).to eq(-1)
end
end
endRun:
crystal spec
# or a specific file
crystal spec spec/calculator_spec.crProject Structure
myapp/
├── shard.yml
├── src/
│ ├── myapp.cr
│ ├── calculator.cr
│ └── user.cr
└── spec/
├── spec_helper.cr
├── calculator_spec.cr
└── user_spec.crThe spec_helper.cr file is the shared entry point:
# spec/spec_helper.cr
require "spec"
require "../src/myapp"Each spec file requires it:
# spec/calculator_spec.cr
require "./spec_helper"
describe Calculator do
# ...
endThe Matcher API
Crystal Spec matchers produce descriptive failure messages:
# Value matchers
expect(value).to eq(expected) # Equality
expect(value).not_to eq(expected) # Not equal
expect(value).to be_true # Truthy
expect(value).to be_false # Falsy
expect(value).to be_nil # Nil
expect(value).not_to be_nil # Not nil
expect(value).to be > 5 # Comparison
expect(value).to be >= 5
expect(value).to be < 10
# String matchers
expect(str).to contain("substring")
expect(str).to start_with("prefix")
expect(str).to end_with("suffix")
expect(str).to match(/regex/)
# Collection matchers
expect(array).to contain(element)
expect(array).to be_empty
expect(array.size).to eq(3)Context Blocks for Organizing Scenarios
describe User do
describe "#can_access?" do
context "when user is admin" do
it "can access admin panel" do
user = User.new(role: "admin")
expect(user.can_access?("admin_panel")).to be_true
end
it "can access user data" do
user = User.new(role: "admin")
expect(user.can_access?("user_data")).to be_true
end
end
context "when user is standard" do
it "cannot access admin panel" do
user = User.new(role: "user")
expect(user.can_access?("admin_panel")).to be_false
end
it "can access own data" do
user = User.new(role: "user")
expect(user.can_access?("user_data")).to be_true
end
end
end
endSetup and Teardown
describe OrderProcessor do
before_each do
# Run before each `it` block
@order = Order.new(customer_id: "c1", total: 99.99)
@processor = OrderProcessor.new
end
after_each do
# Run after each `it` block (cleanup)
Database.clear_test_data
end
it "processes valid orders" do
result = @processor.process(@order)
expect(result.status).to eq("completed")
end
it "rejects negative totals" do
@order.total = -10.0
expect(@processor.process(@order).status).to eq("rejected")
end
endNote: Crystal Spec uses before_each (not before(:each) like RSpec) and instance variables are not automatically shared between blocks — use explicit variables or closures.
Testing Exceptions
describe Parser do
it "raises on invalid input" do
expect_raises(ArgumentError) do
Parser.parse("not-valid-json")
end
end
it "raises with correct message" do
expect_raises(ArgumentError, /invalid json/) do
Parser.parse("{bad}")
end
end
it "raises ParseError for malformed data" do
expect_raises(ParseError, "Unexpected token") do
Parser.parse("{ key: no-quotes }")
end
end
endTesting with Doubles (Mocking)
Crystal doesn't have a mature mock library due to its static type system. Use abstract classes or dependency injection:
# src/notifier.cr
abstract class Mailer
abstract def send(to : String, subject : String, body : String) : Bool
end
class EmailNotifier
def initialize(@mailer : Mailer)
end
def notify_signup(user : User) : Bool
@mailer.send(
user.email,
"Welcome to the app!",
"Hi #{user.name}, thanks for signing up."
)
end
end# spec/email_notifier_spec.cr
require "./spec_helper"
class FakeMailer < Mailer
getter sent_emails : Array(NamedTuple(to: String, subject: String, body: String))
def initialize
@sent_emails = [] of NamedTuple(to: String, subject: String, body: String)
end
def send(to : String, subject : String, body : String) : Bool
@sent_emails << {to: to, subject: subject, body: body}
true
end
end
describe EmailNotifier do
let(mailer) { FakeMailer.new }
let(notifier) { EmailNotifier.new(mailer) }
it "sends welcome email on signup" do
user = User.new(email: "alice@example.com", name: "Alice")
notifier.notify_signup(user)
expect(mailer.sent_emails.size).to eq(1)
expect(mailer.sent_emails[0][:to]).to eq("alice@example.com")
expect(mailer.sent_emails[0][:subject]).to contain("Welcome")
end
it "returns true when email sends" do
user = User.new(email: "alice@example.com", name: "Alice")
result = notifier.notify_signup(user)
expect(result).to be_true
end
endUsing let for Lazy Setup
Crystal Spec supports let for lazy initialization (similar to RSpec):
describe ShoppingCart do
let(cart) { ShoppingCart.new }
let(product) { Product.new(id: "p1", price: 9.99) }
it "starts empty" do
expect(cart.size).to eq(0)
end
it "adds products" do
cart.add(product)
expect(cart.size).to eq(1)
end
it "calculates total" do
cart.add(product)
cart.add(product)
expect(cart.total).to be_close(19.98, 0.01)
end
endlet values are created fresh for each example (lazy, memoized per example).
Pending Tests
Mark work-in-progress tests as pending:
it "will implement this later" do
pending "not implemented yet"
end
pending "this entire spec is pending"Pending tests don't fail the suite — they're reported separately.
CI Configuration
# .github/workflows/test.yml
name: Crystal Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Crystal
uses: crystal-lang/install-crystal@v1
with:
crystal: 1.12.1
- name: Install dependencies
run: shards install
- name: Run specs
run: crystal spec --error-traceThe --error-trace flag shows full stack traces for failures in CI.
Running Specific Specs
# Run all specs
crystal spec
<span class="hljs-comment"># Run a specific file
crystal spec spec/user_spec.cr
<span class="hljs-comment"># Run a specific line (specific example)
crystal spec spec/user_spec.cr:42
<span class="hljs-comment"># Run with verbose output
crystal spec --verbose
<span class="hljs-comment"># Run with no-color for CI logs
crystal spec --no-colorSummary
Crystal's built-in Spec framework provides a clean, RSpec-inspired testing experience:
describe/context/itorganize tests semanticallyexpect(value).to matcherprovides readable assertionsbefore_each/after_eachhandle setup/teardownexpect_raisestests exception behavior- Dependency injection with abstract classes enables mocking
crystal specdiscovers all*_spec.crfiles automatically
Crystal's type system makes mocking harder than in dynamic languages — lean into dependency injection and abstract class fakes rather than trying to dynamically intercept method calls.