Webmockを使って、OAuth認証のあるTwitter Gemの処理をモックする
昨日フィヨルドブートキャンプでペアプログラミングを行い、OAuth認証のある(2回通信を行っている)Gemの処理をモックしたのでその内容についてまとめました。
目次
- 目次
- 【重要】追記:Header情報にAuthorizationを記述してはいけません
- 環境
- まずは実装を用意
- テスト書きはじめ
- まずは失敗しないテストを作成
- 下準備として、APIのレスポンスを用意
- APIのIDとSECRETを偽物に変更する
- test_helperにWebmockの設定を追加
- ここでようやくWebmockを作成
- Twitter Developer公式のガイドによるとheadersの指定がある
- テストの to_return の内容を、以下のように書き換える
- 2つ目のエラー「WebMock::NetConnectNotAllowedError」
- 2つ目のエラーに書いてある通り、コードを追加
- 3つ目のエラー「NoMethodError: undefined method `fetch' for "":String」が発生
- APIのレスポンスを修正
- テストにassertの追加
- リファクタリング
- 完成したコード
- 備考
【重要】追記:Header情報にAuthorizationを記述してはいけません
ブログの記事の中にも書いたのですが、Webmockのエラー内容通りに作業を行っているとテストにAuthorization情報を記載してしまう可能性があります。
AuthorizationはBase64というエンコード(暗号化ではない)処理を行ったAPIのIDとSECRETを記載してしまっているので、公開(Gitログに残った状態でGitHubにPushしてしまうなど)してしまうとAPIのID&SECRETが流出してしまいます。 ※私はこのブログで既に一度やらかしてしまいました・・・
十分扱いには注意しましょう。
環境
今回使用する主なRuby, Gemのバージョンです。
まずは実装を用意
モデルに以下内容のファイルを用意しました。
Twitter::REST::Client
を作成してから、#search
するという簡単な実装です。
app/models/TweetRepository.rb
# frozen_string_literal: true class TweetRepository def initialize(twitter_app_id, twitter_app_secret) @twitter_app_id = twitter_app_id @twitter_app_secret = twitter_app_secret end def search(params) search_tweets end private def client @client ||= Twitter::REST::Client.new( consumer_key: @twitter_app_id, consumer_secret: @twitter_app_secret ) end def search_tweets client.search( "s4na_penguin", count: 2, result_type: "recent", until: "2020-02-19", exclude: "retweets", since_id: nil).to_h end end
テスト書きはじめ
下準備が完了したので、テストを書いていきましょう。
まずは失敗しないテストを作成
以下ファイルを作成します。 実行して、結果が正常に終了することを確認します。
test/models/tweet_repository_test.rb
# frozen_string_literal: true require "test_helper" require "webmock/minitest" class TweetRepositoryTest < ActiveSupport::TestCase test "#search" do TweetRepository.new(ENV["TWITTER_APP_ID"], ENV["TWITTER_APP_SECRET"]).search end end
下準備として、APIのレスポンスを用意
モックする際にレスポンスが必要になります。 以下手順に従って用意しましょう。 ※あとで用意してもいいのですが、一度設定したwebmockをオフにする必要があるので少し複雑になります
search_tweets
をpp search_tweets
と編集- テストを一度実行
- コンソールの出力結果をファイルに保存
APIのIDとSECRETを偽物に変更する
【重要】そのまま本番の環境変数を実行してしまうと、外部に流出する恐れがあるので書き換えます。
test/models/tweet_repository_test.rb
# 変更前 TweetRepository.new(ENV["TWITTER_APP_ID"], ENV["TWITTER_APP_SECRET"]).search # 変更後:引数を変更しています。引数は数が合えばなんでもOKです👍 TweetRepository.new("ID", "SECRET").search
test_helperにWebmockの設定を追加
test/test_helper.rb
# require "rails/test_help" の下あたりに👇を追加 require "webmock/minitest"
追加した後で実行すると、テストが失敗するようになります。
エラー例
E Error: TweetRepositoryTest#test_#search: WebMock::NetConnectNotAllowedError: Real HTTP connections are disabled. ...長いので以下省略
ここでようやくWebmockを作成
特に制約のないAPIへのモックだと、エラーメッセージに書いてある You can stub this request with the following snippet:
より下の行をコピーして貼り付けることでWebmockが完了するのですが、Twitter Gemはそうではありません。
追加してテストしてみると、このようなエラーメッセージが表示されます。
E Error: TweetRepositoryTest#test_#search: HTTP::Error: Unknown MIME type: app/models/tweet_repository.rb:22:in `search_tweets' app/models/tweet_repository.rb:10:in `search' /test/models/tweet_repository_test.rb:20:in `block in <class:TweetRepositoryTest>'
ではどうすればいいのか?🤔
Twitter Developer公式のガイドによるとheadersの指定がある
Twitter Developer/Authentication
ドキュメントの Example response:
によれば、以下のように書いてあればいいそうです。
HTTP/1.1 200 OK Status: 200 OK Content-Type: application/json; charset=utf-8 ... Content-Encoding: gzip Content-Length: 140 {"token_type":"bearer","access_token":"AAAA%2FAAA%3DAAAAAAAA"}
テストの to_return
の内容を、以下のように書き換える
書き換えたらテストを実行しましょう。
to_return( status: 200, body: { "token_type": "bearer", "access_token": "AAAA%2FAAA%3DAAAAAAAA" }.to_json, headers: { "Content-Type": "application/json; charset=utf-8" }, )
2つ目のエラー「WebMock::NetConnectNotAllowedError」
以下のようなエラーが表示されます。 これは1つ目のエラーとは別のエラーです。
E Error: TweetRepositoryTest#test_#search: WebMock::NetConnectNotAllowedError: Real HTTP connections are disabled. Unregistered request: GET https://api.twitter.com/1.1/search/tweets.json?count=2&exclude=retweets&q=s4na_penguin&result_type=recent&since_id&until=2020-02-19 with headers {'Authorization'=>'Bearer AAAA%2FAAA%3DAAAAAAAA', 'Connection'=>'close', 'Host'=>'api.twitter.com', 'User-Agent'=>'TwitterRubyGem/6.2.0'} You can stub this request with the following snippet: stub_request(:get, "https://api.twitter.com/1.1/search/tweets.json?count=2&exclude=retweets&q=s4na_penguin&result_type=recent&since_id&until=2020-02-19"). with( headers: { 'Connection'=>'close', 'Host'=>'api.twitter.com', 'User-Agent'=>'TwitterRubyGem/6.2.0' }). to_return(status: 200, body: "", headers: {}) registered request stubs: stub_request(:post, "https://api.twitter.com/oauth2/token"). with( body: {"grant_type"=>"client_credentials"}, headers: { 'Accept'=>'*/*', 'Connection'=>'close', 'Content-Type'=>'application/x-www-form-urlencoded', 'Host'=>'api.twitter.com', 'User-Agent'=>'TwitterRubyGem/6.2.0' }) ============================================================ app/models/tweet_repository.rb:22:in `search_tweets' app/models/tweet_repository.rb:10:in `search' /test/models/tweet_repository_test.rb:24:in `block in <class:TweetRepositoryTest>'
2つ目のエラーに書いてある通り、コードを追加
【重要】Authorization情報は追加してはいけません。もし本番の環境変数を適応したまま行った場合、エンコードされたAPIのIDとSECRET情報が含まれてしまっています。
エラーに書いてある内容のうち「Authorization」を除いてTweetRepositoryTest.rbに追記を行います。
1つ目のモックと同じく、headersに "Content-Type": "application/json; charset=utf-8"
を追記します。
stub_request(:get, "https://api.twitter.com/1.1/search/tweets.json?count=2&exclude=retweets&q=s4na_penguin&result_type=recent&since_id&until=2020-02-19"). with( headers: { 'Connection'=>'close', 'Host'=>'api.twitter.com', 'User-Agent'=>'TwitterRubyGem/6.2.0' }). to_return(status: 200, body: "", headers: { "Content-Type": "application/json; charset=utf-8" }) # 👇の上に追加 TweetRepository.new(ENV["TWITTER_APP_ID"], ENV["TWITTER_APP_SECRET"]).search
3つ目のエラー「NoMethodError: undefined method `fetch' for "":String」が発生
bodyのレスポンスがjsonではなくstringなため、エラーが発生しています
E Error: TweetRepositoryTest#test_#search: NoMethodError: undefined method `fetch' for "":String app/models/tweet_repository.rb:22:in `search_tweets' app/models/tweet_repository.rb:10:in `search' /test/models/tweet_repository_test.rb:34:in `block in <class:TweetRepositoryTest>'
APIのレスポンスを修正
ここで「下準備として、APIのレスポンスの用意」で作成したAPIのレスポンスを使います!
bodyに「APIのレスポンス + 末尾に .to_json
」を追記します。
長くなるのですが、まずはテストのグリーンを目指しているので保留します。
to_return(status: 200, body: {:statuses=> [{:created_at=>"Tue Feb 18 17:48:37 +0000 2020", :id=>1229825058098466816, :id_str=>"1229825058098466816", :text=>"@s4na_penguin ありがとうございます!", :truncated=>false, :entities=> {:hashtags=>[], :symbols=>[], :user_mentions=> [{:screen_name=>"s4na_penguin", :name=>"s4na🐧/ Nabetani そうだ!Rubyを書こう!", :id=>1072823223190835201, :id_str=>"1072823223190835201", :indices=>[0, 13]}], :urls=>[]}, # 長い為省略 :search_metadata=> {:completed_in=>0.016, :max_id=>1229825058098466816, :max_id_str=>"1229825058098466816", :next_results=> "?max_id=1229693053872431103&q=s4na_penguin%20until%3A2020-02-19%20exclude%3Aretweets&count=2&include_entities=1&result_type=recent", :query=>"s4na_penguin+until%3A2020-02-19+exclude%3Aretweets", :refresh_url=> "?since_id=1229825058098466816&q=s4na_penguin%20until%3A2020-02-19%20exclude%3Aretweets&result_type=recent&include_entities=1", :count=>2, :since_id=>0, :since_id_str=>"0"}}.to_json, headers: { 'Content-Type': "application/json; charset=utf-8", })
テストにassertの追加
テストの最下行を以下のように書き換えてテストを実行しましょう!
テストが成功すれば、グリーンです。
※なぜ tweets[:statuses].first[:text]
という書き方をするのかわからなければ、 p
でtweets
, tweets[:status]
・・・という感じで出力してみるのがおすすめです👍
before
tweets = TweetRepository.new(ENV["TWITTER_APP_ID"], ENV["TWITTER_APP_SECRET"]).search
after
tweets = TweetRepository.new(ENV["TWITTER_APP_ID"], ENV["TWITTER_APP_SECRET"]).search assert_equal "@s4na_penguin ありがとうございます!", tweets[:statuses].first[:text]
リファクタリング
長いところをリファクタリングしていきましょう。
今回利用しているのは test
とTwitter Gem側で ID
なので、最終的に以下のように省略できます。
※IDが必要なことは消すとエラーになり、追加したら正常終了していたので推測しました。
body: { statuses: [{ id: 1229825058098466816,text: "@s4na_penguin ありがとうございます!" }] }.to_json,
完成したコード
test/models/tweet_repository_test.rb
# frozen_string_literal: true require "test_helper" require "webmock/minitest" class TweetRepositoryTest < ActiveSupport::TestCase test "#search" do stub_request(:post, "https://api.twitter.com/oauth2/token"). with( body: { "grant_type"=>"client_credentials" }, headers: { "Accept"=>"*/*", "Connection"=>"close", "Content-Type"=>"application/x-www-form-urlencoded", "Host"=>"api.twitter.com", "User-Agent"=>"TwitterRubyGem/6.2.0" }). to_return( status: 200, body: { "token_type": "bearer", "access_token": "AAAA%2FAAA%3DAAAAAAAA" }.to_json, headers: { "Content-Type": "application/json; charset=utf-8" }, ) stub_request(:get, "https://api.twitter.com/1.1/search/tweets.json?count=2&exclude=retweets&q=s4na_penguin&result_type=recent&since_id&until=2020-02-19"). with( headers: { "Connection"=>"close", "Host"=>"api.twitter.com", "User-Agent"=>"TwitterRubyGem/6.2.0" }). to_return( status: 200, body: { statuses: [{ id: 1229825058098466816, text: "@s4na_penguin ありがとうございます!" }] }.to_json, headers: { 'Content-Type': "application/json; charset=utf-8" } ) tweets = TweetRepository.new("ID", "SECRET").search assert_equal "@s4na_penguin ありがとうございます!", tweets[:statuses].first[:text] end end
test/test_helper.rb
# frozen_string_literal: true ENV["RAILS_ENV"] ||= "test" require_relative "../config/environment" require "rails/test_help" class ActiveSupport::TestCase # Run tests in parallel with specified workers parallelize(workers: :number_of_processors) # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order. fixtures :all # Add more helper methods to be used by all tests here... end
app/models/tweet_repository.rb
# frozen_string_literal: true class TweetRepository def initialize(twitter_app_id, twitter_app_secret) @twitter_app_id = twitter_app_id @twitter_app_secret = twitter_app_secret end def search(params) search_tweets end private def client @client ||= Twitter::REST::Client.new( consumer_key: @twitter_app_id, consumer_secret: @twitter_app_secret ) end def search_tweets client.search( "s4na_penguin", count: 2, result_type: "recent", until: "2020-02-19", exclude: "retweets", since_id: nil).to_h end end
備考
誤字脱字などがあればこちらにご連絡いただけると助かります🙇♂️