RSpec で ActiveModel::Errors のモックを作成する

ActiveRecord::RecordInvalid が発生するかどうかをテストするために ActiveModel::Errors のモックを作りたい。

実行環境

ruby 2.6.6
Rails 6.0.3.6

ActiveRecord::RecordInvalid が発生するかどうかのテスト

subjectが実行された際 ActiveRecord::RecordInvalid が発生するかどうかのテストコードを実装します。
なお、subjectは省略していますが、subjectで定義しているものには、user.save! の処理が含まれています。

# Userのインスタンスに対してsave!メソッド実行時にActiveRecord::RecordInvalidがraiseされるようにスタブ化
before { allow_any_instance_of(User).to receive(:save!).and_raise(ActiveRecord::RecordInvalid) }


it { expect { subject }.to raise_error(ActiveRecord::RecordInvalid) }

Model にerrors や class を定義するスタブを作成

つぎに user.save! に失敗した場合、以下のようにエラーメッセージをログに出力するコードが書かれているとします。

Rails.logger.error e.message

その場合に、subjectを実行し ActiveRecord::RecordInvalid が発生した時に、エラーログが出力されるかどうかのテストコードを実装します。

まずテストの前処理としてスタブの定義を行います。

let(:errored_model) do
  instance_double(
    User,
    errors: instance_double('errors', full_messages: ['nameを入力してください'], messages: { name: ['を入力してください'] }),
    class: class_double(User, i18n_scope: :activerecord),
  )
end
before do
  # Userのインスタンスに対してsave!メソッドが実行された時にActiveRecord::RecordInvalidがraiseされるようにスタブ化
  allow_any_instance_of(User).to receive(:save!).and_raise(ActiveRecord::RecordInvalid, errored_model)
  # Rails.loggerのオブジェクトに対してerrorメソッドを呼び出せるようにする
  allow(Rails.logger).to receive(:error)
end

and_raise について

rspec-mocks/lib/rspec/mocks/message_expectation.rb at main · rspec/rspec-mocks · GitHub
こちらにあるように and_raise 内で、raise メソッドが呼び出されているため、raise ActiveRecord::RecordInvalid, userとするように、
and_raise の引数に ActiveRecord::RecordInvalid, user を渡してあげるとよさそうです。

instance_double の errors や class について

User の InstanceDouble で errors を指定していないと以下のようなエラーが発生します。

