Skip to content

Instantly share code, notes, and snippets.

@arika
Last active June 21, 2020 03:23
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save arika/59bc45b2ce97cf889befb592f41d62f6 to your computer and use it in GitHub Desktop.
Save arika/59bc45b2ce97cf889befb592f41d62f6 to your computer and use it in GitHub Desktop.
# frozen_string_literal: true
require 'ostruct'
require 'open3'
require 'shellwords'
# 小さなコマンドをテストするためのヘルパーモジュール
#
# 内部で他のコマンドを呼び出すのが主となる10〜20行程度の小さなシェルスクリプト
# やRubyなどのスクリプトを主なテスト対象とする。
#
# 基本的な使用例は次の通り。
#
# test "test some command" do
# result = run_command("some/command")
# assert_command_succeeded result
# end
#
# `run_command`は引数で指定されたコマンド(ここでは`some/command`)を実行する。返
# り値にはコマンドの実行結果をOpenStructオブジェクトを返す。
# `assert_command_succeeded`は正常終了したことを検査する。
#
# コマンドの出力は返り値の`stdout`、`stderr`により参照できる。終了ステータスは
# `exit_status`により参照できる。
#
# `run_command`はコマンド実行に先立って以下の三つの一時ディレクトリを作成する。
#
# * 一時実行パスディレクトリ
# * 一時ホームディレクトリ
# * 一時ワークディレクトリ
#
# 指定されたコマンドを実行する際には一時ワークディレクトリ直下の`work`に移動し、
# さらに以下の環境変数を設定する。
#
# * `HOME` - 一時ホームディレクトリのフルパス
# * `PWD` - 一時ワークディレクトリのフルパス
# * `PATH` - 一時実行パスディレクトリのフルパス
#
# この状態では一時実行パスディレクトリ空であるため、実行されるコマンドの内部か
# らは他のコマンドを実行することはできない。(*1)
#
# そこでコマンド内部から実行されるはずの、そして実行のされ方を検査する対象とな
# るコマンドのスタブを`stub_command`によって定義する。
#
# たとえばコマンド内部で`ls`コマンドが実行されるならば次のようにする。
#
# stub_command("ls")
#
# `ls`コマンドにオプションが与えられるべきであれば次のようにする。
#
# stub_command("ls", "-l", "-t")
#
# 詳細は`run_command`、`stub_command`のドキュメントを参照のこと。
#
# (*1)
# フルパス(たとえば`/bin/ls`)でのコマンド実行は可能。またシェルスクリプトの場合
# は内部コマンドも実行可能。
module RunCommandWithExternalCommandStubs
def self.included(mod)
super
mod.module_eval do
setup do
stub_command_setup
end
teardown do
stub_command_teardown
end
end
end
private
# テスト対象のコマンドを実行する
#
# 引数は以下の通り。
#
# full_path: 実行するファイル名
# argv: コマンドライン引数
# stdin: コマンドの標準入力に与える文字列
# path: コマンド実行時に設定するPATHに追加で含めるパスの配列
# env: コマンド実行時に設定する環境変数(PATHは指定できない)
# block: 実行されたコマンドとのやり取りをするブロック(省略可)
#
# 実行結果として以下を持つOpenStructオブジェクトを返す。
#
# exit_status: コマンドの終了ステータス(Process::Status)
# stdout: コマンドから標準出力に出力された文字列
# stderr: コマンドから標準エラー出力に出力された文字列
def run_command(full_path, *argv, stdin: '', path: [], env: {}, &block)
create_stub_commands_and_specs
result = {}
block ||= default_run_command_block(stdin)
Dir.chdir(@_workdir_path) do
env = env_for_run_command(env, path)
Open3.popen3(env, full_path.to_s, *argv) do |i, o, e, th|
result[:stdout], result[:stderr] = block.call(i, o, e)
ensure
result[:exit_status] = th.value
end
end
OpenStruct.new(result)
end
# テスト対象のコマンドの実行時のホームディレクトリ
def run_command_home_directory
@_homedir_path
end
# テスト対象のコマンドの実行時のカレントディレクトリ
def run_command_work_directory
@_workdir_path
end
def default_run_command_block(data)
lambda do |stdin, stdout, stderr|
stdin.write data
stdin.close
[stdout, stderr].map do |io|
buf = +''
begin
loop { buf << io.read_nonblock(4096) }
rescue IO::WaitReadable
IO.select([io])
retry
rescue EOFError
buf
end
end
end
end
def env_for_run_command(env, path)
env = env.dup
env['PATH'] = [
*path.map { |p| expand_path_for_run_command(p) },
@_stub_command_path,
].join(':')
env['PWD'] = @_workdir_path
env['HOME'] ||= @_homedir_path
env['RUBYOPT'] ||= '--disable=gems --disable=did_you_mean' # stub perfomance
env
end
def expand_path_for_run_command(path)
case path
when %r{\A/}
path
when %r{\A~/}
File.join(@_homedir_path, Regexp.last_match.post_match)
else
File.join(@_workdir_path, path)
end
end
# 実行したコマンドが正常終了したことを検証する
def assert_command_succeeded(result, msg = nil)
unless msg
stderr = result.stderr || ''
msg = 'Command execution failed'
msg += " with error message:\n#{stderr}" unless stderr.empty?
end
assert result.exit_status.success?, msg
end
# name:
# 実行されるべきコマンド名またはファイル名(必須)
# *argv:
# コマンドに与えられることを期待する引数を表す配列
# expected_stdin:
# コマンドの標準入力に与えられることを期待する
# 文字列または正規表現(省略時は//)
# expected_env:
# コマンド実行時に設定されていることを期待する
# 環境変数名と値または正規表現からなるハッシュ(省略時は{})
# stub_stdout:
# コマンドに代わって標準出力に出力する文字列(省略時は"")
# stub_stderr:
# コマンドに代わって標準エラー出力に出力する文字列(省略時は"")
# stub_exit_code:
# コマンドに代わって返す終了コード(省略時は0)
# group:
# 順不同での実行を想定するコマンドスタブをグループ化する名前を指定する
# (シェルスクリプトでパイプを使用する場合など)
# command_location:
# コマンドスタブを作成するパスを指定する
# "~/"で始まる場合は一時ホームディレクトリからの相対パス
# それ以外の場合は一時ワークディレクトリからの相対パス
# 省略した場合は一時実行パスからの相対パス
# フルパスは指定できない
# exec_command:
# スタブから指定したコマンドをexecする
# これを指定した場合、expected_stdin、stub_stdout、
# stub_stderr、stub_exit_codeは無視される
def stub_command(name, *argv, **spec)
raise ArgumentError, 'Invalid name' unless name
raise ArgumentError, 'Invalid command location' if spec[:command_location]&.start_with?('/')
spec[:expected_name] = name
spec[:expected_argv] = argv
if spec[:exec_command] && !spec[:exec_command].start_with?('/')
found = find_actual_command_in_current_path(spec[:exec_command])
spec[:exec_command] = found if found
end
register_stub_command(spec)
end
def find_actual_command_in_current_path(name)
found = nil
ENV['PATH'].split(/:/).each do |path|
path = "#{path}/#{name}"
next unless File.executable?(path)
found = path
break
end
found
end
# stub_commandで指定したコマンド実行結果を検査する。
def examine_command_stub_results!
return if defined?(@_examine_command_stub_results)
@_examine_command_stub_results = true
command_results = load_command_stub_results
loop do
spec = @_stub_command_specs.first
result = command_results.delete(spec[:id]) if spec
break unless spec && result
examine_command_stub_result!(spec, result)
@_stub_command_specs.shift
end
examine_unexpected_command_stub_executions!(command_results)
examine_expected_command_stub_executions!(@_stub_command_specs)
end
def examine_command_stub_result!(spec, result)
ex_name, msg = result[:exception]
return unless ex_name
assert_nil ex_name, msg unless ex_name == 'UnexpectedCommandArguments'
errors = []
result.each_key do |key|
next unless /\Avalid_(?<arg_type>\w+)/ =~ key
next if result[key]
errors << command_stub_argument_error_message(
spec[:expected_name], arg_type,
spec[:"expected_#{arg_type}"],
result[:"actual_#{arg_type}"]
)
end
assert errors.empty?, errors.join("\n")
end
def command_stub_argument_error_message(name, arg_type, expected, actual)
"Unexpected #{arg_type} are given for #{name}\n" \
" expected: #{expected.inspect}\n" \
" actual: #{actual.inspect}"
end
def examine_unexpected_command_stub_executions!(unexpected_results)
assert(
unexpected_results.empty?,
"Unexpected command executions:\n " +
unexpected_results
.values
.map { |r| { name: r[:actual_name], argv: r[:actual_argv] }.inspect }
.join(" \n")
)
end
def examine_expected_command_stub_executions!(rest_specs)
assert(
rest_specs.empty?,
"Expected command executions:\n " +
rest_specs
.map { |s| { name: s[:expected_name], argv: s[:expected_argv] }.inspect }
.join(" \n")
)
end
def load_command_stub_results
Dir.glob("#{@_stub_command_result_file_path}/*.dump").sort
.each_with_object({}) do |result_file_path, command_results|
data = File.binread(result_file_path)
result = Marshal.load(data) # rubocop:disable Security/MarshalLoad
command_results[result[:id]] = result
end
end
def stub_command_setup
@_homedir_path = Dir.mktmpdir
@_workdir_path = "#{@_homedir_path}/work"
@_stub_command_path = Dir.mktmpdir
@_stub_command_spec_file_path = "#{@_stub_command_path}/.specs"
@_stub_command_result_file_path = "#{@_stub_command_path}/.results"
Dir.mkdir @_workdir_path
Dir.mkdir @_stub_command_spec_file_path
Dir.mkdir @_stub_command_result_file_path
@_stub_command_specs = []
end
def stub_command_teardown
examine_command_stub_results! if _test_success?
ensure
@_stub_command_specs = nil
FileUtils.remove_entry @_stub_command_path if @_stub_command_path
FileUtils.remove_entry @_homedir_path if @_homedir_path
end
def _test_success?
case self
when ::Test::Unit::TestCase
!current_result.error_occurred? && !current_result.failure_occurred?
when ::Minitest::Test
passed?
else
true
end
end
def register_stub_command(spec)
spec = {
expected_avgv: [],
expected_stdin: //,
expected_env: {},
stub_stdout: '',
stub_stderr: '',
stub_exit_code: 0,
id: @_stub_command_specs.size + 1,
}.merge(spec)
@_stub_command_specs << spec
end
def create_stub_commands_and_specs
@_stub_command_specs.each do |spec|
name = spec[:expected_name]
location = spec[:command_location] || @_stub_command_path
location = expand_path_for_run_command(location)
spec[:command_location] = location
create_stub_command(name, location, stub_command_body)
create_stub_command_spec(spec)
end
end
def create_stub_command_spec(spec)
path = format("#{@_stub_command_spec_file_path}/%05d.dump", spec[:id])
File.open(path, 'wb', 0o600) do |io|
Marshal.dump(spec, io)
end
end
def create_stub_command(name, location, body)
path = "#{location}/#{name}"
return if File.exist?(path)
FileUtils.mkdir_p location
File.open(path, 'w', 0o755) do |io|
io.write body
end
end
def stub_command_body
<<~END_OF_CMD
\#!#{ruby_install_path}
\# frozen_string_literal: true
time = Time.now
class UnexpectedCommand < RuntimeError; end
class UnexpectedCommandArguments < RuntimeError; end
class UnexpectedCommandOfGroup < RuntimeError; end
def read_stdin
buf = +""
begin
loop { buf << $stdin.read_nonblock(4096) }
try_utf8(buf)
rescue IO::WaitReadable
IO.select([$stdin])
retry
rescue EOFError
try_utf8(buf)
end
end
def try_utf8(orig_str)
str = orig_str.dup
str.force_encoding("UTF-8")
str.valid_encoding? ? str : orig_str
end
def valid?(expected, actual)
if expected.is_a?(Hash) && actual.is_a?(Hash)
expected.all? do |name, value|
valid?(value, actual[name])
end
elsif expected.is_a?(Array) && actual.is_a?(Array)
expected.size == actual.size &&
expected.each_index.all? do |idx|
valid?(expected[idx], actual[idx])
end
elsif expected.is_a?(Regexp)
expected.match?(actual)
else
expected == actual
end
end
name = $0
[
#{@_stub_command_path.dump},
#{@_homedir_path.dump},
#{@_workdir_path.dump},
].each do |prefix|
if name.start_with?(prefix + "/")
name = name[(prefix.size + 1)..-1]
break
end
end
time_str = time.strftime("%Y-%m-%dT%H:%M:%S.%3N%:z")
result_file_path = File.join(#{@_stub_command_result_file_path.dump}, "\#{time_str}.\#{$$}.dump")
result = {
actual_name: name,
actual_argv: ARGV,
valid_name: nil,
valid_argv: nil,
valid_stdin: nil,
valid_env: nil,
time: time_str,
}
spec_file_path = nil
spec_file_paths = Dir.glob(File.join(#{@_stub_command_spec_file_path.dump}, "*")).sort
spec_idx = 0
spec_group = nil
begin
spec = nil
loop do
spec_file_path = spec_file_paths[spec_idx]
break unless spec_file_path
spec = Marshal.load(File.binread(spec_file_path)) rescue nil
unless spec
spec_idx += 1
redo
end
if spec_group && spec[:group] != spec_group
spec = nil
spec_idx += 1
redo
end
spec_group ||= spec[:group]
break
end
if spec_group && !spec
cmdline = result.values_at(:actual_name, :actual_argv).flatten.join(" ")
raise UnexpectedCommandOfGroup,
"Command `\#{cmdline}` isn't in \#{spec_group.inspect} group\nCommand `#{@_stub_command_path}`"
end
msg_base = "Command `\#{name}` executed"
raise UnexpectedCommand, "\#{msg_base} unexpectedly" unless spec
location_pattern = Regexp.quote(spec[:command_location])
actual_name = $0.sub(%r{\\A\#{location_pattern}/}, "")
result[:actual_name] = name
result[:id] = spec[:id]
expected_name = spec[:expected_name]
if actual_name == expected_name
result[:valid_name] = true
else
raise UnexpectedCommand, "\#{msg_base} unexpectedly (expected `\#{expected_name}`)"
end
result[:actual_env] = {}
spec[:expected_env].each_key do |key|
result[:actual_env][key] = ENV[key]
end
checks = %w(argv env)
unless spec[:exec_command]
checks += %w(stdin)
result[:actual_stdin] = read_stdin
end
errors = []
checks.each do |n|
expected = spec[:"expected_\#{n}"]
actual = result[:"actual_\#{n}"]
valid = valid?(expected, actual)
result[:"valid_\#{n}"] = valid
unless valid
errors << "expected \#{n}: \#{expected.inspect}\\n" \\
" actual \#{n}: \#{actual.inspect}"
end
end
unless errors.empty?
raise UnexpectedCommandArguments, "\#{msg_base} with unexpected arguments.\\n\#{errors.join(', ')}"
end
rescue Exception => e
if spec_group && e.is_a?(UnexpectedCommand)
spec_idx += 1
e = nil
retry
end
abort e.message
ensure
result[:exception] = [e.class.name, e.message] if e
File.open(result_file_path, "wb", 0o600) do |io|
Marshal.dump(result, io)
end
File.unlink(spec_file_path) if spec && spec_file_path
end
exec(spec[:exec_command], *result[:actual_argv]) if spec[:exec_command]
$stdout.write spec[:stub_stdout]
$stderr.write spec[:stub_stderr]
exit(spec[:stub_exit_code])
END_OF_CMD
end
# 指定したコマンドを実行できるようにする
# (stub_commandよりも優先される)
def proxy_command(name)
raise ArgumentError, 'Invalid name' unless name
found = find_actual_command_in_current_path(name)
raise ArgumentError, "Command #{name} not found" unless found
create_stub_command(name, @_stub_command_path, proxy_command_body(found))
end
def proxy_command_body(actual)
<<~END_OF_CMD
\#!/bin/sh
exec #{Shellwords.escape(actual)} "$@"
END_OF_CMD
end
def ruby_install_path
File.join(RbConfig::CONFIG['bindir'], RbConfig::CONFIG['ruby_install_name'])
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment