見出し画像

Rubyで動的解析して、関係があるテストも実行する

この記事は note株式会社 Advent Calendar 2024 の17日目の記事です。

あるRubyクラスを変更したがそのプログラムのテストケースの実行結果の確認だけでは不十分なことがよくある。例えば、CreateUserService -> Userのようなクラスの依存関係があったとする。プログラマはUserクラスに対して変更を加えたので、Userクラスに対するテストケースを実行すれば確認としては十分であると考えた。しかし、実際にはUserクラス単体ではCreateUserServiceからの実際の利用のされ方は確認できないし、保証もできないので(特にRubyプログラムであることも踏まえて)、うっかりUserクラスの振る舞いが変わってしまったことで、連鎖的にCreateUserServiceの振る舞いに影響してしまうということがよくある。

この問題には、以下のような解決策が考えられる。

  • A: そもそもUserクラスの振る舞いを保証する仕組みを整える

  • B: 頑張ってテストを書いて全てのテストケースを実行する(CIなどで)

  • C: RBSなど、型をつけてプログラムを実行せずとも振る舞いを解析できる方法を整える

Aについては、Rubyプログラムには厳密にオブジェクトの振る舞いを保証する仕組みがない(例えば、インターフェース)。Bについては、テストケースをCI上で全て実行する運用は頻出であるが、Railsアプリケーションはテスト用のDBの実体と通信をしてテストを実行している場合が多いので、並列実行したとしてもテストに非常に時間がかかる上、手元で開発して即座にテストを実行しフィードバックを得るというループを回しにくいし、なによりCIはたくさんの計算資源を確保し安定して実行するのにお金がかかる。余談だが、note社のCIの実行時間は最近爆速になった(アドカレ2日目の記事を参考のこと)が、このように、CIのメンテナンスするコストというのも中々のものであるといえよう。Cについて、Rubyはそもそも動的型付けのプログラミング言語であり、Railsに代表されるようにその性質を活かしたライブラリも多く、加えてRBSについてもあくまで必須ではなくオプショナルな扱いとなっており、アプリケーション全体に対して完全な型付けを行うまでのハードルは高い。

Rubyにおけるコード解析

先述の解決策に関連して、Rubyアプリケーションのプログラム構造を解析する方法は度々話題になっておりその分類としては主に、RBSSorbetにみられるようにそもそもRubyプログラムに静的な型付けを行い、プログラムを実行する前に解析するパターンと、TypeProfにみられるようにそもそもRubyプログラムにタイプをつけるのは難しいからプログラムを実行している最中に型情報を収集して、その結果として型を吐き出そうとするものがある。

またpackwerkのように、プログラム内部のモジュール間の関連を依存関係として宣言的に定義することで、全体の「システム」としては巨大であるが、個々のクラスないしモジュールの依存関係をパッケージとして簡略化していく仕組みがある(モジュラモノリスと呼ばれる)。この場合では、モジュール間の依存関係は、事前にユーザによって宣言され違反がないか確認されるので、暗黙のうちにモジュールの依存関係が発生してしまうようなことが発生しにくく、そもそもプログラムを書くこと = 依存関係を宣言することとなっている。依存関係は事前に宣言しているから、どのパッケージの変更がどのパッケージに影響を及ぼすかは、おおよそプログラムを実行する前に判別することができる。

動的解析の魔法

RubyにはTracepointというRubyKaigiでよく出てくる標準ライブラリがありこれを利用すると、実行中のRubyインタプリター上で発生したイベント(メソッド呼び出しやクラス定義など)を簡単にトラッキングできる。例えば以下のシンプルなRubyプログラムは、Hogeクラスの実体に対するメソッド呼び出しhelloをトラッキングし、標準出力に出力する。

trace = TracePoint.new(:call) do |tp|
  pp [tp.self, tp.method_id]
end

trace.enable

class Hoge
  def hello
    "hello"
  end
end

Hoge.new.hello

# kihaya@kihaya-Mac-Studio ~/w/G/tptest (main)> ruby tpex.rb
# [#<Hoge:0x0000000102ca50f0>, :hello]

diver_downはこのTracepointをラップし、実際にRubyプログラムを実行することで、クラスやモジュール間の依存関係を可視化してくれるgemである。アプリケーションにテストコード(たとえばRSpec)が付属している場合、diver_downを有効にした上で、全てのテストコードを実行することで、テスト的にカバーされているコード行については動的に依存関係を可視化できるはずであり、依存対象のクラスのテストケースを特定できれば、変更に関係のあるテストケースを自動実行することが可能になるはずである、という思惑に筆者は至った。

実アプリケーションを解析する

