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.