RSpec::Mocks::MockExpectationError: #<InstanceDouble(User) (anonymous)> received unexpected message :errors with (no args)
from /app/.bundle/ruby/2.6.0/gems/rspec-support-3.10.2/lib/rspec/support.rb:102:in `block in <module:Support>'

ほかにも i18n_scope メソッドが定義されていないと以下のようなエラーが発生します。

NoMethodError: undefined method `i18n_scope' for RSpec::Mocks::InstanceVerifyingDouble:Class
from /app/.bundle/ruby/2.6.0/gems/activerecord-6.0.3.6/lib/active_record/validations.rb:22:in `initialize'

内部的に ActiveRecord において i18n_scope メソッドを呼び出しているようでした。
rails/activerecord/lib/active_record/translation.rb at main · rails/rails · GitHub

最終的なテストコード

let(:errored_model) do
  instance_double(
    User,
    errors: instance_double('errors', full_messages: ['nameを入力してください'], messages: { name: ['を入力してください'] }),
    class: class_double(User, i18n_scope: :activerecord),
  )
end
before do
  allow_any_instance_of(User).to receive(:save!).and_raise(ActiveRecord::RecordInvalid, errored_model)
  allow(Rails.logger).to receive(:error)
end

# 実際にRails.logger.errorが呼び出され、エラーメッセージが引数として渡されることをモックで確認する
it 'エラーログが出力されること' do
  subject
  expect(Rails.logger).to have_received(:error).with("バリデーションに失敗しました: nameを入力してください")
end

モック/スタブ実装のためのメソッド

上記で使用したメソッドを整理します。

そもそも RSpec におけるスタブとモックの違い

スタブは、他のメソッドが実行されないようにして欲しい値を返させるものです。

つまり、テストで確認したい内容のために何か都合のいい値を返すように設定したものをスタブと言います。

モックは、指定のメソッドが実行されたかどうかを、引数や頻度を指定してチェックするものです。

instance_double

特定のクラスに対して double(身代わり)を作成します。
指定したクラスで定義されているインスタンスメソッドのみが、スタブとして許可されます。
ほとんどdoubleと同様だが、allowなどで定義するメソッドが、引数で指定したクラス(今回の User)のインスタンスメソッドとして存在していないとテストが失敗します。

class_double

実際のクラスの情報と紐付いて実際のクラスに実装されていないメソッドのスタブ化はできないようになります。
ほとんどinstance_doubleと同様だが、こちらはクラスメソッドに対して適用されます。

allow_any_instance_of

生成されたインスタンスすべてに対して、スタブを設定できます。

参考にした記事

techracho.bpsinc.jp

zenn.dev

コンテナ間通信でデータベースを共有する

異なるリポジトリのDBコンテナにデータを投入する方法をメモします。
例えば、サービス(言語)ごとにリポジトリを分けていて、DBを共有したいケースに活用できるかと思います。

前提として、AリポジトリをメインとしてDBコンテナを起動します。こちらにBリポジトリで構築した、appコンテナのデータを保存できるようにします。
やることは、コンテナのネットワークを利用して、コンテナ間通信を行います。

手順

まず、Aリポジトリの docker-compose.yml の内容を以下のようにします。(insert-mysqlは例です。)

  db:
   (他の項目については省略)
    networks:
      - default
      - insert-mysql

networks:
  insert-mysql:
    driver: bridge

上記の設定変更を反映します。

$ docker-compose up -d
Creating network "hogehoge_insert-mysql" with driver "bridge"

ここでネットワーク一覧に新しいネットワーク(hogehoge_insert-mysql)が表示されていることを確認します。

$ docker network list
NETWORK ID     NAME                       DRIVER    SCOPE
abcdef123456   hogehoge_default            bridge    local
123456abcdef   hogehoge_insert-mysql       bridge    local
aabbccddeeff   bridge                      bridge    local

また、現時点では上記の新しいネットワークを使用しているコンテナが、AリポジトリのDBコンテナしかないことを確認しておきます。
"Containers"の項目の部分です。

$ docker network inspect hogehoge_insert-mysql

次に、Bリポジトリの docker-compose.yml の編集を行います。

  app:
    (他の項目については省略)
    networks:
      - default
      - hogehoge_insert-mysql
networks:
  hogehoge_insert-mysql:
    external: true

先ほどと同様に設定の変更を反映します。

$ docker-compose up -d

再度hogehoge_insert-mysqlのネットワークを使用しているコンテナの確認をします。
"Containers"の項目に、Bリポジトリのappコンテナが追加されていることが確認できると思います。

$ docker network inspect hogehoge_insert-mysql

これでリポジトリに跨ってDBの共有ができるはずです。

もしコンテナ起動に失敗する場合

既存のコンテナが影響している可能性があるので、一旦全部のネットワークを削除(docker network rm)とdocker-compose downをすると良いかと思います。

参考

はじめdocker network create hoge_networkで手動で新しくネットワークを作成しようとしたのですが、docker-compose.yml に設定することにより、自動作成したかったので、上記の方法にしました。
https://tech-blog.rakus.co.jp/entry/20181211/docker/postgresql/go

Rails のコネクションプールについて( Puma の Worker 数も変えてみた)

Railsのコネクションプール経由で行っているDB接続について、少し調べたのでメモを残します。

実行環境

Rails 6.1.3.1

Connection Pool とは

コネクションプールとは、Railsの処理がデータベースにアクセスするたびにコネクション接続と切断を行って負荷が高くなったり、パフォーマンスが低下するのを防ぐために、予め決められた上限数を考慮してデータベースとの間に作っておく接続のグループのことです。

tech-book.precena.co.jp

データベースに接続した状態を、メモリ上にあらかじめ確保(キャッシュ)しておき、データベースにアクセスするタイミングで、Poolから利用可能な接続を再利用します。
毎回接続して切断するより、DB接続というコストの高い処理をあらかじめ行うことで、DB接続にかかる時間を短縮でき、効率よく処理を行うことができるというメリットがあるとのこと。

Connection pool の設定について

Connection pool は、database.yml で設定します。

default: &default
  adapter: mysql2
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

pool はアプリがデータベースに対して保持できる接続の最大数です。
上記でデフォルトの 5 を設定しています。

また、ActiveRecord はスレッドごとに個別のデータベース接続を使用します。
そのため、puma.rb において、スレッドはデフォルトのまま 5 として pool と同じ値になるようにしています。(スレッド数 = pool 数)

max_threads_count = ENV.fetch('RAILS_MAX_THREADS', 5)
min_threads_count = ENV.fetch('RAILS_MIN_THREADS') { max_threads_count }

さて、接続を使用した後、自動的に解放されますが、使用可能な数を超える接続を使用しようとすると、Active Record によってブロックされ、残りのリクエストはプールの接続の待ち状態になってしまいます。

動作確認

接続待ちの状態になることを実際に見てみたいと思います。
Promise.all() を用いて 10 リクエストを並列実行されるようにしてみます。

Promise.all(
    [...new Array(10)].map((_, i) => {
        api.get(`http://localhost:3000/home/${index + 1}`)
    })
)

