行動すれば次の現実

ほどよくモダンなシステム開発を目指しています。メインテーマは生産性、Ruby、Javascriptです。

Sidekiqで実装したWorkerをRSpecでテストする

Sidekiqで実装したWorkerクラスのRSpecでテストする方法をまとめました。

SidekiqをRSpecでテストする方法

Sidekiqのテスト手法には主に以下の2種類があります。

Sidekiq::Testing.fake!を使用したテスト

キューへのジョブ登録(エンキュー)から、ジョブの実行までの流れをテストしたい場合などに適しています。 非同期処理を見立てたテストケースを実行することができます。

Sidekiq::Testing.inline!を使用したテスト

キューには登録せずにジョブを即時実行します。 同期処理のような動きになりますので、ジョブを実行した結果をテストしたい場合などに適しています。

Sidekiq::Testing.fake!のテストコード

Sidekiq::Testing.fake!はジョブをエンキューする際に、redisを使用するのはなく、redisに見立てた仮想的なキューとして配列(Array)を使用します。

# ワンライナーでの記法
expect {
  SampleWorker.perform_async(10)
}.to change(SampleWorker.jobs, :size).by(1)

# 上記と同じ内容のテストです
assert_equal 0, SampleWorker.jobs.size
SampleWorker.perform_async(10)
assert_equal 1, SampleWorker.jobs.size

上記はジョブをエンキューするまでのテストになります。 ジョブ自体はまだ実行されていません。

drainでキューに積まれたジョブを実行することができます。

assert_equal 0, SampleWorker.jobs.size
SampleWorker.perform_async(10)
assert_equal 1, SampleWorker.jobs.size
SampleWorker.drain # ジョブが実行されます
assert_equal 0, SampleWorker.jobs.size

SampleWorkerが実際に実行されますので、必要に応じてテストデータを用意したりmock化したりする必要があります。

Sidekiq::Testing.fake!を使用していると、キューに未実行のジョブが残ってしまうことがあります。 そのためテスト毎に下記のようにキューをクリアする必要があります。

Sidekiq::Worker.clear_all

以上を踏まえると、RSpecの全体像はこのような形になるかと思います。

require 'rails_helper'
require 'sidekiq/testing'
RSpec.describe SampleWorker, type: :worker do
  describe 'testing worker' do
    before do
      Sidekiq::Worker.clear_all
    end
    example 'エンキューされたジョブが実行されること' do
      Sidekiq::Testing.fake!
      expect { SampleWorker.perform_async }.to change { SampleWorker.jobs.size }.by(1)
      SampleWorker.drain # ジョブの実行
      assert_equal 0, SampleWorker.jobs.size
      # 実行結果の確認などあれば適宜記述
    end
  end
end

Sidekiq::Testing.inline!のテストコード

Sidekiq::Testing.inline!はキューには登録しないため即座に実行されます。

RSpec.describe SampleWorker, type: :worker do
  describe 'testing worker' do
    example 'ジョブの結果が正しいこと' do
      Sidekiq::Testing.inline! do
        SampleWorker.perform_async
        # ここに想定結果を記述します。
      end
    end
  end
end

同期処理のような振る舞いになりますので、ジョブが実行された結果をassertしたい場合などに適しています。

参考

Testing · mperham/sidekiq Wiki · GitHub

Turbolinksとcookieは相性が悪い

Javascriptでcookieに値を設定する処理を実装していたのですが、タイミングによって同じcookie名で重複してvalueが登録される事案が発生しました。 調査するとどうやらTurbolinksが原因であることがわかりました。

Turbolinksを有効にするとcookieのpathに適切な値が設定されないことがある

/customersというページで以下のようなJavascriptを実行していたとします。

<script type="text/javascript">
  $(function () {
    document.cookie = 'key=value'
  });
</script>

Developerツールで確認するとCookieは以下のように設定されます。

Name:   key
Value:  value
Domain: localhost
Path:   /

その後、/customers/newというページにアクセスした後に、画面内に配置されたリンクから/customersに遷移するとどうなるでしょうか?

先程と同じ結果になるかと思いきや、Cookieの中身は以下のように設定されていました。

Name:   key
Value:  value
Domain: localhost
Path:   /

# 異なるpathでcookieが重複登録されてしまう
Name:   key
Value:  value
Domain: localhost
Path:   /customers/new

これは、Turbolinksによる画面遷移時には、Cookieに設定するpathが前の画面URLのままであることが原因です。 そのため、Turbolinksの有効時はcookieにpathを設定する必要があります。

<script type="text/javascript">
  $(function () {
    document.cookie = 'key=value; path=/'
  });
</script>

このようにすることで異なるpathでcookeが重複登録されてしまうという事態を避けることができます。

HerokuアプリのStackを18から20にアップグレード

Herokuのstack-18からstack-20へのアップグレードしました。その際に行ったことをまとめましたので、ぜひ参考にしてみてください。

stackのアップグレードとは?

stackのアップグレードとはOSのバージョンアップのようなものです。 stackとは、Herokuで使用されるOSのイメージのことであり、Ubuntuなどの既存のオープンソースのLinux ディストリビューションが基になっています。

stackをアップグレードする前に必ずテストしよう

いきなりstackをアップグレードするのではなく、以下のいずれかの方法で動作確認してからアップグレードすることをオススメします。 私はステージングでアップグレードして動作確認を行いました。

  1. 既存のステージング環境または開発環境でアップグレードする
  2. 既存アプリを複製したテスト用アプリを作成してアップグレードする

アップグレード手順

1.アプリのstackにheroku-20を設定します。次のデプロイから適用されるようになります。

$ heroku stack:set heroku-20 -a <アプリ名>

2.デプロイ用の空コミットを作成します。

$ git commit --allow-empty -m "Upgrading to heroku-20"

