From ad34d33bfd10d3ff8078032e9f390c30892e80c1 Mon Sep 17 00:00:00 2001
From: Matt Jankowski <matt@jankowski.online>
Date: Thu, 7 Dec 2023 08:49:14 -0500
Subject: [PATCH] Formalize some patterns in cli specs (#28255)

---
 spec/lib/mastodon/cli/accounts_spec.rb        | 431 +++++++++---------
 spec/lib/mastodon/cli/cache_spec.rb           |  29 +-
 .../cli/canonical_email_blocks_spec.rb        |  26 +-
 spec/lib/mastodon/cli/domains_spec.rb         |  14 +-
 .../mastodon/cli/email_domain_blocks_spec.rb  |  63 +--
 spec/lib/mastodon/cli/emoji_spec.rb           |  12 +-
 spec/lib/mastodon/cli/feeds_spec.rb           |  29 +-
 spec/lib/mastodon/cli/ip_blocks_spec.rb       | 113 ++---
 spec/lib/mastodon/cli/main_spec.rb            |  15 +-
 spec/lib/mastodon/cli/maintenance_spec.rb     |  24 +-
 spec/lib/mastodon/cli/media_spec.rb           |  73 ++-
 spec/lib/mastodon/cli/preview_cards_spec.rb   |  33 +-
 spec/lib/mastodon/cli/settings_spec.rb        |  39 +-
 spec/lib/mastodon/cli/statuses_spec.rb        |  17 +-
 spec/lib/mastodon/cli/upgrade_spec.rb         |  13 +-
 spec/rails_helper.rb                          |   1 +
 spec/support/command_line_helpers.rb          |   9 +
 17 files changed, 492 insertions(+), 449 deletions(-)
 create mode 100644 spec/support/command_line_helpers.rb

diff --git a/spec/lib/mastodon/cli/accounts_spec.rb b/spec/lib/mastodon/cli/accounts_spec.rb
index 3216d0d1bd..06860c2ff8 100644
--- a/spec/lib/mastodon/cli/accounts_spec.rb
+++ b/spec/lib/mastodon/cli/accounts_spec.rb
@@ -4,7 +4,11 @@ require 'rails_helper'
 require 'mastodon/cli/accounts'
 
 describe Mastodon::CLI::Accounts do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
@@ -27,15 +31,17 @@ describe Mastodon::CLI::Accounts do
   end
 
   describe '#create' do
+    let(:action) { :create }
+
     shared_examples 'a new user with given email address and username' do
       it 'creates a new user with the specified email address' do
-        cli.invoke(:create, arguments, options)
+        subject
 
         expect(User.find_by(email: options[:email])).to be_present
       end
 
       it 'creates a new local account with the specified username' do
-        cli.invoke(:create, arguments, options)
+        subject
 
         expect(Account.find_local('tootctl_username')).to be_present
       end
@@ -43,9 +49,8 @@ describe Mastodon::CLI::Accounts do
       it 'returns "OK" and newly generated password' do
         allow(SecureRandom).to receive(:hex).and_return('test_password')
 
-        expect { cli.invoke(:create, arguments, options) }.to output(
-          a_string_including("OK\nNew password: test_password")
-        ).to_stdout
+        expect { subject }
+          .to output_results("OK\nNew password: test_password")
       end
     end
 
@@ -61,9 +66,8 @@ describe Mastodon::CLI::Accounts do
           let(:options) { { email: 'invalid' } }
 
           it 'exits with an error message' do
-            expect { cli.invoke(:create, arguments, options) }.to output(
-              a_string_including('Failure/Error: email')
-            ).to_stdout
+            expect { subject }
+              .to output_results('Failure/Error: email')
               .and raise_error(SystemExit)
           end
         end
@@ -75,7 +79,7 @@ describe Mastodon::CLI::Accounts do
         it_behaves_like 'a new user with given email address and username'
 
         it 'creates a new user with confirmed status' do
-          cli.invoke(:create, arguments, options)
+          subject
 
           user = User.find_by(email: options[:email])
 
@@ -93,7 +97,7 @@ describe Mastodon::CLI::Accounts do
         it_behaves_like 'a new user with given email address and username'
 
         it 'creates a new user with approved status' do
-          cli.invoke(:create, arguments, options)
+          subject
 
           user = User.find_by(email: options[:email])
 
@@ -109,7 +113,7 @@ describe Mastodon::CLI::Accounts do
           it_behaves_like 'a new user with given email address and username'
 
           it 'creates a new user and assigns the specified role' do
-            cli.invoke(:create, arguments, options)
+            subject
 
             role = User.find_by(email: options[:email])&.role
 
@@ -121,9 +125,8 @@ describe Mastodon::CLI::Accounts do
           let(:options) { { email: 'tootctl@example.com', role: '404' } }
 
           it 'exits with an error message indicating the role name was not found' do
-            expect { cli.invoke(:create, arguments, options) }.to output(
-              a_string_including('Cannot find user role with that name')
-            ).to_stdout
+            expect { subject }
+              .to output_results('Cannot find user role with that name')
               .and raise_error(SystemExit)
           end
         end
@@ -139,16 +142,15 @@ describe Mastodon::CLI::Accounts do
           end
 
           it 'returns an error message indicating the username is already taken' do
-            expect { cli.invoke(:create, arguments, options) }.to output(
-              a_string_including("The chosen username is currently in use\nUse --force to reattach it anyway and delete the other user")
-            ).to_stdout
+            expect { subject }
+              .to output_results("The chosen username is currently in use\nUse --force to reattach it anyway and delete the other user")
           end
 
           context 'with --force option' do
             let(:options) { { email: 'tootctl_new@example.com', reattach: true, force: true } }
 
             it 'reattaches the account to the new user and deletes the previous user' do
-              cli.invoke(:create, arguments, options)
+              subject
 
               user = Account.find_local('tootctl_username')&.user
 
@@ -173,20 +175,21 @@ describe Mastodon::CLI::Accounts do
       let(:arguments) { ['tootctl_username'] }
 
       it 'raises a required argument missing error (Thor::RequiredArgumentMissingError)' do
-        expect { cli.invoke(:create, arguments) }
+        expect { subject }
           .to raise_error(Thor::RequiredArgumentMissingError)
       end
     end
   end
 
   describe '#modify' do
+    let(:action) { :modify }
+
     context 'when the given username is not found' do
       let(:arguments) { ['non_existent_username'] }
 
       it 'exits with an error message indicating the user was not found' do
-        expect { cli.invoke(:modify, arguments) }.to output(
-          a_string_including('No user with such username')
-        ).to_stdout
+        expect { subject }
+          .to output_results('No user with such username')
           .and raise_error(SystemExit)
       end
     end
@@ -197,13 +200,12 @@ describe Mastodon::CLI::Accounts do
 
       context 'when no option is provided' do
         it 'returns a successful message' do
-          expect { cli.invoke(:modify, arguments) }.to output(
-            a_string_including('OK')
-          ).to_stdout
+          expect { subject }
+            .to output_results('OK')
         end
 
         it 'does not modify the user' do
-          cli.invoke(:modify, arguments)
+          subject
 
           expect(user).to eq(user.reload)
         end
@@ -214,9 +216,8 @@ describe Mastodon::CLI::Accounts do
           let(:options) { { role: '404' } }
 
           it 'exits with an error message indicating the role was not found' do
-            expect { cli.invoke(:modify, arguments, options) }.to output(
-              a_string_including('Cannot find user role with that name')
-            ).to_stdout
+            expect { subject }
+              .to output_results('Cannot find user role with that name')
               .and raise_error(SystemExit)
           end
         end
@@ -226,7 +227,7 @@ describe Mastodon::CLI::Accounts do
           let(:options) { { role: default_role.name } }
 
           it "updates the user's role to the specified role" do
-            cli.invoke(:modify, arguments, options)
+            subject
 
             role = user.reload.role
 
@@ -241,7 +242,7 @@ describe Mastodon::CLI::Accounts do
         let(:user) { Fabricate(:user, role: role) }
 
         it "removes the user's role successfully" do
-          cli.invoke(:modify, arguments, options)
+          subject
 
           role = user.reload.role
 
@@ -254,13 +255,13 @@ describe Mastodon::CLI::Accounts do
         let(:options) { { email: 'new_email@email.com' } }
 
         it "sets the user's unconfirmed email to the provided email address" do
-          cli.invoke(:modify, arguments, options)
+          subject
 
           expect(user.reload.unconfirmed_email).to eq(options[:email])
         end
 
         it "does not update the user's original email address" do
-          cli.invoke(:modify, arguments, options)
+          subject
 
           expect(user.reload.email).to eq('old_email@email.com')
         end
@@ -270,13 +271,13 @@ describe Mastodon::CLI::Accounts do
           let(:options) { { email: 'new_email@email.com', confirm: true } }
 
           it "updates the user's email address to the provided email" do
-            cli.invoke(:modify, arguments, options)
+            subject
 
             expect(user.reload.email).to eq(options[:email])
           end
 
           it "sets the user's email address as confirmed" do
-            cli.invoke(:modify, arguments, options)
+            subject
 
             expect(user.reload.confirmed?).to be(true)
           end
@@ -288,7 +289,7 @@ describe Mastodon::CLI::Accounts do
         let(:options) { { confirm: true } }
 
         it "confirms the user's email address" do
-          cli.invoke(:modify, arguments, options)
+          subject
 
           expect(user.reload.confirmed?).to be(true)
         end
@@ -303,7 +304,7 @@ describe Mastodon::CLI::Accounts do
         end
 
         it 'approves the user' do
-          expect { cli.invoke(:modify, arguments, options) }.to change { user.reload.approved }.from(false).to(true)
+          expect { subject }.to change { user.reload.approved }.from(false).to(true)
         end
       end
 
@@ -312,7 +313,7 @@ describe Mastodon::CLI::Accounts do
         let(:options) { { disable: true } }
 
         it 'disables the user' do
-          expect { cli.invoke(:modify, arguments, options) }.to change { user.reload.disabled }.from(false).to(true)
+          expect { subject }.to change { user.reload.disabled }.from(false).to(true)
         end
       end
 
@@ -321,7 +322,7 @@ describe Mastodon::CLI::Accounts do
         let(:options) { { enable: true } }
 
         it 'enables the user' do
-          expect { cli.invoke(:modify, arguments, options) }.to change { user.reload.disabled }.from(true).to(false)
+          expect { subject }.to change { user.reload.disabled }.from(true).to(false)
         end
       end
 
@@ -331,9 +332,8 @@ describe Mastodon::CLI::Accounts do
         it 'returns a new password for the user' do
           allow(SecureRandom).to receive(:hex).and_return('new_password')
 
-          expect { cli.invoke(:modify, arguments, options) }.to output(
-            a_string_including('new_password')
-          ).to_stdout
+          expect { subject }
+            .to output_results('new_password')
         end
       end
 
@@ -342,7 +342,7 @@ describe Mastodon::CLI::Accounts do
         let(:options) { { disable_2fa: true } }
 
         it 'disables the two-factor authentication for the user' do
-          expect { cli.invoke(:modify, arguments, options) }.to change { user.reload.otp_required_for_login }.from(true).to(false)
+          expect { subject }.to change { user.reload.otp_required_for_login }.from(true).to(false)
         end
       end
 
@@ -351,9 +351,8 @@ describe Mastodon::CLI::Accounts do
         let(:options) { { email: 'invalid' } }
 
         it 'exits with an error message' do
-          expect { cli.invoke(:modify, arguments, options) }.to output(
-            a_string_including('Failure/Error: email')
-          ).to_stdout
+          expect { subject }
+            .to output_results('Failure/Error: email')
             .and raise_error(SystemExit)
         end
       end
@@ -361,9 +360,8 @@ describe Mastodon::CLI::Accounts do
   end
 
   describe '#delete' do
+    let(:action) { :delete }
     let(:account) { Fabricate(:account) }
-    let(:arguments) { [account.username] }
-    let(:options) { { email: account.user.email } }
     let(:delete_account_service) { instance_double(DeleteAccountService) }
 
     before do
@@ -372,26 +370,29 @@ describe Mastodon::CLI::Accounts do
     end
 
     context 'when both username and --email are provided' do
+      let(:arguments) { [account.username] }
+      let(:options) { { email: account.user.email } }
+
       it 'exits with an error message indicating that only one should be used' do
-        expect { cli.invoke(:delete, arguments, options) }.to output(
-          a_string_including('Use username or --email, not both')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Use username or --email, not both')
           .and raise_error(SystemExit)
       end
     end
 
     context 'when neither username nor --email are provided' do
       it 'exits with an error message indicating that no username was provided' do
-        expect { cli.invoke(:delete) }.to output(
-          a_string_including('No username provided')
-        ).to_stdout
+        expect { subject }
+          .to output_results('No username provided')
           .and raise_error(SystemExit)
       end
     end
 
     context 'when username is provided' do
+      let(:arguments) { [account.username] }
+
       it 'deletes the specified user successfully' do
-        cli.invoke(:delete, arguments)
+        subject
 
         expect(delete_account_service).to have_received(:call).with(account, reserve_email: false).once
       end
@@ -400,15 +401,14 @@ describe Mastodon::CLI::Accounts do
         let(:options) { { dry_run: true } }
 
         it 'does not delete the specified user' do
-          cli.invoke(:delete, arguments, options)
+          subject
 
           expect(delete_account_service).to_not have_received(:call).with(account, reserve_email: false)
         end
 
         it 'outputs a successful message in dry run mode' do
-          expect { cli.invoke(:delete, arguments, options) }.to output(
-            a_string_including('OK (DRY RUN)')
-          ).to_stdout
+          expect { subject }
+            .to output_results('OK (DRY RUN)')
         end
       end
 
@@ -416,17 +416,18 @@ describe Mastodon::CLI::Accounts do
         let(:arguments) { ['non_existent_username'] }
 
         it 'exits with an error message indicating that no user was found' do
-          expect { cli.invoke(:delete, arguments) }.to output(
-            a_string_including('No user with such username')
-          ).to_stdout
+          expect { subject }
+            .to output_results('No user with such username')
             .and raise_error(SystemExit)
         end
       end
     end
 
     context 'when --email is provided' do
+      let(:options) { { email: account.user.email } }
+
       it 'deletes the specified user successfully' do
-        cli.invoke(:delete, nil, options)
+        subject
 
         expect(delete_account_service).to have_received(:call).with(account, reserve_email: false).once
       end
@@ -435,15 +436,14 @@ describe Mastodon::CLI::Accounts do
         let(:options) { { email: account.user.email, dry_run: true } }
 
         it 'does not delete the user' do
-          cli.invoke(:delete, nil, options)
+          subject
 
           expect(delete_account_service).to_not have_received(:call).with(account, reserve_email: false)
         end
 
         it 'outputs a successful message in dry run mode' do
-          expect { cli.invoke(:delete, nil, options) }.to output(
-            a_string_including('OK (DRY RUN)')
-          ).to_stdout
+          expect { subject }
+            .to output_results('OK (DRY RUN)')
         end
       end
 
@@ -451,9 +451,8 @@ describe Mastodon::CLI::Accounts do
         let(:options) { { email: '404@example.com' } }
 
         it 'exits with an error message indicating that no user was found' do
-          expect { cli.invoke(:delete, nil, options) }.to output(
-            a_string_including('No user with such email')
-          ).to_stdout
+          expect { subject }
+            .to output_results('No user with such email')
             .and raise_error(SystemExit)
         end
       end
@@ -461,6 +460,7 @@ describe Mastodon::CLI::Accounts do
   end
 
   describe '#approve' do
+    let(:action) { :approve }
     let(:total_users) { 4 }
 
     before do
@@ -469,8 +469,10 @@ describe Mastodon::CLI::Accounts do
     end
 
     context 'with --all option' do
+      let(:options) { { all: true } }
+
       it 'approves all pending registrations' do
-        cli.invoke(:approve, nil, all: true)
+        subject
 
         expect(User.pluck(:approved).all?(true)).to be(true)
       end
@@ -481,7 +483,7 @@ describe Mastodon::CLI::Accounts do
         let(:options) { { number: 2 } }
 
         it 'approves the earliest n pending registrations' do
-          cli.invoke(:approve, nil, options)
+          subject
 
           n_earliest_pending_registrations = User.order(created_at: :asc).first(options[:number])
 
@@ -489,7 +491,7 @@ describe Mastodon::CLI::Accounts do
         end
 
         it 'does not approve the remaining pending registrations' do
-          cli.invoke(:approve, nil, options)
+          subject
 
           pending_registrations = User.order(created_at: :asc).last(total_users - options[:number])
 
@@ -498,10 +500,11 @@ describe Mastodon::CLI::Accounts do
       end
 
       context 'when the number is negative' do
+        let(:options) { { number: -1 } }
+
         it 'exits with an error message indicating that the number must be positive' do
-          expect { cli.invoke(:approve, nil, number: -1) }.to output(
-            a_string_including('Number must be positive')
-          ).to_stdout
+          expect { subject }
+            .to output_results('Number must be positive')
             .and raise_error(SystemExit)
         end
       end
@@ -510,13 +513,13 @@ describe Mastodon::CLI::Accounts do
         let(:options) { { number: total_users * 2 } }
 
         it 'approves all users' do
-          cli.invoke(:approve, nil, options)
+          subject
 
           expect(User.pluck(:approved).all?(true)).to be(true)
         end
 
         it 'does not raise any error' do
-          expect { cli.invoke(:approve, nil, options) }
+          expect { subject }
             .to_not raise_error
         end
       end
@@ -528,7 +531,7 @@ describe Mastodon::CLI::Accounts do
         let(:arguments) { [user.account.username] }
 
         it 'approves the specified user successfully' do
-          cli.invoke(:approve, arguments)
+          subject
 
           expect(user.reload.approved?).to be(true)
         end
@@ -538,9 +541,8 @@ describe Mastodon::CLI::Accounts do
         let(:arguments) { ['non_existent_username'] }
 
         it 'exits with an error message indicating that no such account was found' do
-          expect { cli.invoke(:approve, arguments) }.to output(
-            a_string_including('No such account')
-          ).to_stdout
+          expect { subject }
+            .to output_results('No such account')
             .and raise_error(SystemExit)
         end
       end
@@ -548,13 +550,14 @@ describe Mastodon::CLI::Accounts do
   end
 
   describe '#follow' do
+    let(:action) { :follow }
+
     context 'when the given username is not found' do
       let(:arguments) { ['non_existent_username'] }
 
       it 'exits with an error message indicating that no account with the given username was found' do
-        expect { cli.invoke(:follow, arguments) }.to output(
-          a_string_including('No such account')
-        ).to_stdout
+        expect { subject }
+          .to output_results('No such account')
           .and raise_error(SystemExit)
       end
     end
@@ -565,6 +568,7 @@ describe Mastodon::CLI::Accounts do
       let!(:follower_rony)    { Fabricate(:account, username: 'rony') }
       let!(:follower_charles) { Fabricate(:account, username: 'charles') }
       let(:follow_service)    { instance_double(FollowService, call: nil) }
+      let(:arguments) { [target_account.username] }
 
       before do
         allow(FollowService).to receive(:new).and_return(follow_service)
@@ -572,7 +576,7 @@ describe Mastodon::CLI::Accounts do
       end
 
       it 'makes all local accounts follow the target account' do
-        cli.follow(target_account.username)
+        subject
 
         expect(follow_service).to have_received(:call).with(follower_bob, target_account, any_args).once
         expect(follow_service).to have_received(:call).with(follower_rony, target_account, any_args).once
@@ -580,21 +584,21 @@ describe Mastodon::CLI::Accounts do
       end
 
       it 'displays a successful message' do
-        expect { cli.follow(target_account.username) }.to output(
-          a_string_including("OK, followed target from #{Account.local.count} accounts")
-        ).to_stdout
+        expect { subject }
+          .to output_results("OK, followed target from #{Account.local.count} accounts")
       end
     end
   end
 
   describe '#unfollow' do
+    let(:action) { :unfollow }
+
     context 'when the given username is not found' do
       let(:arguments) { ['non_existent_username'] }
 
       it 'exits with an error message indicating that no account with the given username was found' do
-        expect { cli.invoke(:unfollow, arguments) }.to output(
-          a_string_including('No such account')
-        ).to_stdout
+        expect { subject }
+          .to output_results('No such account')
           .and raise_error(SystemExit)
       end
     end
@@ -605,6 +609,7 @@ describe Mastodon::CLI::Accounts do
       let!(:follower_rambo)  { Fabricate(:account, username: 'rambo', domain: nil) }
       let!(:follower_ana)    { Fabricate(:account, username: 'ana', domain: nil) }
       let(:unfollow_service) { instance_double(UnfollowService, call: nil) }
+      let(:arguments) { [target_account.username] }
 
       before do
         accounts = [follower_chris, follower_rambo, follower_ana]
@@ -614,7 +619,7 @@ describe Mastodon::CLI::Accounts do
       end
 
       it 'makes all local accounts unfollow the target account' do
-        cli.unfollow(target_account.username)
+        subject
 
         expect(unfollow_service).to have_received(:call).with(follower_chris, target_account).once
         expect(unfollow_service).to have_received(:call).with(follower_rambo, target_account).once
@@ -622,21 +627,21 @@ describe Mastodon::CLI::Accounts do
       end
 
       it 'displays a successful message' do
-        expect { cli.unfollow(target_account.username) }.to output(
-          a_string_including('OK, unfollowed target from 3 accounts')
-        ).to_stdout
+        expect { subject }
+          .to output_results('OK, unfollowed target from 3 accounts')
       end
     end
   end
 
   describe '#backup' do
+    let(:action) { :backup }
+
     context 'when the given username is not found' do
       let(:arguments) { ['non_existent_username'] }
 
       it 'exits with an error message indicating that there is no such account' do
-        expect { cli.invoke(:backup, arguments) }.to output(
-          a_string_including('No user with such username')
-        ).to_stdout
+        expect { subject }
+          .to output_results('No user with such username')
           .and raise_error(SystemExit)
       end
     end
@@ -647,22 +652,21 @@ describe Mastodon::CLI::Accounts do
       let(:arguments) { [account.username] }
 
       it 'creates a new backup for the specified user' do
-        expect { cli.invoke(:backup, arguments) }.to change { user.backups.count }.by(1)
+        expect { subject }.to change { user.backups.count }.by(1)
       end
 
       it 'creates a backup job' do
         allow(BackupWorker).to receive(:perform_async)
 
-        cli.invoke(:backup, arguments)
+        subject
         latest_backup = user.backups.last
 
         expect(BackupWorker).to have_received(:perform_async).with(latest_backup.id).once
       end
 
       it 'displays a successful message' do
-        expect { cli.invoke(:backup, arguments) }.to output(
-          a_string_including('OK')
-        ).to_stdout
+        expect { subject }
+          .to output_results('OK')
       end
     end
   end
@@ -724,9 +728,8 @@ describe Mastodon::CLI::Accounts do
       end
 
       it 'displays a successful message' do
-        expect { cli.refresh }.to output(
-          a_string_including('Refreshed 2 accounts')
-        ).to_stdout
+        expect { cli.refresh }
+          .to output_results('Refreshed 2 accounts')
       end
 
       context 'with --dry-run option' do
@@ -761,9 +764,8 @@ describe Mastodon::CLI::Accounts do
         end
 
         it 'displays a successful message with (DRY RUN)' do
-          expect { cli.refresh }.to output(
-            a_string_including('Refreshed 2 accounts (DRY RUN)')
-          ).to_stdout
+          expect { cli.refresh }
+            .to output_results('Refreshed 2 accounts (DRY RUN)')
         end
       end
     end
@@ -823,9 +825,7 @@ describe Mastodon::CLI::Accounts do
           allow(account_example_com_a).to receive(:reset_avatar!).and_raise(Mastodon::UnexpectedResponseError)
 
           expect { cli.refresh(*arguments) }
-            .to output(
-              a_string_including("Account failed: #{account_example_com_a.username}@#{account_example_com_a.domain}")
-            ).to_stdout
+            .to output_results("Account failed: #{account_example_com_a.username}@#{account_example_com_a.domain}")
         end
       end
 
@@ -833,9 +833,8 @@ describe Mastodon::CLI::Accounts do
         it 'exits with an error message' do
           allow(Account).to receive(:find_remote).with(account_example_com_b.username, account_example_com_b.domain).and_return(nil)
 
-          expect { cli.refresh(*arguments) }.to output(
-            a_string_including('No such account')
-          ).to_stdout
+          expect { cli.refresh(*arguments) }
+            .to output_results('No such account')
             .and raise_error(SystemExit)
         end
       end
@@ -878,7 +877,6 @@ describe Mastodon::CLI::Accounts do
         allow(cli).to receive(:parallelize_with_progress).and_yield(account_example_com_a)
                                                          .and_yield(account_example_com_b)
                                                          .and_return([2, nil])
-
         cli.options = { domain: domain }
       end
 
@@ -925,32 +923,33 @@ describe Mastodon::CLI::Accounts do
 
     context 'when neither a list of accts nor options are provided' do
       it 'exits with an error message' do
-        expect { cli.refresh }.to output(
-          a_string_including('No account(s) given')
-        ).to_stdout
+        expect { cli.refresh }
+          .to output_results('No account(s) given')
           .and raise_error(SystemExit)
       end
     end
   end
 
   describe '#rotate' do
+    let(:action) { :rotate }
+
     context 'when neither username nor --all option are given' do
       it 'exits with an error message' do
-        expect { cli.rotate }.to output(
-          a_string_including('No account(s) given')
-        ).to_stdout
+        expect { subject }
+          .to output_results('No account(s) given')
           .and raise_error(SystemExit)
       end
     end
 
     context 'when a username is given' do
       let(:account) { Fabricate(:account) }
+      let(:arguments) { [account.username] }
 
       it 'correctly rotates keys for the specified account' do
         old_private_key = account.private_key
         old_public_key = account.public_key
 
-        cli.rotate(account.username)
+        subject
         account.reload
 
         expect(account.private_key).to_not eq(old_private_key)
@@ -960,16 +959,17 @@ describe Mastodon::CLI::Accounts do
       it 'broadcasts the new keys for the specified account' do
         allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_in)
 
-        cli.rotate(account.username)
+        subject
 
         expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_in).with(anything, account.id, anything).once
       end
 
       context 'when the given username is not found' do
