The describe Block Looks Familiar#
The first time I opened an RSpec file, the relief was immediate. It looks like Jest:
# RSpec
describe UrlShortenerService do
describe ".call" do
context "with a valid URL" do
it "creates a shortened URL" do
url = UrlShortenerService.call(target_url: "https://example.com")
expect(url.short_code).to be_present
expect(url.short_code.length).to eq(6)
end
end
end
end
// Jest
describe("UrlShortenerService", () => {
describe("create", () => {
it("creates a shortened URL", async () => {
const url = await UrlShortenerService.create("https://example.com");
expect(url.shortCode).toBeDefined();
expect(url.shortCode.length).toBe(6);
});
});
});
Same structure: describe groups, context (RSpec) or nested describe (Jest) for scenarios, it for individual tests, expect for assertions. The syntax differences are cosmetic. The mental model is identical.
RSpec vs Jest: The Differences That Matter#
| Aspect | RSpec | Jest |
|---|---|---|
| Assertion style | expect(x).to eq(y) | expect(x).toBe(y) |
| Grouping | describe + context | describe (nested) |
| Setup/teardown | before, after, let, subject | beforeEach, afterEach |
| Mocking | Built-in (allow, expect_to receive) | Built-in (jest.fn(), jest.spyOn()) |
| Test data | FactoryBot (factories) | Manual or custom helpers |
| HTTP mocking | WebMock | MSW or nock |
| Coverage | SimpleCov | Built-in (--coverage) |
| Run command | bundle exec rspec | npx jest or npm test |
The biggest difference isn’t syntax — it’s let and subject:
describe Url do
let(:url) { create(:url, target_url: "https://example.com") }
subject { url.valid? }
it { is_expected.to be true }
context "without a target_url" do
let(:url) { build(:url, target_url: nil) }
it { is_expected.to be false }
end
end
let is lazy — it creates the object only when first referenced. subject defines what’s being tested. context blocks can override let definitions, so the same test structure works with different data. It’s more declarative than Jest’s beforeEach pattern.
FactoryBot: Better Than Fixtures, Different From Mocks#
In Jest, you create test data manually or build helper functions. Rails has two options: fixtures (YAML files with static data) and FactoryBot (programmatic factories).
# spec/factories/urls.rb
FactoryBot.define do
factory :url do
target_url { "https://example.com" }
short_code { SecureRandom.alphanumeric(6) }
title { "Example" }
clicks_count { 0 }
trait :popular do
clicks_count { 1000 }
end
trait :untitled do
title { nil }
end
end
end
# In tests:
create(:url) # persisted to DB
create(:url, :popular) # with 1000 clicks
create(:url, target_url: "custom") # override specific fields
build(:url) # in-memory only (no DB)
create_list(:url, 5) # create 5 URLs
Traits are the killer feature. Instead of creating separate factories for every variation, you compose traits:
create(:url, :popular, :untitled) # popular URL with no title
The TypeScript equivalent would be something like:
function createUrl(overrides?: Partial<Url>): Url {
return {
targetUrl: "https://example.com",
shortCode: "abc123",
...overrides,
};
}
FactoryBot is more structured and handles database persistence, associations, and sequences automatically.
WebMock: Stubbing External APIs#
When your service calls an external API (fetching a page title, for example), you don’t want tests hitting the real API. WebMock intercepts HTTP requests:
# Stub any request to example.com
stub_request(:get, "https://example.com")
.to_return(
status: 200,
body: "<html><title>Example Page</title></html>",
headers: { "Content-Type" => "text/html" }
)
# Now UrlMetadataService.call("https://example.com") returns "Example Page"
# without making a real HTTP request
Similar to MSW (Mock Service Worker) or nock in TypeScript. The API is slightly different but the concept is identical: intercept outgoing requests, return canned responses.
Security That Comes Free#
This surprised me. Rails includes security features that TypeScript projects need to install as separate packages:
| Security Feature | Rails | TypeScript/Express |
|---|---|---|
| CSRF protection | Built-in (token in every form) | csurf middleware (deprecated) or custom |
| SQL injection | ActiveRecord parameterizes by default | Depends on ORM (Prisma: yes, raw queries: no) |
| XSS prevention | ERB auto-escapes output | Must use DOMPurify or framework escaping |
| Mass assignment | Strong parameters (permit) | Manual validation or Zod |
| Session management | Built-in (encrypted cookies) | express-session + Redis |
| Security headers | Default middleware | helmet middleware |
| Security scanning | Brakeman (static analysis) | npm audit + ESLint security plugin |
CSRF protection is the one that stands out. Every Rails form automatically includes a CSRF token. The framework verifies it on every non-GET request. In Express, CSRF protection was the csurf middleware — which was deprecated because it was hard to use correctly.
XSS prevention is similarly automatic. ERB escapes all output by default:
<%= user_input %> <%# auto-escaped: <script> becomes <script> %>
<%= raw user_input %> <%# NOT escaped — only use for trusted HTML %>
In React, JSX escapes by default too. But in Express with EJS or Handlebars, it depends on the syntax you use. Rails and React both got this right.
Puma: The Web Server#
Rails ships with Puma — a multi-threaded web server. The equivalent in Node.js is just Node itself (single-threaded with async I/O) or PM2 for process management.
| Aspect | Puma (Rails) | Node.js |
|---|---|---|
| Concurrency model | Threads (real parallelism with I/O) | Event loop (single thread, async) |
| GVL (Global VM Lock) | Limits CPU parallelism | N/A (no GVL) |
| Workers | Fork multiple processes | Cluster module or PM2 |
| Typical config | 2-4 workers, 5 threads each | 1 process per core (cluster) |
Ruby has a GVL (Global VM Lock, similar to Python’s GIL) that prevents true CPU parallelism within a single process. But for I/O-bound work (web requests, database queries), threads work fine because the GVL is released during I/O. Puma forks multiple worker processes for CPU parallelism, each with multiple threads for I/O concurrency.
The Testing Workflow#
My workflow for testing (assuming I am doing TDD) in Rails ended up being similar to TypeScript:
- Write the test first (or at least think about what to test)
- Run
bundle exec rspec spec/services/url_shortener_service_spec.rb(specific file) - Red — test fails
- Implement the code
- Green — test passes
- Refactor if needed
- Run full suite:
bundle exec rspec - Check coverage: SimpleCov generates an HTML report
The tooling is different. The process is the same. If you can write Jest tests, you can write RSpec tests — the learning curve is syntax, not concepts.
The final post is an honest retrospective: what was harder than expected, what was easier, and what I’d tell myself before starting.
