新しい discourse_api エンドポイントのテスト構築

Adding some polls API endpoints for PR to discourse_api, which work fine. Now I’m trying to understand how to create tests before submitting the PR, e.g.:

require 'spec_helper'

describe DiscourseApi::API::Polls do
  subject { DiscourseApi::Client.new("http://localhost:3000", "test_d7fd0429940", "test_user" )}

  describe "#polls" do
    before do
      stub_get("http://localhost:3000/polls/voters.json").to_return(body: fixture("voters.json"), headers: { content_type: "application/json" })
    end

    it "requests the correct resource" do
      subject.voters :post_id => 27885, :poll_name => 'poll' 
      expect(a_get("http://localhost:3000/polls/voters.json")).to have_been_made
    end
  end
end

But am getting the error:

   Failed to open TCP connection to localhost:3000 (Connection refused - connect(2) for "localhost" port 3000)
 # ./lib/discourse_api/client.rb:141:in `rescue in request'
 # ./lib/discourse_api/client.rb:132:in `request'
 # ./lib/discourse_api/client.rb:85:in `get'
 # ./lib/discourse_api/api/polls.rb:22:in `voters'
 # ./spec/discourse_api/api/polls_spec.rb:12:in `block (3 levels) in <top (required)>'
 # ------------------
 # --- Caused by: ---
 # Errno::ECONNREFUSED:
 #   Connection refused - connect(2) for "localhost" port 3000
 #   /Users/kimardenmiller/.rbenv/versions/2.5.3/gemsets/d_api/gems/webmock-2.3.2/lib/webmock/http_lib_adapters/net_http.rb:109:in `request'

All the existing tests pass fine for me, and I’m trying to copy the format of those existing tests, but cannot for the life of me figure out what I’m doing wrong.

Here’s the (new) endpoint being tested:

module DiscourseApi
  module API
    module Polls
     def poll_voters(args)
        args = API.params(args)    # post_id, poll_name, user, opts = {}
                  .required(:post_id, :poll_name)
                  .optional(:opts, :api_username)
        response = get("/polls/voters.json", args)
        response[:body]
      end
    end
  end
end

Have working tests, though my gut tells me I could use tips on making those urls more elegant.

require 'spec_helper'

describe DiscourseApi::API::Polls do
  subject { DiscourseApi::Client.new("http://localhost:3000", "test_d7fd0429940", "test_user" )}

  describe "#polls" do
    before do
      stub_get("http://localhost:3000/polls/voters.json?post_id=27885&poll_name=poll").to_return(body: fixture("polls_voters.json"), headers: { content_type: "application/json" })
    end

    it "requests the correct resource" do
      subject.poll_voters post_id: 27885, poll_name: 'poll'
      expect(a_get("http://localhost:3000/polls/voters.json?post_id=27885&poll_name=poll")).to have_been_made
    end

    it "returns the expected votes" do
      voters = subject.poll_voters post_id: 27885, poll_name: 'poll'
      expect(voters).to be_a Hash
      voters.each { |g| expect(g).to be_an Array }
      expect(voters['voters']['e539a9df8700d0d05c69356a07b768cf']).to be_an Array
      expect(voters['voters']['e539a9df8700d0d05c69356a07b768cf'][0]['id']).to eq(356)
    end
    
  end
end

Glad you got the tests working. They can be tricky sometimes because if the request doesn’t match exactly it will try and create an actual request rather that using the stubbed request.

I think the urls are okay. It would be nice not to use such a high post_id, but I’m not concerned about that. And ALL the tests need to have http://localhost:3000 extracted out into a common variable, but that should probably be done in a separate commit. Whenever you are ready you can go ahead and submit a pr and I can review it.

One thing I did notice though is I that passing in the api_username as a parameter is no longer supported.

This is because the discourse_api gem now only passes in the auth via the headers, and discourse core ignores any auth credentials in the body if the header is already being used for auth.

からの会話を継続します。

