Crystal Spec Framework: Testing in Crystal with describe, it, and expect

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
end

Run:

crystal spec
# or a specific file
crystal spec spec/calculator_spec.cr

Project Structure

myapp/
├── shard.yml
├── src/
│   ├── myapp.cr
│   ├── calculator.cr
│   └── user.cr
└── spec/
    ├── spec_helper.cr
    ├── calculator_spec.cr
    └── user_spec.cr

The 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
  # ...
end

The 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
end

Setup 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
end

Note: 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
end

Testing 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
end

Using 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
end

let 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-trace

The --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-color

Summary

Crystal's built-in Spec framework provides a clean, RSpec-inspired testing experience:

  • describe / context / it organize tests semantically
  • expect(value).to matcher provides readable assertions
  • before_each / after_each handle setup/teardown
  • expect_raises tests exception behavior
  • Dependency injection with abstract classes enables mocking
  • crystal spec discovers all *_spec.cr files 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.

Read more