diff --git a/README.md b/README.md index 9d43fab..828d047 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/Rakefile b/Rakefile index 7ceb0fd..055010b 100644 --- a/Rakefile +++ b/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" @@ -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] diff --git a/lib/dotenv.rb b/lib/dotenv.rb index c0ec36e..09f5969 100644 --- a/lib/dotenv.rb +++ b/lib/dotenv.rb @@ -63,7 +63,7 @@ def instrument(name, payload = {}, &block) if instrumenter instrumenter.instrument(name, payload, &block) else - yield + block&.call end end @@ -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) diff --git a/lib/dotenv/autorestore.rb b/lib/dotenv/autorestore.rb new file mode 100644 index 0000000..c54851e --- /dev/null +++ b/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 diff --git a/lib/dotenv/rails.rb b/lib/dotenv/rails.rb index 90b33b0..ba71343 100644 --- a/lib/dotenv/rails.rb +++ b/lib/dotenv/rails.rb @@ -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 @@ -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 @@ -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 diff --git a/spec/dotenv/rails_spec.rb b/spec/dotenv/rails_spec.rb index d2935f0..8ba5875 100644 --- a/spec/dotenv/rails_spec.rb +++ b/spec/dotenv/rails_spec.rb @@ -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 @@ -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 @@ -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) @@ -61,7 +80,7 @@ end context "load" do - subject { Dotenv::Rails.load } + subject { application.initialize! } it "watches .env with Spring" do subject @@ -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 diff --git a/spec/dotenv_spec.rb b/spec/dotenv_spec.rb index 749a245..9dca8a2 100644 --- a/spec/dotenv_spec.rb +++ b/spec/dotenv_spec.rb @@ -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 @@ -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 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 8d1cdce..a0df41c 100644 --- a/spec/spec_helper.rb +++ b/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 diff --git a/test/autorestore_test.rb b/test/autorestore_test.rb new file mode 100644 index 0000000..d3c660b --- /dev/null +++ b/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