From 6416aa52f20c7c4a2db37158a9117961ba2b20a3 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Sun, 21 Jan 2024 10:13:02 -0500 Subject: [PATCH 1/9] Add ability to save/restore ENV, automatically load in Rails --- Rakefile | 9 ++--- lib/dotenv.rb | 13 ++++++- lib/dotenv/rails.rb | 25 +++++++++----- lib/dotenv/test_help.rb | 21 ++++++++++++ spec/dotenv/rails_spec.rb | 71 +++++++++++++++++++++++++++++---------- spec/dotenv_spec.rb | 10 ++++-- spec/spec_helper.rb | 11 ++---- test/test_help_test.rb | 18 ++++++++++ 8 files changed, 137 insertions(+), 41 deletions(-) create mode 100644 lib/dotenv/test_help.rb create mode 100644 test/test_help_test.rb diff --git a/Rakefile b/Rakefile index 7ceb0fd..0069e36 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,12 @@ 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 -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/rails.rb b/lib/dotenv/rails.rb index 90b33b0..81d4aa2 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=, :test_help, :test_help=, 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, + test_help: 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.test_help" do |app| + require "dotenv/test_help" if test_help + end + config.before_configuration { load } end diff --git a/lib/dotenv/test_help.rb b/lib/dotenv/test_help.rb new file mode 100644 index 0000000..5133d4a --- /dev/null +++ b/lib/dotenv/test_help.rb @@ -0,0 +1,21 @@ +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 + # Save ENV when the test suite loads + Dotenv.save + + ActiveSupport::TestCase.class_eval do + # Restore ENV after each test + setup { Dotenv.restore } + end + end +end diff --git a/spec/dotenv/rails_spec.rb b/spec/dotenv/rails_spec.rb index d2935f0..cb0ca08 100644 --- a/spec/dotenv/rails_spec.rb +++ b/spec/dotenv/rails_spec.rb @@ -3,10 +3,31 @@ 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 + 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 +36,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 +54,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 +77,7 @@ end context "load" do - subject { Dotenv::Rails.load } + subject { application.initialize! } it "watches .env with Spring" do subject @@ -109,4 +125,25 @@ end end end + + describe "test_help" do + it "is loaded if RAILS_ENV=test" do + expect(Dotenv::Rails.test_help).to eq(true) + expect(Dotenv::Rails.instance).to receive(:require).with("dotenv/test_help") + application.initialize! + end + + it "is not loaded if RAILS_ENV=development" do + Rails.env = "development" + expect(Dotenv::Rails.test_help).to eq(false) + expect(Dotenv::Rails.instance).not_to receive(:require).with("dotenv/test_help") + application.initialize! + end + + it "is not loaded if test_help set to false" do + Dotenv::Rails.test_help = false + expect(Dotenv::Rails.instance).not_to receive(:require).with("dotenv/test_help") + 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..ee138af 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,11 +1,6 @@ require "dotenv" +require "dotenv/test_help" -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/test_help_test.rb b/test/test_help_test.rb new file mode 100644 index 0000000..9fad86f --- /dev/null +++ b/test/test_help_test.rb @@ -0,0 +1,18 @@ +require "active_support/deprecator" +require "active_support/test_case" +require "minitest/autorun" + +require "dotenv" +require "dotenv/test_help" + +class TestHelpTest < 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 From 73d0512cb6e2b4e36a4740cc0759daa37b5f8959 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Mon, 22 Jan 2024 18:54:55 -0500 Subject: [PATCH 2/9] Fix lint error --- lib/dotenv/rails.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/dotenv/rails.rb b/lib/dotenv/rails.rb index 81d4aa2..5de070b 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.load') 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 From e480e2156a59e5b1c5136e397dd1e7ce0eeb2c2d Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Mon, 22 Jan 2024 20:32:36 -0500 Subject: [PATCH 3/9] Try to load deprecator in 7.1 --- test/test_help_test.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/test_help_test.rb b/test/test_help_test.rb index 9fad86f..0eba3b4 100644 --- a/test/test_help_test.rb +++ b/test/test_help_test.rb @@ -1,4 +1,9 @@ -require "active_support/deprecator" +begin + require "active_support/deprecator" +rescue LoadError + # Rails 7.1 fails if this is not loaded +end + require "active_support/test_case" require "minitest/autorun" From 7f3640ca9e3da7332154cb964e75d7bad169cf1a Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Mon, 22 Jan 2024 20:44:06 -0500 Subject: [PATCH 4/9] Fix specs for Rails 7.0 --- spec/dotenv/rails_spec.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/spec/dotenv/rails_spec.rb b/spec/dotenv/rails_spec.rb index cb0ca08..d2a4b80 100644 --- a/spec/dotenv/rails_spec.rb +++ b/spec/dotenv/rails_spec.rb @@ -9,6 +9,9 @@ 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 From 561c94e1c1608eb5f53cb157b23e14e4e13e8fbf Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Mon, 22 Jan 2024 20:48:17 -0500 Subject: [PATCH 5/9] Fix tests for Rails 6.1 --- test/test_help_test.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/test/test_help_test.rb b/test/test_help_test.rb index 0eba3b4..7c20e6a 100644 --- a/test/test_help_test.rb +++ b/test/test_help_test.rb @@ -4,6 +4,7 @@ # Rails 7.1 fails if this is not loaded end +require "active_support" # Rails 6.1 fails if this is not loaded require "active_support/test_case" require "minitest/autorun" From dff63ef0ebd473e990fdb7dc601e173c015de788 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Wed, 24 Jan 2024 08:08:07 -0500 Subject: [PATCH 6/9] Rename test_help to autorestore --- lib/dotenv/{test_help.rb => autorestore.rb} | 2 ++ lib/dotenv/rails.rb | 8 ++++---- spec/dotenv/rails_spec.rb | 16 ++++++++-------- spec/spec_helper.rb | 2 +- test/{test_help_test.rb => autorestore_test.rb} | 10 ++-------- 5 files changed, 17 insertions(+), 21 deletions(-) rename lib/dotenv/{test_help.rb => autorestore.rb} (89%) rename test/{test_help_test.rb => autorestore_test.rb} (70%) diff --git a/lib/dotenv/test_help.rb b/lib/dotenv/autorestore.rb similarity index 89% rename from lib/dotenv/test_help.rb rename to lib/dotenv/autorestore.rb index 5133d4a..8132779 100644 --- a/lib/dotenv/test_help.rb +++ b/lib/dotenv/autorestore.rb @@ -1,3 +1,5 @@ +# Automatically restore `ENV` to its original state after + if defined?(RSpec.configure) RSpec.configure do |config| # Save ENV before the suite starts diff --git a/lib/dotenv/rails.rb b/lib/dotenv/rails.rb index 5de070b..ba71343 100644 --- a/lib/dotenv/rails.rb +++ b/lib/dotenv/rails.rb @@ -16,7 +16,7 @@ module Dotenv # Rails integration for using Dotenv to load ENV variables from a file class Rails < ::Rails::Railtie - delegate :files, :files=, :overwrite, :overwrite=, :test_help, :test_help=, to: "config.dotenv" + delegate :files, :files=, :overwrite, :overwrite=, :autorestore, :autorestore=, to: "config.dotenv" def initialize super() @@ -28,7 +28,7 @@ def initialize root.join(".env.#{env}"), root.join(".env") ].compact, - test_help: env.test? + autorestore: env.test? ) end @@ -88,8 +88,8 @@ def self.load app.deprecators[:dotenv] = deprecator if app.respond_to?(:deprecators) end - initializer "dotenv.test_help" do |app| - require "dotenv/test_help" if test_help + initializer "dotenv.autorestore" do |app| + require "dotenv/autorestore" if autorestore end config.before_configuration { load } diff --git a/spec/dotenv/rails_spec.rb b/spec/dotenv/rails_spec.rb index d2a4b80..8ba5875 100644 --- a/spec/dotenv/rails_spec.rb +++ b/spec/dotenv/rails_spec.rb @@ -129,23 +129,23 @@ end end - describe "test_help" do + describe "autorestore" do it "is loaded if RAILS_ENV=test" do - expect(Dotenv::Rails.test_help).to eq(true) - expect(Dotenv::Rails.instance).to receive(:require).with("dotenv/test_help") + 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.test_help).to eq(false) - expect(Dotenv::Rails.instance).not_to receive(:require).with("dotenv/test_help") + 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 test_help set to false" do - Dotenv::Rails.test_help = false - expect(Dotenv::Rails.instance).not_to receive(:require).with("dotenv/test_help") + 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 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ee138af..a0df41c 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,5 +1,5 @@ require "dotenv" -require "dotenv/test_help" +require "dotenv/autorestore" def fixture_path(*parts) Pathname.new(__dir__).join("./fixtures", *parts) diff --git a/test/test_help_test.rb b/test/autorestore_test.rb similarity index 70% rename from test/test_help_test.rb rename to test/autorestore_test.rb index 7c20e6a..d3c660b 100644 --- a/test/test_help_test.rb +++ b/test/autorestore_test.rb @@ -1,17 +1,11 @@ -begin - require "active_support/deprecator" -rescue LoadError - # Rails 7.1 fails if this is not loaded -end - require "active_support" # Rails 6.1 fails if this is not loaded require "active_support/test_case" require "minitest/autorun" require "dotenv" -require "dotenv/test_help" +require "dotenv/autorestore" -class TestHelpTest < ActiveSupport::TestCase +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" From 98c9fc63ea71744e06249b307a761c31851a590e Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Wed, 24 Jan 2024 08:10:36 -0500 Subject: [PATCH 7/9] Configure files for `rake test` --- Rakefile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Rakefile b/Rakefile index 0069e36..055010b 100644 --- a/Rakefile +++ b/Rakefile @@ -33,6 +33,8 @@ RSpec::Core::RakeTask.new(:spec) do |t| t.verbose = false end -Rake::TestTask.new +Rake::TestTask.new do |t| + t.test_files = Dir["test/**/*_test.rb"] +end task default: [:spec, :test, :standard] From 234e126d0fd16a523468465aca91f8d0fdc276a6 Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Wed, 24 Jan 2024 11:51:24 -0500 Subject: [PATCH 8/9] Update README --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) 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: From 4df96c100c215e694c72afeb17fedca222c917ac Mon Sep 17 00:00:00 2001 From: Brandon Keepers Date: Wed, 24 Jan 2024 11:52:07 -0500 Subject: [PATCH 9/9] Use setup+teardown in AS::TestCase --- lib/dotenv/autorestore.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/dotenv/autorestore.rb b/lib/dotenv/autorestore.rb index 8132779..c54851e 100644 --- a/lib/dotenv/autorestore.rb +++ b/lib/dotenv/autorestore.rb @@ -12,12 +12,12 @@ if defined?(ActiveSupport) ActiveSupport.on_load(:active_support_test_case) do - # Save ENV when the test suite loads - Dotenv.save - ActiveSupport::TestCase.class_eval do + # Save ENV before each test + setup { Dotenv.save } + # Restore ENV after each test - setup { Dotenv.restore } + teardown { Dotenv.restore } end end end