+        let(:arguments) { ['non_existent_username'] }
+
         it 'exits with an error message when the specified username is not found' do
-          expect { cli.rotate('non_existent_username') }.to output(
-            a_string_including('No such account')
-          ).to_stdout
+          expect { subject }
+            .to output_results('No such account')
             .and raise_error(SystemExit)
         end
       end
@@ -977,17 +977,13 @@ describe Mastodon::CLI::Accounts do
 
     context 'when --all option is provided' do
       let!(:accounts) { Fabricate.times(2, :account) }
-      let(:options)   { { all: true } }
-
-      before do
-        cli.options = { all: true }
-      end
+      let(:options) { { all: true } }
 
       it 'correctly rotates keys for all local accounts' do
         old_private_keys = accounts.map(&:private_key)
         old_public_keys = accounts.map(&:public_key)
 
-        cli.rotate
+        subject
         accounts.each(&:reload)
 
         expect(accounts.map(&:private_key)).to_not eq(old_private_keys)
@@ -997,7 +993,7 @@ describe Mastodon::CLI::Accounts do
       it 'broadcasts the new keys for each account' do
         allow(ActivityPub::UpdateDistributionWorker).to receive(:perform_in)
 
-        cli.rotate
+        subject
 
         accounts.each do |account|
           expect(ActivityPub::UpdateDistributionWorker).to have_received(:perform_in).with(anything, account.id, anything).once