比較的外部ライブラリへの依存関係が少なく、内部のモジュールがそこそこあるライブラリを例として実際に解析を実行してみよう。例として、ruby-jwt を採用する。

セットアップ

$ git clone git@github.com:jwt/ruby-jwt.git
$ cd ruby-jwt && bundle
$ touch analyze.rb # このファイルを実行して解析を実行する
$ ls -la
total 312
drwxr-xr-x@ 25 kihaya  staff    800 12 14 20:44 .
drwxr-xr-x@ 36 kihaya  staff   1152 12 14 20:43 ..
-rw-r--r--@  1 kihaya  staff    125 12 14 20:43 .codeclimate.yml
drwxr-xr-x@ 12 kihaya  staff    384 12 14 20:43 .git
drwxr-xr-x@  4 kihaya  staff    128 12 14 20:43 .github
-rw-r--r--@  1 kihaya  staff    152 12 14 20:43 .gitignore
-rw-r--r--@  1 kihaya  staff     30 12 14 20:43 .rspec
-rw-r--r--@  1 kihaya  staff    369 12 14 20:43 .rubocop.yml
-rw-r--r--@  1 kihaya  staff    471 12 14 20:43 .simplecov
-rw-r--r--@  1 kihaya  staff   1594 12 14 20:43 AUTHORS
-rw-r--r--@  1 kihaya  staff    308 12 14 20:43 Appraisals
-rw-r--r--@  1 kihaya  staff  62629 12 14 20:43 CHANGELOG.md
-rw-r--r--@  1 kihaya  staff   5215 12 14 20:43 CODE_OF_CONDUCT.md
-rw-r--r--@  1 kihaya  staff   3072 12 14 20:43 CONTRIBUTING.md
-rw-r--r--@  1 kihaya  staff    152 12 14 20:43 Gemfile
-rw-r--r--@  1 kihaya  staff   1692 12 14 20:44 Gemfile.lock
-rw-r--r--@  1 kihaya  staff   1056 12 14 20:43 LICENSE
-rw-r--r--@  1 kihaya  staff  31071 12 14 20:43 README.md
-rw-r--r--@  1 kihaya  staff    241 12 14 20:43 Rakefile
-rw-r--r--@  1 kihaya  staff      0 12 14 20:44 analyze.rb
drwxr-xr-x@  4 kihaya  staff    128 12 14 20:43 bin
drwxr-xr-x@  6 kihaya  staff    192 12 14 20:43 gemfiles
drwxr-xr-x@  4 kihaya  staff    128 12 14 20:43 lib
-rw-r--r--@  1 kihaya  staff   1434 12 14 20:43 ruby-jwt.gemspec
drwxr-xr-x@  7 kihaya  staff    224 12 14 20:43 spec

diver_down gem を依存関係に加える。

diff --git a/ruby-jwt.gemspec b/ruby-jwt.gemspec
index 67ff391..a2b781b 100644
--- a/ruby-jwt.gemspec
+++ b/ruby-jwt.gemspec
@@ -39,4 +39,5 @@ Gem::Specification.new do |spec|
   spec.add_development_dependency 'rspec'
   spec.add_development_dependency 'rubocop'
   spec.add_development_dependency 'simplecov'
+  spec.add_development_dependency 'diver_down'
 end

解析準備とRSpecの実行

analyze.rbの基本構造をdiver_downのREADMEを元につくろう。diver_downをgemのdevelopment依存関係に追加したので、アプリケーション内でrequire可能になっている。解析対象のファイルがあるのは、lib/ 配下のみなので、設定で指定する。

require "diver_down"
require "diver_down-trace"

application_paths = [
  *Dir['lib/**/*.rb'],
].map { File.expand_path(_1) }

tracer = DiverDown::Trace::Tracer.new(caller_paths: application_paths)

definition = tracer.trace {}
session = tracer.new_session

session.start

session.stop

definition = session.definition

Tracepointのようにsession.start メソッド呼び出しを実行した時点でトラッキングが開始されるので、その直後に全てのspecファイルを実行すれば解析が可能である。RSpecをロードして、specファイルをプログラム中から実行できるようにしてみよう。

require "diver_down"
require "diver_down-trace"
require "rspec"

spec_dir = "spec"

application_paths = [
  *Dir['lib/**/*.rb'],
].map { File.expand_path(_1) }

tracer = DiverDown::Trace::Tracer.new(caller_paths: application_paths)

definition = tracer.trace {}
session = tracer.new_session

session.start

RSpec::Core::Runner.run([spec_dir])

session.stop

definition = session.definition

このままプログラムを実行すると、diver_downによるトラッキングが有効になった状態で、全てのspecファイルを実行できる。