Rails側では、リクエストを受け付け、DB処理を行います。
わかりやすいように、処理時間を5秒ほど伸ばします。

class HomeController < ApplicationController
    def index
        @user = User.find params[:id]
        sleep 5
    end
end

先ほど並列実行数(pool)として 5 を設定しているので、10 リクエストのうち残りの 5 つのリクエストが待機されるはずです。

同時接続数 5 の 10 リクエスト実行結果

画像から最後の 5 リクエストは遅れて返ってきていることがわかります。

次に、pool 数を変更してみます。変更されれば単純に実行時間にも影響が出てきます。
pool 数を 3 にしてみます。

default: &default
  adapter: mysql2
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 3 } %>

同時接続数 3 の 10 リクエスト実行結果
10リクエストの中で 3 ずつ並列処理されていることがわかります。

なお、処理時間が長いなどで接続を取得できない場合は、アプリケーションからの接続がタイムアウトになり、例外が発生する可能性があります。

could not obtain a connection from the pool within 5.000 seconds (waited 5.001 seconds); all pooled connections were in use excluded from capture: No host specified, no public_key specified, no project_id specified
Completed 500 Internal Server Error in 5017ms (Views: 5.4ms | ActiveRecord: 0.0ms | Allocations: 158211)

Puma の Worker 数を変更する

Worker 数 ≒ (子)プロセス数と捉え、スレッド数 = pool 数 は 3 でそのままとし、その状態で Worker 数を変更してみます。
puma.rb でコメントアウトされていた以下の記述をアンコメントし有効化します。

workers ENV.fetch("WEB_CONCURRENCY") { 2 }

すると 同時並列数は 3(スレッド数) × 2(worker数) = 6 に増えていることがわかります!

同時接続数 3 の 10 リクエスト実行結果( 2 worker)

参考URL

railsguides.jp

devcenter.heroku.com

qiita.com

RailsとMySQLとでタイムゾーンが異なる場合の検索の仕方

RailsMySQLなどのDBとでタイムゾーンが異なる場合に、データ抽出や検索の際にミスが起こりそうなので自戒の意味を込めてメモします。
なおRailsコンソールで検索する方法について記載しています。

実行環境

ruby 2.6.6
Rails 6.0.3.6

まずはそれぞれにおけるタイムゾーンを確認します。

mysql でのタイムゾーン確認

UTCとなっています。

mysql> show variables like "%time_zone";
+------------------+--------+
| Variable_name    | Value  |
+------------------+--------+
| system_time_zone | UTC    |
| time_zone        | SYSTEM |
+------------------+--------+
2 rows in set (0.00 sec)