@@ -1007,11 +1003,12 @@ describe Mastodon::CLI::Accounts do
   end
 
   describe '#merge' do
+    let(:action) { :merge }
+
     shared_examples 'an account not found' do |acct|
       it 'exits with an error message indicating that there is no such account' do
-        expect { cli.invoke(:merge, arguments) }.to output(
-          a_string_including("No such account (#{acct})")
-        ).to_stdout
+        expect { subject }
+          .to output_results("No such account (#{acct})")
           .and raise_error(SystemExit)
       end
     end
@@ -1061,9 +1058,8 @@ describe Mastodon::CLI::Accounts do
       end
 
       it 'exits with an error message indicating that the accounts do not have the same pub key' do
-        expect { cli.invoke(:merge, arguments) }.to output(
-          a_string_including("Accounts don't have the same public key, might not be duplicates!\nOverride with --force")
-        ).to_stdout
+        expect { subject }
+          .to output_results("Accounts don't have the same public key, might not be duplicates!\nOverride with --force")
           .and raise_error(SystemExit)
       end
 
@@ -1076,13 +1072,13 @@ describe Mastodon::CLI::Accounts do
         end
 
         it 'merges "from_account" into "to_account"' do
-          cli.invoke(:merge, arguments, options)
+          subject
 
           expect(to_account).to have_received(:merge_with!).with(from_account).once
         end
 
         it 'deletes "from_account"' do
-          cli.invoke(:merge, arguments, options)
+          subject
 
           expect(from_account).to have_received(:destroy).once
         end
@@ -1104,13 +1100,13 @@ describe Mastodon::CLI::Accounts do
       end
 
       it 'merges "from_account" into "to_account"' do
-        cli.invoke(:merge, arguments)
+        subject
 
         expect(to_account).to have_received(:merge_with!).with(from_account).once
       end
 
       it 'deletes "from_account"' do
-        cli.invoke(:merge, arguments)
+        subject
 
         expect(from_account).to have_received(:destroy)
       end
@@ -1118,6 +1114,7 @@ describe Mastodon::CLI::Accounts do
   end
 
   describe '#cull' do
+    let(:action) { :cull }
     let(:delete_account_service) { instance_double(DeleteAccountService, call: nil) }
     let!(:tom)   { Fabricate(:account, updated_at: 30.days.ago, username: 'tom', uri: 'https://example.com/users/tom', domain: 'example.com', protocol: :activitypub) }
     let!(:bob)   { Fabricate(:account, updated_at: 30.days.ago, last_webfingered_at: nil, username: 'bob', uri: 'https://example.org/users/bob', domain: 'example.org', protocol: :activitypub) }
@@ -1138,14 +1135,14 @@ describe Mastodon::CLI::Accounts do
       end
 
       it 'deletes all inactive remote accounts that longer exist in the origin server' do
-        cli.cull
+        subject
 
         expect(delete_account_service).to have_received(:call).with(bob, reserve_username: false).once
         expect(delete_account_service).to have_received(:call).with(gon, reserve_username: false).once
       end
 
       it 'does not delete any active remote account that still exists in the origin server' do
-        cli.cull
+        subject
 
         expect(delete_account_service).to_not have_received(:call).with(tom, reserve_username: false)
         expect(delete_account_service).to_not have_received(:call).with(ana, reserve_username: false)
@@ -1153,18 +1150,17 @@ describe Mastodon::CLI::Accounts do
       end
 
       it 'touches inactive remote accounts that have not been deleted' do
-        expect { cli.cull }.to(change { tales.reload.updated_at })
+        expect { subject }.to(change { tales.reload.updated_at })
       end
 
       it 'displays the summary correctly' do
