Skip to main content
  1. Posts/

Testing in a New Language

·966 words·5 mins
Photograph By Girl with red hat
Blog Ruby Ruby on Rails
Table of Contents
Rails for the TypeScript Engineer - This article is part of a series.
Part 4: This Article

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
#

AspectRSpecJest
Assertion styleexpect(x).to eq(y)expect(x).toBe(y)
Groupingdescribe + contextdescribe (nested)
Setup/teardownbefore, after, let, subjectbeforeEach, afterEach
MockingBuilt-in (allow, expect_to receive)Built-in (jest.fn(), jest.spyOn())
Test dataFactoryBot (factories)Manual or custom helpers
HTTP mockingWebMockMSW or nock
CoverageSimpleCovBuilt-in (--coverage)
Run commandbundle exec rspecnpx 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 FeatureRailsTypeScript/Express
CSRF protectionBuilt-in (token in every form)csurf middleware (deprecated) or custom
SQL injectionActiveRecord parameterizes by defaultDepends on ORM (Prisma: yes, raw queries: no)
XSS preventionERB auto-escapes outputMust use DOMPurify or framework escaping
Mass assignmentStrong parameters (permit)Manual validation or Zod
Session managementBuilt-in (encrypted cookies)express-session + Redis
Security headersDefault middlewarehelmet middleware
Security scanningBrakeman (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 &lt;script&gt; %>
<%= 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.

AspectPuma (Rails)Node.js
Concurrency modelThreads (real parallelism with I/O)Event loop (single thread, async)
GVL (Global VM Lock)Limits CPU parallelismN/A (no GVL)
WorkersFork multiple processesCluster module or PM2
Typical config2-4 workers, 5 threads each1 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:

  1. Write the test first (or at least think about what to test)
  2. Run bundle exec rspec spec/services/url_shortener_service_spec.rb (specific file)
  3. Red — test fails
  4. Implement the code
  5. Green — test passes
  6. Refactor if needed
  7. Run full suite: bundle exec rspec
  8. 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.

Aaron Yong
Author
Aaron Yong
Building things for the web. Writing about development, Linux, cloud, and everything in between.
Rails for the TypeScript Engineer - This article is part of a series.
Part 4: This Article

Related

The Rails Way
·918 words·5 mins
Photograph By Aleksandr Popov
Blog Ruby Ruby on Rails
Service objects, Hotwire, ERB, and where Rails and Node.js disagree about how web apps should work
Convention Over Configuration
·924 words·5 mins
Photograph By Thomas Delacrétaz
Blog Ruby Ruby on Rails
Rails MVC, ActiveRecord, and why the framework makes decisions so you don’t have to
Everything Is an Object
·1061 words·5 mins
Photograph By Thought Catalog
Blog Ruby Ruby on Rails
Ruby syntax through TypeScript eyes — implicit returns, truthiness gotchas, and why 0 is truthy