mysql> select NOW();
+---------------------+
| NOW()               |
+---------------------+
| 2023-12-17 14:52:01 |
+---------------------+
1 row in set (0.00 sec)

Rails でのタイムゾーン確認

日本時間で検索しています。

pry(main)> Time.zone
=> #<ActiveSupport::TimeZone:0x000055555c991608 @name="Tokyo", @tzinfo=#<TZInfo::DataTimezone: Asia/Tokyo>, @utc_offset=nil>

Rubyコンテナサーバーのタイムゾーン

UTCとなっています。

pry(main)> Time.now
=> "2023-12-17T15:00:03.228+00:00"

Railsコンソールでの検索方法

いくつか方法があるのでそれぞれの違いを見ます。
なお現在時刻は日本時間で2023-12-18 00:00であるとします。

Time(RailsはTimeWithZone)クラスを使用する

Time.zone.now や TIme.zone.now.ago など TimeWithZone クラスのメソッドを使って検索します。

pry(main)> User.where("updated_at > ?", Time.zone.now).to_sql
=> "SELECT `users`.* FROM `users` WHERE (updated_at > '2023-12-17 15:00:47.921823')"

日本時間がUTC(9時間前)に変換されてデータベース検索を行なっていることがわかります。

日時の文字列で指定する

直接日時の文字列を指定して検索します。

pry(main)> User.where("updated_at > ?", '2023-12-18 00:00').to_sql
=> "SELECT `users`.* FROM `users` WHERE (updated_at > '2023-12-18 00:00')"

指定した時間からUTCには変換されていないことがわかります。
なので、UTCに変換してから指定しないと、日本時間に変換されると勘違いしていたら、期待した検索結果が得られないので注意が必要です。

Integerに変換する

Timeクラス(RailsならTimeWithZoneクラス)のオブジェクトを to_i でIntegerに変換したものを指定します。

# Time.zone.now → integer
pry(main)> User.where("updated_at > ?", Time.zone.now.to_i).to_sql
=> "SELECT `users`.* FROM `users` WHERE (updated_at > 1702825200)"

# Time.zone.parse → integer
pry(main)> User.where("updated_at > ?", Time.zone.parse("2023-12-18 00:00:00").to_i).to_sql
=> "SELECT `users`.* FROM `users` WHERE (updated_at > 1702825200)"

# 日本時間 2023-12-18 00:00 です
pry(main)> Time.zone.at(1702825200)
=> Mon, 18 Dec 2023 00:00:00 JST +09:00

Integerに変換しUnixTImeとして扱うことができるようになります。

pry(main)> User.where("updated_at > ?", Time.parse("2023-12-17 15:00:00").to_i).to_sql
=> "SELECT `users`.* FROM `users` WHERE (updated_at > 1702825200)"

上記のように、Time.parse でUTC時間のままTimeオブジェクトを to_i した時と同じ1702825200で検索されています。 そのため、タイムゾーンを気にすることなく検索することができます。

参考ページ

docs.ruby-lang.org

ハッシュのキーについて

少し調べたのでメモします。

実行環境

ruby 2.7.2
Rails 6.0.3.6

ハッシュリテラルについて

ハッシュのキーには、文字列よりシンボルの方が適していると言われています。
そのため、

{"a" => "b"}

より

{:a => "b"}

という書き方の方が良さそうです。 これは、

{a: "b"}

と同じです。こちらの方が矢印を省略できるのでシンプルです。

ちなみに、

:"a".class
# => Symbol

とあるように:"a"はシンボルのため以下の書き方もできます。

{:"a" => "b"}
{"a": "b"}

ハッシュのキーに空白や記号を含めたい場合は、上記のようにダブルクウォーテーションで囲みます。

{:"@a" =>  "b"}
{"@a": "b"}
{@a: "b"}  # これは SyntaxError となる

Rails でのハッシュ関連メソッド

いくつかRailsにおけるハッシュ関連メソッドを調べます。

symbolize_keys / stringify_keys

ruby の Hashクラスの to_hash や to_h はハッシュリテラルに対してselfを返します。キーについては、シンボルだったらシンボルのまま、文字列は文字列のままです。

