Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Autorestore ENV between tests #472

Merged
merged 9 commits into from Jan 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Expand Up @@ -52,6 +52,14 @@ require 'dotenv'
Dotenv.load('file1.env', 'file2.env')
```

## Autorestore in tests

Since 3.0, dotenv in a Rails app will automatically restore `ENV` to its original state before each test. This means you can modify `ENV` in your tests without fear of leaking state to other tests. It works with both `ActiveSupport::TestCase` and `Rspec`.

To disable this behavior, set `config.dotenv.autorestore = false` in `config/application.rb` or `config/environments/test.rb`.

To use this behavior outside of a Rails app, just `require "dotenv/autorestore"` in your test suite.

### Rake

To ensure `.env` is loaded in rake, load the tasks:
Expand Down
11 changes: 7 additions & 4 deletions Rakefile
@@ -1,6 +1,9 @@
#!/usr/bin/env rake

require "bundler/gem_helper"
require "rspec/core/rake_task"
require "rake/testtask"
require "standard/rake"

namespace "dotenv" do
Bundler::GemHelper.install_tasks name: "dotenv"
Expand All @@ -24,14 +27,14 @@ task build: ["dotenv:build", "dotenv-rails:build"]
task install: ["dotenv:install", "dotenv-rails:install"]
task release: ["dotenv:release", "dotenv-rails:release"]

require "rspec/core/rake_task"

desc "Run all specs"
RSpec::Core::RakeTask.new(:spec) do |t|
t.rspec_opts = %w[--color]
t.verbose = false
end

require "standard/rake"
Rake::TestTask.new do |t|
t.test_files = Dir["test/**/*_test.rb"]
end

task default: [:spec, :standard]
task default: [:spec, :test, :standard]
13 changes: 12 additions & 1 deletion lib/dotenv.rb
Expand Up @@ -63,7 +63,7 @@ def instrument(name, payload = {}, &block)
if instrumenter
instrumenter.instrument(name, payload, &block)
else
yield
block&.call
end
end

Expand All @@ -72,6 +72,17 @@ def require_keys(*keys)
return if missing_keys.empty?
raise MissingKeys, missing_keys
end

# Save a snapshot of the current `ENV` to be restored later
def save
@snapshot = ENV.to_h.freeze
instrument("dotenv.save", env: @snapshot)
end

# Restore the previous snapshot of `ENV`
def restore
instrument("dotenv.restore", env: @snapshot) { ENV.replace(@snapshot) }
end
end

require "dotenv/rails" if defined?(Rails::Railtie)
23 changes: 23 additions & 0 deletions lib/dotenv/autorestore.rb
@@ -0,0 +1,23 @@
# Automatically restore `ENV` to its original state after

if defined?(RSpec.configure)
RSpec.configure do |config|
# Save ENV before the suite starts
config.before(:suite) { Dotenv.save }

# Restore ENV after each example
config.after { Dotenv.restore }
end
end

if defined?(ActiveSupport)
ActiveSupport.on_load(:active_support_test_case) do
ActiveSupport::TestCase.class_eval do
# Save ENV before each test
setup { Dotenv.save }

# Restore ENV after each test
teardown { Dotenv.restore }
end
end
end
25 changes: 16 additions & 9 deletions lib/dotenv/rails.rb
Expand Up @@ -5,7 +5,7 @@
# Watch all loaded env files with Spring
begin
require "spring/commands"
ActiveSupport::Notifications.subscribe(/^dotenv/) do |*args|
ActiveSupport::Notifications.subscribe("dotenv.load") do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
Spring.watch event.payload[:env].filename if Rails.application
end
Expand All @@ -16,17 +16,20 @@
module Dotenv
# Rails integration for using Dotenv to load ENV variables from a file
class Rails < ::Rails::Railtie
attr_accessor :overwrite, :files
delegate :files, :files=, :overwrite, :overwrite=, :autorestore, :autorestore=, to: "config.dotenv"

def initialize
super()
@overwrite = false
@files = [
root.join(".env.#{env}.local"),
(root.join(".env.local") unless env.test?),
root.join(".env.#{env}"),
root.join(".env")
].compact
config.dotenv = ActiveSupport::OrderedOptions.new.update(
overwrite: false,
files: [
root.join(".env.#{env}.local"),
(root.join(".env.local") unless env.test?),
root.join(".env.#{env}"),
root.join(".env")
].compact,
autorestore: env.test?
)
end

# Public: Load dotenv
Expand Down Expand Up @@ -85,6 +88,10 @@ def self.load
app.deprecators[:dotenv] = deprecator if app.respond_to?(:deprecators)
end

initializer "dotenv.autorestore" do |app|
require "dotenv/autorestore" if autorestore
end

config.before_configuration { load }
end

Expand Down
74 changes: 57 additions & 17 deletions spec/dotenv/rails_spec.rb
Expand Up @@ -3,10 +3,34 @@
require "dotenv/rails"

describe Dotenv::Rails do
let(:application) do
Class.new(Rails::Application) do
config.load_defaults Rails::VERSION::STRING.to_f
config.eager_load = false
config.logger = ActiveSupport::Logger.new(StringIO.new)
config.root = fixture_path

# Remove method fails since app is reloaded for each test
config.active_support.remove_deprecated_time_with_zone_name = false
end.instance
end

around do |example|
# These get frozen after the app initializes
autoload_paths = ActiveSupport::Dependencies.autoload_paths.dup
autoload_once_paths = ActiveSupport::Dependencies.autoload_once_paths.dup

# Run in fixtures directory
Dir.chdir(fixture_path) { example.run }
ensure
# Restore autoload paths to unfrozen state
ActiveSupport::Dependencies.autoload_paths = autoload_paths
ActiveSupport::Dependencies.autoload_once_paths = autoload_once_paths
end

before do
Rails.env = "test"
allow(Rails).to receive(:root).and_return Pathname.new(__dir__).join("../fixtures")
Rails.application = double(:application)
Rails.application = nil
Spring.watcher = Set.new # Responds to #add
end

Expand All @@ -15,22 +39,16 @@
Dotenv::Rails.remove_instance_variable(:@instance)
end

after do
# Reset
Spring.watcher = nil
Rails.application = nil
end

describe "files" do
it "loads files for development environment" do
Rails.env = "development"

expect(Dotenv::Rails.files).to eql(
[
Rails.root.join(".env.development.local"),
Rails.root.join(".env.local"),
Rails.root.join(".env.development"),
Rails.root.join(".env")
application.root.join(".env.development.local"),
application.root.join(".env.local"),
application.root.join(".env.development"),
application.root.join(".env")
]
)
end
Expand All @@ -39,15 +57,16 @@
Rails.env = "test"
expect(Dotenv::Rails.files).to eql(
[
Rails.root.join(".env.test.local"),
Rails.root.join(".env.test"),
Rails.root.join(".env")
application.root.join(".env.test.local"),
application.root.join(".env.test"),
application.root.join(".env")
]
)
end
end

it "watches loaded files with Spring" do
it "watches other loaded files with Spring" do
application.initialize!
path = fixture_path("plain.env")
Dotenv.load(path)
expect(Spring.watcher).to include(path.to_s)
Expand All @@ -61,7 +80,7 @@
end

context "load" do
subject { Dotenv::Rails.load }
subject { application.initialize! }

it "watches .env with Spring" do
subject
Expand Down Expand Up @@ -109,4 +128,25 @@
end
end
end

describe "autorestore" do
it "is loaded if RAILS_ENV=test" do
expect(Dotenv::Rails.autorestore).to eq(true)
expect(Dotenv::Rails.instance).to receive(:require).with("dotenv/autorestore")
application.initialize!
end

it "is not loaded if RAILS_ENV=development" do
Rails.env = "development"
expect(Dotenv::Rails.autorestore).to eq(false)
expect(Dotenv::Rails.instance).not_to receive(:require).with("dotenv/autorestore")
application.initialize!
end

it "is not loaded if autorestore set to false" do
Dotenv::Rails.autorestore = false
expect(Dotenv::Rails.instance).not_to receive(:require).with("dotenv/autorestore")
application.initialize!
end
end
end
10 changes: 8 additions & 2 deletions spec/dotenv_spec.rb
Expand Up @@ -94,7 +94,10 @@

it "fails silently" do
expect { subject }.not_to raise_error
expect(ENV.keys).to eq(@env_keys)
end

it "does not change ENV" do
expect { subject }.not_to change { ENV.inspect }
end
end
end
Expand Down Expand Up @@ -149,7 +152,10 @@

it "fails silently" do
expect { subject }.not_to raise_error
expect(ENV.keys).to eq(@env_keys)
end

it "does not change ENV" do
expect { subject }.not_to change { ENV.inspect }
end
end
end
Expand Down
11 changes: 3 additions & 8 deletions spec/spec_helper.rb
@@ -1,11 +1,6 @@
require "dotenv"
require "dotenv/autorestore"

RSpec.configure do |config|
# Restore the state of ENV after each spec
config.before { @env_keys = ENV.keys }
config.after { ENV.delete_if { |k, _v| !@env_keys.include?(k) } }
end

def fixture_path(name)
Pathname.new(__dir__).join("./fixtures", name)
def fixture_path(*parts)
Pathname.new(__dir__).join("./fixtures", *parts)
end
18 changes: 18 additions & 0 deletions test/autorestore_test.rb
@@ -0,0 +1,18 @@
require "active_support" # Rails 6.1 fails if this is not loaded
require "active_support/test_case"
require "minitest/autorun"

require "dotenv"
require "dotenv/autorestore"

class AutorestoreTest < ActiveSupport::TestCase
test "restores ENV between tests, part 1" do
assert_nil ENV["DOTENV"], "ENV was not restored between tests"
ENV["DOTENV"] = "1"
end

test "restores ENV between tests, part 2" do
assert_nil ENV["DOTENV"], "ENV was not restored between tests"
ENV["DOTENV"] = "2"
end
end