Last active
June 21, 2020 03:23
-
-
Save arika/59bc45b2ce97cf889befb592f41d62f6 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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