Having a blast with Crystal 💯

Just wanted to share some of my experiences lately working with Crystal. I’ve been working on a web app the past couple weeks and, even though I’ve been using Crystal for about 6 years now, I’m still blown away by how easy it can be to make code simple to read and write. Writing an integration test via a session-aware layer on top of HotTopic is pretty slick. A simple one looks like this:

it "gets notifications" do
  user = UserFactory.create
  notification = NotificationFactory.create(user: user)
  session = new_session(user) # No need to stub a whole login process when you can set the active user directly on the session

  response = session.get("/notifications")
  response.should have_http_status :ok
  response.should have_html notification.title # there's also `have_json(foo: "bar")` for API requests

Others are more involved and handle interacting with the page similar to Capybara in Ruby (things like session.click_link "Foo"). I’ll extract that into a shard soon.

The most amazing part of it for me is that, even when my integration tests do multiple requests, run a dozen or more DB queries, load sessions from/save them to Redis, etc, they’re still executing in 2-3ms each on average. The simple one above averages ~1.2ms, even with hundreds of thousands of rows in every table in the test DB (I usually avoid cleaning out test DBs to expose query-performance issues with production-level volumes of data). :exploding_head:

This is such a huge departure from my experiences with Ruby on Rails where a single integration test in so many Rails apps I’ve worked on took anywhere from hundreds of milliseconds to multiple seconds each, even when they don’t invoke a headless browser. So to see them taking 2-3ms on average is mind-blowing.

It also reminds me of so many arguments against using factories in Rails app tests or even hitting the DB at all in order to keep your tests from getting too slow, but that’s a perfectly fine thing to do when it all happens in 200 microseconds.

Some of the tools I’m using:

  • armature for the web framework, sessions, caching API, templates (which are really just ECR but with HTML sanitization), etc
  • interro as the ORM to be able to use a lot of Postgres-specific functionality, uses a similar API to Diesel in Rust
  • Factories, which are just lightweight wrappers around Interro query objects
  • A custom validations mixin to use for mutating query methods, which works really well with Interro’s query style
  • redis handling backing storage for sessions and caching (via armature/redis_session and armature/cache, respectively)
  • hot_topic for testing requests at either the unit-testing or integration-testing layer
  • A custom Google client for Google OAuth2 and Calendar APIs, stubbed in tests with HotTopic (I would’ve used PlaceOS/google, but it uses the pattern where loading the shard loads a lot of APIs I don’t need, leading to increased compilation times)
  • A custom class on top of Armature::Session and HotTopic to maintain cookies and navigation, currently named Armature::TestSession
  • Some custom Spec matchers for things like response.should have_http_status :ok and response.should have_headers HTTP::Headers{"foo" => "bar"}, which will be extracted along with Armature::TestSession

I’d like to add some tooling to generate some of the code (new routes/queries especially) because it’s currently pretty repetitive, but overall I’m enjoying this style of development.


Thank you for that write-up! Do your integration tests execute JS too?
I remember the slow integration tests from ruby/rails too.
Another pain point that i run into in ruby is the lack of type safety. You have to write (and maintain) some extra tests that you just don’t have to write in crystal.

No, I’m not using a lot of JS in my app. I have only a few API endpoints. Nearly everything requests plain HTML (some via htmx). I don’t know how much JS specs would speed up with Crystal, though, since it has to communicate HTTP requests and query DOM state in separate processes. I don’t know how much overhead that all adds.


Would love to see how you are using htmx

1 Like

It’s pretty typical htmx usage. I’m using it for things like loading secondary content like notifications (an example I used in this talk, though that was with Turbo) and auto-saving settings on change so there’s no “save changes” step.

1 Like