-        expect { cli.cull }.to output(
-          a_string_including('Visited 5 accounts, removed 2')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Visited 5 accounts, removed 2')
       end
     end
 
     context 'when a domain is specified' do
-      let(:domain) { 'example.net' }
+      let(:arguments) { ['example.net'] }
 
       before do
         stub_parallelize_with_progress!
@@ -1173,16 +1169,15 @@ describe Mastodon::CLI::Accounts do
       end
 
       it 'deletes inactive remote accounts that longer exist in the specified domain' do
-        cli.cull(domain)
+        subject
 
         expect(delete_account_service).to have_received(:call).with(gon, reserve_username: false).once
         expect(delete_account_service).to have_received(:call).with(tales, reserve_username: false).once
       end
 
       it 'displays the summary correctly' do
-        expect { cli.cull(domain) }.to output(
-          a_string_including('Visited 2 accounts, removed 2')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Visited 2 accounts, removed 2')
       end
     end
 
@@ -1195,15 +1190,14 @@ describe Mastodon::CLI::Accounts do
         end
 
         it 'skips accounts from the unavailable domain' do
-          cli.cull
+          subject
 
           expect(delete_account_service).to_not have_received(:call).with(tales, reserve_username: false)
         end
 
         it 'displays the summary correctly' do
-          expect { cli.cull }.to output(
-            a_string_including("Visited 5 accounts, removed 0\nThe following domains were not available during the check:\n    example.net")
-          ).to_stdout
+          expect { subject }
+            .to output_results("Visited 5 accounts, removed 0\nThe following domains were not available during the check:\n    example.net")
         end
       end
 
@@ -1242,25 +1236,25 @@ describe Mastodon::CLI::Accounts do
   end
 
   describe '#reset_relationships' do
+    let(:action) { :reset_relationships }
     let(:target_account) { Fabricate(:account) }
     let(:arguments)      { [target_account.username] }
 
     context 'when no option is given' do
       it 'exits with an error message indicating that at least one option is required' do
-        expect { cli.invoke(:reset_relationships, arguments) }.to output(
-          a_string_including('Please specify either --follows or --followers, or both')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Please specify either --follows or --followers, or both')
           .and raise_error(SystemExit)
       end
     end
 
     context 'when the given username is not found' do
       let(:arguments) { ['non_existent_username'] }
+      let(:options) { { follows: true } }
 
       it 'exits with an error message indicating that there is no such account' do
-        expect { cli.invoke(:reset_relationships, arguments, follows: true) }.to output(
-          a_string_including('No such account')
-        ).to_stdout
+        expect { subject }
+          .to output_results('No such account')
           .and raise_error(SystemExit)
       end
     end
@@ -1277,7 +1271,7 @@ describe Mastodon::CLI::Accounts do
         end
 
         it 'resets all "following" relationships from the target account' do
-          cli.invoke(:reset_relationships, arguments, options)
+          subject
 
           expect(target_account.reload.following).to be_empty
         end
@@ -1285,15 +1279,14 @@ describe Mastodon::CLI::Accounts do
         it 'calls BootstrapTimelineWorker once to rebuild the timeline' do
           allow(BootstrapTimelineWorker).to receive(:perform_async)
 
-          cli.invoke(:reset_relationships, arguments, options)
+          subject
 
           expect(BootstrapTimelineWorker).to have_received(:perform_async).with(target_account.id).once
         end
 
         it 'displays a successful message' do
-          expect { cli.invoke(:reset_relationships, arguments, options) }.to output(
-            a_string_including("Processed #{total_relationships} relationships")
-          ).to_stdout
+          expect { subject }
+            .to output_results("Processed #{total_relationships} relationships")
         end
       end
 
@@ -1305,15 +1298,14 @@ describe Mastodon::CLI::Accounts do
         end
 
         it 'resets all "followers" relationships from the target account' do
-          cli.invoke(:reset_relationships, arguments, options)
+          subject
 
           expect(target_account.reload.followers).to be_empty
         end
 
         it 'displays a successful message' do
-          expect { cli.invoke(:reset_relationships, arguments, options) }.to output(
-            a_string_including("Processed #{total_relationships} relationships")
-          ).to_stdout
+          expect { subject }
+            .to output_results("Processed #{total_relationships} relationships")
         end
       end
 
@@ -1326,13 +1318,13 @@ describe Mastodon::CLI::Accounts do
         end
 
         it 'resets all "followers" relationships from the target account' do
-          cli.invoke(:reset_relationships, arguments, options)
+          subject
 
           expect(target_account.reload.followers).to be_empty
         end
 
         it 'resets all "following" relationships from the target account' do
-          cli.invoke(:reset_relationships, arguments, options)
+          subject
 
           expect(target_account.reload.following).to be_empty
         end
@@ -1340,21 +1332,21 @@ describe Mastodon::CLI::Accounts do
         it 'calls BootstrapTimelineWorker once to rebuild the timeline' do
           allow(BootstrapTimelineWorker).to receive(:perform_async)
 
-          cli.invoke(:reset_relationships, arguments, options)
+          subject
 
           expect(BootstrapTimelineWorker).to have_received(:perform_async).with(target_account.id).once
         end
 
         it 'displays a successful message' do
-          expect { cli.invoke(:reset_relationships, arguments, options) }.to output(
-            a_string_including("Processed #{total_relationships} relationships")
-          ).to_stdout
+          expect { subject }
+            .to output_results("Processed #{total_relationships} relationships")
         end
       end
     end
   end
 
   describe '#prune' do
+    let(:action) { :prune }
     let!(:local_account)     { Fabricate(:account) }
     let!(:bot_account)       { Fabricate(:account, bot: true, domain: 'example.com') }
     let!(:group_account)     { Fabricate(:account, actor_type: 'Group', domain: 'example.com') }
@@ -1369,7 +1361,7 @@ describe Mastodon::CLI::Accounts do
     end
 
     it 'prunes all remote accounts with no interactions with local users' do
-      cli.prune
+      subject
 
       prunable_account_ids = prunable_accounts.pluck(:id)
 
@@ -1377,42 +1369,39 @@ describe Mastodon::CLI::Accounts do
     end
 
     it 'displays a successful message' do
-      expect { cli.prune }.to output(
-        a_string_including("OK, pruned #{prunable_accounts.size} accounts")
-      ).to_stdout
+      expect { subject }
+        .to output_results("OK, pruned #{prunable_accounts.size} accounts")
     end
 
     it 'does not prune local accounts' do
-      cli.prune
+      subject
 
       expect(Account.exists?(id: local_account.id)).to be(true)
     end
 
     it 'does not prune bot accounts' do
-      cli.prune
+      subject
 
       expect(Account.exists?(id: bot_account.id)).to be(true)
     end
 
     it 'does not prune group accounts' do
-      cli.prune
+      subject
 
       expect(Account.exists?(id: group_account.id)).to be(true)
     end
 
     it 'does not prune accounts that have been mentioned' do
-      cli.prune
+      subject
 
       expect(Account.exists?(id: mentioned_account.id)).to be true
     end
 
     context 'with --dry-run option' do
-      before do
-        cli.options = { dry_run: true }
-      end
+      let(:options) { { dry_run: true } }
 
       it 'does not prune any account' do
-        cli.prune
+        subject
 
         prunable_account_ids = prunable_accounts.pluck(:id)
 
@@ -1420,14 +1409,14 @@ describe Mastodon::CLI::Accounts do
       end
 
       it 'displays a successful message with (DRY RUN)' do
-        expect { cli.prune }.to output(
-          a_string_including("OK, pruned #{prunable_accounts.size} accounts (DRY RUN)")
-        ).to_stdout
+        expect { subject }
+          .to output_results("OK, pruned #{prunable_accounts.size} accounts (DRY RUN)")
       end
     end
   end
 
   describe '#migrate' do
+    let(:action) { :migrate }
     let!(:source_account)         { Fabricate(:account) }
     let!(:target_account)         { Fabricate(:account, domain: 'example.com') }
     let(:arguments)               { [source_account.username] }
@@ -1441,7 +1430,7 @@ describe Mastodon::CLI::Accounts do
 
     shared_examples 'a successful migration' do
       it 'calls the MoveService for the last migration' do
-        cli.invoke(:migrate, arguments, options)
+        subject
 
         last_migration = source_account.migrations.last
 
@@ -1449,9 +1438,8 @@ describe Mastodon::CLI::Accounts do
       end
 
       it 'displays a successful message' do
-        expect { cli.invoke(:migrate, arguments, options) }.to output(
-          a_string_including("OK, migrated #{source_account.acct} to #{target_account.acct}")
-        ).to_stdout
+        expect { subject }
+          .to output_results("OK, migrated #{source_account.acct} to #{target_account.acct}")
       end
     end
 
@@ -1459,29 +1447,27 @@ describe Mastodon::CLI::Accounts do
       let(:options) { { replay: true, target: "#{target_account.username}@example.com" } }
 
       it 'exits with an error message indicating that using both options is not possible' do
-        expect { cli.invoke(:migrate, arguments, options) }.to output(
-          a_string_including('Use --replay or --target, not both')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Use --replay or --target, not both')
           .and raise_error(SystemExit)
       end
     end
 
     context 'when no option is given' do
       it 'exits with an error message indicating that at least one option must be used' do
-        expect { cli.invoke(:migrate, arguments, {}) }.to output(
-          a_string_including('Use either --replay or --target')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Use either --replay or --target')
           .and raise_error(SystemExit)
       end
     end
 
     context 'when the given username is not found' do
       let(:arguments) { ['non_existent_username'] }
+      let(:options) { { replay: true } }
 
       it 'exits with an error message indicating that there is no such account' do
-        expect { cli.invoke(:migrate, arguments, replay: true) }.to output(
-          a_string_including("No such account: #{arguments.first}")
-        ).to_stdout
+        expect { subject }
+          .to output_results("No such account: #{arguments.first}")
           .and raise_error(SystemExit)
       end
     end
@@ -1491,9 +1477,8 @@ describe Mastodon::CLI::Accounts do
 
       context 'when the specified account has no previous migrations' do
         it 'exits with an error message indicating that the given account has no previous migrations' do
-          expect { cli.invoke(:migrate, arguments, options) }.to output(
-            a_string_including('The specified account has not performed any migration')
-          ).to_stdout
+          expect { subject }
+            .to output_results('The specified account has not performed any migration')
             .and raise_error(SystemExit)
         end
       end
@@ -1515,9 +1500,8 @@ describe Mastodon::CLI::Accounts do
           end
 
           it 'exits with an error message' do
-            expect { cli.invoke(:migrate, arguments, options) }.to output(
-              a_string_including('The specified account is not redirecting to its last migration target. Use --force if you want to replay the migration anyway')
-            ).to_stdout
+            expect { subject }
+              .to output_results('The specified account is not redirecting to its last migration target. Use --force if you want to replay the migration anyway')
               .and raise_error(SystemExit)
           end
         end
@@ -1544,9 +1528,8 @@ describe Mastodon::CLI::Accounts do
         end
 
         it 'exits with an error message indicating that there is no such account' do
-          expect { cli.invoke(:migrate, arguments, options) }.to output(
-            a_string_including("The specified target account could not be found: #{options[:target]}")
-          ).to_stdout
+          expect { subject }
+            .to output_results("The specified target account could not be found: #{options[:target]}")
             .and raise_error(SystemExit)
         end
       end
@@ -1557,7 +1540,7 @@ describe Mastodon::CLI::Accounts do
         end
 
         it 'creates a migration for the specified account with the target account' do
-          cli.invoke(:migrate, arguments, options)
+          subject
 
           last_migration = source_account.migrations.last
 
@@ -1569,9 +1552,8 @@ describe Mastodon::CLI::Accounts do
 
       context 'when the migration record is invalid' do
         it 'exits with an error indicating that the validation failed' do
-          expect { cli.invoke(:migrate, arguments, options) }.to output(
-            a_string_including('Error: Validation failed')
-          ).to_stdout
+          expect { subject }
+            .to output_results('Error: Validation failed')
             .and raise_error(SystemExit)
         end
       end
@@ -1582,9 +1564,8 @@ describe Mastodon::CLI::Accounts do
         end
 
         it 'exits with an error message' do
-          expect { cli.invoke(:migrate, arguments, options) }.to output(
-            a_string_including('The specified account is redirecting to a different target account. Use --force if you want to change the migration target')
-          ).to_stdout
+          expect { subject }
+            .to output_results('The specified account is redirecting to a different target account. Use --force if you want to change the migration target')
             .and raise_error(SystemExit)
         end
       end
diff --git a/spec/lib/mastodon/cli/cache_spec.rb b/spec/lib/mastodon/cli/cache_spec.rb
index c1ce04710c..b1515801eb 100644
--- a/spec/lib/mastodon/cli/cache_spec.rb
+++ b/spec/lib/mastodon/cli/cache_spec.rb
@@ -4,22 +4,29 @@ require 'rails_helper'
 require 'mastodon/cli/cache'
 
 describe Mastodon::CLI::Cache do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#clear' do
+    let(:action) { :clear }
+
     before { allow(Rails.cache).to receive(:clear) }
 
     it 'clears the Rails cache' do
-      expect { cli.invoke(:clear) }.to output(
-        a_string_including('OK')
-      ).to_stdout
+      expect { subject }
+        .to output_results('OK')
       expect(Rails.cache).to have_received(:clear)
     end
   end
 
   describe '#recount' do
+    let(:action) { :recount }
+
     context 'with the `accounts` argument' do
       let(:arguments) { ['accounts'] }
       let(:account_stat) { Fabricate(:account_stat) }
@@ -29,9 +36,8 @@ describe Mastodon::CLI::Cache do
       end
 
       it 're-calculates account records in the cache' do
-        expect { cli.invoke(:recount, arguments) }.to output(
-          a_string_including('OK')
-        ).to_stdout
+        expect { subject }
+          .to output_results('OK')
 
         expect(account_stat.reload.statuses_count).to be_zero
       end
@@ -46,9 +52,8 @@ describe Mastodon::CLI::Cache do
       end
 
       it 're-calculates account records in the cache' do
-        expect { cli.invoke(:recount, arguments) }.to output(
-          a_string_including('OK')
-        ).to_stdout
+        expect { subject }
+          .to output_results('OK')
 
         expect(status_stat.reload.replies_count).to be_zero
       end
@@ -58,9 +63,9 @@ describe Mastodon::CLI::Cache do
       let(:arguments) { ['other-type'] }
 
       it 'Exits with an error message' do
-        expect { cli.invoke(:recount, arguments) }.to output(
-          a_string_including('Unknown')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('Unknown')
+          .and raise_error(SystemExit)
       end
     end
   end
diff --git a/spec/lib/mastodon/cli/canonical_email_blocks_spec.rb b/spec/lib/mastodon/cli/canonical_email_blocks_spec.rb
index 6e4675748e..1745ea01bf 100644
--- a/spec/lib/mastodon/cli/canonical_email_blocks_spec.rb
+++ b/spec/lib/mastodon/cli/canonical_email_blocks_spec.rb
@@ -4,42 +4,45 @@ require 'rails_helper'
 require 'mastodon/cli/canonical_email_blocks'
 
 describe Mastodon::CLI::CanonicalEmailBlocks do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#find' do
+    let(:action) { :find }
     let(:arguments) { ['user@example.com'] }
 
     context 'when a block is present' do
       before { Fabricate(:canonical_email_block, email: 'user@example.com') }
 
       it 'announces the presence of the block' do
-        expect { cli.invoke(:find, arguments) }.to output(
-          a_string_including('user@example.com is blocked')
-        ).to_stdout
+        expect { subject }
+          .to output_results('user@example.com is blocked')
       end
     end
 
     context 'when a block is not present' do
       it 'announces the absence of the block' do
-        expect { cli.invoke(:find, arguments) }.to output(
-          a_string_including('user@example.com is not blocked')
-        ).to_stdout
+        expect { subject }
+          .to output_results('user@example.com is not blocked')
       end
     end
   end
 
   describe '#remove' do
+    let(:action) { :remove }
     let(:arguments) { ['user@example.com'] }
 
     context 'when a block is present' do
       before { Fabricate(:canonical_email_block, email: 'user@example.com') }
 
       it 'removes the block' do
-        expect { cli.invoke(:remove, arguments) }.to output(
-          a_string_including('Unblocked user@example.com')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Unblocked user@example.com')
 
         expect(CanonicalEmailBlock.matching_email('user@example.com')).to be_empty
       end
@@ -47,9 +50,8 @@ describe Mastodon::CLI::CanonicalEmailBlocks do
 
     context 'when a block is not present' do
       it 'announces the absence of the block' do
-        expect { cli.invoke(:remove, arguments) }.to output(
-          a_string_including('user@example.com is not blocked')
-        ).to_stdout
+        expect { subject }
+          .to output_results('user@example.com is not blocked')
       end
     end
   end
diff --git a/spec/lib/mastodon/cli/domains_spec.rb b/spec/lib/mastodon/cli/domains_spec.rb
index add754159c..a10907f76e 100644
--- a/spec/lib/mastodon/cli/domains_spec.rb
+++ b/spec/lib/mastodon/cli/domains_spec.rb
@@ -4,20 +4,26 @@ require 'rails_helper'
 require 'mastodon/cli/domains'
 
 describe Mastodon::CLI::Domains do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#purge' do
+    let(:action) { :purge }
+
     context 'with accounts from the domain' do
-      let(:options) { {} }
       let(:domain) { 'host.example' }
       let!(:account) { Fabricate(:account, domain: domain) }
+      let(:arguments) { [domain] }
 
       it 'removes the account' do
-        expect { cli.invoke(:purge, [domain], options) }.to output(
-          a_string_including('Removed 1 accounts')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Removed 1 accounts')
+
         expect { account.reload }.to raise_error(ActiveRecord::RecordNotFound)
       end
     end
diff --git a/spec/lib/mastodon/cli/email_domain_blocks_spec.rb b/spec/lib/mastodon/cli/email_domain_blocks_spec.rb
index f5cb6c332b..13deb05b6c 100644
--- a/spec/lib/mastodon/cli/email_domain_blocks_spec.rb
+++ b/spec/lib/mastodon/cli/email_domain_blocks_spec.rb
@@ -4,96 +4,99 @@ require 'rails_helper'
 require 'mastodon/cli/email_domain_blocks'
 
 describe Mastodon::CLI::EmailDomainBlocks do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#list' do
+    let(:action) { :list }
+
     context 'with email domain block records' do
       let!(:parent_block) { Fabricate(:email_domain_block) }
       let!(:child_block) { Fabricate(:email_domain_block, parent: parent_block) }
-      let(:options) { {} }
 
       it 'lists the blocks' do
-        expect { cli.invoke(:list, [], options) }.to output(
-          a_string_including(parent_block.domain)
-          .and(a_string_including(child_block.domain))
-        ).to_stdout
+        expect { subject }
+          .to output_results(
+            parent_block.domain,
+            child_block.domain
+          )
       end
     end
   end
 
   describe '#add' do
-    context 'without any options' do
-      let(:options) { {} }
+    let(:action) { :add }
 
+    context 'without any options' do
       it 'warns about usage and exits' do
-        expect { cli.invoke(:add, [], options) }.to output(
-          a_string_including('No domain(s) given')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('No domain(s) given')
+          .and raise_error(SystemExit)
       end
     end
 
     context 'when blocks exist' do
       let(:options) { {} }
       let(:domain) { 'host.example' }
+      let(:arguments) { [domain] }
 
       before { Fabricate(:email_domain_block, domain: domain) }
 
       it 'does not add a new block' do
-        expect { cli.invoke(:add, [domain], options) }.to output(
-          a_string_including('is already blocked')
-        ).to_stdout
+        expect { subject }
+          .to output_results('is already blocked')
           .and(not_change(EmailDomainBlock, :count))
       end
     end
 
     context 'when no blocks exist' do
-      let(:options) { {} }
       let(:domain) { 'host.example' }
+      let(:arguments) { [domain] }
 
       it 'adds a new block' do
-        expect { cli.invoke(:add, [domain], options) }.to output(
-          a_string_including('Added 1')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Added 1')
           .and(change(EmailDomainBlock, :count).by(1))
       end
     end
   end
 
   describe '#remove' do
-    context 'without any options' do
-      let(:options) { {} }
+    let(:action) { :remove }
 
+    context 'without any options' do
       it 'warns about usage and exits' do
-        expect { cli.invoke(:remove, [], options) }.to output(
-          a_string_including('No domain(s) given')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('No domain(s) given')
+          .and raise_error(SystemExit)
       end
     end
 
     context 'when blocks exist' do
-      let(:options) { {} }
       let(:domain) { 'host.example' }
+      let(:arguments) { [domain] }
 
       before { Fabricate(:email_domain_block, domain: domain) }
 
       it 'removes the block' do
-        expect { cli.invoke(:remove, [domain], options) }.to output(
-          a_string_including('Removed 1')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Removed 1')
           .and(change(EmailDomainBlock, :count).by(-1))
       end
     end
 
     context 'when no blocks exist' do
-      let(:options) { {} }
       let(:domain) { 'host.example' }
+      let(:arguments) { [domain] }
 
       it 'does not remove a block' do
-        expect { cli.invoke(:remove, [domain], options) }.to output(
-          a_string_including('is not yet blocked')
-        ).to_stdout
+        expect { subject }
+          .to output_results('is not yet blocked')
           .and(not_change(EmailDomainBlock, :count))
       end
     end
diff --git a/spec/lib/mastodon/cli/emoji_spec.rb b/spec/lib/mastodon/cli/emoji_spec.rb
index 3441413b90..d05e972e77 100644
--- a/spec/lib/mastodon/cli/emoji_spec.rb
+++ b/spec/lib/mastodon/cli/emoji_spec.rb
@@ -4,10 +4,10 @@ require 'rails_helper'
 require 'mastodon/cli/emoji'
 
 describe Mastodon::CLI::Emoji do
-  subject { cli.invoke(action, args, options) }
+  subject { cli.invoke(action, arguments, options) }
 
   let(:cli) { described_class.new }
-  let(:args) { [] }
+  let(:arguments) { [] }
   let(:options) { {} }
 
   it_behaves_like 'CLI Command'
@@ -29,7 +29,7 @@ describe Mastodon::CLI::Emoji do
     context 'with existing custom emoji' do
       let(:import_path) { Rails.root.join('spec', 'fixtures', 'files', 'elite-assets.tar.gz') }
       let(:action) { :import }
-      let(:args) { [import_path] }
+      let(:arguments) { [import_path] }
 
       it 'reports about imported emoji' do
         expect { subject }
@@ -51,7 +51,7 @@ describe Mastodon::CLI::Emoji do
       after { FileUtils.rm_rf(export_path.dirname) }
 
       let(:export_path) { Rails.root.join('tmp', 'cli-tests', 'export.tar.gz') }
-      let(:args) { [export_path.dirname.to_s] }
+      let(:arguments) { [export_path.dirname.to_s] }
       let(:action) { :export }
 
       it 'reports about exported emoji' do
@@ -61,8 +61,4 @@ describe Mastodon::CLI::Emoji do
       end
     end
   end
-
-  def output_results(string)
-    output(a_string_including(string)).to_stdout
-  end
 end
diff --git a/spec/lib/mastodon/cli/feeds_spec.rb b/spec/lib/mastodon/cli/feeds_spec.rb
index e16113c854..1997980527 100644
--- a/spec/lib/mastodon/cli/feeds_spec.rb
+++ b/spec/lib/mastodon/cli/feeds_spec.rb
@@ -4,20 +4,25 @@ require 'rails_helper'
 require 'mastodon/cli/feeds'
 
 describe Mastodon::CLI::Feeds do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#build' do
+    let(:action) { :build }
+
     before { Fabricate(:account) }
 
     context 'with --all option' do
       let(:options) { { all: true } }
 
       it 'regenerates feeds for all accounts' do
-        expect { cli.invoke(:build, [], options) }.to output(
-          a_string_including('Regenerated feeds')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Regenerated feeds')
       end
     end
 
@@ -27,9 +32,8 @@ describe Mastodon::CLI::Feeds do
       let(:arguments) { ['alice'] }
 
       it 'regenerates feeds for the account' do
-        expect { cli.invoke(:build, arguments) }.to output(
-          a_string_including('OK')
-        ).to_stdout
+        expect { subject }
+          .to output_results('OK')
       end
     end
 
@@ -37,22 +41,23 @@ describe Mastodon::CLI::Feeds do
       let(:arguments) { ['invalid-username'] }
 
       it 'displays an error and exits' do
-        expect { cli.invoke(:build, arguments) }.to output(
-          a_string_including('No such account')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('No such account')
+          .and raise_error(SystemExit)
       end
     end
   end
 
   describe '#clear' do
+    let(:action) { :clear }
+
     before do
       allow(redis).to receive(:del).with(key_namespace)
     end
 
     it 'clears the redis `feed:*` namespace' do
-      expect { cli.invoke(:clear) }.to output(
-        a_string_including('OK')
-      ).to_stdout
+      expect { subject }
+        .to output_results('OK')
 
       expect(redis).to have_received(:del).with(key_namespace).once
     end
diff --git a/spec/lib/mastodon/cli/ip_blocks_spec.rb b/spec/lib/mastodon/cli/ip_blocks_spec.rb
index 684314dc7a..dc967a69c9 100644
--- a/spec/lib/mastodon/cli/ip_blocks_spec.rb
+++ b/spec/lib/mastodon/cli/ip_blocks_spec.rb
@@ -4,11 +4,16 @@ require 'rails_helper'
 require 'mastodon/cli/ip_blocks'
 
 describe Mastodon::CLI::IpBlocks do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#add' do
+    let(:action) { :add }
     let(:ip_list) do
       [
         '192.0.2.1',
@@ -25,10 +30,11 @@ describe Mastodon::CLI::IpBlocks do
       ]
     end
     let(:options) { { severity: 'no_access' } }
+    let(:arguments) { ip_list }
 
     shared_examples 'ip address blocking' do
       it 'blocks all specified IP addresses' do
-        cli.invoke(:add, ip_list, options)
+        subject
 
         blocked_ip_addresses = IpBlock.where(ip: ip_list).pluck(:ip)
         expected_ip_addresses = ip_list.map { |ip| IPAddr.new(ip) }
@@ -37,7 +43,7 @@ describe Mastodon::CLI::IpBlocks do
       end
 
       it 'sets the severity for all blocked IP addresses' do
-        cli.invoke(:add, ip_list, options)
+        subject
 
         blocked_ips_severity = IpBlock.where(ip: ip_list).pluck(:severity).all?(options[:severity])
 
@@ -45,9 +51,8 @@ describe Mastodon::CLI::IpBlocks do
       end
 
       it 'displays a success message with a summary' do
-        expect { cli.invoke(:add, ip_list, options) }.to output(
-          a_string_including("Added #{ip_list.size}, skipped 0, failed 0")
-        ).to_stdout
+        expect { subject }
+          .to output_results("Added #{ip_list.size}, skipped 0, failed 0")
       end
     end
 
@@ -57,19 +62,19 @@ describe Mastodon::CLI::IpBlocks do
 
     context 'when a specified IP address is already blocked' do
       let!(:blocked_ip) { IpBlock.create(ip: ip_list.last, severity: options[:severity]) }
+      let(:arguments) { ip_list }
 
       it 'skips the already blocked IP address' do
         allow(IpBlock).to receive(:new).and_call_original
 
-        cli.invoke(:add, ip_list, options)
+        subject
 
         expect(IpBlock).to_not have_received(:new).with(ip: ip_list.last)
       end
 
       it 'displays the correct summary' do
-        expect { cli.invoke(:add, ip_list, options) }.to output(
-          a_string_including("#{ip_list.last} is already blocked\nAdded #{ip_list.size - 1}, skipped 1, failed 0")
-        ).to_stdout
+        expect { subject }
+          .to output_results("#{ip_list.last} is already blocked\nAdded #{ip_list.size - 1}, skipped 1, failed 0")
       end
 
       context 'with --force option' do
@@ -77,7 +82,7 @@ describe Mastodon::CLI::IpBlocks do
         let(:options) { { severity: 'sign_up_requires_approval', force: true } }
 
         it 'overwrites the existing IP block record' do
-          expect { cli.invoke(:add, ip_list, options) }
+          expect { subject }
             .to change { blocked_ip.reload.severity }
             .from('no_access')
             .to('sign_up_requires_approval')
@@ -89,11 +94,11 @@ describe Mastodon::CLI::IpBlocks do
 
     context 'when a specified IP address is invalid' do
       let(:ip_list) { ['320.15.175.0', '9.5.105.255', '0.0.0.0'] }
+      let(:arguments) { ip_list }
 
       it 'displays the correct summary' do
-        expect { cli.invoke(:add, ip_list, options) }.to output(
-          a_string_including("#{ip_list.first} is invalid\nAdded #{ip_list.size - 1}, skipped 0, failed 1")
-        ).to_stdout
+        expect { subject }
+          .to output_results("#{ip_list.first} is invalid\nAdded #{ip_list.size - 1}, skipped 0, failed 1")
       end
     end
 
@@ -124,6 +129,7 @@ describe Mastodon::CLI::IpBlocks do
     context 'when a specified IP address fails to be blocked' do
       let(:ip_address) { '127.0.0.1' }
       let(:ip_block) { instance_double(IpBlock, ip: ip_address, save: false) }
+      let(:arguments) { [ip_address] }
 
       before do
         allow(IpBlock).to receive(:new).and_return(ip_block)
@@ -132,24 +138,25 @@ describe Mastodon::CLI::IpBlocks do
       end
 
       it 'displays an error message' do
-        expect { cli.invoke(:add, [ip_address], options) }
-          .to output(
-            a_string_including("#{ip_address} could not be saved")
-          ).to_stdout
+        expect { subject }
+          .to output_results("#{ip_address} could not be saved")
       end
     end
 
     context 'when no IP address is provided' do
+      let(:arguments) { [] }
+
       it 'exits with an error message' do
-        expect { cli.add }.to output(
-          a_string_including('No IP(s) given')
-        ).to_stdout
+        expect { subject }
+          .to output_results('No IP(s) given')
           .and raise_error(SystemExit)
       end
     end
   end
 
   describe '#remove' do
+    let(:action) { :remove }
+
     context 'when removing exact matches' do
       let(:ip_list) do
         [
@@ -166,21 +173,21 @@ describe Mastodon::CLI::IpBlocks do
           '::/128',
         ]
       end
+      let(:arguments) { ip_list }
 
       before do
         ip_list.each { |ip| IpBlock.create(ip: ip, severity: :no_access) }
       end
 
       it 'removes exact IP blocks' do
-        cli.invoke(:remove, ip_list)
+        subject
 
         expect(IpBlock.where(ip: ip_list)).to_not exist
       end
 
       it 'displays success message with a summary' do
-        expect { cli.invoke(:remove, ip_list) }.to output(
-          a_string_including("Removed #{ip_list.size}, skipped 0")
-        ).to_stdout
+        expect { subject }
+          .to output_results("Removed #{ip_list.size}, skipped 0")
       end
     end
 
@@ -192,13 +199,13 @@ describe Mastodon::CLI::IpBlocks do
       let(:options) { { force: true } }
 
       it 'removes blocks for IP ranges that cover given IP(s)' do
-        cli.invoke(:remove, arguments, options)
+        subject
 
         expect(IpBlock.where(id: [first_ip_range_block.id, second_ip_range_block.id])).to_not exist
       end
 
       it 'does not remove other IP ranges' do
-        cli.invoke(:remove, arguments, options)
+        subject
 
         expect(IpBlock.where(id: third_ip_range_block.id)).to exist
       end
@@ -206,47 +213,46 @@ describe Mastodon::CLI::IpBlocks do
 
     context 'when a specified IP address is not blocked' do
       let(:unblocked_ip) { '192.0.2.1' }
+      let(:arguments) { [unblocked_ip] }
 
       it 'skips the IP address' do
-        expect { cli.invoke(:remove, [unblocked_ip]) }.to output(
-          a_string_including("#{unblocked_ip} is not yet blocked")
-        ).to_stdout
+        expect { subject }
+          .to output_results("#{unblocked_ip} is not yet blocked")
       end
 
       it 'displays the summary correctly' do
-        expect { cli.invoke(:remove, [unblocked_ip]) }.to output(
-          a_string_including('Removed 0, skipped 1')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Removed 0, skipped 1')
       end
     end
 
     context 'when a specified IP address is invalid' do
       let(:invalid_ip) { '320.15.175.0' }
+      let(:arguments) { [invalid_ip] }
 
       it 'skips the invalid IP address' do
-        expect { cli.invoke(:remove, [invalid_ip]) }.to output(
-          a_string_including("#{invalid_ip} is invalid")
-        ).to_stdout
+        expect { subject }
+          .to output_results("#{invalid_ip} is invalid")
       end
 
       it 'displays the summary correctly' do
-        expect { cli.invoke(:remove, [invalid_ip]) }.to output(
-          a_string_including('Removed 0, skipped 1')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Removed 0, skipped 1')
       end
     end
 
     context 'when no IP address is provided' do
       it 'exits with an error message' do
-        expect { cli.remove }.to output(
-          a_string_including('No IP(s) given')
-        ).to_stdout
+        expect { subject }
+          .to output_results('No IP(s) given')
           .and raise_error(SystemExit)
       end
     end
   end
 
   describe '#export' do
+    let(:action) { :export }
+
     let(:first_ip_range_block) { IpBlock.create(ip: '192.168.0.0/24', severity: :no_access) }
     let(:second_ip_range_block) { IpBlock.create(ip: '10.0.0.0/16', severity: :no_access) }
     let(:third_ip_range_block) { IpBlock.create(ip: '127.0.0.1', severity: :sign_up_block) }
@@ -255,15 +261,13 @@ describe Mastodon::CLI::IpBlocks do
       let(:options) { { format: 'plain' } }
 
       it 'exports blocked IPs with "no_access" severity in plain format' do
-        expect { cli.invoke(:export, nil, options) }.to output(
-          a_string_including("#{first_ip_range_block.ip}/#{first_ip_range_block.ip.prefix}\n#{second_ip_range_block.ip}/#{second_ip_range_block.ip.prefix}")
-        ).to_stdout
+        expect { subject }
+          .to output_results("#{first_ip_range_block.ip}/#{first_ip_range_block.ip.prefix}\n#{second_ip_range_block.ip}/#{second_ip_range_block.ip.prefix}")
       end
 
       it 'does not export bloked IPs with different severities' do
-        expect { cli.invoke(:export, nil, options) }.to_not output(
-          a_string_including("#{third_ip_range_block.ip}/#{first_ip_range_block.ip.prefix}")
-        ).to_stdout
+        expect { subject }
+          .to_not output_results("#{third_ip_range_block.ip}/#{first_ip_range_block.ip.prefix}")
       end
     end
 
@@ -271,23 +275,20 @@ describe Mastodon::CLI::IpBlocks do
       let(:options) { { format: 'nginx' } }
 
       it 'exports blocked IPs with "no_access" severity in plain format' do
-        expect { cli.invoke(:export, nil, options) }.to output(
-          a_string_including("deny #{first_ip_range_block.ip}/#{first_ip_range_block.ip.prefix};\ndeny #{second_ip_range_block.ip}/#{second_ip_range_block.ip.prefix};")
-        ).to_stdout
+        expect { subject }
+          .to output_results("deny #{first_ip_range_block.ip}/#{first_ip_range_block.ip.prefix};\ndeny #{second_ip_range_block.ip}/#{second_ip_range_block.ip.prefix};")
       end
 
       it 'does not export bloked IPs with different severities' do
-        expect { cli.invoke(:export, nil, options) }.to_not output(
-          a_string_including("deny #{third_ip_range_block.ip}/#{first_ip_range_block.ip.prefix};")
-        ).to_stdout
+        expect { subject }
+          .to_not output_results("deny #{third_ip_range_block.ip}/#{first_ip_range_block.ip.prefix};")
       end
     end
 
     context 'when --format option is not provided' do
       it 'exports blocked IPs in plain format by default' do
-        expect { cli.export }.to output(
-          a_string_including("#{first_ip_range_block.ip}/#{first_ip_range_block.ip.prefix}\n#{second_ip_range_block.ip}/#{second_ip_range_block.ip.prefix}")
-        ).to_stdout
+        expect { subject }
+          .to output_results("#{first_ip_range_block.ip}/#{first_ip_range_block.ip.prefix}\n#{second_ip_range_block.ip}/#{second_ip_range_block.ip.prefix}")
       end
     end
   end
diff --git a/spec/lib/mastodon/cli/main_spec.rb b/spec/lib/mastodon/cli/main_spec.rb
index b5b5d69062..59f1fc4784 100644
--- a/spec/lib/mastodon/cli/main_spec.rb
+++ b/spec/lib/mastodon/cli/main_spec.rb
@@ -4,13 +4,20 @@ require 'rails_helper'
 require 'mastodon/cli/main'
 
 describe Mastodon::CLI::Main do
+  subject { cli.invoke(action, arguments, options) }
+
+  let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
+
   it_behaves_like 'CLI Command'
 
-  describe 'version' do
+  describe '#version' do
+    let(:action) { :version }
+
     it 'returns the Mastodon version' do
-      expect { described_class.new.invoke(:version) }.to output(
-        a_string_including(Mastodon::Version.to_s)
-      ).to_stdout
+      expect { subject }
+        .to output_results(Mastodon::Version.to_s)
     end
   end
 end
diff --git a/spec/lib/mastodon/cli/maintenance_spec.rb b/spec/lib/mastodon/cli/maintenance_spec.rb
index 95e695ab55..02169b7a42 100644
--- a/spec/lib/mastodon/cli/maintenance_spec.rb
+++ b/spec/lib/mastodon/cli/maintenance_spec.rb
@@ -4,20 +4,26 @@ require 'rails_helper'
 require 'mastodon/cli/maintenance'
 
 describe Mastodon::CLI::Maintenance do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#fix_duplicates' do
+    let(:action) { :fix_duplicates }
+
     context 'when the database version is too old' do
       before do
         allow(ActiveRecord::Migrator).to receive(:current_version).and_return(2000_01_01_000000) # Earlier than minimum
       end
 
       it 'Exits with error message' do
-        expect { cli.invoke :fix_duplicates }.to output(
-          a_string_including('is too old')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('is too old')
+          .and raise_error(SystemExit)
       end
     end
 
@@ -28,9 +34,9 @@ describe Mastodon::CLI::Maintenance do
       end
 
       it 'Exits with error message' do
-        expect { cli.invoke :fix_duplicates }.to output(
-          a_string_including('more recent')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('more recent')
+          .and raise_error(SystemExit)
       end
     end
 
@@ -41,9 +47,9 @@ describe Mastodon::CLI::Maintenance do
       end
 
       it 'Exits with error message' do
-        expect { cli.invoke :fix_duplicates }.to output(
-          a_string_including('Sidekiq is running')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('Sidekiq is running')
+          .and raise_error(SystemExit)
       end
     end
   end
diff --git a/spec/lib/mastodon/cli/media_spec.rb b/spec/lib/mastodon/cli/media_spec.rb
index 6d510c1f5a..6bbe7e7469 100644
--- a/spec/lib/mastodon/cli/media_spec.rb
+++ b/spec/lib/mastodon/cli/media_spec.rb
@@ -4,18 +4,24 @@ require 'rails_helper'
 require 'mastodon/cli/media'
 
 describe Mastodon::CLI::Media do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#remove' do
+    let(:action) { :remove }
+
     context 'with --prune-profiles and --remove-headers' do
       let(:options) { { prune_profiles: true, remove_headers: true } }
 
       it 'warns about usage and exits' do
-        expect { cli.invoke(:remove, [], options) }.to output(
-          a_string_including('--prune-profiles and --remove-headers should not be specified simultaneously')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('--prune-profiles and --remove-headers should not be specified simultaneously')
+          .and raise_error(SystemExit)
       end
     end
 
@@ -23,9 +29,9 @@ describe Mastodon::CLI::Media do
       let(:options) { { include_follows: true } }
 
       it 'warns about usage and exits' do
-        expect { cli.invoke(:remove, [], options) }.to output(
-          a_string_including('--include-follows can only be used with --prune-profiles or --remove-headers')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('--include-follows can only be used with --prune-profiles or --remove-headers')
+          .and raise_error(SystemExit)
       end
     end
 
@@ -38,9 +44,8 @@ describe Mastodon::CLI::Media do
         let(:options) { { prune_profiles: true } }
 
         it 'removes account avatars' do
-          expect { cli.invoke(:remove, [], options) }.to output(
-            a_string_including('Visited 1')
-          ).to_stdout
+          expect { subject }
+            .to output_results('Visited 1')
 
           expect(account.reload.avatar).to be_blank
         end
@@ -50,9 +55,8 @@ describe Mastodon::CLI::Media do
         let(:options) { { remove_headers: true } }
 
         it 'removes account header' do
-          expect { cli.invoke(:remove, [], options) }.to output(
-            a_string_including('Visited 1')
-          ).to_stdout
+          expect { subject }
+            .to output_results('Visited 1')
 
           expect(account.reload.header).to be_blank
         end
@@ -64,9 +68,8 @@ describe Mastodon::CLI::Media do
 
       context 'without options' do
         it 'removes account avatars' do
-          expect { cli.invoke(:remove) }.to output(
-            a_string_including('Removed 1')
-          ).to_stdout
+          expect { subject }
+            .to output_results('Removed 1')
 
           expect(media_attachment.reload.file).to be_blank
           expect(media_attachment.reload.thumbnail).to be_blank
@@ -76,25 +79,24 @@ describe Mastodon::CLI::Media do
   end
 
   describe '#usage' do
-    context 'without options' do
-      let(:options) { {} }
+    let(:action) { :usage }
 
+    context 'without options' do
       it 'reports about storage size' do
-        expect { cli.invoke(:usage, [], options) }.to output(
-          a_string_including('0 Bytes')
-        ).to_stdout
+        expect { subject }
+          .to output_results('0 Bytes')
       end
     end
   end
 
   describe '#refresh' do
-    context 'without any options' do
-      let(:options) { {} }
+    let(:action) { :refresh }
 
+    context 'without any options' do
       it 'warns about usage and exits' do
-        expect { cli.invoke(:refresh, [], options) }.to output(
-          a_string_including('Specify the source')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('Specify the source')
+          .and raise_error(SystemExit)
       end
     end
 
@@ -108,9 +110,8 @@ describe Mastodon::CLI::Media do
       let(:status) { Fabricate(:status) }
 
       it 'redownloads the attachment file' do
-        expect { cli.invoke(:refresh, [], options) }.to output(
-          a_string_including('Downloaded 1 media')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Downloaded 1 media')
       end
     end
 
@@ -119,9 +120,9 @@ describe Mastodon::CLI::Media do
         let(:options) { { account: 'not-real-user@example.host' } }
 
         it 'warns about usage and exits' do
-          expect { cli.invoke(:refresh, [], options) }.to output(
-            a_string_including('No such account')
-          ).to_stdout.and raise_error(SystemExit)
+          expect { subject }
+            .to output_results('No such account')
+            .and raise_error(SystemExit)
         end
       end
 
@@ -135,9 +136,8 @@ describe Mastodon::CLI::Media do
         let(:account) { Fabricate(:account) }
 
         it 'redownloads the attachment file' do
-          expect { cli.invoke(:refresh, [], options) }.to output(
-            a_string_including('Downloaded 1 media')
-          ).to_stdout
+          expect { subject }
+            .to output_results('Downloaded 1 media')
         end
       end
     end
@@ -153,9 +153,8 @@ describe Mastodon::CLI::Media do
       let(:account) { Fabricate(:account, domain: domain) }
 
       it 'redownloads the attachment file' do
-        expect { cli.invoke(:refresh, [], options) }.to output(
-          a_string_including('Downloaded 1 media')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Downloaded 1 media')
       end
     end
   end
diff --git a/spec/lib/mastodon/cli/preview_cards_spec.rb b/spec/lib/mastodon/cli/preview_cards_spec.rb
index a766d250eb..951ae3758f 100644
--- a/spec/lib/mastodon/cli/preview_cards_spec.rb
+++ b/spec/lib/mastodon/cli/preview_cards_spec.rb
@@ -4,11 +4,17 @@ require 'rails_helper'
 require 'mastodon/cli/preview_cards'
 
 describe Mastodon::CLI::PreviewCards do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#remove' do
+    let(:action) { :remove }
+
     context 'with relevant preview cards' do
       before do
         Fabricate(:preview_card, updated_at: 10.years.ago, type: :link)
@@ -18,10 +24,11 @@ describe Mastodon::CLI::PreviewCards do
 
       context 'with no arguments' do
         it 'deletes thumbnails for local preview cards' do
-          expect { cli.invoke(:remove) }.to output(
-            a_string_including('Removed 2 preview cards')
-              .and(a_string_including('approx. 119 KB'))
-          ).to_stdout
+          expect { subject }
+            .to output_results(
+              'Removed 2 preview cards',
+              'approx. 119 KB'
+            )
         end
       end
 
@@ -29,10 +36,11 @@ describe Mastodon::CLI::PreviewCards do
         let(:options) { { link: true } }
 
         it 'deletes thumbnails for local preview cards' do
-          expect { cli.invoke(:remove, [], options) }.to output(
-            a_string_including('Removed 1 link-type preview cards')
-              .and(a_string_including('approx. 59.6 KB'))
-          ).to_stdout
+          expect { subject }
+            .to output_results(
+              'Removed 1 link-type preview cards',
+              'approx. 59.6 KB'
+            )
         end
       end
 
@@ -40,10 +48,11 @@ describe Mastodon::CLI::PreviewCards do
         let(:options) { { days: 365 } }
 
         it 'deletes thumbnails for local preview cards' do
-          expect { cli.invoke(:remove, [], options) }.to output(
-            a_string_including('Removed 1 preview cards')
-              .and(a_string_including('approx. 59.6 KB'))
-          ).to_stdout
+          expect { subject }
+            .to output_results(
+              'Removed 1 preview cards',
+              'approx. 59.6 KB'
+            )
         end
       end
     end
diff --git a/spec/lib/mastodon/cli/settings_spec.rb b/spec/lib/mastodon/cli/settings_spec.rb
index 7dcd1110ba..02d1042c56 100644
--- a/spec/lib/mastodon/cli/settings_spec.rb
+++ b/spec/lib/mastodon/cli/settings_spec.rb
@@ -7,59 +7,64 @@ describe Mastodon::CLI::Settings do
   it_behaves_like 'CLI Command'
 
   describe 'subcommand "registrations"' do
+    subject { cli.invoke(action, arguments, options) }
+
     let(:cli) { Mastodon::CLI::Registrations.new }
+    let(:arguments) { [] }
+    let(:options) { {} }
 
     before do
       Setting.registrations_mode = nil
     end
 
     describe '#open' do
+      let(:action) { :open }
+
       it 'changes "registrations_mode" to "open"' do
-        expect { cli.open }.to change(Setting, :registrations_mode).from(nil).to('open')
+        expect { subject }.to change(Setting, :registrations_mode).from(nil).to('open')
       end
 
       it 'displays success message' do
-        expect { cli.open }.to output(
-          a_string_including('OK')
-        ).to_stdout
+        expect { subject }
+          .to output_results('OK')
       end
     end
 
     describe '#approved' do
+      let(:action) { :approved }
+
       it 'changes "registrations_mode" to "approved"' do
-        expect { cli.approved }.to change(Setting, :registrations_mode).from(nil).to('approved')
+        expect { subject }.to change(Setting, :registrations_mode).from(nil).to('approved')
       end
 
       it 'displays success message' do
-        expect { cli.approved }.to output(
-          a_string_including('OK')
-        ).to_stdout
+        expect { subject }
+          .to output_results('OK')
       end
 
       context 'with --require-reason' do
-        before do
-          cli.options = { require_reason: true }
-        end
+        let(:options) { { require_reason: true } }
 
         it 'changes "registrations_mode" to "approved"' do
-          expect { cli.approved }.to change(Setting, :registrations_mode).from(nil).to('approved')
+          expect { subject }.to change(Setting, :registrations_mode).from(nil).to('approved')
         end
 
         it 'sets "require_invite_text" to "true"' do
-          expect { cli.approved }.to change(Setting, :require_invite_text).from(false).to(true)
+          expect { subject }.to change(Setting, :require_invite_text).from(false).to(true)
         end
       end
     end
 
     describe '#close' do
+      let(:action) { :close }
+
       it 'changes "registrations_mode" to "none"' do
-        expect { cli.close }.to change(Setting, :registrations_mode).from(nil).to('none')
+        expect { subject }.to change(Setting, :registrations_mode).from(nil).to('none')
       end
 
       it 'displays success message' do
-        expect { cli.close }.to output(
-          a_string_including('OK')
-        ).to_stdout
+        expect { subject }
+          .to output_results('OK')
       end
     end
   end
diff --git a/spec/lib/mastodon/cli/statuses_spec.rb b/spec/lib/mastodon/cli/statuses_spec.rb
index 70e4e2c086..63d494bbb6 100644
--- a/spec/lib/mastodon/cli/statuses_spec.rb
+++ b/spec/lib/mastodon/cli/statuses_spec.rb
@@ -4,26 +4,31 @@ require 'rails_helper'
 require 'mastodon/cli/statuses'
 
 describe Mastodon::CLI::Statuses do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#remove', use_transactional_tests: false do
+    let(:action) { :remove }
+
     context 'with small batch size' do
       let(:options) { { batch_size: 0 } }
 
       it 'exits with error message' do
-        expect { cli.invoke :remove, [], options }.to output(
-          a_string_including('Cannot run')
-        ).to_stdout.and raise_error(SystemExit)
+        expect { subject }
+          .to output_results('Cannot run')
+          .and raise_error(SystemExit)
       end
     end
 
     context 'with default batch size' do
       it 'removes unreferenced statuses' do
-        expect { cli.invoke :remove }.to output(
-          a_string_including('Done after')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Done after')
       end
     end
   end
diff --git a/spec/lib/mastodon/cli/upgrade_spec.rb b/spec/lib/mastodon/cli/upgrade_spec.rb
index 0d6494eeee..6861e04887 100644
--- a/spec/lib/mastodon/cli/upgrade_spec.rb
+++ b/spec/lib/mastodon/cli/upgrade_spec.rb
@@ -4,23 +4,26 @@ require 'rails_helper'
 require 'mastodon/cli/upgrade'
 
 describe Mastodon::CLI::Upgrade do
+  subject { cli.invoke(action, arguments, options) }
+
   let(:cli) { described_class.new }
+  let(:arguments) { [] }
+  let(:options) { {} }
 
   it_behaves_like 'CLI Command'
 
   describe '#storage_schema' do
-    context 'with records that dont need upgrading' do
-      let(:options) { {} }
+    let(:action) { :storage_schema }
 
+    context 'with records that dont need upgrading' do
       before do
         Fabricate(:account)
         Fabricate(:media_attachment)
       end
 
       it 'does not upgrade storage for the attachments' do
-        expect { cli.invoke(:storage_schema, [], options) }.to output(
-          a_string_including('Upgraded storage schema of 0 records')
-        ).to_stdout
+        expect { subject }
+          .to output_results('Upgraded storage schema of 0 records')
       end
     end
   end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index d30e7201c4..4394b470e6 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -88,6 +88,7 @@ RSpec.configure do |config|
   config.include Chewy::Rspec::Helpers
   config.include Redisable
   config.include SignedRequestHelpers, type: :request
+  config.include CommandLineHelpers, type: :cli
 
   config.around(:each, use_transactional_tests: false) do |example|
     self.use_transactional_tests = false
diff --git a/spec/support/command_line_helpers.rb b/spec/support/command_line_helpers.rb
new file mode 100644
index 0000000000..6f9d63d939
--- /dev/null
+++ b/spec/support/command_line_helpers.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module CommandLineHelpers
+  def output_results(*args)
+    output(
+      include(*args)
+    ).to_stdout
+  end
+end
-- 
GitLab