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)