kihaya@kihaya-Mac-Studio ~/w/G/ruby-jwt (main)> bundle exec ruby analyze.rb
OpenSSL::VERSION: 3.1.0
OpenSSL::OPENSSL_VERSION: OpenSSL 3.1.0 14 Mar 2023
OpenSSL::OPENSSL_LIBRARY_VERSION: OpenSSL 3.1.0 14 Mar 2023

Run options: include {:focus=>true}

All examples were filtered out; ignoring {:focus=>true}

Randomized with seed 471
[DEPRECATION WARNING] The ::JWT::ClaimsValidator class is deprecated and will be removed in the next major version of ruby-jwt
.[DEPRECATION WARNING] The ::JWT::ClaimsValidator class is deprecated and will be removed in the next major version of ruby-jwt
.[DEPRECATION WARNING] The ::JWT::ClaimsValidator class is deprecated and will be removed in the next major version of ruby-jwt
.[DEPRECATION WARNING] The ::JWT::ClaimsValidator class is deprecated and will be removed in the next major version of ruby-jwt
.[DEPRECATION WARNING] The ::JWT::ClaimsValidator class is deprecated and will be removed in the next m

〜〜 長い出力 〜〜

  33) JWT::JWK::OKPRbNaCl .import when JWK is given creates a new instance of the class
     # Requires the rbnacl gem
     # ./spec/jwt/jwk/okp_rbnacl_spec.rb:119


Finished in 12.59 seconds (files took 4.25 seconds to load)
603 examples, 0 failures, 33 pending

Randomized with seed 471

Coverage report generated for Job Gemfile to /Users/kihaya/workspace/GitRepos/ruby-jwt/coverage.
Line Coverage: 95.2% (1130 / 1187)

ちなみに普通にspecファイルを全て実行したときの実行時間とはおおよそ4倍の差があるようだ(Apple M2 Max 64GB環境)。

kihaya@kihaya-Mac-Studio ~/w/G/ruby-jwt (main)> bundle exec rspec spec/
OpenSSL::VERSION: 3.1.0
OpenSSL::OPENSSL_VERSION: OpenSSL 3.1.0 14 Mar 2023
OpenSSL::OPENSSL_LIBRARY_VERSION: OpenSSL 3.1.0 14 Mar 2023

Run options: include {:focus=>true}

All examples were filtered out; ignoring {:focus=>true}

Randomized with seed 30429
......................................................................................................[DEPRECATION WARNING] Support for calling sign with positional arguments will be removed in future ruby-jwt versions
[DEPRECATION WARNING] Support for calling verify with positional arguments will be removed in future ruby-jwt versions
....[DEPRECATION WARNING] Calling ::JWT::Claims::Numeric.new with the payload will

〜〜長い出力〜〜

Finished in 3.25 seconds (files took 0.14061 seconds to load)
603 examples, 0 failures, 33 pending

Randomized with seed 30429

Coverage report generated for Job Gemfile to /Users/kihaya/workspace/GitRepos/ruby-jwt/coverage.
Line Coverage: 95.2% (1130 / 1187)

解析結果の表示

analyze.rbの実行が完了した時点で、解析結果(クラス間の依存関係の一覧)はdefinition変数に格納されている。中身はおおよそ以下のようになっており、キーsource_name がクラス名(モジュール名)、キーdependenciesが、依存するクラス名情報を格納したハッシュの配列になっている。例えば以下の例だと、クラスArrayはJWT::Base64 クラスへの依存がありそれはメソッド呼び出しurl_decodeによるもであることがわかる。