3.デプロイします。

$ git push heroku master

stackをアップグレードしたら動作確認も忘れずに

アップグレード後は全ての機能が正常に動作するか一通り動作確認しましょう。

万が一、問題が発生した場合は以下の手順でロールバックを行います。

$ heroku releases -a <アプリ名>
v335  Deploy 0040f033
v334  Deploy 6448f534
v333  Deploy e424e234

以前のリリースバージョンを指定してロールバックします

heroku rollback v335

参考

Upgrading to the Latest Stack | Heroku Dev Center

Rubyのヒアドキュメント記法

一番ノーマルなヒアドキュメント

終端を示す識別子にはスペースなどの文字列を記述してはいけないので、階層が深いと見づらく感じることがあります。

query = <<SQL
  select *
    from users;
SQL

p query #=> "  select *\n    from users;\n"
=> 

階層が深い場合のヒアドキュメント

識別子の頭にハイフン「-」をつけることで終端を行頭に置くことが可能です。

def get_user
  <<-SQL
    select *
      from users;
  SQL
end

p get_user #=> "  select *\n    from users;\n"

RailsでPostgreSQLの配列型の項目に対して検索をする

PostgreSQLの配列型に対して「〜を含む」という検索したかったので調べました。

配列型はアンチパターンなのですが、そこは目を瞑ってください。。

環境

  • ruby 2.7.4
  • rails 5.2.5
  • PostgreSQL 13.4

Migration

ブログ(Blogs)に複数のタグ(tags)を付与できるテーブルを作成します

class CreateBlogs < ActiveRecord::Migration[5.2]
  def change
    create_table :order_cycle_types do |t|
      t.references :user, foreign_key: true
      t.string :title, null: false
      t.text :detail, null: false
      t.text :tags, array: true

      t.timestamps
    end
  end
end

実装

railsというタグを保持しているブログを検索する場合

  • SQLで書くと
select * from blogs where 'rails' = ANY(tags)
  • Railsで書くと
Blog.where("'rails' = ANY(tags)")

AWS SDK for Rubyを使ってS3上のファイル存在チェックをする方法

AWS SDK for Rubyを使用して、S3上に特定のファイルが存在するかどうかチェックする方法をまとめました。

前提

AWS SDK for Ruby Version 3のaws-sdk-s3を使用します。

github.com

実装

# 事前に初期化しておく
@s3_client = Aws::S3::Client.new

def exists?(key)
  @s3_client.head_object(key)
  true
rescue StandardError
  false
end

Aws::S3::Client#head_objectというメソッドを使用します。 head_objectとはオブジェクトのメタ情報のみを取得するメソッドです。 get_objectだとオブジェクト自体を取得してしまいますが、head_objectだと取得するのはファイルのメタ情報のみですので軽量でオススメです。

rescue StandardErrorしているのは、ファイルが存在しない場合にAws::S3::Errors::NotFoundが発生するためです。権限エラー時は別の例外クラスが発生したりしますので、一律StandardErrorとしてキャッチしています。

迷ったら公式リファレンスをチェックしよう

「Aws::S3::Clientを使用して他のことをやりたいんだけど、サンプルソースが見つからない」という場合は公式リファレンスをチェックすると良いでしょう。

https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3/Client.html にアクセスすると、使用できるメソッドの一覧が表示されます。単語でページ内検索をして、やりたいことに近いメソッドを検索しましょう。

AWS SDKはS3:Client以外のもさまざまなクラスが存在します。他のクラスの仕様を確認したい場合は、URLのAws/S3/Client.htmlのところを該当のクラスに変更すればAPIリファレンスが確認できます。

RubyのAWS SDKを使用してS3からファイルをダウンロードするときのエンコーディング問題

S3 bucketにあるファイルをダウンロードしてTempfileに書き込みしようとしたところ以下のエラーが発生しました。

 Encoding::UndefinedConversionError:
       "\xE5" from ASCII-8BIT to UTF-8

その時のコードは以下の通りです

s3_client = Aws::S3::Client.new
Tempfile.open(file_name) do |file|
  s3_client.get_object({ bucket: 'your-bucket', key: 'your-key' }, target: file)
  file
end

ファイルはCSV形式でエンコーディングはUTF-8なのですが、ASCII-8BITとして見なされているようです。 原因と対処方法を整理したので、同様のことで悩んでいる方はぜひ参考にしてみてください。

エラーの原因

ASCII-8BITとはバイトストリームを取り扱う際に使用される特殊なエンコーディングです。S3上のファイルはエンコーディングがUTF-8のCSVファイルなのですが、S3からget_objectする際はバイナリデータとしてダウンロードされます。

バイナリデータをTempfileに焼き付けようとしたためEncoding::UndefinedConversionErrorのエラーが発生したようです。

Tempfileは画像などのバイナリデータをIO.binmodeとしてバイナリモードにする必要があります。

対処方法

コードを下記に変更することで解決しました。

s3_client = Aws::S3::Client.new
Tempfile.open(file_name) do |file|
  s3_client.get_object({ bucket: 'your-bucket', key: 'your-key' }, target: File.open(file, 'wb'))
  file
end

バイナリ形式の書き込みモード(wb)でFile.openしてあげてからファイルを書き込むようにします。 こうすることでTempfileへのバイナリストリームでの書き込みが可能となります。

もう一つの方法として、以下のようにTempfileをbinmodeにしたものを使用して書き込む方法もあります。 今回はタイプ数の少ないFile.openの方を採用しましたが、こちらの対応のほうが一般的のようです。

temp = Tempfile.new('temp.csv')
temp.binmode
File.read(temp.path, encoding: "bom|utf-8")

参考

ruby - File encoding issue when downloading file from AWS S3 - Stack Overflow