For the last 20 years, Rubyists have adopted dozens of tools and technologies that allow us to write better software, scale projects, and ship what needs to be shipped to production the way we want it. I will name just a few of them: Docker, ruby-lsp, AI, RuboCop, MiniTest, RSpec, Cucumber.
The interesting fact, however, is that all these tools faced criticism when they were introduced. Some were heavily criticized, others faced a little skepticism. But the fact is, eventually, we adopted them and now it’s hard to imagine our programming life without them. We no longer argue about spaces or tabs; we just do gem install rubocop
and then rubocop -a
. We adopted these tools so that we could achieve even more. We delegated part of what we were doing to these artificial electronic helpers.
Think about it. The first version (and some subsequent ones as well) of Ruby on Rails was implemented by DHH in TextMate with just syntax highlighting. No code completion, no linters, no IDEs, no AIs. I remember those days. I was using Notepad++ on Windows for PHP and Ruby development.
As we see across the years, the process of adopting new tools and new ways to help us ship more, faster, and better is endless. If we cannot come up with something internally, like RuboCop, we look elsewhere and adopt things used in other ecosystems like Docker, or MiniTest (which is an adaptation of a Java library).
For the last several years, I have been actively working with JVM languages like Java and Kotlin. When I first started working with them, I was quite irritated by the amount of code and things I needed to do just to run my program. Over time, however, I noticed the significant confidence I gained every time the compiler successfully built my code. It doesn’t guarantee that the code works as I expect, but it already gives me a lot - a full understanding of the data flow and all the connections between various parts of my code. My software may still crash once started, but I’m still certain that a big deal of correctness is already ensured. With a few additional tests, I can make sure the business logic works just as it should.
What about Ruby? Without a single line of tests, you don’t know anything about your code. Do you pass the correct data? Does your business logic even make sense? With simple scripts, you might guess and imagine how the code works, but will you be right? What about a simple refactoring or a feature request where you have no idea what kind of input you’re dealing with? Are they simple primitives like Strings or Integers? Or maybe instances of some classes or, even worse, nested Arrays or Hashes?
The only ways to check this are either to run the code and see, or to write tests. So we go with tests:
- Tests to ensure the code accepts the correct data.
- Tests to ensure the business logic is correct.
- Tests to ensure edge cases won’t cause issues.
Tests are great, don’t get me wrong. But looking at the picture I described above, it seems we rely too much on one single source of confidence. What if they fail? And this happens. What if the tests are wrong? Maybe we stubbed too much and now we’re testing stubs, not the real code. No one will save us then. It looks like it’s time to look around and see what else we can do to make things better.
Here is a simple Ruby script:
# frozen_string_literal: true
require 'json'
require 'time'
require 'httparty'
module RubyVersion
class Response
attr_reader :code
attr_reader :json
def initialize(code:, json:)
@code = code
@json = json
end
def success? = (200..299).include?(code)
end
API_URL = 'https://api.github.com/repos/ruby/ruby/releases/latest'
def self.fetch_response
http = HTTParty.get(API_URL, headers: { 'User-Agent' => 'static-typing-demo' })
Response.new(code: http.code.to_i, json: JSON.parse(http.body))
end
def self.fetch(printer)
resp = fetch_response
raise "HTTP #{resp.code}" unless resp.success?
data = resp.json
published_at = Time.parse((data['published_at'] || Time.now.utc.iso8601).to_s)
printer&.call((data['tag_name'] || data['name']).to_s, data['html_url'], published_at)
end
end
PRINTER = proc { |tag, url, published_at|
puts "Fetched tag=#{tag} published_at=#{published_at.utc.iso8601} (url=#{url})"
nil
}
puts RubyVersion.fetch(PRINTER) if $PROGRAM_NAME == __FILE__
Before we move on, let’s check what this code does. The script fetches information about the latest Ruby release via the .fetch_response
method. The information is returned as an instance of the RubyVersion::Response
class. The .fetch_response
method is called by our main entry point .fetch
, which accepts one mandatory parameter printer
- a Proc defined in the constant PRINTER
.
Just like we would do in real life, we’ll cover this code with the necessary tests. Here is a simple MiniTest file that covers all major aspects of our script.
# frozen_string_literal: true
require 'minitest/autorun'
require_relative '../app/fetch_ruby_latest_with_types'
class TestRubyVersionFetch < Minitest::Test
def test_fetch_response_structure
resp = RubyVersion.fetch_response
assert_kind_of RubyVersion::Response, resp
assert_kind_of Integer, resp.code
assert_kind_of Hash, resp.json
end
def test_fetch_with_printer_proc
received = {}
printer = proc do |tag, url, published_at|
received[:tag] = tag
received[:url] = url
received[:published_at] = published_at
nil
end
summary = RubyVersion.fetch(printer)
assert_nil summary
assert received[:tag]
assert received[:url]
assert_kind_of Time, received[:published_at]
end
def test_fetch_without_printer
summary = RubyVersion.fetch(nil)
assert_nil summary
end
end
Let’s check what we do here. With test_fetch_response_structure
, we ensure that .fetch_response
works correctly, doesn’t raise exceptions, and returns a non-empty response of the correct type. With test_fetch_with_printer_proc
, we cover our main business logic of accepting the Proc, calling .fetch_response
, and passing all the necessary data to it. Finally, with test_fetch_without_printer
, we test what happens when we pass nil
. Even though the printer
parameter is defined as mandatory, without any guard statement, we can still pass any value — including nil
.
Now let’s add a file with type definitions.
module RubyVersion
class Response
attr_reader code: Integer
attr_reader json: Hash[String, untyped]
def initialize: (code: Integer, json: Hash[String, untyped]) -> void
def success?: () -> bool
end
API_URL: ::String
def self.fetch_response: () -> Response
def self.fetch: (^(String, String, Time) -> nil) -> nil
end
PRINTER: ^(String, String, Time) -> nil
For simplicity, I didn’t provide detailed type definitions for the Hash
we receive from GitHub’s endpoint. In real life, we might want to go deeper and cover that part with types as well.
The syntax of RBS is very similar to Ruby, so it’s not difficult to understand what it describes. You may notice that for our .fetch
method, we specified what type of data it supports. This means we don’t just “allow” a printer
parameter to be present (though nil
is possible), but we describe exactly what kind of value we accept. The same applies to all other parameters and return values.
Let’s break the code a bit and pass nil
to the .fetch
method like this:
puts RubyVersion.fetch(nil) if $PROGRAM_NAME == __FILE__
.
When we run steep check
, it will immediately spot the issue:
> bundle exec steep check
# Type checking files:
.F
app/fetch_ruby_latest_with_types.rb:49:23: [error] Cannot pass a value of type `nil` as an argument of type `^(::String, ::String, ::Time) -> nil`
│ nil <: ^(::String, ::String, ::Time) -> nil
│
│ Diagnostic ID: Ruby::ArgumentTypeMismatch
│
└ puts RubyVersion.fetch(nil) if $PROGRAM_NAME == __FILE__
~~~
Detected 1 problem from 1 file
It tells us:
Cannot pass a value of type nil as an argument of type ^(::String, ::String, ::Time) -> nil
— which gives us enough information about the issue and how to fix it. Now, without even running our tests, we can ensure we’ll never pass invalid data.
That also means we no longer need this test:
def test_fetch_without_printer
summary = RubyVersion.fetch(nil)
assert_nil summary
end
Steep
(a static type checker) simply won’t let us pass anything but a Proc
of the right shape. We can go even further and simplify other tests.
# frozen_string_literal: true
require 'minitest/autorun'
require_relative '../app/fetch_ruby_latest_with_types'
class TestRubyVersionMinimal < Minitest::Test
def test_fetch_response_structure
resp = RubyVersion.fetch_response
refute_nil resp
end
def test_fetch_with_printer_proc
received = {}
printer = proc do |tag, url, published_at|
received[:tag] = tag
received[:url] = url
received[:published_at] = published_at
nil
end
summary = RubyVersion.fetch(printer)
assert_nil summary
assert received[:tag]
assert received[:url]
end
end
Now, for the remaining cases, we only test the logic, not the types or the shape of the data we deal with.
In other words, we split responsibilities between MiniTest and Steep: the first validates that the business logic works as expected, the latter ensures we always deal with what was designed.
Static typing in Ruby cannot and will never replace tests. Similar to how it works in strongly typed languages, it’s a tool that brings clarity and confidence to your code.
And just like you’d spend your time in, say, Java, working with RBS or Sorbet (whichever you prefer) is not an “extra” time that none of us has. It’s the same time you’d otherwise spend debugging your app manually or writing an excessive amount of tests. As we’ve seen many times before - using static typing pays off.
Top comments (0)