[{:source_name=>"ArgumentError", :dependencies=>[]},
 {:source_name=>"Array",
  :dependencies=>
   [{:source_name=>"Enumerator", :method_ids=>[{:name=>"drop_while", :context=>"instance", :paths=>["/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/version.rb:58"]}]},
    {:source_name=>"Hash",
     :method_ids=>
      [{:name=>"[]",
        :context=>"instance",
        :paths=>["/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/decode.rb:80", "/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/decode.rb:96", "/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/jwk/rsa.rb:143"]},
       {:name=>"[]=",
        :context=>"instance",
        :paths=>
         ["/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/jwk.rb:43",
          "/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/jwk/ec.rb:52",
          "/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/jwk/hmac.rb:55",
          "/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/jwk/rsa.rb:114",
          "/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/jwk/rsa.rb:58"]},
       {:name=>"each_key", :context=>"instance", :paths=>["/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/claims/verifier.rb:45"]},
       {:name=>"is_a?", :context=>"instance", :paths=>["/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/claims/required.rb:21", "/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/claims/verifier.rb:45"]},
       {:name=>"key?", :context=>"instance", :paths=>["/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/claims/required.rb:21"]}]},
    {:source_name=>"Integer",
     :method_ids=>
      [{:name=>"+", :context=>"instance", :paths=>["/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/decode.rb:66"]},
       {:name=>"==", :context=>"instance", :paths=>["/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/version.rb:58"]},
       {:name=>"is_a?", :context=>"instance", :paths=>["/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/version.rb:58"]},
       {:name=>"zero?", :context=>"instance", :paths=>["/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/decode.rb:70", "/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/jwa.rb:48"]}]},
    {:source_name=>"JWT::Base64", :method_ids=>[{:name=>"url_decode", :context=>"class", :paths=>["/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/x5c_key_finder.rb:46"]}]},
    {:source_name=>"JWT::Claims::Error", :method_ids=>[{:name=>"new", :context=>"class", :paths=>["/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/claims/verifier.rb:45"]}]},
    {:source_name=>"JWT::Claims::Numeric", :method_ids=>[{:name=>"validate_is_numeric", :context=>"instance", :paths=>["/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/claims/numeric.rb:61"]}]},
    {:source_name=>"JWT::Claims::Required", :method_ids=>[{:name=>"raise", :context=>"instance", :paths=>["/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/claims/required.rb:21"]}]},
    {:source_name=>"JWT::Claims::Verifier", :method_ids=>[{:name=>"verify_one!", :context=>"class", :paths=>["/Users/kihaya/workspace/GitRepos/ruby-jwt/lib/jwt/claims/verifier.rb:45"]}]},
    {:source_name=>"JWT::Configuration::Container",

特定のクラスの依存関係を確認する

ここまでで、テストとしてカバーされた全てのクラス間の依存関係が得られたはずなので、特定のクラスの依存・被依存関係を解析することができる。試しにJWT::Tokenクラスの依存関係を確認できるようにしてみよう。

kihaya@kihaya-Mac-Studio ~/w/G/ruby-jwt (main)> bundle exec ruby analyze.rb JWT::Token
〜〜長い出力〜〜
参照元
JWT::Claims::Numeric
JWT::Encode
JWT::JWA::Ecdsa
JWT::JWA::Hmac
JWT::JWA::None
JWT::JWA::Ps
JWT::JWA::Rsa
JWT::JWA::Unsupported
JWT::JWA::Wrapper
Object
Coverage report generated for Job Gemfile to /Users/kihaya/workspace/GitRepos/ruby-jwt/coverage.
Line Coverage: 95.2% (1130 / 1187)

specファイルを特定する

RSpecのテストケースは、minitestなどの他のテストフレームワークと異なり、クラスとして定義されないので、クラス名からそのテスト対象をプログラム的に特定するのは容易ではない。アプリケーションと同等の構造がspec/ 配下に用意されていて、テストケースは*_spec.rb というファイル名で配置するという規約がよくみられるので、そういったディレクトリ構造から自動的にspecファイルを特定する方法は考えられそうである。RSpecのテストケースは、RSpec.describe のDSLで定義されることが多いので、ここではヒューリスティック(力技)に実行すべきspecファイルを特定してみよう。この方法でもおおよそ対象となるspecファイルを特定できた。後はいつも通りこれらのspecファイルを実行するだけで良い。

kihaya@kihaya-Mac-Studio ~/w/G/ruby-jwt (main)> cat specfind.rb
x = %w(
  JWT::Claims::Numeric
  JWT::Encode
  JWT::JWA::Ecdsa
  JWT::JWA::Hmac
  JWT::JWA::None
  JWT::JWA::Ps
  JWT::JWA::Rsa
  JWT::JWA::Unsupported
  JWT::JWA::Wrapper).map do |klass_name|
    `git grep -e 'describe #{klass_name}'`.split(":")[0]
  end.compact
p x
kihaya@kihaya-Mac-Studio ~/w/G/ruby-jwt (main)> bundle exec ruby specfind.rb
["spec/jwt/claims/numeric_spec.rb", "spec/jwt/encoded_token_spec.rb", "spec/jwt/jwa/ecdsa_spec.rb", "spec/jwt/jwa/hmac_spec.rb", "spec/jwt/jwa/ps_spec.rb", "spec/jwt/jwa/rsa_spec.rb"]

まとめ

diver_down gem(Tracepoint)を利用してRubyプログラム中の依存関係を動的に解析し、関連するクラスのテストケースを実行できるかどうかを確認することができた。動的解析はリッチな計算資源を持つCIで高速に実行して、解析結果をローカルでのテスト実行の効率化に使うなどの面白い利用方法が考えられそうである。

末筆ではありますが、note株式会社ではエンジニアを募集しています。プログラムの動的解析の可能性を信じるエンジニアのご応募を心よりお待ちしております。

▼さらにnoteの技術記事が読みたい方はこちら


いいなと思ったら応援しよう!