{a: "b"}.to_hash
# => {:a =>"b"}
{"a" => "b"}.to_hash
# => {"a"=>"b"}

ここで、キーをシンボルに変換したい場合は symbolize_keys メソッドを使用します。

{"a" => "b"}.symbolize_keys
# => {:a =>"b"}

逆に、キーをシンボルから文字列に変換したい場合は stringify_keys メソッドを使用します。

{a: "b"}.stringify_keys
# => {"a"=>"b"}

with_indifferent_access

通常、キーに対応する値を取得したい場合は以下のように[key]を指定します。なお、該当のキーがない場合は nil を返します。

{a: "b"}[:a]
# => "b"
{a: "b"}[:c]
# => nil

この場合、キーを文字列で指定した場合は、値を取得できません。

{a: "b"}["a"]
# => nil

ここで with_indifferent_access メソッドを使うと、ハッシュのキーに関わらず、文字列やシンボルどちらを指定しても、値を取得することができるようになります。

hoge_hash = {a: "b"}.with_indifferent_access
hoge_hash["a"]
# => "b"

参考リンク

class Hash (Ruby 3.2 リファレンスマニュアル)
Hashの拡張機能

AmazonLinux2でmysqlクライアントをインストールする

mysqlコマンドで、DBサーバ上のMySQLに接続できるようになるまでの手順をメモします。

Amazon Linux のベースは RedHat 系のディストリビューションであると言われています。 特にAmazon Linux 2 RHEL7 / CentOS7 をベースとしていると言われております

なので今回は、RedHat系のディストリビューションCentOSなど)における、mysqlパッケージのmysql-community-clientをインストールします。

手順

では手順を見ていきます。
AmazonLinux2(RHEL 7 や CentOS7も同様)にはMySQLの替わりにMariaDBがデフォルトのデータベースとして採用されています。
mysqlコマンド実行時に影響するかもしれないので、削除します。

$ yum list installed | grep mariadb
$ yum remove mariadb-libs


現時点ではリポジトリがないことが確認できます。

$ yum repolist all | grep mysql


最初に書いたとおり AmazonLinux 2 は RHEL7 系に対応しているため、Red Hat Enterprise Linux 7 / Oracle Linux 7 RPM Packageをダウンロードして MySQL 用のパッケージをインストールします。
具体的なURLはOracle公式の MySQL Yum Repository から取得します。

なお、パッケージのバージョンが違うと以下のようなエラーが出てしまいます。

ERROR You need to update rpm to handle:
rpmlib(PayloadIsZstd) <= 5.4.18-1 is needed by mysql80-community-release-el9-4.noarch
RPM needs to be updated
 You could try running: rpm -Va --nofiles --nodigest
Your transaction was saved, rerun it with:
 yum load-transaction /tmp/yum_save_tx.2023-12-10.05-40.bjFWFR.yumtx


インストールします。

$ yum localinstall -y https://dev.mysql.com/get/mysql80-community-release-el7-11.noarch.rpm
(省略)
Installed:
  mysql80-community-release.noarch 0:el7-11                                                                                                                      

Complete!


リポジトリ一覧をみてみると利用可能なリポジトリの中にmysql57-communityも入っていますが、mysql80-communityenabledとなっているため、yum install でMYSQL8.0が自動的にインストールされます。
もしMySQL5.7 をインストールしたい場合は、yum-config-managerコマンドでMYSQL8.0のリポジトリを無効化し、MySQL5.7のリポジトリを有効化した後に yum install しましょう。

bash-4.2# yum repolist all | grep mysql
(省略)
mysql57-community/x86_64                            MySQL 5.7 Co disabled
mysql57-community-source                            MySQL 5.7 Co disabled
mysql80-community/x86_64                            MySQL 8.0 Co enabled:    446
mysql80-community-debuginfo/x86_64                  MySQL 8.0 Co disabled
mysql80-community-source                            MySQL 8.0 Co disabled


mysql-community-clientをインストールします。

$ yum install -y mysql-community-client