目標:

  • テスト: http://localhost:3000 を共通変数として抽出する

  • 例: ホスト、ユーザー名、api_key を config.yml ファイルに抽出する

現在、どこに何を配置し、読み込むかを決めています。現時点では discourse_api.rb ファイル(ルートディレクトリに config.yml を配置)に以下のように記述しています:

require 'yaml'
CONFIG = YAML.load_file(File.expand_path('../../config.yml', __FILE__))

…その後、各例のファイルでは:

client = DiscourseApi::Client.new(CONFIG['host'])
client.api_key = CONFIG['api_key']
client.api_username = CONFIG['api_username']

これは例には機能しますが、テストには機能しません。この PR に関するベストプラクティスについて、いくつかのガイダンスをいただければ幸いです。

PR を分割しましょう。まずは仕様の PR に集中し、共通変数を spec フォルダに移動させます。それを spec ヘルパーファイルに追加できるはずです。

@blake 上記のデザインについてご意見があればお知らせください。

これで問題ありません。

これは例示のみであるため、これに関連するものはすべて examples ディレクトリ内に収めるべきです。lib ディレクトリ内の既存の機能を変更してはいけません。config.yml ファイルも examples ディレクトリ内に配置すべきです。YAML ファイルの読み込みは非常に軽量なので、各例ファイルの先頭に追加することもできますが、おそらく examples ディレクトリ内の共通ファイルに追加し、そこからすべてが参照できるようにするのがよいでしょう。

以下が私の手元にあるものです:

examples/example_helper.rb

$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
require File.expand_path('../../lib/discourse_api', __FILE__)
require 'yaml'

CONFIG = YAML.load_file(File.expand_path('../../config.yml', __FILE__))

def client
  discourse_client = DiscourseApi::Client.new(CONFIG['host'])
  discourse_client.api_key = CONFIG['api_key']
  discourse_client.api_username = CONFIG['api_username']
  discourse_client
end

examples/config-example.yml

# host e.g. localhost:3000 or discourse.my_domain.com
host: YOUR_HOST_NAME

# api user (can effect results returned, e.g. .categories method returns only the categories your api_username can see)
# create a new client with when changing api user
api_username: YOUR_API_KEY

# api key from Discourse admin panel /admin/api/keys
api_key: YOUR_API_KEY

examples/badges.rb

require_relative 'example_helper'

# get badges
puts client.badges

ご意見をお聞かせください。example_helper.rb を使用する以上、今 yml ファイルが必要でしょうか?

良さそうです。デフォルト値を設定して、yml ファイルがなくても動作するように例のヘルパーを更新しましょう。その後、README を更新して、例の yml ファイルを examples/config.yml にコピーする手順を追記してください。また、examples/config.yml を .gitignore に追加することを忘れないでください。

これは非常に一般的な慣習であり、本番環境の認証情報を含む config.yml ファイルを誤ってコミットするのを防ぐためです。また、このファイルを変更しても Git が変更として検知しないようにできます。

問題ありませんが、この部分だけが少しわかりません。

example_helper.rb は config.yml が存在すればそのままで動作します。yml ファイルがなくても動作するようにしたいのですが、それでもなお yml ファイルが存在しているのでしょうか?

はい、このようにすれば、YAML ファイルがまだ設定されていなくても Ruby がエラーで停止しません。

正確にはこれではありませんが、以下のような感じです:

  discourse_client = DiscourseApi::Client.new(CONFIG['host'] || 'localhost:3000')
  discourse_client.api_key = CONFIG['api_key'] || 'api-key'
  discourse_client.api_username = CONFIG['api_username'] || 'api-username'

つまり、config.yml ファイルが作成される前には、ダブルパイプのロジックがあっても、以下のエラーが発生します。

