s4na's blog

s4naのテックブログ

ファーストペンギン

Webmockを使って、OAuth認証のあるTwitter Gemの処理をモックする

昨日フィヨルドブートキャンプでペアプログラミングを行い、OAuth認証のある(2回通信を行っている)Gemの処理をモックしたのでその内容についてまとめました。

目次

【重要】追記: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_tweetspp 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] という書き方をするのかわからなければ、 ptweets, 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]

リファクタリング

長いところをリファクタリングしていきましょう。 今回利用しているのは testTwitter 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

備考

誤字脱字などがあればこちらにご連絡いただけると助かります🙇‍♂️

👉Twitter@s4na_penguin