これでmysqlコマンドが実行できるようになりました!

$ mysql --version
mysql  Ver 8.0.35 for Linux on x86_64 (MySQL Community Server - GPL)

Dockerfile での記述

Dockerfile 内でインストール実行する際は、以下のように書くと良さそうです。

FROM amazonlinux:2
(省略 yum update & install など)
RUN rpm -ivh https://dev.mysql.com/get/mysql80-community-release-el7-11.noarch.rpm \
    && yum -y install mysql-community-client

mysql-devel について

gem mysql2 を利用する場合は、mysql-devel (mysql-community-devel) をインストールする必要があるみたいです。

mysql2をインストールする際に、mysqlのヘッダファイルが必要となるため、追加でsudo yum install mysql-community-devel -yを実行しておきましょう。これを忘れると次のmysql2をインストール中にエラーが出て途中で停止してしまいます。 gihyo.jp

Debian系のディストリビューションの場合

Debian系のLinuxディストリビューション(Ubuntuなど)の場合は、以下のパッケージをインストールします。

  • mysql-client
    なお、Debian GNU/Linux 10 (buster) からは、こちらは無くなったようなので、default-mysql-clientを使うようにします。
  • libmysqlclient-dev

localStorage にオブジェクトを保存したい

オブジェクトを文字列に変換したい

localStorage にオブジェクトを保存したかったのですが、
文字列にどう変換したら良いのかわからず調べたので、その際のメモです。

toString() をみてみる

まず配列を toString() してみます。
配列オブジェクトは Object の toString() メソッドをオーバーライドしています。
配列の toString メソッドは内部的に join() を呼び出し、すべての要素をカンマで区切って一つの文字列に収めて返します。
Array.prototype.toString() - JavaScript | MDN

["orange", "apple"].toString() // => 'orange,apple'


他のタイプのオブジェクトについてもみてみます。
それぞれのオブジェクト毎に用意されている toString メソッドが呼び出されます。

const f = function(x){return x + 1;} 
f.toString() // => 'function(x){return x + 1;}'

const d = new Date
d.toString() // => 'Thu Dec 05 2023 20:00:00 GMT+0900 (日本標準時)'

しかしオブジェクトに関しては'{orange: 100, apple: 50}'のように返ってきません。

const o = {orange: 100, apple: 50}
o.toString() // => '[object Object]'

toString() メソッドは Object の子孫にあたるあらゆるオブジェクトに継承されていますが、上記のように、カスタムオブジェクト中で上書きされていない場合、 toString() は "[object type]" という文字列を返します。
Object.prototype.toString() - JavaScript | MDN

また、nullundefinedについては以下のように、TypeError が返ってきます。

const n = null
n.toString() // => Uncaught TypeError: Cannot read properties of null (reading 'toString')

const u = undefined
u.toString() // => Uncaught TypeError: Cannot read properties of undefined (reading 'toString')

String()

次に String() メソッドを使ってみてみます。
String - JavaScript | MDN

String(["orange", "apple"]) // => 'orange,apple'
String(function(x){return x + 1;}) // => 'function(x){return x + 1;}'
String(new Date) // => 'Thu Dec 05 2023 20:00:00 GMT+0900 (日本標準時)'
String({orange: 100, apple: 50}) // => '[object Object]'

オブジェクトに関しては変わらず "[object type]" が返ってきています。

nullundefinedについては以下のようになります。

String(null) // => 'null'
String(undefined) // => 'undefined'

String - JavaScript | MDN

JSON.stringify()

では、本題のオブジェクトを localStorage に保存したい場合など、オブジェクトを文字列に変換したい場合どうしたら良いでしょうか。
JSON.stringify() を使います。
JSON.stringify() - JavaScript | MDN

const value = JSON.stringify({orange: 100, apple: 50}) // => '{"orange":100,"apple":50}'
localStorage.setItem('keyname', value)

そして、localStorageから取り出す場合は、JSON.parse()を使います。

const value = localStorage.getItem('keyname')
JSON.parse(value) // => {orange: 100, apple: 50}