2020年7月3日金曜日

Ruby (test/unit) でモンキーパッチを当てる方法を考える

概要

主にテスト時に当てることを想定しています
基本はオープンクラスを使う感じにはなると思います
prepend や delegate を使ってもできるかもしれませんが今回はオープンクラスを使った方法を考えます

環境

  • macOS 10.15.5
  • Ruby 2.7.1p83

サンプルコード

メインのコードです
外部にアクセスすることを想定して作っています
理由は net/http 自体にモンキーパッチを当てるケースがあることを想定しているためです

  • vim lib/user.rb
require 'net/https'

class User
  def initialize(name, age)
    @name = name
    @age = age
  end

  def hello
    "#{@name},#{@age}"
  end

  def access
    uri = URI.parse 'https://kaka-request-dumper.herokuapp.com/'
    http = Net::HTTP.new(uri.host, uri.port)
    http.use_ssl = true
    req = Net::HTTP::Get.new uri.request_uri
    res = http.request req
    res.body
  end
end

テストコード

テストコードを複数のファイルに分けて書く場合を想定しています
メインのテストファイルを書いてあとは test ディレクトリ配下に test_hogehoge.rb のようなファイルを作成していきます

  • vim test/run_test.rb
base_dir = File.expand_path(File.join(File.dirname(__FILE__), ".."))
lib_dir  = File.join(base_dir, "lib")
test_dir = File.join(base_dir, "test")

$LOAD_PATH.unshift(lib_dir)

require 'test/unit'

exit Test::Unit::AutoRunner.run(true, test_dir)
  • vim test/test_user.rb
require 'user'
require 'json'

class TestUser < Test::Unit::TestCase
  def test_hello
    u = User.new("hawksnowlog", 10)
    assert_equal("hawksnowlog,5", u.hello)
  end

  def test_acccess
    u = User.new("hawksnowlog", 10)
    assert_equal(true, JSON.parse(u.access).has_key?("key"))
    # assert_nothing_thrown {
    #   JSON.parse(u.access)
    # }
  end
end

実行

すべて失敗することを確認しましょう
ここからモンキーパッチを当ててテストを成功するようにしてみます

  • ruby test/run_test.rb

パッチを当ててみる (単純にクラスをオープンするだけ)

テストコード内でオープンすればアプリ側に影響はないのでこれが一番単純です
ruby のオープンクラスはグローバルなのですべてのテストに影響する点に注意しましょう

  • vim test/test_user.rb
require 'user'
require 'json'

class User
  def hello
    "hawksnowlog,5"
  end

  def access
    '{"key":"value"}'
  end
end

class TestUser < Test::Unit::TestCase
  def test_hello
    u = User.new("hawksnowlog", 10)
    assert_equal("hawksnowlog,5", u.hello)
  end

  def test_acccess
    u = User.new("hawksnowlog", 10)
    assert_equal(true, JSON.parse(u.access).has_key?("key"))
  end
end

net/http にパッチを当てるには

既存のクラスにパッチを当ててみます
愚直にやるのであれば先ほどと同じように既存のクラスを開いてメソッドを再定義すれば OK です

  • vim test/test_user.rb
require 'user'
require 'json'

class User
  def hello
    "hawksnowlog,5"
  end
end

module Net
  class HTTP
    def request(req, data = nil)
      HTTPResponse.new(nil, nil, nil)
    end
  end

  class HTTPResponse
    def body
      '{"key":"value"}'
    end
  end
end

class TestUser < Test::Unit::TestCase
  def test_hello
    u = User.new("hawksnowlog", 10)
    assert_equal("hawksnowlog,5", u.hello)
  end

  def test_acccess
    u = User.new("hawksnowlog", 10)
    assert_equal(true, JSON.parse(u.access).has_key?("key"))
  end
end

そもそもパッチは別ディレクトリで管理したほうがいい

パッチをテストと同じファイルに書いていましたが管理しやすいように別ディレクトリにしたほうが良いでしょう
また後で紹介しますが alias でパッチを当てる前の古いメソッドを保存しておきます

  • vim monkeypatch/patch.rb
module Net
  class HTTP
    alias old_request request
    def request(req, data = nil)
      HTTPResponse.new(nil, nil, nil)
    end
  end

  class HTTPResponse
    alias old_body body
    def body
      '{"key":"value"}'
    end
  end
end

そしてこの monkeypatch/ ディレクトリを run_test.rb でロードしていつでもパッチを当てられるようにしておきます

  • vim test/run_test.rb
base_dir = File.expand_path(File.join(File.dirname(__FILE__), ".."))
lib_dir  = File.join(base_dir, "lib")
patch_dir = File.join(base_dir, "monkeypatch")
test_dir = File.join(base_dir, "test")

$LOAD_PATH.unshift(patch_dir)
$LOAD_PATH.unshift(lib_dir)

require 'test/unit'

exit Test::Unit::AutoRunner.run(true, test_dir)

こうすることで以下のような感じでテスト時にパッチを require することができます
ただ前述の通りオープンクラスはグローバルに影響するためどこで require しても影響はグローバルに及ぶことを認識しておきましょう

  • vim test/test_user.rb
require 'user'
require 'json'

class TestUser < Test::Unit::TestCase
  def test_hello
    u = User.new("hawksnowlog", 10)
    assert_equal("hawksnowlog,10", u.hello)
  end

  def test_acccess
    # これ以降のテストもパッチは当たった状態になる
    require 'patch'
    u = User.new("hawksnowlog", 10)
    assert_equal(true, JSON.parse(u.access).has_key?("key"))
  end
end

パッチを外すには

ではグローバルに当たってしまったパッチを外すにはどうするか考えます
一番簡単そうに実現できそうなのは alias を使う方法かなと思います
解除する場合を考慮してパッチを当てる前のメソッドを残しておきそれを後からアンパッチします
alias は先程 patch.rb で定義したのでアンパッチ用のファイルを作成します

  • vim monkeypatch/unpatch.rb
module Net
  class HTTP
    alias request old_request
  end

  class HTTPResponse
    alias body old_body
  end
end

あとはパッチを外したいテストのときに require すれば OK です

  • vim test/test_user.rb
require 'user'
require 'json'

class TestUser < Test::Unit::TestCase
  def test_hello
    u = User.new("hawksnowlog", 10)
    assert_equal("hawksnowlog,10", u.hello)
  end

  def test_acccess
    require 'patch'
    u = User.new("hawksnowlog", 10)
    assert_equal(true, JSON.parse(u.access).has_key?("key"))
  end

  def test_acccess2
    require 'unpatch'
    u = User.new("hawksnowlog", 10)
    assert_equal(true, JSON.parse(u.access).has_key?("body"))
  end
end

最後に

Ruby でオープンクラスを使ったモンキーパッチの当て方を考えてみました
基本的にはモックと同じようにテストや開発時のデバッグに使うことが多いかなと思います
オープンクラスを使った場合のポイントはスコープがグローバルになるということをしっかりと理解して使いましょう
またプロダクションのコードで同じようにモンキーパッチを当てるようなことをする場合はアプリ全体に影響を及ぼす可能性があるので注意して使いましょう

実は過去に rspec-mock という rspec 用のモックライブラリの使い方を紹介しています
すでに rspec を使っているのであればこっちを使ったほうがテスト専用なので安心して使えるかなと思います

参考サイト

0 件のコメント:

コメントを投稿