diff --git a/Changelog.md b/Changelog.md index 0477ed31c..e0a2d745e 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,15 @@ +# v.0.11.1 2021-11-xx + +* [#1274](https://github.com/mbj/mutant/pull/1274) + + Add ability to set environment variables via the CLI. + The environment variables are inserted before the application is + loaded. This is especially useful to load rails in test mode via setting + `RAILS_ENV=test`. + + For CLI use the `--env` argument. + For config file use the `environment_variables` key. + # v.0.11.0 2021-10-18 * [#1270](https://github.com/mbj/mutant/pull/1270) diff --git a/docs/configuration.md b/docs/configuration.md index 45cc1eb56..e427dff58 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -28,6 +28,20 @@ requires: Additional requires can be added by providing the `-r` or `--require` option to the CLI. +#### `environment_variables` + +Allows to set environment variables that are loaded just before the target application is +loaded. This is especially useful when dealing with rails that has to be initialized with +`RAILS_ENV=test` to behave correctly under mutation testing. + +```yml +--- +environment_variables: + RAILS_ENV: test +``` + +Additional environment variables can be added by providing the `--env KEY=VALUE` option to the CLI. + #### `integration` Specifies which mutant integration to use. If your tests are writen in [RSpec](https://rspec.info/), this should be set to `rspec`. If your tests are written in [minitest](https://github.com/seattlerb/minitest), this should be set to `minitest`. diff --git a/lib/mutant.rb b/lib/mutant.rb index b5be32eee..90b894660 100644 --- a/lib/mutant.rb +++ b/lib/mutant.rb @@ -234,46 +234,48 @@ module Mutant module Mutant WORLD = World.new( - condition_variable: ConditionVariable, - gem: Gem, - gem_method: method(:gem), - io: IO, - json: JSON, - kernel: Kernel, - load_path: $LOAD_PATH, - marshal: Marshal, - mutex: Mutex, - object_space: ObjectSpace, - open3: Open3, - pathname: Pathname, - process: Process, - stderr: $stderr, - stdout: $stdout, - thread: Thread, - timer: Timer.new(Process) + condition_variable: ConditionVariable, + environment_variables: ENV, + gem: Gem, + gem_method: method(:gem), + io: IO, + json: JSON, + kernel: Kernel, + load_path: $LOAD_PATH, + marshal: Marshal, + mutex: Mutex, + object_space: ObjectSpace, + open3: Open3, + pathname: Pathname, + process: Process, + stderr: $stderr, + stdout: $stdout, + thread: Thread, + timer: Timer.new(Process) ) # Reopen class to initialize constant to avoid dep circle class Config DEFAULT = new( - coverage_criteria: Config::CoverageCriteria::EMPTY, - expression_parser: Expression::Parser.new([ + coverage_criteria: Config::CoverageCriteria::EMPTY, + expression_parser: Expression::Parser.new([ Expression::Method, Expression::Methods, Expression::Namespace::Exact, Expression::Namespace::Recursive ]), - fail_fast: false, - hooks: EMPTY_ARRAY, - includes: EMPTY_ARRAY, - integration: nil, - isolation: Mutant::Isolation::Fork.new(WORLD), - jobs: nil, - matcher: Matcher::Config::DEFAULT, - mutation_timeout: nil, - reporter: Reporter::CLI.build(WORLD.stdout), - requires: EMPTY_ARRAY, - zombie: false + fail_fast: false, + environment_variables: EMPTY_HASH, + hooks: EMPTY_ARRAY, + includes: EMPTY_ARRAY, + integration: nil, + isolation: Mutant::Isolation::Fork.new(WORLD), + jobs: nil, + matcher: Matcher::Config::DEFAULT, + mutation_timeout: nil, + reporter: Reporter::CLI.build(WORLD.stdout), + requires: EMPTY_ARRAY, + zombie: false ) end # Config diff --git a/lib/mutant/bootstrap.rb b/lib/mutant/bootstrap.rb index 8bedc4ede..5431f92cc 100644 --- a/lib/mutant/bootstrap.rb +++ b/lib/mutant/bootstrap.rb @@ -71,6 +71,10 @@ def self.infect(env) hooks.run(:env_infection_pre, env) + config.environment_variables.each do |key, value| + world.environment_variables[key] = value + end + config.includes.each(&world.load_path.public_method(:<<)) config.requires.each(&world.kernel.public_method(:require)) diff --git a/lib/mutant/cli/command/environment.rb b/lib/mutant/cli/command/environment.rb index d1e19cd3b..f0157fec0 100644 --- a/lib/mutant/cli/command/environment.rb +++ b/lib/mutant/cli/command/environment.rb @@ -15,6 +15,8 @@ class Environment < self add_matcher_options ].freeze + ENV_PATTERN = /\A(?[A-Za-z\d]+)=(?.*)\z/.freeze + private def initialize(attributes) @@ -62,6 +64,7 @@ def add_matcher(attribute, value) set(matcher: @config.matcher.add(attribute, value)) end + # rubocop:disable Metrics/MethodLength def add_environment_options(parser) parser.separator('Environment:') parser.on('--zombie', 'Run mutant zombified') do @@ -73,7 +76,14 @@ def add_environment_options(parser) parser.on('-r', '--require NAME', 'Require file with NAME') do |name| add(:requires, name) end + parser.on('--env KEY=VALUE', 'Set environment variable') do |value| + match = ENV_PATTERN.match(value) || fail("Invalid env variable: #{value.inspect}") + set( + environment_variables: @config.environment_variables.merge(match[:key] => match[:value]) + ) + end end + # rubocop:enable Metrics/MethodLength def add_integration_options(parser) parser.separator('Integration:') diff --git a/lib/mutant/config.rb b/lib/mutant/config.rb index e04aeacda..3b26c3364 100644 --- a/lib/mutant/config.rb +++ b/lib/mutant/config.rb @@ -5,9 +5,12 @@ module Mutant # # Does not reference any "external" volatile state. The configuration applied # to current environment is being represented by the Mutant::Env object. + # + # rubocop:disable Metrics/ClassLength class Config include Adamantium, Anima.new( :coverage_criteria, + :environment_variables, :expression_parser, :fail_fast, :hooks, @@ -48,16 +51,17 @@ class Config # rubocop:disable Metrics/MethodLength def merge(other) other.with( - coverage_criteria: coverage_criteria.merge(other.coverage_criteria), - fail_fast: fail_fast || other.fail_fast, - hooks: hooks + other.hooks, - includes: includes + other.includes, - integration: other.integration || integration, - jobs: other.jobs || jobs, - matcher: matcher.merge(other.matcher), - mutation_timeout: other.mutation_timeout || mutation_timeout, - requires: requires + other.requires, - zombie: zombie || other.zombie + coverage_criteria: coverage_criteria.merge(other.coverage_criteria), + environment_variables: environment_variables.merge(other.environment_variables), + fail_fast: fail_fast || other.fail_fast, + hooks: hooks + other.hooks, + includes: includes + other.includes, + integration: other.integration || integration, + jobs: other.jobs || jobs, + matcher: matcher.merge(other.matcher), + mutation_timeout: other.mutation_timeout || mutation_timeout, + requires: requires + other.requires, + zombie: zombie || other.zombie ) end # rubocop:enable Metrics/AbcSize @@ -117,6 +121,24 @@ def self.env ) ) + # Parse a hash of environment variables + # + # @param [Hash] + # + # @return [Either] + # + def self.parse_environment_variables(hash) + invalid = hash.keys.reject { |key| key.instance_of?(String) } + return Either::Left.new("Non string keys: #{invalid}") if invalid.any? + + invalid = hash.keys.grep_v(/\A[A-Za-z\d]+\z/) + return Either::Left.new("Invalid keys: #{invalid}") if invalid.any? + + invalid = hash.values.reject { |value| value.instance_of?(String) } + return Either::Left.new("Non string values: #{invalid}") if invalid.any? + + Either::Right.new(hash) + end TRANSFORM = Transform::Sequence.new( [ Transform::Exception.new(SystemCallError, :read.to_proc), @@ -124,14 +146,23 @@ def self.env Transform::Hash.new( optional: [ Transform::Hash::Key.new('coverage_criteria', ->(value) { CoverageCriteria::TRANSFORM.call(value) }), - Transform::Hash::Key.new('fail_fast', Transform::BOOLEAN), - Transform::Hash::Key.new('hooks', PATHNAME_ARRAY), - Transform::Hash::Key.new('includes', Transform::STRING_ARRAY), - Transform::Hash::Key.new('integration', Transform::STRING), - Transform::Hash::Key.new('jobs', Transform::INTEGER), - Transform::Hash::Key.new('matcher', Matcher::Config::LOADER), - Transform::Hash::Key.new('mutation_timeout', Transform::FLOAT), - Transform::Hash::Key.new('requires', Transform::STRING_ARRAY) + Transform::Hash::Key.new( + 'environment_variables', + Transform::Sequence.new( + [ + Transform::Primitive.new(Hash), + Transform::Block.capture(:environment_variables, &method(:parse_environment_variables)) + ] + ) + ), + Transform::Hash::Key.new('fail_fast', Transform::BOOLEAN), + Transform::Hash::Key.new('hooks', PATHNAME_ARRAY), + Transform::Hash::Key.new('includes', Transform::STRING_ARRAY), + Transform::Hash::Key.new('integration', Transform::STRING), + Transform::Hash::Key.new('jobs', Transform::INTEGER), + Transform::Hash::Key.new('matcher', Matcher::Config::LOADER), + Transform::Hash::Key.new('mutation_timeout', Transform::FLOAT), + Transform::Hash::Key.new('requires', Transform::STRING_ARRAY) ], required: [] ), @@ -141,4 +172,5 @@ def self.env private_constant(:TRANSFORM) end # Config + # rubocop:enable Metrics/ClassLength end # Mutant diff --git a/lib/mutant/world.rb b/lib/mutant/world.rb index 4a619753e..c3f3c5d02 100644 --- a/lib/mutant/world.rb +++ b/lib/mutant/world.rb @@ -5,6 +5,7 @@ module Mutant class World include Adamantium, Anima.new( :condition_variable, + :environment_variables, :gem, :gem_method, :io, diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2a3c14fb1..e8e49d118 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -80,23 +80,24 @@ def undefined # rubocop:disable Metrics/MethodLength def fake_world Mutant::World.new( - condition_variable: class_double(ConditionVariable), - gem: class_double(Gem), - gem_method: instance_double(Proc), - io: class_double(IO), - json: class_double(JSON), - kernel: class_double(Kernel), - load_path: instance_double(Array), - marshal: class_double(Marshal), - mutex: class_double(Mutex), - object_space: class_double(ObjectSpace), - open3: class_double(Open3), - pathname: class_double(Pathname), - process: class_double(Process), - stderr: instance_double(IO), - stdout: instance_double(IO), - thread: class_double(Thread), - timer: instance_double(Mutant::Timer) + condition_variable: class_double(ConditionVariable), + environment_variables: instance_double(Hash), + gem: class_double(Gem), + gem_method: instance_double(Proc), + io: class_double(IO), + json: class_double(JSON), + kernel: class_double(Kernel), + load_path: instance_double(Array), + marshal: class_double(Marshal), + mutex: class_double(Mutex), + object_space: class_double(ObjectSpace), + open3: class_double(Open3), + pathname: class_double(Pathname), + process: class_double(Process), + stderr: instance_double(IO), + stdout: instance_double(IO), + thread: class_double(Thread), + timer: instance_double(Mutant::Timer) ) end # rubocop:enable Metrics/MethodLength diff --git a/spec/unit/mutant/bootstrap_spec.rb b/spec/unit/mutant/bootstrap_spec.rb index cc9cf90d3..0e0c5bb23 100644 --- a/spec/unit/mutant/bootstrap_spec.rb +++ b/spec/unit/mutant/bootstrap_spec.rb @@ -22,12 +22,13 @@ def require(_); end let(:config) do Mutant::Config::DEFAULT.with( - includes: %w[include-a include-b], - integration: integration, - jobs: 1, - matcher: matcher_config, - reporter: instance_double(Mutant::Reporter), - requires: %w[require-a require-b] + environment_variables: { 'foo' => 'bar' }, + includes: %w[include-a include-b], + integration: integration, + jobs: 1, + matcher: matcher_config, + reporter: instance_double(Mutant::Reporter), + requires: %w[require-a require-b] ) end @@ -52,11 +53,12 @@ def require(_); end let(:world) do instance_double( Mutant::World, - kernel: kernel, - load_path: load_path, - object_space: object_space, - pathname: Pathname, - timer: timer + environment_variables: {}, + kernel: kernel, + load_path: load_path, + object_space: object_space, + pathname: Pathname, + timer: timer ) end @@ -81,6 +83,11 @@ def require(_); end selector: :run, arguments: [:env_infection_pre, env_initial] }, + { + receiver: world.environment_variables, + selector: :[]=, + arguments: %w[foo bar] + }, { receiver: load_path, selector: :<<, diff --git a/spec/unit/mutant/cli_spec.rb b/spec/unit/mutant/cli_spec.rb index da0707c4d..a63fb4af7 100644 --- a/spec/unit/mutant/cli_spec.rb +++ b/spec/unit/mutant/cli_spec.rb @@ -250,6 +250,7 @@ def self.main_body --zombie Run mutant zombified -I, --include DIRECTORY Add DIRECTORY to $LOAD_PATH -r, --require NAME Require file with NAME + --env KEY=VALUE Set environment variable Runner: @@ -965,6 +966,28 @@ def self.main_body include_examples 'CLI run' end + context 'with --env option' do + let(:arguments) { super() + %W[--env #{argument}] } + + context 'on valid env syntax' do + let(:argument) { 'foo=bar' } + + let(:bootstrap_config) do + super().with(environment_variables: { 'foo' => 'bar' }) + end + + include_examples 'CLI run' + end + + context 'on invalid env syntax' do + let(:argument) { 'foobar' } + + it 'raises expected error' do + expect { apply }.to raise_error(RuntimeError, 'Invalid env variable: "foobar"') + end + end + end + context 'with --jobs option on absent file config' do let(:arguments) { super() + %w[--jobs 10] } let(:bootstrap_config) { super().with(jobs: 10) } diff --git a/spec/unit/mutant/config_spec.rb b/spec/unit/mutant/config_spec.rb index 92f7d213e..f352a249b 100644 --- a/spec/unit/mutant/config_spec.rb +++ b/spec/unit/mutant/config_spec.rb @@ -169,6 +169,20 @@ def expect_value(value) include_examples 'maybe value' end + context 'merging environment variables' do + let(:key) { :environment_variables } + let(:original_value) { { 'KEY_A' => 'VALUE_A', 'KEY_B' => 'VALUE_B' } } + let(:other_value) { { 'KEY_A' => 'VALUE_X', 'KEY_C' => 'VALUE_C' } } + + it 'merges with preference for other' do + expect_value( + 'KEY_A' => 'VALUE_X', + 'KEY_B' => 'VALUE_B', + 'KEY_C' => 'VALUE_C' + ) + end + end + context 'merging integration' do let(:key) { :integration } let(:original_value) { 'rspec' } @@ -411,4 +425,42 @@ def apply end end end + + describe '.parse_enviroment_variables' do + def apply + described_class.parse_environment_variables(input) + end + + context 'on non string keys' do + let(:input) { { 1 => 'foo' } } + + it 'returns expected value' do + expect(apply).to eql(left('Non string keys: [1]')) + end + end + + context 'on malformed string keys' do + let(:input) { { 'foo=' => 'foo' } } + + it 'returns expected value' do + expect(apply).to eql(left('Invalid keys: ["foo="]')) + end + end + + context 'non string values' do + let(:input) { { 'foo' => 1 } } + + it 'returns expected value' do + expect(apply).to eql(left('Non string values: [1]')) + end + end + + context 'valid input' do + let(:input) { { 'foo' => 'bar' } } + + it 'returns expected value' do + expect(apply).to eql(right('foo' => 'bar')) + end + end + end end