From 76a584f93b11b45c94c59c55653e657243b6fa8d Mon Sep 17 00:00:00 2001 From: Matt Brictson Date: Tue, 20 Aug 2024 15:56:23 -0700 Subject: [PATCH] Add a true integration test that actually executes Bundler (#41) Before, our CLI tests were doing a significant amount of stubbing to avoid making external calls to Bundler commands. This limited the value of these tests. It also made them tedious to write, and required behind-the-scenes knowledge of when and how the lower-level code called out to Bundler. In this PR, I refactored the existing CLI tests so that the stubbing is done at a higher level: at the `Reporter` API. This makes test setup easier while still allowing us to write tests to cover various CLI edge cases, like error handing. For the primary, "happy path" case, I've created a `CLIIntegrationTest`. This runs the actual `update-interactive` executable via `Open3.capture3`, so every layer of the code base is exercised. Most importantly, Bundler commands themselves are actually executed. The integration test confirms that when a gem is selected to be updated, the update really happens. It does this by inspecting the `Gemfile.lock` to see that the gem version was changed as expected. I also updated the `mocha` configuration to make stubbing more strict going forward. --- test/bundle_update_interactive/cli_test.rb | 65 +++++++------------ test/factories/outdated_gems.rb | 2 +- test/fixtures/integration/Gemfile | 6 ++ test/fixtures/integration/Gemfile.lock | 18 ++++++ test/integration/cli_integration_test.rb | 73 ++++++++++++++++++++++ test/support/mocha.rb | 8 +++ test/test_helper.rb | 1 - 7 files changed, 128 insertions(+), 45 deletions(-) create mode 100644 test/fixtures/integration/Gemfile create mode 100644 test/fixtures/integration/Gemfile.lock create mode 100644 test/integration/cli_integration_test.rb create mode 100644 test/support/mocha.rb diff --git a/test/bundle_update_interactive/cli_test.rb b/test/bundle_update_interactive/cli_test.rb index 8536508..e00a393 100644 --- a/test/bundle_update_interactive/cli_test.rb +++ b/test/bundle_update_interactive/cli_test.rb @@ -27,8 +27,7 @@ def test_prints_error_in_red_to_stderr_and_exits_with_failure_status end def test_returns_if_no_gems_to_update_and_nothing_withheld - empty_report = stub(empty?: true, updatable_gems: {}, withheld_gems: {}) - Reporter.expects(:new).returns(mock(generate_report: empty_report)) + stub_report(updatable_gems: {}, withheld_gems: {}) stdout, stderr = capture_io do CLI.new.run(argv: []) @@ -39,17 +38,16 @@ def test_returns_if_no_gems_to_update_and_nothing_withheld end def test_prints_withheld_gems_and_returns_if_nothing_to_update - stdout, stderr, status = Dir.chdir(File.expand_path("../fixtures", __dir__)) do - VCR.use_cassette("changelog_requests") do - unchanged_lockfile = File.read("Gemfile.lock") - BundlerCommands.expects(:parse_outdated).returns({ "sqlite3" => "2.0.3" }) - BundlerCommands.expects(:read_updated_lockfile).returns(unchanged_lockfile) - mock_vulnerable_gems([]) - - capture_io_and_exit_status do - CLI.new.run(argv: []) - end - end + report = stub_report( + updatable_gems: {}, + withheld_gems: { + "sqlite3" => build(:outdated_gem, name: "sqlite3", updated_version: "2.0.3", changelog_uri: nil) + } + ) + report.expects(:scan_for_vulnerabilities!) + + stdout, stderr, status = capture_io_and_exit_status do + CLI.new.run(argv: []) end assert_equal(<<~EXPECTED_STDERR, stderr) @@ -64,38 +62,19 @@ def test_prints_withheld_gems_and_returns_if_nothing_to_update assert_nil(status) end - def test_shows_withheld_gems_and_interactive_list_of_gems_and_updates_the_selected_ones - stdout, stderr, status = Dir.chdir(File.expand_path("../fixtures", __dir__)) do - VCR.use_cassette("changelog_requests") do - updated_lockfile = File.read("Gemfile.lock.updated") - BundlerCommands.expects(:parse_outdated).returns({ "sqlite3" => "2.0.3" }) - BundlerCommands.expects(:read_updated_lockfile).returns(updated_lockfile) - BundlerCommands.expects(:update_gems_conservatively).with("addressable", "bigdecimal", "builder") - mock_vulnerable_gems([]) - - stdin_data = " j j \n" # SPACE,DOWN,SPACE,DOWN,SPACE,ENTER selects first three gems to update - capture_io_and_exit_status(stdin_data: stdin_data) do - CLI.new.run(argv: []) - end - end - end + private - assert_equal(<<~EXPECTED_STDERR, stderr) - Resolving latest gem versions... - Checking for security vulnerabilities... - Finding changelogs.................. - EXPECTED_STDERR - - menu, selected_gems = stdout.split("Updating the following gems.") + def stub_report(withheld_gems: {}, updatable_gems: {}) + report = Report.new( + current_lockfile: nil, + withheld_gems: withheld_gems, + updatable_gems: updatable_gems + ) - assert_match(/The following gems are being held back and cannot be updated/, menu) - assert_match(/sqlite3.*2\.0\.3/, menu) - - assert_equal(3, selected_gems.lines.grep(/→/).count) - assert_match(/addressable/, selected_gems) - assert_match(/bigdecimal/, selected_gems) - assert_match(/builder/, selected_gems) - assert_nil(status) + reporter = Reporter.new + reporter.stubs(:generate_report).returns(report) + Reporter.stubs(:new).returns(reporter) + report end end end diff --git a/test/factories/outdated_gems.rb b/test/factories/outdated_gems.rb index 9d88d73..154b88b 100644 --- a/test/factories/outdated_gems.rb +++ b/test/factories/outdated_gems.rb @@ -3,7 +3,7 @@ FactoryBot.define do factory :outdated_gem, class: "BundleUpdateInteractive::OutdatedGem" do current_git_version { nil } - current_version { "7.0.3" } + current_version { "0.0.1" } git_source_uri { nil } name { "rails" } rubygems_source { true } diff --git a/test/fixtures/integration/Gemfile b/test/fixtures/integration/Gemfile new file mode 100644 index 0000000..88d9c6e --- /dev/null +++ b/test/fixtures/integration/Gemfile @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +source "https://rubygems.org" +gem "bigdecimal" +gem "minitest", "~> 5.0.0" +gem "rake" diff --git a/test/fixtures/integration/Gemfile.lock b/test/fixtures/integration/Gemfile.lock new file mode 100644 index 0000000..c961836 --- /dev/null +++ b/test/fixtures/integration/Gemfile.lock @@ -0,0 +1,18 @@ +GEM + remote: https://rubygems.org/ + specs: + bigdecimal (3.1.7) + minitest (5.0.0) + rake (12.3.3) + +PLATFORMS + arm64-darwin-23 + ruby + +DEPENDENCIES + bigdecimal + minitest (~> 5.0.0) + rake + +BUNDLED WITH + 2.5.17 diff --git a/test/integration/cli_integration_test.rb b/test/integration/cli_integration_test.rb new file mode 100644 index 0000000..97fdfc0 --- /dev/null +++ b/test/integration/cli_integration_test.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "test_helper" +require "open3" +require "tmpdir" + +module BundleUpdateInteractive + class CLIIntegrationIest < Minitest::Test + def test_updates_lock_file_based_on_selected_gem_while_honoring_gemfile_requirement + out, _gemfile, lockfile = run_bundle_update_interactive( + fixture: "integration", + argv: [], + key_presses: "j \n" + ) + + assert_includes out, "Color legend:" + + assert_includes out, "3 gems can be updated." + assert_includes out, "‣ ⬡ bigdecimal 3.1.7 →" + assert_includes out, " ⬡ minitest 5.0.0 → 5.0.8" + assert_includes out, " ⬡ rake 12.3.3 →" + + assert_includes out, "‣ ⬢ minitest 5.0.0 → 5.0.8" + + assert_includes out, "Updating the following gems." + assert_includes out, "minitest 5.0.0 → 5.0.8 :default" + + assert_includes out, "Bundle updated!" + + assert_includes lockfile, <<~LOCK + GEM + remote: https://rubygems.org/ + specs: + bigdecimal (3.1.7) + minitest (5.0.8) + LOCK + assert_includes lockfile, <<~LOCK + DEPENDENCIES + bigdecimal + minitest (~> 5.0.0) + LOCK + end + + private + + def run_bundle_update_interactive(fixture:, argv:, key_presses: "\n") + command = [ + { "GEM_HOME" => ENV.fetch("GEM_HOME", nil) }, + Gem.ruby, + "-I", + File.expand_path("../../lib", __dir__), + File.expand_path("../../exe/bundler-update-interactive", __dir__), + *argv + ] + within_fixture_copy(fixture) do + Bundler.with_unbundled_env do + out, err, status = Open3.capture3(*command, stdin_data: key_presses) + raise "Command failed: #{[out, err].join}" unless status.success? + + [out, File.read("Gemfile"), File.read("Gemfile.lock")] + end + end + end + + def within_fixture_copy(fixture, &block) + fixture_path = File.join(File.expand_path("../fixtures", __dir__), fixture) + Dir.mktmpdir do |tmp| + FileUtils.cp_r(fixture_path, tmp) + Dir.chdir(File.join(tmp, File.basename(fixture_path)), &block) + end + end + end +end diff --git a/test/support/mocha.rb b/test/support/mocha.rb new file mode 100644 index 0000000..5ce1241 --- /dev/null +++ b/test/support/mocha.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "mocha/minitest" + +Mocha.configure do |config| + config.stubbing_method_on_nil = :prevent + config.stubbing_non_existent_method = :prevent +end diff --git a/test/test_helper.rb b/test/test_helper.rb index a0200c0..7e2d846 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -3,7 +3,6 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__) require "bundle_update_interactive" require "minitest/autorun" -require "mocha/minitest" BundleUpdateInteractive.pastel = Pastel.new(enabled: true)