`initialize': No such file or directory @ rb_sysopen

パイプが機能するためには、YAML.load_file も調整する必要があります。

はい、それも調整する必要があります。

そのため、以下のコード

config_yml = File.expand_path('../config.yml', __FILE__)

if File.exists? config_yml
  CONFIG = YAML.load_file config_yml
else
  CONFIG = {}
end

def client
  discourse_client = DiscourseApi::Client.new(CONFIG['host'] || 'http://localhost:3000')
  discourse_client.api_key = CONFIG['api_key'] || 'YOUR_API_KEY'
  discourse_client.api_username = CONFIG['api_username'] || 'YOUR_USERNAME'
  discourse_client
end

を実行すると、以下のようなエラーが発生します。

Failed to open TCP connection to localhost:3000 (Connection refused - connect(2) for "localhost" port 3000) (Faraday::ConnectionFailed)

これは、私たちが望むエラーメッセージでしょうか?

そのエラーは問題ないと思います。なぜなら、これは現在の例の動作であり、しかも例の中だけの話だからです。もしよろしければ、今か後か、いつでも例外をキャッチして、役立つメッセージを表示するように変更できます。

もしかして…

config_yml = File.expand_path('../configg.yml', __FILE__)

begin
  CONFIG = YAML.load_file config_yml
rescue Errno::ENOENT
  raise ArgumentError, '/examples/config.yml ファイルが見つかりません。環境用の config.yml を作成するために、config-example.yml をコピーしてください。'
end

def client
  discourse_client = DiscourseApi::Client.new(CONFIG['host'] || 'http://localhost:3000')
  discourse_client.api_key = CONFIG['api_key'] || 'YOUR_API_KEY'
  discourse_client.api_username = CONFIG['api_username'] || 'YOUR_USERNAME'
  discourse_client
end

では、パイプ記号は依然として必要ですか?残しておくべきでしょうか?

はい、それで問題ありません。その場合、パイプは不要ですので、削除して大丈夫です。

現在の「テスト」セクションは少し混乱しています。discourse_api に関連するタスクと Discourse 自体のインストールが混在しているためです。例えば、クラウド上にステージングサーバーがある場合、ローカルに Discourse をインストールする必要は全くありません。以下の案についてどうお考えでしょうか:

例とテスト

例を試したりテストを実行するには、Discourse インスタンスが稼働している 必要があります。これはローカルまたはクラウドのいずれかでインストールされている必要があります。

/examples 内の例を実行するには:

  1. config-example.yml をコピーして config.yml というファイルを作成し、環境設定を指定します。
  2. 特定の例ファイル内で、実行したい例以外をコメントアウトします。
  3. 必要なパラメータ(例:username)を例ファイルで編集します。
  4. ファイルを実行すると、config.yml から設定を読み取り、Discourse インスタンスに対して特定のクライアントメソッド(例:client.badges)を実行します。

テスト

spec 内の discourse_api テストを実行するには:

  1. discourse_api ディレクトリで bundler をインストールします。gem install bundler を実行します。
  2. discourse_api ディレクトリ内で、bundle exec rspec spec/ を実行します。

いいですね、README の更新が必要そうです

テストを実行するには、実際には Discourse インスタンスを起動して動作させる必要はありません。すべての spec リクエストはモック化されており、サーバーは不要です。

ああ、なるほど。では、例えば…

/examples 内の例を実行するには、Discourse インスタンスが起動している 必要があります。ローカルまたはクラウドにインストールしてください。その後、以下の手順に従ってください。

  1. config-example.yml をコピーして config.yml を作成し、環境設定を指定します。
  2. 対象の例ファイル内で、実行したい例以外の行をコメントアウトします。
  3. 必要なパラメータ(例:メソッドに渡す username など)を例ファイルで編集します。
  4. ファイルを実行すると、config.yml から設定を読み取り、Discourse インスタンスに対して指定されたクライアントメソッド(例:client.badges)が実行されます。

テスト

spec 内の discourse_api テストを実行するには:

  1. discourse_api ディレクトリ内で bundler をインストールします。gem install bundler を実行してください。
  2. discourse_api ディレクトリ内で、bundle exec rspec spec/ を実行します。