Testing and Environment Variables in Ruby

Josh Clayton
Edited by Stefanni Brasil

As developers have moved hosting applications to cloud-based solutions like AWS and Heroku, a number of patterns for developing and deploying applications emerged. Perhaps the most well-known set of methodologies is the Twelve-Factor App, which outlines areas in which applications become difficult to maintain and what to do to improve them.

One area which impacts developers directly in the code we write and test is application configuration. Because we deploy across multiple environments (e.g. staging and production) with different sets of configurations for services like Stripe and Segment, and because often these are critical aspects of our application, we want to ensure things work correctly.

There are a few routes we can take to test the code using environment variables, but none are ideal.

Rely on the values of the environment variables set by our application

Most often, these environment variables are defined either in the .env or config/environments/test.rb files. Here is an example:

# config/environments/test.rb

ENV["TWILIO_CALLER_ID"] = "+15555551212"

# spec/models/calls/call_initiator_spec.rb
require "spec_helper"

describe Calls::CallInitiator do
  it "creates a new call with the appropriate data" do
    call_creator = double("calls", create: nil)
    initiator = Calls::CallInitiator.new("555-555-1234", call_creator)

    initiator.run

    call_data = {
      from: "+15555551212",
      to: "555-555-1234",
    }
    expect(call_creator).to have_received(:create).with(call_data)
  end
end

However, testing against these values introduces mystery guests, as any values we’re testing against are defined outside of the specs themselves.

Override environment variables on a per-test basis

Another common way to test environment variables is by overriding them on a per-test basis. This introduces additional complexity by adding additional setup and teardown steps.

# spec/models/calls/call_initiator_spec.rb
require "spec_helper"

describe Calls::CallInitiator do
  it "creates a new call with the appropriate data" do
    cached_twilio_caller_id = ENV["TWILIO_CALLER_ID"]
    ENV["TWILIO_CALLER_ID"] = "+15555551212"

    call_creator = double("calls", create: nil)
    initiator = Calls::CallInitiator.new("555-555-1234", call_creator)

    initiator.run

    call_data = {
      from: "+15555551212",
      to: "555-555-1234",
    }
    expect(call_creator).to have_received(:create).with(call_data)

    ENV["TWILIO_CALLER_ID"] = cached_twilio_caller_id
  end
end

Because ENV contains global state, and because there are no expectations about which other tests are relying on this state, we must always cache and reassign state every time we modify ENV for a test.

Stub or Mock ENV

Stubbing or mocking ENV is another option (at least at the unit level) which allows us to control the values. One added benefit is that mocking and stubbing libraries traditionally handle cleaning up stubs during the teardown phase.

# spec/models/calls/call_initiator_spec.rb
require "spec_helper"

describe Calls::CallInitiator do
  it "creates a new call with the appropriate data" do
    allow(ENV).to receive(:[]).with("TWILIO_CALLER_ID").and_return("+15555551212")

    call_creator = double("calls", create: nil)
    initiator = Calls::CallInitiator.new("555-555-1234", call_creator)

    initiator.run

    call_data = {
      from: "+15555551212",
      to: "555-555-1234",
    }
    expect(call_creator).to have_received(:create).with(call_data)
  end
end

I’ve always been a fan of following “Don’t mock what you don’t own”, and in the case of ENV (part of Ruby’s core library), we don’t own it (even though I’d consider its interface to be fairly stable).

Explicitly access keys with ENV#fetch

By using ENV#fetch instead of ENV#[] to retrieve values in the code we’d be testing, we reduce likelihood of misspellings or mis-configurations. This doesn’t guarantee variables are used correctly.

One example I’ve seen firsthand was the same value (admin and support email addresses) assigned to two environment variables, and misused in the mailer. When the environment variable was updated on production (after finding the bug), one email was emailed to the wrong group of people.

Test Environment Variables with Climate Control

Climate Control is a gem which handles the above case of modifying environment variables on a per-test basis. It avoids mystery guests, doesn’t stub ENV, and (with arbitrarily strange strings!) provides a high level of confidence that the appropriate environment variables are being used correctly.

It’s likely most applicable in unit and integration level tests, since we’ll likely be using fakes at the acceptance level.

Let’s see Climate Control in action:

# spec/models/calls/call_initiator_spec.rb
require "spec_helper"

describe Calls::CallInitiator do
  it "creates a new call with the appropriate data" do
    ClimateControl.modify TWILIO_CALLER_ID: "awesome Twilio caller ID" do
      call_creator = double("calls", create: nil)
      initiator = Calls::CallInitiator.new("555-555-1234", call_creator)

      initiator.run

      call_data = {
        from: "awesome Twilio caller ID",
        to: "555-555-1234",
      }
      expect(call_creator).to have_received(:create).with(call_data)
    end
  end
end

Overriding environment variables only within the block ensures state is reset accordingly and environment variable values are immediately obvious to developers. Because of this, Climate Control fosters moving more configuration into ENV by making it easier to test, resulting in more adherence to the twelve-factor app methodology.

Test Environment Variables with Climate Control using RSpec

To use it with RSpec, define theis module in your spec folder:

# spec/support/climate_control.rb
module EnvHelper
  def with_modified_env(options, &block)
    ClimateControl.modify(options, &block)
  end
end

RSpec.configure { |config| config.include(EnvHelper) }

Then, your tests would read more straightforward by calling with_modified_env when needed:

# spec/models/calls/call_initiator_spec.rb
require "spec_helper"

describe Calls::CallInitiator do
  it "creates a new call with the appropriate data" do
    with_modified_env TWILIO_CALLER_ID: "awesome Twilio caller ID" do
      # your tests
    end
  end
end

Check out climate-control README for more examples, including how to modify multiple environment variables, and use the library with Threads.