RailsのActiveRecordで、joinsメソッドやwhereメソッドで、ハッシュで条件を指定する

RailsActiveRecordにて、SQLのJOIN句やWHERE句の条件を記載する場合、

が用意されています。

それらのメソッドでは、条件を指定する場合に

  • 文字列
  • ハッシュ

が使えます。

 
そんな中、各条件をハッシュで書きたいと思ったときに毎回調べているため、メモとして残しておきます。

 
目次

 

環境

 

データ構造

今回は

  • 複数のテーブルを結合する
  • 複数のテーブルに対して条件を追加する

を試すため、以下のようなデータ構造を用意しました。

 
画像では見わけづらいかもしれないため、文字でも表現しておきます。

[Maker] 1 --- n [Plant] 1 --- n [Employee]
[Maker] 1 --- n [ReservedProduct] 1 --- n [Sale]
                                  n --- 1 [Shop]
                                  1 --- n [SaleByCustomer] n --- 1 [Customer] n --- 1 [Country]
[Maker] n --- 1 [Country]

 
また、Makerモデルからたどれるよう、各モデルには以下のようなRailsの関連がついているものとします。

# MakerからPlant方向
class Maker < ApplicationRecord
  belongs_to :country, optional: true
  has_many :plants
  has_many :reserved_products
end

class Plant < ApplicationRecord
  has_many :employees
end

class Employee < ApplicationRecord
end


# MakerからReservedProduct方向
class ReservedProduct < ApplicationRecord
  has_many :sales
  has_many :sale_by_customers
  belongs_to :maker, optional: true
  belongs_to :shop, optional: true
end

class Sale < ApplicationRecord
  belongs_to :reserved_product
end

class Shop < ApplicationRecord
end

class SaleByCustomer < ApplicationRecord
  belongs_to :reserved_product
  belongs_to :customer
end

class Customer < ApplicationRecord
  belongs_to :country
end

class Country < ApplicationRecord
end


# MakerからCountry方向は、上記のCountryモデルを使うので省略

 

準備

RSpecのテストで実行されたSQLを標準出力へ出す設定を追加

デフォルトの設定の場合、実行されたSQLが出力されません。

今回は joinswhere の条件がどのようなSQLになるのかを確認するため、rails_helper.rb に、以下を追加します。
ruby on rails - How do I turn on SQL debug logging for ActiveRecord in RSpec tests? - Stack Overflow

ActiveRecord::Base.logger = Logger.new($stdout) # SQLを出力

 

joinsメソッドにてハッシュによる条件を書く

JOIN句の条件を指定するため、今回は joins メソッドを使います。なお、 eager_load などでも書き方は同じになります。

Railsガイドを参考にしながら、複数のテーブルの結合を試してみます。
Active Record クエリインターフェイス - Railsガイド

 

隣のモデル (Country) を結合

ER図で

[Maker] n --- 1 [Country]

と表記している、MakerとCountryを結合したいとします。

 
この場合は joins に結合先の関連名をシンボルで渡します。

Maker.joins(:country)

 
発行されるSQLはこちら。

SELECT
  "makers".*
FROM
  "makers"
  INNER JOIN "countries" ON "countries"."id" = "makers"."country_id"

 
なお、joins に指定するシンボルは、モデルに

class Maker < ApplicationRecord
  belongs_to :country, optional: true
end

と定義した関連名を設定します。

 
もし関連を定義していない場合、実行時に

ActiveRecord::ConfigurationError: Can't join 'Maker' to association named 'country'; perhaps you misspelled it?

というエラーになります。

 

隣とその隣のモデル (Plant & Employee) を結合

ER図で

[Maker] 1 --- n [Plant] 1 --- n [Employee]

と表記している、MakerとCountryを結合したいとします。

 
この場合は、

  • 隣のモデルをハッシュのキー
  • 隣の隣のモデルを、配列の要素

にします。
13.1.3.1 ネストした関連付けを結合する(単一レベル) | Active Record クエリインターフェイス - Railsガイド

Maker.joins(plants: [:employees])

 
なお、今回は隣の隣は1つなので、 [] がなくても動作します。

Maker.joins(plants: :employees)

 
発行されるSQLはこちら。

SELECT
  "makers".*
FROM
  "makers"
  INNER JOIN "plants" ON "plants"."maker_id" = "makers"."id"
    INNER JOIN "employees" ON "employees"."plant_id" = "plants"."id"

 

さらに先のモデル (ReservedProduct方向) を結合

ER図で

[Maker] 1 --- n [ReservedProduct] 1 --- n [Sale]
                                  n --- 1 [Shop]
                                  1 --- n [SaleByCustomer] n --- 1 [Customer] n --- 1 [Country]

と表記している、ReservedProduct方向で結合したいとします。

 
隣の隣のモデルとの結合と同じように考えれば良いため、

  • reserved_productsをキーに、値の要素として、各結合先を指定
    • sales
    • sale_by_customers
    • shop
  • sale_by_customersは更に結合するので、同じような考え方で定義

とします。

Maker.joins(reserved_products: [
  :sale,
  :shop,
  { sale_by_customers: { customer: :country }},
])

 
発行されるSQLです。

SELECT
  "makers".*
FROM
  "makers"
  INNER JOIN "reserved_products" ON "reserved_products"."maker_id" = "makers"."id"
    INNER JOIN "sales" ON "sales"."reserved_product_id" = "reserved_products"."id"
    INNER JOIN "shops" ON "shops"."id" = "reserved_products"."shop_id"
    INNER JOIN "sale_by_customers" ON "sale_by_customers"."reserved_product_id" = "reserved_products"."id"
      INNER JOIN "customers" ON "customers"."id" = "sale_by_customers"."customer_id"
        INNER JOIN "countries" ON "countries"."id" = "customers"."country_id"

 

ここまでの3パターンを一度に書く

Makerモデルから3方向に伸びるJOINなため、 joins メソッドにそれぞれの方向のJOIN定義を書きます。

Maker.joins(
  :country,
  plants: :employees,
  reserved_products: [:sales, :shop, { sale_by_customers: { customer: :country }}]
)

 
発行されるSQLです。

SELECT
  "makers".*
FROM
  "makers"
  INNER JOIN "countries" ON "countries"."id" = "makers"."country_id"
  INNER JOIN "plants" ON "plants"."maker_id" = "makers"."id"
    INNER JOIN "employees" ON "employees"."plant_id" = "plants"."id"
  INNER JOIN "reserved_products" ON "reserved_products"."maker_id" = "makers"."id"
    INNER JOIN "sales" ON "sales"."reserved_product_id" = "reserved_products"."id"
    INNER JOIN "shops" ON "shops"."id" = "reserved_products"."shop_id"
    INNER JOIN "sale_by_customers" ON "sale_by_customers"."reserved_product_id" = "reserved_products"."id"
      INNER JOIN "customers" ON "customers"."id" = "sale_by_customers"."customer_id"
        INNER JOIN "countries" "countries_customers" ON "countries_customers"."id" = "customers"."country_id"

 

whereメソッドでハッシュによる条件を書く

where メソッドでも、ハッシュを使って条件を書くことができます。
13.1.3 複数の関連付けを結合する | 3.3 条件でハッシュを使う | Active Record クエリインターフェイス - Railsガイド

 

条件のうちハッシュで書けるもの

Railsガイドにある通り、 where メソッドにてハッシュで書けるのは以下の通りです。

  • 等号
  • 不等号 ( > など)
  • BETWEEN
  • IN
  • IS NULL
  • 外部キーのID
  • 関連名

 
それぞれ見ていきます。

 

等号

where のキーに属性を、値に取得したいものを指定します。

Maker.where(id: 1)

 
発行されるSQLです。

SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  "makers"."id" = ? [["id", 1]]

 

不等号(大なり小なり)

Railsガイドに

Rubyの終端/始端を持たない範囲オブジェクト(beginless/endless range)がサポートされており、以下のように「〜より大きい」「〜より小さい」条件の構築で利用できます。

とあるため、試してみます。

 

大なり (<) は ...n

...1 のように、最後の値を含まない形式で書きます。

Maker.where(id: ...1)

 
発行されるSQLです。

SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  "makers"."id" < ? [["id", 1]]

 

大なりイコール (<=) は ..n

..1 のように、最後の値を含む形式で書きます。

Maker.where(id: ..1)

 
発行されるSQLです。

SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  "makers"."id" <= ? [["id", 1]]

 
 

小なり (>) はArelを使うしかない

今のところ、Arelを使うしかなさそうです。
activerecord - Rails: Using greater than/less than with a where statement - Stack Overflow

Maker.where(Maker.arel_table[:id].gt(1))

 
発行されるSQLです。

SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  "makers"."id" > 1

 

小なりイコール (>=) は ..n か ...n

1.. もしくは 1... で書きます。

# .. を使う
Maker.joins.where(id: 1..)

# ... を使う
Maker.joins.where(id: 1...)

 
両方とも同じSQLが発行されます。

SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  "makers"."id" >= ? [["id", 1]]

 

BETWEEN は m..n

両端を含む範囲オブジェクト(..)で書きます。

Maker.where(id: 1..2)

 
発行されるSQLです。

SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  "makers"."id" BETWEEN ?
  AND ? [["id", 1], ["id", 2]]

 
ちなみに、終端を含まない範囲オブジェクト(...)で書いた場合は、BETWEENにはなりません。

例えば

Maker.where(id: 1...2)

の時に発行されるSQLは不等号になっています。

SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  "makers"."id" >= ?
  AND "makers"."id" < ? [["id", 1], ["id", 2]]

 

IN

ハッシュの値に配列を指定することで、INへと変換されます。

Maker.where(id: [1, 2])

 
発行されるSQLです。

SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  "makers"."id" IN (?, ?) [["id", 1], ["id", 2]]

 

IS NULL

ハッシュの値に nil を指定することで、IS NULLへと変換されます。

Maker.where(country: nil)

 
発行されるSQLです。

SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  "makers"."country_id" IS NULL

 

外部キーのIDを指定

外部キーの列名(***_id)を指定

Maker.where(country_id: 2)

 
発行されるSQLです。

SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  "makers"."country_id" = ? [["country_id", 2]]

 

関連名を指定

モデルに関連がある場合、関連名を指定すると外部キーの列名へと変換されます。

Maker.where(country: 2)

 
発行されるSQLです。

SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  "makers"."country_id" = ? [["country_id", 2]]

 

関連先のテーブルの列を指定

モデルの関連(belongs_tohas_many など)を使って、関連先のテーブルの列を指定します。

今回は、関連名が単数と複数、それぞれの挙動を見ていきます。

 

関連名が単数 (belongs_to)

関連が

class Maker < ApplicationRecord
  belongs_to :country, optional: true
end

と定義してある country の列で絞り込みたいとします。

 
この場合、joins で結合した上で、 where で絞り込みをします。

なお、 where のハッシュのキーには

  • 関連名
  • テーブル名

のいずれも使えるため、それぞれ見ていきます。

 

ハッシュのキーに関連名を使う

ハッシュのキーに関連名、値にもハッシュとして「キー:列名、値:絞り込みたい値」を指定します。

今回の場合は関連名が country になります。

Maker.joins(:country).where(country: { name: '日本' })

 
発行されるSQLです。

JOINするときに、テーブル名に別名として関連名が付与されています。

SELECT
  COUNT(*)
FROM
  "makers"
  INNER JOIN "countries" "country" ON "country"."id" = "makers"."country_id"
WHERE
  "country"."name" = ? [["name", "日本"]]

 

ハッシュのキーにテーブル名を使う

ハッシュのキーにテーブル名を使ってみます。

今回の場合は、テーブル名は countries です。

Maker.joins(:country).where(countries: { name: '日本' })

 
発行されるSQLです。別名は使われていません。

SELECT
  COUNT(*)
FROM
  "makers"
  INNER JOIN "countries" ON "countries"."id" = "makers"."country_id"
WHERE
  "countries"."name" = ? [["name", "日本"]]

 

関連名が複数 (has_many)

続いて関連名が複数の場合です。

class Maker < ApplicationRecord
  has_many :plants
end

また、Plantモデルにも belongs_to が設定してあるものとします。

class Plant < ApplicationRecord
  belongs_to :maker
end

 
この場合、デフォルトでは関連名・テーブル名とも複数形なため、ハッシュのキーは複数形を指定します。

Maker.joins(:plants).where(plants: { name: '北工場' })

 
発行されるSQLです。

SELECT
  COUNT(*)
FROM
  "makers"
  INNER JOIN "plants" ON "plants"."maker_id" = "makers"."id"
WHERE
  "plants"."name" = ? [["name", "北工場"]]

 

複数の条件をANDでつなぐ

where に複数のハッシュのキーを渡します。

Maker.where(id: 1, country: nil)

 
発行されるSQLです。

SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  "makers"."id" = ?
  AND "makers"."country_id" IS NULL [["id", 1]]

 

否定形

where.not を使います。
3.4 NOT条件 | Active Record クエリインターフェイス - Railsガイド

ここでは

  • 否定
  • NOT IN
  • NOT NULL

を見ていきます。

 

否定 (!=)

Maker.where.not(id: 1)

 
発行されるSQLです。

SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  "makers"."id" != ? [["id", 1]]

 

NOT IN

Maker.where.not(id: [1, 2])

 
発行されるSQLです。

SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  "makers"."id" NOT IN (?, ?) [["id", 1],
  ["id", 2]]

 

NOT NULL

Maker.where.not(country: nil)

 
発行されるSQLです。

SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  "makers"."country_id" IS NOT NULL

 

複数の否定形

NOT IN かつ IS NOT NULL のような複数の否定形のSQLとしたい場合も、 where.not を使います。

ただ、Rails6.1から、1つ where.not で書くとNANDな否定形の形になっています。

where.notがNORではなくNANDを述部で生成するようになった

Ruby on Rails 6.1 リリースノート - Railsガイド

 
そこで、NANDな書き方とNORな書き方を見ていきます。

 

NANDな書き方: NOT(IN AND IS NULL)

1つの where.not に条件を入れ込みます。

Maker.where.not(id: 2, country: nil)

 
発行されるSQLです。

SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  NOT (
    "makers"."id" = ?
    AND "makers"."country_id" IS NULL
  ) [["id", 2]]

 

NORな書き方: NOT IN AND IS NOT NULL

別々の where.not にそれぞれの条件を記載します。

Maker.where.not(id: 2).where.not(country: nil)

 
発行されるSQLです。

SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  "makers"."id" != ?
  AND "makers"."country_id" IS NOT NULL [["id", 2]]

 

ここまで joins と where を組み合わせる

ここまで書いてきた joinswhere を組み合わせて、1つのSQLを作ってみます。

Maker.joins(
  :country,
  plants: :employees,
  reserved_products: [{ sale_by_customers: { customer: :country }}, :shop]
).where(
  id: [1, 2, 3],
  plants: { id: 4..6 },
  employees: { id: ...7 },
  reserved_products: { id: 8.. },
  sale_by_customers: { customer_id: 9 },
).where.not(
  customers: { country: [10, 11, 12] },
  countries: { name: nil },
)

 
発行されるSQLです。

SELECT
  COUNT(*)
FROM
  "makers"
  INNER JOIN "countries" ON "countries"."id" = "makers"."country_id"
  INNER JOIN "plants" ON "plants"."maker_id" = "makers"."id"
  INNER JOIN "employees" ON "employees"."plant_id" = "plants"."id"
  INNER JOIN "reserved_products" ON "reserved_products"."maker_id" = "makers"."id"
  INNER JOIN "sale_by_customers" ON "sale_by_customers"."reserved_product_id" = "reserved_products"."id"
  INNER JOIN "customers" ON "customers"."id" = "sale_by_customers"."customer_id"
  INNER JOIN "countries" "countries_customers" ON "countries_customers"."id" = "customers"."country_id"
  INNER JOIN "shops" ON "shops"."id" = "reserved_products"."shop_id"
WHERE
  "makers"."id" IN (?, ?, ?)
  AND "plants"."id" BETWEEN ?
  AND ?
  AND "employees"."id" < ?
  AND "reserved_products"."id" >= ?
  AND "sale_by_customers"."customer_id" = ?
  AND NOT (
    "customers"."country_id" IN (?, ?, ?)
    AND "countries"."name" IS NULL
  ) 
  [
    ["id", 1],
    ["id", 2],
    ["id", 3],
    ["id", 4],
    ["id", 6],
    ["id", 7],
    ["id", 8],
    ["customer_id", 9],
    ["country_id", 10],
    ["country_id", 11],
    ["country_id", 12]
  ]

 

LIKEはハッシュではなく文字列で書くが、注意点あり

where メソッドのところで見たとおり、SQLのWHERE句でLIKE検索をしたい場合、ハッシュでは指定できません。

そのため、 where メソッドに文字列で条件を指定することになります。

 
ただ、LIKEの場合は適切な形でサニタイズが必要になるので注意が必要です。

 
ここではどのような結果になるかを見ていきます。

 

NG: 引数をサニタイズしない

whereに渡す引数をサニタイズしない場合、 %_エスケープされないため、そのままワイルドカードとして使えてしまいます。

 
引数に % が使われる時のテストを書いてみると、テストがパスしました。

context '%という文字を渡す' do
  before do
    create(:maker, name: '')
    create(:maker, name: '西')
    create(:maker, name: '')
    create(:maker, name: '')
  end

  let(:keyword) { '%' }

  context 'サニタイズしない' do
    it '全件取得できる' do
      actual = Maker.where('name LIKE ?', "%#{keyword}%")

      expect(actual.count).to eq(4)
    end
  end
end

 
発行されるSQLです。

ワイルドカード文字がエスケープされていません。

SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  (name LIKE '%%%')

 
続いて、引数に _ が使われる場合もテストがパスしています。

context '_という文字を渡す' do
  let(:keyword) { '_' }

  context 'サニタイズしない' do
    it '全件取得できる' do
      actual = Maker.where('name LIKE ?', "%#{keyword}%")

      expect(actual.count).to eq(4)
    end
  end
end

 
発行されるSQLです。

こちらもワイルドカード文字がエスケープされていません。

SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  (name LIKE '%_%')

 

NG: 引数にsanitize_sql_arrayを使う

SQLを書くときにSQLインジェクションを防ぐために使うメソッドとして、 sanitize_sql_array があります。

 
sanitize_sql_array を使って、 % に対するテストコードを書いてみます。

しかし、こちらも %サニタイズされることなく、4件取得できてしまいます。

context 'sanitize_sql_arrayを使う' do
  it '全件取得できる' do
    actual = Maker.where(Maker.sanitize_sql_array(['name LIKE ?', "%#{keyword}%"]))

    expect(actual.count).to eq(4)
  end
end

 
発行されるSQLを見ても、ワイルドカード文字がエスケープされていません。

SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  (name LIKE '%%%')

 

OK: 引数にsanitize_sql_likeを使う

LIKE中のワイルドカード文字をエスケープするには、別のメソッド sanitize_sql_like を使います。

 
同じくテストコードを書いてみると、先ほどとは異なり1件も取得できません。

context 'sanitize_sql_likeを使う' do
  it '1件も取得できない' do
    actual = Maker.where('name LIKE ?', "%#{Maker.sanitize_sql_like(keyword)}%")

    expect(actual.count).to eq(0)
  end
end

 
発行されるSQLを見ると、 %_エスケープされています。

-- `%` の場合
SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  (name LIKE '%\%%')


-- `_` の場合
SELECT
  COUNT(*)
FROM
  "makers"
WHERE
  (name LIKE '%\_%')

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_association-sample

今回のプルリクはこちら
https://github.com/thinkAmi-sandbox/rails_association-sample/pull/5

Rails + factory_botで、sub factoryやtrait・callbackを使って関連データを生成してみた

Rails + RSpec + factory_bot にて、あるモデルの関連データを生成する方法を調べたところ、factory_botのGETTING_STARTEDにいろいろな方法が記載されていました。
https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md

そこで、GETTING_STARTEDの内容を素振りしてみたときのメモを残します。

 
目次

 

環境

 

テーブル構造

前回の記事同様、 AreaApple モデルを使います。

 
Area

class Area < ApplicationRecord
  has_many :apples
end

 
Apple

area に関連付けなくても良いとするため、

とします。

class Apple < ApplicationRecord
  belongs_to :area, optional: true
end

 

関連データを順次生成

各factoryを用意します。factoryの中では関連付けを定義しません。

# rspec/factories/apples.rb
FactoryBot.define do
  factory :apple do
  end
end

# rspec/factories/area.rb
FactoryBot.define do
  factory :area do
    name { '日本' }
  end
end

 
多対一の一側(area)を生成し、その値を多側(apple)へ渡して生成します。

context '順次生成' do
  let(:area) { create(:area) }
  let(:apple) { create(:apple, area: area) }

  it do
    expect(Apple.find(apple.id).area).to eq(area)
  end
end

 

多側のfactoryで、関連データを一度に生成 (Associations)

先ほど見たように、関連を順次生成する場合、必要な関連をletなどで定義しておく必要があります。

「一つのfactoryを実行したら、合わせて関連データも作成したい」場合を調べたところ、factory_botAssociations という機能を使えば良さそうでした。
https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md#associations

Associations では

  • 関連を使う
  • factoryを使う

という方法があるため、それぞれ見ていきます。

 

関連を使った生成

関連を使う場合、

  • 暗黙的定義
  • 明示的定義
  • インライン定義

の3パターンの指定方法があるため、それぞれ見ていきます。

 

暗黙的定義

一側のfactory名(area)と、多側のモデルにある関連名(belongs_to)が同じ場合、暗黙的な関連が使えます。

例えば

# 一側のfactory
FactoryBot.define do
  factory :area do
    name { '日本' }
  end
end


# 多側のモデル
class Apple < ApplicationRecord
  belongs_to :area, optional: true
end

という関連の定義がモデルでされていたとします。

 
その場合、多側のfactoryで

FactoryBot.define do
  factory :apple do
    factory :apple_association_by_implicit do
      # Areaのfactory名(area)と、Appleモデルの関連名(belongs_to)が同じ場合、暗黙的な関連を使える
      area
    end
  end
end

のように定義できます。

 
これにより、テストコードでは create を1回使うだけで関連を生成できます。

context '暗黙的な定義' do
  before { create(:apple_association_by_implicit) }

  it do
    expect(Apple.last.area.name).to eq('日本')
  end
end

 

明示的定義

関連を明示的に定義するには、 association を使います。

factory :apple_association_by_explicit do
  association :area
end

 
テストコードは暗黙的な書き方のときと同様です。

context '明示的な定義' do
  before { create(:apple_association_by_explicit) }

  it do
    expect(Apple.last.area.name).to eq('日本')
  end
end

 

インライン定義

関連はインラインでも定義できます。

factory :apple_association_by_inline do
  area { association :area }
end

 
テストコードでの使い方は、他の関連定義と同様です。

context 'インライン定義' do
  before { create(:apple_association_by_inline) }

  it do
    expect(Apple.last.area.name).to eq('日本')
  end
end

 

factoryを使った生成

ここまでは関連を使った定義を見てきました。

ただ、関連の生成にはfactoryを使いたい場合があるかもしれません。

その場合、一側のfactoryを多側のfactoryに指定します。

例えば、一側であるAreaモデルのfactoryが以下のように定義されている場合に、多側であるAppleモデルのfactoryをどう定義すればよいか見ていきます。

# rspec/factories/areas.rb
FactoryBot.define do
  factory :area do
    name { '日本' }

    factory :aomori_area do
      name { '青森県' }
    end

    factory :nagano_area do
      name { '長野県' }
    end

    factory :iwate_area do
      name { '岩手県' }
    end
  end
end

 

暗黙的定義

関連 area の後ろに factory を指定します。

以下の例ではAreaモデルのfactory aomori_area を指定しています。

# rspec/factories/apples.rb
factory :apple_factory_by_implicit do
  area factory: :aomori_area
end

 
これで、関連の生成に aomori_area factoryが使われます。

context '暗黙的な定義' do
  before { create(:apple_factory_by_implicit) }

  it do
    expect(Apple.last.area.name).to eq('青森県')
  end
end

 

明示的定義

明示的なfactory定義は、 associationfactory を渡せばよいです。

以下の例ではAreaモデルのfactory nagano_area を指定しています。

# rspec/factories/apples.rb
factory :apple_factory_by_explicit do
  association :area, factory: :nagano_area
end

 
使い方は暗黙的と同じです。

context '明示的な定義' do
  before { create(:apple_factory_by_explicit) }

  it do
    expect(Apple.last.area.name).to eq('長野県')
  end
end

 

インライン定義

インライン定義の場合は、 association にfactoryを指定します。

以下の例ではAreaモデルのfactory iwate_area を指定しています。

# rspec/factories/apples.rb
factory :apple_factory_by_inline do
  area { association :iwate_area }
end

 
使い方も同様です。

context 'インライン定義' do
  before { create(:apple_factory_by_inline) }

  it do
    expect(Apple.last.area.name).to eq('岩手県')
  end
end

 

一側のfactoryで定義された属性を上書き

ここまでは一側のfactoryの定義に従い、関連データを生成していました。

ただ、一側のfactoryで定義された属性を上書きして関連データを生成したいこともあります。

その場合は、一側のfactoryを定義するときに、上書きしたい属性も合わせて定義すれば良さそうです。

 
例えば、一側のAreaモデルのfactoryが以下のように定義されていたとします。

# rspec/factories/areas.rb
factory :area do
  name { '日本' }
end

 
このとき、 area factoryで定義された属性 name を上書きしたい場合は、以下のように name を指定して上書きしたい属性の値を渡します。

# rspec/factories/apples.rb
factory :apple_factory_with_overriding_attributes do
  association :area, factory: :area, name: '山形県'
end

 
使い方は今までと同じです。

context '関連先のfactoryで定義している属性の上書き' do
  before { create(:apple_factory_with_overriding_attributes) }

  it do
    expect(Apple.last.area.name).to eq('山形県')
  end
end

 

Associationsを定義する場所について

ここまで見てきた通り、factoryの中にAssociationsを定義することで関連データを生成できます。

ただ、

# rspec/factories/apples.rb
FactoryBot.define do
  factory :apple do
    area # 関連を指定
  end
end

となっていた場合、create(:apple) とするごとにareaが生成されます。

そのため、仮にareaが不要な場合は困ってしまうかもしれません。

 
そこで、

  • factoryの継承
  • trait

のいずれかの方法により、areaが不要な場合には生成しないよう定義できます。

 

factoryの継承

まずは、factoryの継承という機能を使って、必要に応じてareaを生成するようにしてみます。

ちなみに、factoryの継承について公式ドキュメントには

  • parentを指定したfactory
  • ネストしたfactory (sub factory)

の2種類の記載がありました。
https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md#inheritance

それぞれ見ていきます。

 

parentを指定したfactory

apple factoryと同階層に別のfactory apple_with_parent を定義し、 parent として親factoryである apple を指定します。

また、別factoryの中で関連を指定します。

# rspec/factories/apples.rb
FactoryBot.define do
  factory :apple do
    # ベースとなるfactoryでは関連を指定しない
  end

  # parentを指定したfactoryを用意し、関連を指定する
  factory :apple_with_parent, parent: :apple do
    area
  end
end

 
これにより、

  • areaが不要なときは apple factoryを使う
  • areaが必要なときは apple_with_parent factoryを使う

という方法が取れます。

context 'parent指定したfactoryを使う' do
  before { create(:apple_with_parent) }

  it do
    expect(Apple.last.area.name).to eq('日本')
  end
end

 

ネストしたfactory

factory_botでは、factoryの中にfactoryをネストして定義することができます。

ネストしている場合、一階層上のfactoryの定義を継承できます。

例えば、 apple factoryの中に apple_association_by_implicit factoryを定義し、その中で関連を指定したとします。

FactoryBot.define do
  factory :apple do
    factory :apple_association_by_implicit do
      area
    end
  end
end

 
これでも、areaが必要なときと不要なときでfactoryの使い分けができるようになります。

context '暗黙的な定義' do
  context '関連を生成する' do
    before { create(:apple_association_by_implicit) }

    it '関連が生成されていること' do
      expect(Apple.last.area.name).to eq('日本')
    end

    it 'その他の属性は継承されていること' do
      expect(Apple.last.name).to eq('秋映')
    end
  end

  context '関連を生成しない' do
    before { create(:apple) }

    it '関連が生成されていないこと' do
      expect(Apple.last.area.blank?).to eq(true)
    end

    it 'その他の属性は継承されていること' do
      expect(Apple.last.name).to eq('秋映')
    end
  end
end

 

traitを使った生成

trait1つ使う

factory_botには trait という、属性をグルーピングする機能があるため、これも試してみます。
https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md#traits

まずfactoryに trait を定義します。

FactoryBot.define do
  factory :apple do
    trait :aomori_apple do
      association :area, factory: :aomori_area
    end
  end
end

 
create 時にtrait名のシンボルを渡すことで、 trait で指定した属性の値がfactoryの生成時に使われるようになります。

以下では apple factoryを使うときに aomori_apple trait を使用して、関連 area を生成しています。

context 'trait 1つ' do
  # trait `aomori_apple` を使う
  before { create(:apple, :aomori_apple) }

  it do
    expect(Apple.last.area.name).to eq('青森県')
  end
end

 

traitを複数使ったときに優先されるtrait

factory_botにて生成する際、 trait は複数指定できます。

例えば、以下のような同じ関連だけど別のnameを設定するtraitが複数あるとします。

FactoryBot.define do
  factory :apple do
    trait :aomori_apple do
      association :area, factory: :aomori_area
    end

    trait :hokkaido_apple do
      association :area, factory: :hokkaido_area
    end
  end
end

 
これら複数のtraitを1回の生成で利用した場合、何が優先されるのかをみていきます。

 

create時にtraitを指定

まずは create 時にtraitを指定する場合です。
https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md#using-traits

 
今回は、 aomori_apple > hokkaido_apple の順で指定します。

create(:apple, :aomori_apple, :hokkaido_apple)

 
すると、後ろで指定したtrait (今回であれば hokkaido_apple ) の定義が利用されました。

context 'createで複数traitを指定' do
  before { create(:apple, :aomori_apple, :hokkaido_apple) }

  it do
    # 後ろのtraitが適用される
    expect(Apple.count).to eq(1)
    expect(Apple.last.area.name).to eq('北海道')
  end
end

 

factoryの定義に traits を指定

factoryでtraitを使用する場合、 traits にtrait名のシンボルを渡すことで複数のtraitを適用できます。
https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md#defining-traits

 
factoryでtraitsを指定する場合も、先ほど同様 aomori_apple > hokkaido_apple の順で指定します。

factory :multiple_traits, traits: %i[aomori_apple hokkaido_apple]

 
すると、この場合も後ろで定義したtrait (hokkaido_apple) が利用されました。

context 'factoryで複数traitを指定' do
  before { create(:multiple_traits) }

  it do
    # 後ろのtraitが適用される
    expect(Apple.count).to eq(1)
    expect(Apple.last.area.name).to eq('北海道')
  end
end

 

一側のfactoryで、関連データを生成(callback)

ここまでは多側のfactoryを使うときに一側の関連データも生成する例を見てきました。

一方、callbackという仕組みを使えば、一側のデータ生成時に多側の関連データを作成することもできます。
https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md#callbacks

 
今回は一側である area のfactoryにて、create後のcallback after(:create) を使って関連データを生成してみます。

まずはfactoryを定義します。

factory :callback_area do
  name { '秋田県' }
  after(:create) { |area| create(:apple, area: area) }
end

 
続いて、定義したfactoryを利用してみると、callbackで指定した内容で applearea の関連が作成されています。

context 'factoryのcallbackで生成' do
  before { create(:callback_area) }

  it do
    expect(Apple.last.area.name).to eq('秋田県')
  end
end

 

transientを利用した条件分岐による関連の生成

factory_botには transient という、factory内でのみ利用できる属性を定義する機能があります。
https://github.com/thoughtbot/factory_bot/blob/main/GETTING_STARTED.md#transient-attributes

 
transient の使用例については、以下の記事が参考になりました。

 
今回は transient でフラグを定義し、フラグが true であれば

  • 関連を生成
  • apple.nameの先頭に シナノ を追加する

という処理を行うcallbackを定義して試してみます。

factory :apple do
  trait :with_transient do
    transient do
      is_nagano { false }
    end

    after(:create) do |apple, evaluator|
      if evaluator.is_nagano
        # areaはoptionalなbelongs_toなので、後から追加可能
        apple.area = create(:nagano_area)
        apple.name = "シナノ#{apple.name}"
        apple.save!
      end
    end
  end
end

 
この trait を使い、 transient のキーを指定する/しないでどう変わるかを見ていきます。

 

transientのキーを引数で指定しない

create時に is_nagano を指定しない場合は、callbackの内容は反映されません。

context '引数なし' do
  before { create(:apple, :with_transient, name: 'フジ',) }

  it do
    actual = Apple.last

    expect(actual.area).to eq(nil)
    expect(actual.name).to eq('フジ')
  end
end

 

transientのキーを引数で指定する

一方、create時に is_nagano: true を指定した場合は、callbackの内容が反映されます。

context '引数あり' do
  before { create(:apple, :with_transient, name: 'フジ', is_nagano: true) }

  it do
    actual = Apple.last

    expect(actual.area.name).to eq('長野県')
    expect(actual.name).to eq('シナノフジ')
  end
end

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app

今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app/pull/7

Railsで、二重否定(!!) + ぼっち演算子(&.) が使われているソースコードの挙動を確認してみた

Railsを使ったソースコードを眺めていたところ、モデルのメソッドにて

def foo
  !!bar&.baz
end

というコードがありました。

どのような挙動になるのか気になったので、試してみた時のメモを残します。

 
目次

 

環境

 

モデル構造

今回は3つのモデル

があるとします。

また、それぞれの関連は

  • Market : Area = n : 1
  • Area : Apple = 1: n

となっています。

各モデルの定義は以下の通りであり、今回挙動を知りたいメソッド sell_apple?Market モデルにあるとします。

なお、今回は各関連先が無いケースについても動作確認をするため、 belongs_tooptional: true をつけています。
4.1.2.11 :optional | Active Record の関連付け - Railsガイド

# Market
class Market < ApplicationRecord
  # optional: trueにすることで #<ActiveRecord::RecordInvalid "Validation failed: Area must exist"> を防ぐ
  # 今回は area が無い場合の動作確認もしているので、optional: true の設定が必要
  belongs_to :area, optional: true

  def sell_apple?
    !!area&.apples
  end
end


# Area
class Area < ApplicationRecord
  has_many :apples
end


# Apple
class Apple < ApplicationRecord
  belongs_to :area, optional: true
end

 
ER図的にはこんな感じです。

 

挙動確認

sell_apple? メソッドの挙動を確認する前に、気になる部分の挙動を確認していきます。

ちなみに、今回はテストコードを書いて挙動を確認してみます。

なお、記事では必要な部分だけ抜粋しています。ソースコード全体は以下で確認できます。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app/pull/6

 

否定(!)や二重否定(!!)の確認

まずは否定(!)や二重否定(!!)の確認をします。

 
動作を確認してみると、インスタンスの値をbooleanに変換しています。

context '!や!!の確認' do
  let!(:market) { create(:market, area: nil) }

  context '取得しただけ' do
    # itの中身がわかりやすいので、itの説明は省略(以降同様)
    it do
      actual = Market.find(market.id)

      expect(actual).to eq(market)
    end
  end

  context '!(否定)' do
  it do
    actual = Market.find(market.id)

    expect(!actual).to eq(false)
  end
end

context '!!(二重否定)' do
  it do
    actual = Market.find(market.id)

    expect(!!actual).to eq(true)
  end
end

 

ぼっち演算子(&.)と組み合わせた、!や!!の確認

続いて、ぼっち演算子と組み合わせた時の挙動を確認します。

 

多対一での確認(market : area = n : 1)

まずは、多対一の確認をします。

 

一側(area)が無い場合

一側が無い場合、ぼっち演算子を使っているので nil になります。
Rubyで使われる記号の意味(正規表現の複雑な記号は除く) (Ruby 3.2 リファレンスマニュアル)

context 'marketのみあり' do
  let!(:market) { create(:market, area: nil) }

  context '&.' do
    it do
      actual = Market.find(market.id)

      expect(actual&.area).to eq(nil)
    end
  end

 
そのため、 nil の否定では true 、二重否定では false になります。

context '!&.' do
  it do
    actual = Market.find(market.id)

    expect(!actual&.area).to eq(true)
  end
end

context '!!&.' do
  it do
    actual = Market.find(market.id)

    expect(!!actual&.area).to eq(false)
  end
end

 

一側(area)がある場合

一側がある場合は、一側が無いときと逆の結果になります。

context 'marketとareaともにあり' do
  let!(:area) { create(:area) }
  let!(:market) { create(:market, area: area) }

  context '&.' do
    it do
      actual = Market.find(market.id)

      expect(actual&.area).to eq(area)
    end
  end

  context '!&.' do
    it do
      actual = Market.find(market.id)

      expect(!actual&.area).to eq(false)
    end
  end

  context '!!&.' do
    it do
      actual = Market.find(market.id)

      expect(!!actual&.area).to eq(true)
    end
  end
end

 

一対多での確認(area : apple = 1 : n)

続いて、一対多の確認をします。

 

多側(apple)が無い場合

多側がない場合、 actual&.apples の結果は #<ActiveRecord::Relation []> になります。

let!(:area) { create(:area) }

context '&.' do
  it do
    actual = Area.find(area.id)

    # 左辺: #<ActiveRecord::Relation []>
    expect(actual&.apples).to eq([])
  end
end

 
続いて否定(!)を見てみます。

この場合、 [] の否定形のbooleanを取得することになるので、 false になります。

これは、Rubyfalse

false は FalseClass クラスの唯一のインスタンスです。 false は nil オブジェクトとともに偽を表し、その他の全てのオブジェクトは真です。

class FalseClass (Ruby 3.2 リファレンスマニュアル)

なことから、 [] は真であり、 ![] は偽であるためです。

context '!&.' do
  it do
    actual = Area.find(area.id)

    expect(!actual&.apples).to eq(false)
  end
end

 
二重否定(!!)の場合は、否定の更に逆なので、元に戻って真になります。

context '!!&.' do
  it do
    actual = Area.find(area.id)

    expect(!!actual&.apples).to eq(true)
  end
end

 

多側(apple)がある場合

多側がない場合の逆の結果となります。

let!(:area) { create(:area) }
let!(:apple) { create(:apple, area: area)}

context '&.' do
  it do
    actual = Area.find(area.id)

    expect(actual&.apples).to eq([apple])
  end
end

context '!&.' do
  it do
    actual = Area.find(area.id)

    expect(!actual&.apples).to eq(false)
  end
end

context '!!&.' do
  it do
    actual = Area.find(area.id)

    expect(!!actual&.apples).to eq(true)
  end
end

 

元々のメソッドの動作確認

気になる動作

改めて、今回のきっかけとなったメソッドと同じ作りのメソッド sell_apple? を見てみます。

class Market < ApplicationRecord
  def sell_apple?
    !!area&.apples
  end
end

 
このメソッドは、関連先の関連先(apple)があれば true を返してほしいように見えます。

ただ、ここまで見てきた通り、多側である apple が存在しないときにも true を返してしまい、意図しない挙動となっています。

context 'Marketのみあり' do
  let!(:actual) { create(:market, area: nil) }

  it do
    expect(actual.sell_apple?).to eq(false)
  end
end

context 'MarketとAreaあり' do
  context 'area without apple' do
    let!(:area) { create(:area) }
    let!(:actual) { create(:market, area: area) }

    it do
      expect(actual.sell_apple?).to eq(true) # << ここ
    end
  end
end 

context 'MarketとAreaとAppleあり' do
  let!(:area) { create(:area) }
  let!(:apple) { create(:apple, area: area)}
  let!(:actual) { create(:market, area: area) }

  it do
    expect(actual.sell_apple?).to eq(true)
  end
end

 

present?を使って解消

多側が存在しないときも false を返してほしい場合は、RailsではActiveSupportpresent? メソッドが使えそうです。
2.1 blank?とpresent? | Active Support コア拡張機能 - Railsガイド

 
そこで、 sell_apple_with_present? メソッドを定義し、 sell_apple? メソッドと挙動を比較してみます。

class Market < ApplicationRecord
  belongs_to :area, optional: true

  def sell_apple?
    !!area&.apples
  end

  # 追加
  def sell_apple_with_present?
    area&.apples.present?
  end
end

 
結果を確認すると、 sell_apple_with_present? メソッドは、多側が無い場合でも false を返すようになりました。良さそうな挙動です。

context 'Marketのみあり' do
  let!(:actual) { create(:market, area: nil) }

  it do
    expect(actual.sell_apple?).to eq(false)
    expect(actual.sell_apple_with_present?).to eq(false)
  end
end

context 'MarketとAreaあり' do
  context 'area without apple' do
    let!(:area) { create(:area) }
    let!(:actual) { create(:market, area: area) }

    it do
      # ここだけ結果が違う
      expect(actual.sell_apple?).to eq(true)
      expect(actual.sell_apple_with_present?).to eq(false)
    end
  end
end

context 'MarketとAreaとAppleあり' do
  let!(:area) { create(:area) }
  let!(:apple) { create(:apple, area: area)}
  let!(:actual) { create(:market, area: area) }

  it do
    expect(actual.sell_apple?).to eq(true)
    expect(actual.sell_apple_with_present?).to eq(true)
  end
end

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app

今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app/pull/6

Railsで、DBから取得するデータに対し、order・sort_by・sortを使って、昇順・降順ソートする

RailsActiveRecordを使ってDBからレコードを取得するときに、以下を考慮した昇順・降順ソートで迷ったことがあったため、メモを残します。

  • 取得するタイミング
    • データ取得時 (SQL発行時にソート)
    • データ取得後 (Rubyのarrayになってからソート)
  • ソートキーの型
    • string
    • integer
    • enum
    • datetime
    • boolean
    • 外部キー先の属性

 
目次  

 

環境

なお、記事中のソースコードは必要な部分だけを抜粋しているため、必要に応じて後述のソースコード全体を確認してください。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app/pull/5/files

ちなみに、ソートが意図通りにできているかの確認は、RSpecで行っています。

 

データについて

テーブル構造

今回は areas (地域) と apples (りんご) の2つのテーブルを用意します。

areas

項目 列名
id id integer
地域名 name string

 
apples

項目 列名 備考
りんご名 name string
平均重量 weight integer
coloer integer enumyellowredgreen をモデルに定義
出荷開始時期 starts_at datetime 年は2023年固定。上旬(1日)、中旬(10日)、下旬(20日)を指定
海外品種 is_imported boolean true の場合は海外から来た品種
育成地域 area_id foreign_key areas を外部キー指定

 
rails-mermaid_erd によるER図は以下です。

 

各テーブルデー

基本的には以下のデータを持っているとします。

もし必要であれば本文中で追記します。

areas

No name
1 青森県
2 長野県
3 アメリ
4 オーストラリア
5 イギリス

 
apples

No name weight color starts_at is_imported area_id
1 シナノドルチェ 300 2 (red) 2023/09/10 false 2 (長野県)
2 秋映 300 2 (red) 2023/10/10 false 2 (長野県)
3 シナノゴールド 350 1 (yellow) 2023/10/20 false 2 (長野県)
4 つがる 300 2 (red) 2023/09/01 false 1 (青森県)
5 ふじ 300 2 (red) 2023/11/01 false 1 (青森県)

 

DBから取得するときにソート

まずは、SQLのORDER句でソートする場合です。

この場合、ActiveRecordorder メソッドを使ってソートします。
https://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html#method-i-order

 

単一テーブル・単一キーでソート

昇順ソート

order メソッドのドキュメントにある通り、デフォルトは昇順ソートになります。

以下の例では、出荷開始時期の昇順でソートしています。

actual = Apple.order(:starts_at).to_a

expect(actual).to eq([tsugaru, shinano_dolce, akibae, shinano_gold, fuji])

 

降順ソート

order メソッドに desc シンボルを指定することで、降順ソートができます。

actual = Apple.order(starts_at: :desc).to_a

expect(actual).to eq([fuji, shinano_gold, akibae, shinano_dolce, tsugaru])

 

複数テーブル・単一キーでソート

関連先のテーブル列でソート

今回は、 applesareas をJOINし、 areas.name をキーにソートします。

ソート方法として、

  • orderメソッドに文字列を渡す
  • mergeメソッドを使う

の2つがあるため、それぞれ見ていきます。
Railsでjoinしたテーブルでorderする - Qiita

 

orderメソッドに文字列を渡す

actual = Apple.joins(:area).order('areas.name DESC').to_a

expect(actual).to eq([tsugaru, fuji,  shinano_dolce, akibae, shinano_gold])

 

mergeメソッドを使う

actual = Apple.joins(:area).merge(Area.order(name: :desc)).to_a

expect(actual).to eq([tsugaru, fuji,  shinano_dolce, akibae, shinano_gold])

 
mergeメソッドの場合も、SQLではORDER句でのソートになっています。

SELECT
  "apples".*
FROM
  "apples"
  INNER JOIN "areas" ON "areas"."id" = "apples"."area_id"
ORDER BY
  "areas"."name" DESC

 

単一テーブル・複数キーでソート

order メソッドにソートキーを複数指定すれば良いです。

actual = Apple.order(name: :asc, starts_at: :desc).to_a

expect(actual).to eq([tsugaru, fuji, shinano_gold, shinano_dolce, akibae])

 
なお、発行されるSQLはこちらです。

SELECT
  "apples".*
FROM
  "apples"
ORDER BY
  "apples"."name" ASC,
  "apples"."starts_at" DESC

 

複数テーブル・複数キーでソート

結果をわかりやすくするため、秋映と同じ時期の青森県育成りんご 津軽ゴールド を追加します。

No name weight color starts_at is_imported area_id
1 シナノドルチェ 300 2 (red) 2023/09/10 false 2 (長野県)
2 秋映 300 2 (red) 2023/10/10 false 2 (長野県)
3 シナノゴールド 350 1 (yellow) 2023/10/20 false 2 (長野県)
4 つがる 300 2 (red) 2023/09/01 false 1 (青森県)
5 ふじ 300 2 (red) 2023/11/01 false 1 (青森県)
6 津軽ゴールド 300 1 (yellow) 2023/10/10 false 1 (青森県)

 

関連元の列 > 関連先の列でソート

例えば、appleのstarts_atの降順 > areaのnameの降順 でソートしたいとします。

この場合、関連元にて order でソートしてから、関連先のソートをmergeします。

秋映の後に津軽ゴールドが来るように並んでいます。

actual = Apple.joins(:area).order(starts_at: :desc).merge(Area.order(name: :desc)).to_a

expect(actual).to eq([fuji, shinano_gold, akibae, tsugaru_gold, shinano_dolce, tsugaru])

 
発行されるSQLはこちら。

SELECT
  "apples".*
FROM
  "apples"
  INNER JOIN "areas" ON "areas"."id" = "apples"."area_id"
ORDER BY
  "apples"."starts_at" DESC,
  "areas"."name" DESC

 

関連先の列 > 関連元の列でソート

例えば、 areaのnameの降順 > appleのstarts_atの降順でソート とします。

この場合、関連先をmergeでソートしてから、関連元をorderでソートします。

areas.nameの降順なので、青森県育成のものが先頭に来てから、長野県育成のものが並んでいます。

actual = Apple.joins(:area).merge(Area.order(name: :desc)).order(starts_at: :desc).to_a

expect(actual).to eq([fuji, tsugaru_gold, tsugaru, shinano_gold, akibae, shinano_dolce])

 
発行されるSQLはこちら。

SELECT
  "apples".*
FROM
  "apples"
  INNER JOIN "areas" ON "areas"."id" = "apples"."area_id"
ORDER BY
  "areas"."name" DESC,
  "apples"."starts_at" DESC

 

データ取得後、Rubyのarrayになってからソート

DBからデータを取得する時点ではなく、要素がモデルオブジェクトのarrayになってからソートしたいことがあるかもしれません。

その場合のソート方法について見ていきます。

なお、特に断りのない限り、 apples は以下となります。

let(:apples) { Apple.all.to_a }

 

モデルオブジェクトの単一属性をキーにソート

今回使うモデル Apple のオブジェクトには属性として

  • string
  • integer
  • enum
  • datetime
  • boolean
  • 外部キー先の属性

などがあります。

そこで、それぞれの属性ごとに昇順・降順ソートを見ていきます。

なお、Rubyでソートをする場合は sortsort_by メソッドが使えます。

 
パフォーマンス的には sort_by の方が良さそうですので、この記事ではできる限り sort_by を使います。
sorting - How to sort an array in descending order in Ruby - Stack Overflow

 

string型の属性

昇順ソートは sort_by

普通にソートできます。

actual = apples.sort_by(&:name)

expect(actual).to eq([tsugaru, fuji, shinano_gold, shinano_dolce, akibae])

 

降順ソートは sort_by + reverse

sort_by での降順ソートは、先頭に - をつける記事がみあたります。

ただ、stringの場合はstringクラスに - メソッドが無いせいか、正しく並び替えが行われません。
https://docs.ruby-lang.org/ja/latest/class/String.html

そのため、昇順ソートした後に reverse メソッドを使って逆に並べ替えることになりそうです。
Sorting an array of strings in Ruby - Stack Overflow

actual = apples.sort_by(&:name).reverse

expect(actual).to eq([akibae, shinano_dolce, shinano_gold, fuji, tsugaru])

 

integer型の属性

昇順・降順とも sort_by

integer型の場合は sort_by が使えます。

昇順はそのまま。

actual = apples.sort_by(&:weight)

expect(actual).to eq([shinano_dolce, akibae, tsugaru, fuji, shinano_gold])

 
降順は、項目の先頭に - を付ければ可能です。

actual = apples.sort_by { |a| -a.weight }

expect(actual).to eq([shinano_gold, shinano_dolce, akibae, tsugaru, fuji])

 

enum型の属性

enum型はinteger型と似ていますが、

  • DBにはinteger型の値が入る
  • shinano_gold.color とすると yellow という文字列が返る
    • shinano_gold.color_before_type_cast とすると、 1 などのinteger型の値が返る

の違いがあります。

今回は「integer型の値でソートしたい」という前提で書いていきます。

また、enumcolor の利用を明確にするため、green な値を持つりんご ブラムリー を追加します。

データの全体はこんな感じです。

No name weight color starts_at is_imported area_id
1 シナノドルチェ 300 2 (red) 2023/09/10 false 2 (長野県)
2 秋映 300 2 (red) 2023/10/10 false 2 (長野県)
3 シナノゴールド 350 1 (yellow) 2023/10/20 false 2 (長野県)
4 つがる 300 2 (red) 2023/09/01 false 1 (青森県)
5 ふじ 300 2 (red) 2023/11/01 false 1 (青森県)
6 ブラムリー 300 3 (green) 2023/09/10 true 5 (イギリス)

 

attributeの場合、string型と同じく sort_by

attributeの場合、取得できる値はstringなので、string型と同じ方法でのソートとなります。

# 昇順ソート
actual = apples.sort_by(&:color)

# green -> red -> yellow
expect(actual).to eq([bramley, shinano_dolce, akibae, tsugaru, fuji, shinano_gold])


# 降順ソート
actual = apples.sort_by { |a| a.color }.reverse

# yellow -> red -> green
expect(actual).to eq([shinano_gold, fuji, tsugaru, akibae, shinano_dolce, bramley])

 

before_type_castの場合、昇順・降順ソートとも sort_by

enumからinteger型の値で取り出す場合には、integer型と同じになります。

# 昇順ソート
actual = apples.sort_by(&:color_before_type_cast)

# yellow (1) -> red (2) -> green (3)
expect(actual).to eq([shinano_gold, shinano_dolce, akibae, tsugaru, fuji, bramley])


# 降順ソート
actual = apples.sort_by { |a| -(a.color_before_type_cast) }

# green (3) -> red (2) -> yellow (1)
expect(actual).to eq([bramley, shinano_dolce, akibae, tsugaru, fuji, shinano_gold])

なお、今回の例では分かりづらいのですが、降順ソートの結果がattributeとbefore_type_castで異なっています。

 

datetime型の属性

昇順ソートは sort_by

昇順ソートは sort_by でdatetime型の属性をそのまま使えます。

actual = apples.sort_by(&:starts_at)

expect(actual).to eq([tsugaru, shinano_dolce, akibae, shinano_gold, fuji])

 

降順ソートは sort_by + to_i

降順ソートの場合、 - を指定するとエラーになります。

actual = apples.sort_by { |a| -(a.starts_at) }
#=> NoMethodError: undefined method `-@' for Sun, 10 Sep 2023 00:00:00.000000000 JST +09:00:ActiveSupport::TimeWithZone

 
そのため、datetime型をそのまま使うのではなく、 to_i メソッドでUNIXタイムスタンプへと変換してから降順ソートすれば良さそうです。
https://api.rubyonrails.org/classes/DateTime.html#method-i-to_i

actual = apples.sort_by { |a| -(a.starts_at.to_i) }

expect(actual).to eq([fuji, shinano_gold, akibae, shinano_dolce, tsugaru])

 

boolean型の属性

boolean型の属性の場合、そのまま使うとエラーになります。

actual = apples.sort_by { |a| a.is_imported }
#=> comparison of FalseClass with true failed

 
そこで、 boolean を integer に変換して使うのが良さそうです。
ruby - How do I convert boolean values to integers? - Stack Overflow

 
なお、booleanに昇順・降順という意味があるのか分からないため、今回は昇順・降順という表現は使わないようにします。

また、ソートを分かりやすくするよう、ここでは is_imported == true なデータを増やしてみます。

No name weight color starts_at is_imported area_id
1 シナノドルチェ 300 2 (red) 2023/09/10 false 2 (長野県)
2 秋映 300 2 (red) 2023/10/10 false 2 (長野県)
3 シナノゴールド 350 1 (yellow) 2023/10/20 false 2 (長野県)
4 つがる 300 2 (red) 2023/09/01 false 1 (青森県)
5 ふじ 300 2 (red) 2023/11/01 false 1 (青森県)
6 ピンクレディ 250 2 (red) 2023/11/20 true 4 (オーストラリア)

 

true -> false の順に並べるときは、trueで 0 を返す

is_importedtrue の場合に 0 となるよう、 sort_by で実装します。

actual = apples.sort_by { |a| a.is_imported ? 0 : 1 }

expect(actual).to eq([pink_lady, shinano_dolce, akibae, shinano_gold, tsugaru, fuji])

 

false -> true の順に並べるときは、true で 1 を返す

先ほどとは逆で、 true のときに 1 が返るようにします。

actual = apples.sort_by { |a| a.is_imported ? 1 : 0 }

expect(actual).to eq([shinano_dolce, akibae, shinano_gold, tsugaru, fuji, pink_lady])

 

関連先の属性

昇順・降順ソートとも、 eager_load + sort_by

まず、関連先の属性を使えるようにするため、 eager_load を使って取得するようにします。

let(:apples) { Apple.eager_load(:area).all.to_a }

 
あとは、関連先の列をドットで参照しつつ、列の型に対するソート方法に従えばよいです。

今回は関連先の id 列でソートするため、integer型のソート方法に従います。

# 昇順ソート
actual = apples.sort_by { |a| a.area.id }

expect(actual).to eq([tsugaru, fuji, shinano_dolce, akibae, shinano_gold, pink_lady])


# 降順ソート
actual = apples.sort_by { |a| -(a.area.id) }

expect(actual).to eq([pink_lady, shinano_dolce, akibae, shinano_gold, tsugaru, fuji])

 

モデルオブジェクトの複数属性をキーにソート

今回は多少手間のかかる

  • color (enum型)
  • name (string型)

の2属性を使い、各種ソートを試してみます。

なお、enumのソートにて yellow なりんごが複数あると分かりやすくなるため、シナノゴールドと同じ色の津軽ゴールドを追加します。

No name weight color starts_at is_imported area_id
1 シナノドルチェ 300 2 (red) 2023/09/10 false 2 (長野県)
2 秋映 300 2 (red) 2023/10/10 false 2 (長野県)
3 シナノゴールド 350 1 (yellow) 2023/10/20 false 2 (長野県)
4 つがる 300 2 (red) 2023/09/01 false 1 (青森県)
5 ふじ 300 2 (red) 2023/11/01 false 1 (青森県)
6 津軽ゴールド 300 1 (yellow) 2023/10/10 false 1 (青森県)

 

並べ方が単一の場合

enumの昇順 + stringの昇順の場合、 sort_by + ブロックに属性の配列を指定

sort_by で複数のキーを指定する場合、ブロックにキーの配列を渡せば良さそうです。
Sorting: Sort array based on multiple conditions in Ruby - Stack Overflow

actual = apples.sort_by { |a| [a.color_before_type_cast, a.name] }

expect(actual).to eq([shinano_gold, tsugaru_gold, tsugaru, fuji, shinano_dolce, akibae])

 

enumの降順 + stringの降順の場合、sort_by + ブロックに属性の配列を指定 + reverse

並び方としては 昇順 + 昇順 の逆となれば良いので、並び替えた結果を reverse します。

actual = apples.sort_by { |a| [a.color_before_type_cast, a.name] }.reverse

expect(actual).to eq([akibae, shinano_dolce, fuji, tsugaru, tsugaru_gold, shinano_gold])

 

並べ方が混在している場合

並び方が混在(昇順 + 降順)している場合、 sort_by でソートするのは難しそうです。

前者で挙げられている実装例は

# gender desc, name asc
p(dogs.sort do |a, b|
  [b[:gender], a[:name]] <=> [a[:gender], b[:name]]
end)

後者で挙げられている実装例は

books.sort! do |a, b|
  (a.author <=> b.author).nonzero? ||
    (b.title <=> a.title)
end

でした。

今回は前者を見ながら実装してみます。

 

enumの昇順 + nameの降順は sort

前者で実装する場合、降順としたい属性の ab を入れ替えれば良さそうでした。

そのため、今回は enumの昇順 + nameの降順であるため、 name 属性の ab を入れ替えます。

actual = apples.sort do |a, b|
  [a.color_before_type_cast, b.name] <=> [b.color_before_type_cast, a.name]
end

# 色が yellow -> red、同じ色の中では漢字 -> カタカナ -> ひらがな 順
expect(actual).to eq([tsugaru_gold, shinano_gold, akibae, shinano_dolce, fuji, tsugaru])

 

enumの降順 + nameの昇順は sort

先ほどとは逆に、 enum型の color 属性について ab を入れ替えます。

actual = apples.sort do |a, b|
  [b.color_before_type_cast, a.name] <=> [a.color_before_type_cast, b.name]
end

# 色が red -> yellow、同じ色の中ではひらがな -> カタカナ -> 漢字 順
expect(actual).to eq([tsugaru, fuji, shinano_dolce, akibae, shinano_gold, tsugaru_gold])

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app

今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/rails_7_0_minimal_app/pull/5

RailsのActiveRecordで、ある条件の関連先レコードがない場合にその関連元データを取得する

以前、RailsActiveRecordにて、 eager_load を使って

  • 子側が存在しない場合は結果に含めずに抽出する
  • 子側が存在しなくても、結果に含めて抽出する

をしました。
Railsで、eager_loadのLEFT OUTER JOINにて、WHERE句とON句のそれぞれで絞り込みしてみた - メモ的な思考的な

 
そんな中、「ある条件の関連先レコードがない関連元データだけを取得する」方法が気になったため、Railsガイドを見ながら試したことをメモとして残します。
Active Record クエリインターフェイス - Railsガイド

なお、今回は「適切にデータが取得できること」を目的とします。そのため、システムの仕様やRDBMSに関係する部分(用途やパフォーマンス)での良し悪しは考慮しません。

 
目次

 

環境

 

関連先を絞らずに、関連先レコードが存在しない関連元データを取得する

本題に入る前段として、まずは「関連先を絞らずに、関連先レコードが存在しない関連元データを取得する」を試してみます。

 

データについて

テーブル構造

今回は、限定商品を管理するということで、限定商品(reserved_products) : 販売先(sales) = 1 : n となるようなテーブルを用意します。

models/reserved_product.rb

# == Schema Information
#
# Table name: reserved_products
#
#  id         :integer          not null, primary key
#  name       :string
#  created_at :datetime         not null
#  updated_at :datetime         not null
#
class ReservedProduct < ApplicationRecord
  has_many :sales
end

 
models/sales.rb

# == Schema Information
#
# Table name: sales
#
#  id                  :integer          not null, primary key
#  memo                :string
#  created_at          :datetime         not null
#  updated_at          :datetime         not null
#  reserved_product_id :integer          not null
#
# Indexes
#
#  index_sales_on_reserved_product_id  (reserved_product_id)
#
# Foreign Keys
#
#  reserved_product_id  (reserved_product_id => reserved_products.id)
#
class Sale < ApplicationRecord
  belongs_to :reserved_product
end

 
Rails mermaid ERDによるER図はこちら。
https://github.com/koedame/rails-mermaid_erd

 

各テーブルデー

各テーブルには以下のようなデータが含まれています。

reserved_products

id name
1 りんご
2 バナナ
3 ぶどう

 
sales

id memo reserved_product_id
1 fooさん - りんご - 1つめ 1
2 fooさん - りんご - 2つめ 1
3 barさん - りんご 1
4 bazさん - ぶどう 3

 

取得結果の期待値

今回は、関連先データとして sales がない reserved_products のレコード (ここではバナナ) を取得したいとします。

[#<ReservedProduct:0x00007f0f59c91d68 id: 59467727, name: "バナナ", ...]

 

実装パターン

LEFT OUTER JOIN + IS NULLで取得する

想定しているSQLはこちらです。

SELECT
  "reserved_products".*
FROM
  "reserved_products"
  LEFT OUTER JOIN "sales" ON "sales"."reserved_product_id" = "reserved_products"."id"
WHERE
  "sales"."id" IS NULL

 
これをActiveRecordで発行する場合の実装パターンはいくつかあるため、それぞれ見ていきます。

 

left_outer_joins + where を使う

このパターンで使う各メソッドは以下の通りです。

 
実装です。

ReservedProduct.left_outer_joins(:sales).where(sales: { id: nil })

 
発行されるSQLはこちら。

SELECT
  "reserved_products".*
FROM
  "reserved_products"
  LEFT OUTER JOIN "sales" ON "sales"."reserved_product_id" = "reserved_products"."id"
WHERE
  "sales"."id" IS NULL

 

Rails6.1から追加された missing を使う

Rails6.1にて WhereChainの missing が追加されましたので、こちらも試してみます。

 
実装はこんな感じで、 left_outer_joins に比べるとシンプルです。

ReservedProduct.where.missing(:sales)

   
発行されるSQLleft_outer_joins と同じです。

SELECT
  "reserved_products".*
FROM
  "reserved_products"
  LEFT OUTER JOIN "sales" ON "sales"."reserved_product_id" = "reserved_products"."id"
WHERE
  "sales"."id" IS NULL

 

eager_load を使う

今回は関連先のデータは使用しないのですが、 eager_load も使えます。

 
実装は left_outer_joins と似たような感じです。

ReservedProduct.eager_load(:sales).where(sales: { id: nil })

 
発行されるSQLです。SELECT句が増えていますが、今回欲しいデータは取得できています。

SELECT
  "reserved_products"."id" AS t0_r0,
  "reserved_products"."name" AS t0_r1,
  "reserved_products"."created_at" AS t0_r2,
  "reserved_products"."updated_at" AS t0_r3,
  "sales"."id" AS t1_r0,
  "sales"."memo" AS t1_r1,
  "sales"."reserved_product_id" AS t1_r2,
  "sales"."created_at" AS t1_r3,
  "sales"."updated_at" AS t1_r4
FROM
  "reserved_products"
  LEFT OUTER JOIN "sales" ON "sales"."reserved_product_id" = "reserved_products"."id"
WHERE
  "sales"."id" IS NULL

 

NOT IN で取得する

想定しているSQLはこちらです。

SELECT
  "reserved_products".*
FROM
  "reserved_products"
WHERE
  "reserved_products"."id" NOT IN (
    SELECT
      "reserved_products"."id"
    FROM
      "reserved_products"
      INNER JOIN "sales" ON "sales"."reserved_product_id" = "reserved_products"."id"
  )

 
ActiveRecordではこうなります。

ReservedProduct.where.not(id: ReservedProduct.joins(:sales).select(:id))

 

NOT EXISTSで取得する

想定しているSQLはこちらです。

SELECT
  "reserved_products".*
FROM
  "reserved_products"
WHERE
  (
    NOT EXISTS (
      SELECT
        "sales".*
      FROM
        "sales"
      WHERE
        (reserved_products.id = sales.reserved_product_id)
    )
  )

 
ただ、ActiveRecordでは EXISTS述語を発行できるメソッドはなさそうです(もしあったら教えていただけるとありがたいです)。

 
そのため、一部で生SQLを書く感じになります。

 

サブクエリをto_sqlし、whereに生SQLを渡す

EXISTS述語を含む部分をすべて生SQLで書くこともできます。

ただ、今回は to_sql を使ってEXISTS述語のサブクエリ部分のSQLActiveRecordに任せることとします。

 
ActiveRecordではこうなります。

sql = Sale.where('reserved_products.id = sales.reserved_product_id').to_sql
ReservedProduct.where("NOT EXISTS (#{sql})")

 
発行されるSQLはこちら。

SELECT
  "reserved_products".*
FROM
  "reserved_products"
WHERE
  (
    NOT EXISTS (
      SELECT
        "sales".*
      FROM
        "sales"
      WHERE
        (reserved_products.id = sales.reserved_product_id)
    )
  )

 

whereにarrayを渡す

先ほどは to_sql を使いました。

ただ、以下の記事よると to_sql しなくても where だけで実現できそうです。

Because Rails follows standard naming conventions when querying (the downcased plural form of our model), we can add the above condition into our subquery without too much difficulty.

Advanced Active Record: Using Subqueries in Rails

また、ActiveRecordwherearray の説明にも渡せそうな記述があります。
https://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html#method-i-where

 
試してみると、たしかに問題なく動作しました。

ReservedProduct.where('NOT EXISTS (:sales)', sales: Sale.where('reserved_products.id = sales.reserved_product_id'))

 
発行されるSQLも同じです。

SELECT
  "reserved_products".*
FROM
  "reserved_products"
WHERE
  (
    NOT EXISTS (
      SELECT
        "sales".*
      FROM
        "sales"
      WHERE
        (reserved_products.id = sales.reserved_product_id)
    )
  )

 
ちなみに、 Advanced Active Record: Using Subqueries in Rails ではサブクエリで select('1') しています。

ただ、書籍「達人に学ぶSQL徹底指南書 第2版」(ミック著、翔泳社) によると、

EXISTS内のサブクエリのSELECT句のリストには、次の3通りの書き方があります。

  1. ワイルドカード: SELECT *
  2. 定数: SELECT 'ここは何でもいいんだよ'
  3. 列名: SELECT col

しかし、この3つの書式は、いずれも結果に違いがないのです。

達人に学ぶSQL徹底指南書 第2版 第1部 1-5 EXISTS述語の使い方

とのことなので、今回はサブクエリでは select('1') を書きませんでした。

 

Arelのexistsを使う

EXISTS述語を生SQLで書きたくない場合は Arel の exists も使えそうです。

 
Arelを使って書くとこんな感じになります。

ReservedProduct.where(Sale.where('reserved_products.id = sales.reserved_product_id').arel.exists.not)

 
発行されるSQLはこちら。カッコが増えているくらいのようです。

SELECT
  "reserved_products".*
FROM
  "reserved_products"
WHERE
  NOT (
    EXISTS (
      SELECT
        "sales".*
      FROM
        "sales"
      WHERE
        (reserved_products.id = sales.reserved_product_id)
    )
  )

 
なお、Arelで書くのは避けたほうが良さそうです。ただ、今回の実装であればそこまで複雑なものにはならなそうです。
Arelでクエリを書くのはやめた方が良い5つの理由 - Qiita

 

RSpecで動作確認

念のため、上記の各実装の結果が同じになることをRSpecで確認してみると、問題なくパスしました。

require 'rails_helper'

RSpec.describe ReservedProduct, type: :model do
  let!(:apple) { create(:reserved_product, name: 'りんご') }
  let!(:banana) { create(:reserved_product, name: 'ばなな') }
  let!(:grape) { create(:reserved_product, name: 'ぶどう') }

  describe '関連先がない' do
    before do
      create(:sale, reserved_product: apple, memo: 'fooさん - りんご - 1つめ')
      create(:sale, reserved_product: apple, memo: 'fooさん - りんご - 2つめ')

      create(:sale, reserved_product: apple, memo: 'barさん - りんご')

      create(:sale, reserved_product: grape, memo: 'bazさん - ぶどう')
    end

    it 'いずれの結果も同じになること' do
      actual_by_left_join = ReservedProduct.left_outer_joins(:sales).where(sales: { id: nil }).to_a
      actual_by_missing = ReservedProduct.where.missing(:sales).to_a
      actual_eager_load = ReservedProduct.eager_load(:sales).where(sales: { id: nil }).to_a
      actual_not_in = ReservedProduct.where.not(id: ReservedProduct.joins(:sales).select(:id)).to_a
      actual_not_exists = ReservedProduct.where('NOT EXISTS (:sales)',
                                                sales: Sale.where('reserved_products.id = sales.reserved_product_id'))
                                         .to_a
      actual_not_exists_with_arel = ReservedProduct.where(Sale.where('reserved_products.id = sales.reserved_product_id').arel.exists.not).to_a

      expect(actual_by_left_join).to eq(actual_by_missing)
                                       .and eq(actual_eager_load)
                                              .and eq(actual_not_in)
                                                     .and eq(actual_not_exists)
                                                            .and eq(actual_not_exists_with_arel)
    end
  end
end

 

ある条件の関連先レコードがない場合にその関連元データを取得する

続いて本題です。

データについて

テーブル構造

題材は同じですが、得意先ごとに限定商品を1つだけ買えるような構造とします。

  • 限定商品(reserved_products) : 限定商品の得意先(sale_by_customers) = 1 : n
  • 限定商品の得意先(sale_by_customers): 得意先 (customers) = n : 1

モデル + annotateはこんな感じです。

reserved_products

# == Schema Information
#
# Table name: reserved_products
#
#  id         :integer          not null, primary key
#  name       :string
#  created_at :datetime         not null
#  updated_at :datetime         not null
#
class ReservedProduct < ApplicationRecord
  has_many :sale_by_customers
end

 
sale_by_customers

# == Schema Information
#
# Table name: sale_by_customers
#
#  id                  :integer          not null, primary key
#  memo                :string
#  created_at          :datetime         not null
#  updated_at          :datetime         not null
#  customer_id         :integer          not null
#  reserved_product_id :integer          not null
#
# Indexes
#
#  index_sale_by_customers_on_customer_id          (customer_id)
#  index_sale_by_customers_on_reserved_product_id  (reserved_product_id)
#
# Foreign Keys
#
#  customer_id          (customer_id => customers.id)
#  reserved_product_id  (reserved_product_id => reserved_products.id)
#
class SaleByCustomer < ApplicationRecord
  belongs_to :reserved_product
  belongs_to :customer
end

 
customers

# == Schema Information
#
# Table name: customers
#
#  id         :integer          not null, primary key
#  name       :string
#  created_at :datetime         not null
#  updated_at :datetime         not null
#
class Customer < ApplicationRecord
end

 
Rails mermaid ERDによるER図はこちら。

各テーブルデー

reserved_products

id name
1 りんご
2 バナナ
3 ぶどう

 
sale_by_customers

id memo reserved_product_id customer_id
1 fooさんのりんご 1 1
2 fooさんのバナナ 2 1
3 fooさんのぶどう 3 1
4 barさんのりんご 1 2
5 bazさんのバナナ 2 3
6 bazさんのぶどう 3 3

 
customers

id name
1 foo
2 bar
3 baz

 

取得結果の期待値 

指定した得意先 (customer) に対して、reserved_productsがsale_by_customersに存在しないレコードをreserved_productsから取得したいとします。

# fooさん
=> []

# barさん
=> [#<ReservedProduct:0x00007f77b1348e58 id: 59467727, name: "バナナ", ...>, 
    #<ReservedProduct:0x00007f77b1330718 id: 938768738, name: "ぶどう", ...>]

# bazさん
=> [#<ReservedProduct:0x00007f77b1322dc0 id: 690933842, name: "りんご", ...>]

 

実装

今回は「指定した得意先」という動的な絞り込み条件が必要です。

そのため、関連先を絞らない場合で使っていたメソッドが使えないケースも出てきます。

 

LEFT OUTER JOIN + ON句での絞り込み + IS NULL で取得する

想定しているSQLはこちらです。

JOINする前に customer の絞り込みが必要になることから、ON句にて絞り込みを行っています。

仮にWHERE句で絞り込みを行おうとしても、 customers の各列がNULLになるため、customer.id での絞り込みはできません。

SELECT
  "reserved_products".*
FROM
  "reserved_products"
  LEFT OUTER JOIN sale_by_customers ON (
    reserved_products.id = sale_by_customers.reserved_product_id
    AND sale_by_customers.customer_id = 【指定した得意先】
  )
WHERE
  "sale_by_customers"."id" IS NULL

   
このパターンについては実装が大きく変更となっています。

 

モデルのscope + joinsで生SQL + where

以前の記事でも調べましたが、ActiveRecordでON句を指定してJOINするようなメソッドはなさそうでした。

 
そこで、 joins メソッドには結合条件の生SQLを渡すことができるのを利用します。

 
また、長めの生SQLを何度も書くことになるため、scopeを定義し引数として customer.id を受け取れるようにします。 15.1 引数を渡す | Active Record クエリインターフェイス - Railsガイド

 
なお、引数をそのまま埋め込むのではなく、ActiveRecordサニタイズ系メソッドを使うようにします。 https://api.rubyonrails.org/classes/ActiveRecord/Sanitization/ClassMethods.html

 
今回はON句でのサニタイズなので、 sanitize_sql_array を使います。

 
まとめると、こんな感じのscopeを ReservedProduct モデルに作ります。

class ReservedProduct < ApplicationRecord
  has_many :sale_by_customers

  scope :no_reservations_by, ->(customer_id) {
    sql = ApplicationRecord.sanitize_sql_array(
      ['LEFT OUTER JOIN sale_by_customers
            ON (reserved_products.id = sale_by_customers.reserved_product_id AND
                sale_by_customers.customer_id = :customer_id)',
       { customer_id: customer_id }])

    joins(sql).where(sale_by_customers: { id: nil })
  }
end

 
定義したscopeを使うことで、期待するSQLや結果が取得できました。

# 実行
ReservedProduct.no_reservations_by(1)

# 結果
#   ReservedProduct Load (0.2ms)  SELECT "reserved_products".* FROM "reserved_products" # LEFT OUTER JOIN sale_by_customers
#            ON (reserved_products.id = sale_by_customers.reserved_product_id AND
#                 sale_by_customers.customer_id = 1) WHERE "sale_by_customers"."id" IS NULL
#=> []

 

NOT IN で取得する

想定しているSQLはこちらです。

SELECT
  "reserved_products".*
FROM
  "reserved_products"
WHERE
  "reserved_products"."id" NOT IN (
    SELECT
      "reserved_products"."id"
    FROM
      "reserved_products"
      INNER JOIN "sale_by_customers" ON "sale_by_customers"."reserved_product_id" = "reserved_products"."id"
      INNER JOIN "customers" ON "customers"."id" = "sale_by_customers"."customer_id"
    WHERE
      "sale_by_customers"."customer_id" = 【指定した得意先】
  )

 
ActiveRecordではこうなります。

ReservedProduct.where.not(id: ReservedProduct.joins(sale_by_customers: :customer).where(sale_by_customers: { customer_id: 1 }).select(:id))

 

NOT EXISTSで取得する

想定しているSQLはこちらです。

SELECT
  "reserved_products".*
FROM
  "reserved_products"
WHERE
  (
    NOT EXISTS (
      SELECT
        "sale_by_customers".*
      FROM
        "sale_by_customers"
      WHERE
        (
          reserved_products.id = sale_by_customers.reserved_product_id
        )
        AND "sale_by_customers"."customer_id" = 【指定した得意先】
    )
  )

 
Arelを使う/使わないで実装が若干異なるため、それぞれ見ていきます。

 

whereにarrayを渡す

ActiveRecordではこうなります。

ReservedProduct.where('NOT EXISTS (:sale_by_customers)', sale_by_customers: SaleByCustomer.where('reserved_products.id = sale_by_customers.reserved_product_id').where(customer_id: 2))

 

Arelのexistsを使う

Arelを使う場合はこうなります。

ReservedProduct.where(SaleByCustomer.where('reserved_products.id = sale_by_customers.reserved_product_id').where(customer_id: 2 ).arel.exists.not)

 

RSpecで動作確認

先程と同じくRSpecのテストコードがパスすることも確認します。

require 'rails_helper'

RSpec.describe ReservedProduct, type: :model do
  let!(:apple) { create(:reserved_product, name: 'りんご') }
  let!(:banana) { create(:reserved_product, name: 'ばなな') }
  let!(:grape) { create(:reserved_product, name: 'ぶどう') }

  describe '条件付きの関連先がない' do
    let!(:foo) { create(:customer, name: 'foo') }
    let!(:bar) { create(:customer, name: 'bar') }
    let!(:baz) { create(:customer, name: 'baz') }

    before do
      create(:sale_by_customer, reserved_product: apple, customer: foo)
      create(:sale_by_customer, reserved_product: banana, customer: foo)
      create(:sale_by_customer, reserved_product: grape, customer: foo)

      create(:sale_by_customer, reserved_product: apple, customer: bar)
      create(:sale_by_customer, reserved_product: banana, customer: bar)

      create(:sale_by_customer, reserved_product: grape, customer: baz)
    end

    shared_examples 'いずれの結果も同じになること' do
      it do
        actual_by_scope = ReservedProduct.no_reservations_by(customer_id).to_a

        actual_not_in = ReservedProduct.where.not(id: ReservedProduct.joins(sale_by_customers: :customer)
                                                                     .where(sale_by_customers: { customer_id: customer_id })
                                                                     .select(:id))
                                       .to_a

        actual_not_exists = ReservedProduct
                              .where('NOT EXISTS (:sale_by_customers)',
                                     sale_by_customers: SaleByCustomer
                                                          .where('reserved_products.id = sale_by_customers.reserved_product_id')
                                                          .where(customer_id: customer_id))
                              .to_a

        actual_not_exists_with_arel = ReservedProduct
                                        .where(SaleByCustomer.where('reserved_products.id = sale_by_customers.reserved_product_id')
                                                             .where(customer_id: customer_id ).arel.exists.not)
                                        .to_a

        expect(actual_by_scope).to eq(actual_not_in).and eq(actual_not_exists).and eq(actual_not_exists_with_arel)
      end
    end

    context 'fooさんの予約がない' do
      let(:customer_id) { foo.id }

      it_behaves_like 'いずれの結果も同じになること'
    end

    context 'barさんの予約がない' do
      let(:customer_id) { bar.id }

      it_behaves_like 'いずれの結果も同じになること'
    end

    context 'bazさんの予約がない' do
      let(:customer_id) { baz.id }

      it_behaves_like 'いずれの結果も同じになること'
    end
  end
end

 

ソースコード

Githubに上げました。
https://github.com/thinkAmi-sandbox/rails_association-sample

プルリクはこちら。
https://github.com/thinkAmi-sandbox/rails_association-sample/pull/4

Railsにて、モデルのカスタムバリデーションメソッドの中で、標準のエラーメッセージを利用する

Railsでは、標準で用意されているモデルのバリデーションの他に、カスタムバリデーションメソッドを作成することで、独自のバリデーションを行えます。
6 カスタムバリデーションを実行する | Active Record バリデーション - Railsガイド

 
そんな中、「カスタムバリデーションメソッドは作るけど、そこで発生するバリデーションエラーは標準で用意されているものを使いたい」場合に、どのようにすればよいか悩んだため、メモを残します。

 
目次

 

環境

 

調査

カスタムバリデーションメソッドでは、 errors.add() メソッドを使うことでエラーメッセージを設定できます。
7.4 errors.add | Active Record バリデーション - Railsガイド

 
このメソッドの定義を見たところ、引数として

  • attribute
    • 属性名
  • type
    • 文字列 or シンボル or Proc
  • **options

がありました。
https://api.rubyonrails.org/v7.0/classes/ActiveModel/Errors.html#method-i-add

 
type にシンボルを渡した場合は、以下のように書かれていました。

If type is a symbol, it will be translated using the appropriate scope (see generate_message).

person.errors.add(:name, :blank)
person.errors.messages
# => {:name=>["can't be blank"]}

person.errors.add(:name, :too_long, { count: 25 })
person.errors.messages
# => ["is too long (maximum is 25 characters)"]

 
次に generate_message のドキュメントを読むと、定義されているシンボルを渡せば良さそうに見えました。

Translates an error message in its default scope (activemodel.errors.messages).

https://api.rubyonrails.org/v7.0/classes/ActiveModel/Errors.html#method-i-generate_message

 

 
次に、定義されているシンボルを調べたところ、以下にありました。
4.5.2 エラーメッセージ内での式展開 | Rails 国際化(i18n)API - Railsガイド

 
これで必要な情報が集まったため、次は検証をしてみます。

 

検証

いくつかのパターンでどのように実装が異なるかを試してみます。

 

エラーメッセージの引数が不要な場合

シンボル :blank を使った、カスタムバリデーションメソッドを持つモデルを用意します。

class Shop < ApplicationRecord
  validate :validate_name

  private def validate_name
    return if name.present?

    errors.add(:name, :blank)
  end
end

 
Railsコンソールで試してみたところ、標準のエラーメッセージが表示されました。

>> shop = Shop.new
   (1.2ms)  SELECT sqlite_version(*)
=> #<Shop:0x00007fad5cfd9bc8 id: nil, name: nil, created_at: nil, updated_at: nil>
>> shop.valid?
=> false
>> shop.errors.full_messages
=> ["Name can't be blank"]

 

エラーメッセージの引数が必要な場合

シンボル :wrong_length では引数 count が必要になるため、これを引数のシンボルとして使ってみます。

class Shop < ApplicationRecord
  validate :validate_name

  private def validate_name
    return if name.present?

    errors.add(:name, :wrong_length, count: 5)
  end
end

 
Railsコンソールで確認すると、引数の値がメッセージに反映されていました。

>> shop = Shop.new
   (1.8ms)  SELECT sqlite_version(*)
=> #<Shop:0x00007fb234ad94c0 id: nil, name: nil, created_at: nil, updated_at: nil>
>> shop.valid?
=> false
>> shop.errors.full_messages
=> ["Name is the wrong length (should be 5 characters)"]

 

標準のバリデータで、標準メッセージを別の標準メッセージに差し替える

Blogタイトルとは異なりますが、標準バリデータのメッセージを標準に差し替えることも試してみます。

Railsガイドによると、 message オプションを使えば良さそうでしたので、試してみます。
3.3 :message | Active Record バリデーション - Railsガイド

 
モデルを変更します。

class Shop < ApplicationRecord
  validates :name, presence: { message: :wrong_length, count: 4 }
end

 
Railsコンソールで確認したところ、メッセージが差し替わっていました。

>> shop = Shop.new
   (1.0ms)  SELECT sqlite_version(*)
=> #<Shop:0x00007f9c02589148 id: nil, name: nil, created_at: nil, updated_at: nil>
>> shop.valid?
=> false
>> shop.errors.full_messages
=> ["Name is the wrong length (should be 4 characters)"]

 

ソースコード

Githubに上げました。

React + DjangoなWebアプリに対して、PlaywrightでいろいろなE2Eのテストコードを書いてみた

前回の記事で、Playwrightを使ってDjango + ReactのE2Eテストができると分かりました。
WSL2 + Playwrightな環境にて、codegen機能によりReactアプリやDjango管理サイト向けのテストコードを自動生成してみた - メモ的な思考的な

前回は自動生成したテストコードを使っていたことから、今度は自分でPlaywrightによるE2のテストコードを書いてみたくなりました。

そこで色々と試したときのメモを残します。

 
なお、この記事ではPlaywrightの書き方に着目するため、Djangoアプリの実装については解説が必要な部分のみとしています。

もしソースコード全体を参照したい場合は、記事の末尾にあるソースコードを合わせてご確認ください。

 
また、この記事はPlaywrightの使い方が中心です。そのため、要素の特定については、深く考えずにid属性を使用しています。

ただ、実際のE2Eテストでは、要素の特定にid属性を使用するのではなく、「テキスト」や「data-testid などのdata-*属性」を使用したほうが良いようです。

そのため、E2Eテストの設計段階にて、要素の特定には何を使うか検討・認識合わせが必要でしょう。

 
目次

 

環境

Django・React・Playwright環境は前回のものに加え、必要なパッケージを追加しています。

 
ちなみに、今回はplaywright用のDjangoアプリを新規作成して試します。

$ python manage.py startapp playwright

 
なお、過去のWebの記事ではJestと組み合わせるために jest-playwright を使うものもありました。
playwright-community/jest-playwright: Running tests using Jest & Playwright

ただ、現在のPlaywrightでは、Jestのようなテストランナーがなくても、Playwright単独でテストコードを書いたり実行できます。

 

基本的な書き方

初めてのテストを書いてみる

Playwrightのドキュメントには First test があります。これを参考に手元のDjangoアプリでも書いてみます。

ここで使うHTMLはこんな感じで、 http://localhost:8000/playwright/locator で提供されているとします。

<p data-p="1" id="id_locator">by id</p>

 
このページに idid_locator なものがあり、テキストが by id であることを確認するテストを書くとこんな感じになります。

import { test, expect } from '@playwright/test';

test.describe('最初のテスト', () => {
  test('idをキーに取得できること', async ({ page }) => {
    // ページへ遷移
    await page.goto('http://localhost:8000/playwright/locator')

    // locatorオブジェクトを取得
    const result = page.locator('#id_locator')

    // テキストを持っているか確認
    await expect(result).toHaveText('by id')
  })
})

 
テストコードは以下の流れになっています。

 
過去にSeleniumをさわっているのであれば、似たような感じテストコードが書けそうなでした。

 

before/afterについて

テスティングフレームワークでは、「各テストの実行前後に何か処理を行いたい」という時に、 beforeafter のようなものが用意されています。

Playwrightでも、 beforeAllafterAllafterAllafterEach が用意されています。

 
そこで、初めてのテストで書いていた page.goto()beforeEach に抜き出してみます。

test.describe('beforeEachを使う', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:8000/playwright/locator')
  })

  test('idをキーに取得できること', async ({ page }) => {
    const result = page.locator('#id_locator')
    await expect(result).toHaveText('by id')
  })
})

 
これで、各テストの実行前に指定のページヘと遷移することができるようになり、各テストでの記述が不要になりました。

 

Locatorについて

初めてのテストでは page.locator() で、検証対象の要素(Locator)を取得していました。

Locatorを取得するには locaotr() メソッドの他、Quick Guideで書かれているメソッド群(例: getGByRole()getByText())が使えます。
https://playwright.dev/docs/locators#quick-guide

また、 locator() メソッドでは、CSSXPath、experimentalながらReact locatorなども指定できます。
https://playwright.dev/docs/other-locators

なお、deprecated なメソッド $()$eval() があることに注意します。
https://playwright.dev/docs/api/class-page#deprecated

 
例えば、以下のようなHTMLがあった場合、

<div id="locators">
  <p data-p="1" id="id_locator">by id</p>
  <p data-p="2" class="class_locator">by class</p>
  <p data-p="3" data-testid="test_id_locator">by test id</p>
  <p data-p="4" role="note">by ARIA role</p>
  <p data-p="5">by Text</p>

  <form>
    <label for="label_locator">by label</label>
    <input type="text" id="label_locator" value="ラベル" />

    <label for="placeholder_locator">by Placeholder</label>
    <input type="text" id="placeholder_locator" placeholder="紅玉" value="シナノゴールド" />
  </form>
</div>

 
次のようなPlaywrightのコードでLocatorを取得・検証ができます。

test.describe('Locatorを一致で取得', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:8000/playwright/locator')
  })

  test('idをキーに取得できること', async ({ page }) => {
    const result = page.locator('#id_locator')
    await expect(result).toHaveText('by id')
  })

  test('classをキーに取得できること', async ({ page }) => {
    const result = page.locator('.class_locator')
    await expect(result).toHaveText('by class')
  })

  test('test-idをキーに取得できること', async ({ page }) => {
    const result = page.getByTestId('test_id_locator')
    await expect(result).toHaveText('by test id')
  })

  test('ARIAのroleをキーに取得できること', async ({ page }) => {
    const result = page.getByRole('note')
    await expect(result).toHaveText('by ARIA role')
  })

  test('data属性をキーに取得できること', async ({ page }) => {
    const result = page.getByText('by Text')
    await expect(result).toHaveAttribute('data-p', '5')
  })

  test('ラベルをキーに取得できること', async ({ page }) => {
    const result = page.getByLabel('by Label')
    await expect(result).toHaveValue('ラベル')
  })

  test('placeholderをキーに取得できること', async ({ page }) => {
    const result = page.getByPlaceholder('紅玉')
    await expect(result).toHaveValue('シナノゴールド')
  })
})

 
また、キーを指定する以外にも、メソッドによっては正規表現を指定することも可能です。

例えば、 getByTestId() は以下のようにも書けます。

test.describe('Locatorを正規表現で取得', () => {
  test('test-idをキーに取得できること', async ({ page }) => {
    await page.goto('http://localhost:8000/playwright/locator')

    const result = page.getByTestId(/test_.*tor/)
    await expect(result).toHaveText('by test id')
  })
})

 
他に、 locator メソッドでは、optionとしてHTML構造を考慮した取得も可能です。
https://playwright.dev/docs/api/class-page#page-locator

例えばこんなHTMLがあったとします。

<div class="self">
    自身
    <div id="child1" class="child">
        子の要素1
        <div class="grandchild">子1の子(孫)の要素1</div>
        <div class="grandchild">子1の子(孫)の要素2</div>
    </div>
    <div id="child2" class="child">
      子の要素2
      <div>子2の子(孫)の要素1</div>
      <div>子2の子(孫)の要素2</div>
    </div>
    <div id="child3" class="child">子の要素3</div>
</div>

 
この場合、子や孫の要素の状態を考慮しての絞り込みができます。

test.describe('Locatorを親子関係で絞って取得', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:8000/playwright/locator')
  })

  test('何も絞り込んでいないこと', async ({ page }) => {
    const result = page.locator('.child')
    await expect(result).toHaveCount(3)
  })

  test('hasでの絞り込みができていること', async ({ page }) => {
    const result = page.locator('.child', { has: page.locator('.grandchild') })
    await expect(result).toHaveCount(1)
    await expect(result).toHaveId('child1')
  })

  test('hasTextでの絞り込みができていること', async ({ page }) => {
    const result = page.locator('.child', { hasText: '子2の子(孫)の要素2' })
    await expect(result).toHaveCount(1)
    await expect(result).toHaveId('child2')
  })
})

 

CSSでの非表示とLocatorについて

CSSで非表示にする場合、 displayvisibilityopacity などが使えます。
display:none; VS visibility:hidden; VS opacity:0; - Qiita

そこで、Playwrightでも非表示の扱いがブラウザと同じかどうかを確認します。

例えば以下のHTMLがあったとします。

<div id="viewable_by_css">
  <button id="none_style" onclick="handleClick('None Style')">None Style</button>
  <button id="display_none" onclick="handleClick('Display None')" style="display: none">Display None</button>
  <button id="visibility_hidden" onclick="handleClick('Visibility Hidden')" style="visibility: hidden">Visibility Hidden</button>
  <button id="opacity_zero" onclick="handleClick('Opacity zero')" style="opacity: 0">Opacity zero</button>
</div>

<script>
  function handleClick(text) { alert(text) }
</script>

 
このHTMLに対しPlaywrightでクリックできるか試すテストを書いてみたところ、ブラウザと同じ結果になりました。

test.describe('ブラウザで見えないものの検証', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('http://localhost:8000/playwright/locator')
  })

  test('なにもないときはクリックできること', async ({ page }) => {
    page.on('dialog', async dialog => {
      expect(dialog.message()).toEqual('None Style')

      await dialog.accept()
    })

    const button = page.locator('#none_style')
    await button.click()
  })

  test('display: noneではクリックできないこと', async ({ page }) => {
    page.on('dialog', async dialog => {
      expect(dialog.message()).toEqual('Display None')

      await dialog.accept()
    })

    const button = page.locator('#display_none')
    
    test.skip(true, 'click()でtimeoutするため')
    await button.click()
  })

  test('visibility: hiddenではクリックできないこと', async ({ page }) => {
    page.on('dialog', async dialog => {
      expect(dialog.message()).toEqual('Visibility Hidden')

      await dialog.accept()
    })

    const button = page.locator('#visibility_hidden')

    test.skip(true, 'click()でtimeoutするため')
    await button.click()
  })

  test('opacity: 0ではクリックできること', async ({ page }) => {
    page.on('dialog', async dialog => {
      expect(dialog.message()).toEqual('Opacity zero')

      await dialog.accept()
    })

    const button = page.locator('#opacity_zero')
    await button.click()
  })
})

 

Assertionについて

PlaywrightではJestの expect libraryを使ってアサーションをしているようです。

そのため、Jestのアサーションの他、 LocatorAssertions などのPlaywrightで拡張されたアサーションも使えます。
https://playwright.dev/docs/api/class-locatorassertions

 
また、 .not というNegating Matchersも用意されているため、以下のような「存在しないこと」も簡単に記述できます。
https://playwright.dev/docs/test-assertions#negating-matchers

test('否定形による確認もできること', async ({ page }) => {
  await page.goto('http://localhost:8000/playwright/locator')

  const result = page.locator('.class_locator')

  // 指定したTextを持っていないこと
  await expect(result).not.toHaveText('by test id')
})

 

fixtureについて

初めてのテストでは page というfixtureを使っていましたが、他にもテスト中で使えるfixtureとして browsercontext などが用意されています。

 
また、独自のfixtureも作成できるようです。
https://playwright.dev/docs/test-fixtures#creating-a-fixture

 

Webアプリの挙動確認

ここまででPlaywrightの書き方を見てきました。

次は、Webアプリで見かける機能についてPlaywrightではどのようにテストできるか見ていきます。

 

Basic認証があるページを検証する

Basic認証があるページを検証したい場合の書き方について、Djangoで実装しながら見ていきます。

 

Djangoでの実装

DjangoBasic認証を行いたい場合ミドルウェアを書くなどいろいろな方法がありますが、今回は

  • アプリ全体ではなく、一部ページのみBasic認証をしたい
  • ライブラリに任せたい

ということで、 django-basicauth ライブラリを使います。
hirokiky/django-basicauth: Basic auth utilities for Django.

READMEを見ると、今回のPython3.10やDjango4.1でのテストはされてなさそうな記載になっています。ただ、手元では動作したため使ってみます。

pip installして、Viewとurls.py、およびsettings.pyを実装します。

READMEにある通り、Class based Viewで使う場合はこんな感じになります。

@method_decorator(basic_auth_required, name='dispatch')
class BasicAuthView(TemplateView):
    template_name = 'playwright/basic_auth.html'

 
settings.pyにはBasic認証のユーザーとパスワードの組を設定します。

BASICAUTH_USERS = {
    'foo': 'pass',
}

 

Playwrightの実装

Basic認証なため、

https://username:password@www.example.com/

のように、URLに埋め込む形で page.goto() に渡すこともできます。

 
ただ、Mozillaには

これらの URL の使用は推奨されていません。 Chrome ではセキュリティ上の理由から、URL の username:password@ 部分が削除されます。 Firefox ではサイトが実際に認証を要求するかどうかをチェックし、そうでない場合 Firefox はユーザーに「“www.example.com” というサイトに “username” というユーザー名でログインしようとしていますが、このウェブサイトは認証を必要としません。これはあなたを騙そうとしている可能性があります。」と警告します。

URL 内の認証情報を使用したアクセス | HTTP 認証 - HTTP | MDN

とあることから、Playwrightで用意されている別の方法を使います。

 

fixture「browser」とNetworkの「HTTP Authentication」により、テストコードレベルで設定

Playwrightのドキュメントによると、Networkの HTTP Authentication を使うことでBasic認証ができそうでした。
https://playwright.dev/docs/network#http-authentication

 
browser.newContext() を使えば良さそうだったため、 fixture browser と組み合わせて書くことにします。

import {test, expect} from "@playwright/test";

// https://playwright.dev/docs/network#http-authentication
test.describe('Basic認証', () => {
  // 引数のfixtureオブジェクトから browser を取り出す
  // pageオブジェクトは差し替えるので、browserだけで良い
  // https://playwright.dev/docs/api/class-fixtures
  test('Basic認証が必要なページを開けること', async ({ browser }) => {
    // Basic認証付きのブラウザに差し替え
    const context = await browser.newContext({
      httpCredentials: {
        username: 'foo',
        password: 'pass'
      }
    })

    const page = await context.newPage()

    // 差し替えたpageオブジェクトを使って検証
    await page.goto('http://localhost:8000/playwright/basic-auth')

    const result = page.locator('p')
    await expect(result).toHaveText('show Basic auth page')
  })
})

 

TestOptionsにより、ファイルレベルで設定

Playwrightには Test Options という機能があり、ファイルレベルで設定を適用することもできます。
https://playwright.dev/docs/api/class-testoptions

 
そこで、TestOptionsによるBasic認証のテストコードも書いてみます。

import {test, expect} from "@playwright/test";

// ファイルレベルでの設定変更
// https://playwright.dev/docs/api/class-testoptions
test.use({
  httpCredentials: {
    username: 'foo',
    password: 'pass'
  }
})

test.describe('Basic認証でTestOptionを使う場合', () => {
  test('Basic認証が必要なページを開けること', async ({ page }) => {
    await page.goto('http://localhost:8000/playwright/basic-auth')
    
    const result = page.locator('p')
    await expect(result).toHaveText('show Basic auth page')
  })
})

 

playwright.config.tsにより、テストスイート全体で設定

playwright.config.ts ファイルにBasic認証の設定を記載することで、テストスイート全体での設定が有効になりそうです。

 
今回はテストスイート全体では設定されなくて良いので、ためすのは省略します。

 

リダイレクトを検証する

Webアプリでは何かを処理した後にリダイレクトをすることがあります。

そこで、Playwrightではリダイレクトに自動追随するのか試してみます。

ここでは、Django(バックエンド)とReact(フロントエンド)、それぞれでリダイレクトを発生させてみて追随できるかを確認します。

 

Djangoで発生した場合

Djangoの実装

RedirectView を使って、 urls.py を設定します。
https://docs.djangoproject.com/en/4.1/ref/class-based-views/base/#redirectview

今回は playwright アプリでの検証なため、名前空間も考慮して pattern_name などを設定します。
https://docs.djangoproject.com/ja/4.1/topics/http/urls/#url-namespaces

urlpatterns = [
    path('redirect-from', RedirectView.as_view(pattern_name='playwright:redirected')),
    path('redirect-to',  TemplateView.as_view(template_name='playwright/redirected.html'), name='redirected'),
]

 

Playwrightの実装

以下のコードで試したところ、テストがパスしました。

page.goto() でリダイレクトが発生しても自動追随しているようです。

test.describe('Djangoでリダイレクトが発生した場合', () => {
  test('リダイレクトに対応できること', async ({ page }) => {
    await page.goto('http://localhost:8000/playwright/redirect-from')

    // この時点でリダイレクト先(redirect-to)へと遷移している
    const result = page.locator('p')
    await expect(result).toHaveText('Redirected by Django')
  })
})

 

Reactで発生した場合

Reactの実装

ReactのルーティングにはReact Routerを使っているため、 Navigate コンポーネントを使って実装します。
https://reactrouter.com/en/main/components/navigate

<Route path="redirect" >
  <Route path="from" element={<Navigate to="/playwright/react/redirect/to" replace />} />
  <Route path="to" element={<AfterRedirect />} />
</Route>

 

Playwrightの実装

Reactの場合も以下のテストコードがパスしたため、自動追随するようです。

test.describe('Reactでリダイレクトが発生した場合', () => {
  test('リダイレクトに対応できること', async ({ page }) => {
    await page.goto('http://localhost:8000/playwright/react/redirect/from')

    // この時点でリダイレクト先(redirect-to)へと遷移している
    const result = page.locator('p')
    await expect(result).toHaveText('Redirected by react router')
  })
})

 

新しいタブを開く

Webアプリでは新しいタブを開くこともあるため、Playwrightでの検証を確認してみます。

Djangoの実装

TemplateView で、以下のテンプレートをレンダリングするようにします。

なお、 rel には、以下を参考に noreferrernoopener の両方を設定しました。
noopener と noreferrer の整理、結局どっちを使えば良いのか | blog.ojisan.io

<p>
  <a id="new_tab" href="{% url 'playwright:new-tab' %}" target="_blank" rel="noreferrer noopener">
    新しいタブを開く
  </a>
</p>

 

Playwrightの実装

必要な機能は以下を参考にして実装します。

test('新しいタブが開かれること', async ({ page, context }) => {
  await page.goto('http://localhost:8000/playwright/open-tab')

  // タブの中身を確認
  // https://playwright.dev/docs/pages#handling-new-pages
  const pagePromise = context.waitForEvent('page')

  const el = page.locator('#new_tab')
  await el.click()
  
  const newPage = await pagePromise
  await newPage.waitForLoadState()

  const result = newPage.locator('p')
  await expect(result).toHaveText('New Tab!')
})

 

ファイル

Webアプリではファイルを扱うこともあるため、ダウンロード・アップロードをそれぞれ試してみます。

なお、今回は検証を容易にするため、CSVファイルのダウンロード・アップロードとします。

 

ファイルダウンロード

Djangoの実装

TemplateViewで以下のテンプレートを用意し、

<a id="download" href="{% url 'playwright:file-download' %}" target="_blank" rel="noopener">CSVダウンロード</a>

aタグをクリックしたら、以下のViewにリクエストが飛んでダウンロードできるようにします。

class FileDownloadView(View):
    def get(self, request, *args, **kwargs):
        response = HttpResponse(content_type='text/csv')
        response['Content-Disposition'] = 'attachment;  filename="download.csv"'
        writer = csv.writer(response)

        writer.writerow(['1', 'シナノゴールド'])
        writer.writerow(['2', '秋映'])

        return response

 

Playwrightの実装

公式ドキュメントに従い実装します。
https://playwright.dev/docs/downloads

なお、Downloadオブジェクトのメソッドを使えば色々検証できそうですが、今回はファイル名のみの検証にしておきます。
https://playwright.dev/docs/api/class-download

test('ファイルのダウンロードに成功すること', async ({ page }) => {
  await page.goto('http://localhost:8000/playwright/file-index')

  const downloadPromise = page.waitForEvent('download')

  await page.locator('#download').click()

  const download = await downloadPromise

  const fileName = download.suggestedFilename()
  expect(fileName).toEqual('download.csv')
})

ファイルアップロード

Djangoの実装

フォームからファイルをアップロードし、成功したらフラッシュメッセージを表示するようなテンプレートを作成します。

{% if messages %}
  {% for message in messages %}
    <p id="message-{{ forloop.counter }}">{{ message }}</p>
  {% endfor %}
{% endif %}

<form action="{% url 'playwright:file-upload' %}" method='POST' enctype="multipart/form-data">
  {% csrf_token %}

  <input id="upload" type="file" name="upload_file">

  <button type="submit">アップロード</button>
</form>

 
Viewではアップロードされたファイルのバリデーションなどは行わず、フラッシュメッセージを詰め込むだけにします。

class FileUploadView(View):
    def post(self, request, *args, **kwargs):
        data = io.TextIOWrapper(request.FILES['upload_file'].file, encoding='utf-8')
        for row in csv.reader(data):
            print(row)

        messages.success(request, 'アップロードしました')

        redirect_path = reverse("playwright:file-index")
        return redirect(redirect_path)

 

Playwrightでの確認

PlaywrightではLocatorの setInputFiles() メソッドを使って、 input type="file" な要素にファイルを添付できます。

ファイルはテスト機のローカルから取得する他、メモリ上のデータを使うこともできます。今回はメモリのデータを設定します。

 
今回のテストコードでは、レスポンスのステータスコードとフラッシュメッセージを確認してみます。

test('ファイルのアップロードができること', async ({ page }) => {
  await page.goto('http://localhost:8000/playwright/file-index')

  await page.locator('#upload').setInputFiles({
    name: 'upload.csv',
    mimeType: 'text/csv',
    buffer: Buffer.from('foo, bar')
  })

  const responsePromise = page.waitForResponse('http://localhost:8000/playwright/file-upload')
  await page.locator('button').click()

  const response = await responsePromise
  expect(response.status()).toEqual(302)

  // この時点ですでに遷移済
  // フラッシュメッセージを確認
  await expect(page.locator('#message-1')).toHaveText('アップロードしました')
})

 

alert

Playwrightでは、 alert などのJavaScriptによるダイアログも扱えます。

 
今回のアプリはReactで作っていることもあり、Reactに alert を組み込んでみて試してみます。

 

Reactの実装

Show Alert ボタンをクリックするとJavaScriptのアラートを表示するとともに、画面にも done alert を表示します。

import {useState} from "react";

export const Alert = () => {
  const [isAlert, setIsAlert] = useState<boolean>(false)

  const handleClick = () => {
    alert('show alert')
    setIsAlert(true)
  }

  return (
    <>
      {isAlert && <p id="result">done alert</p>}

      <button id="show_alert" onClick={handleClick}>Show Alert</button>
    </>
  )
}

 

Playwrightの実装

pageDialog イベントをハンドリングするような実装とします。

test.describe('JavaScript alertの動作確認', () => {
  test('alertのハンドリングに成功すること', async ({ page }) => {
    await page.goto('http://localhost:8000/playwright/react/alert')

    page.on('dialog', async (dialog) => {
      expect(dialog.message()).toEqual('show alert')
      await dialog.accept()

      // ここが検証コード
      await expect(page.locator('#result')).toHaveText('done alert')
    })

    await page.locator('#show_alert').click()
  })
})

     

クリップボードへの書き込み

ボタンを押したらクリップボードへ書き込む機能がある場合でも、Playwrightではクリップボードの中身を検証できます。

 

Reactの実装

Clipboard.writeText() を使い、ボタンを押したら data from clipboard というテキストがクリップボードに書き込まれるようにします。
https://developer.mozilla.org/ja/docs/Web/API/Clipboard/writeText

import {useState} from "react";

export const Clipboard = () => {
  const [isSave, setIsSave] = useState<boolean>(false)

  const handleClick = async () => {
    await navigator.clipboard.writeText('data from clipboard')
    setIsSave(true)
  }

  return (
    <>
      {isSave && <p id="result">saved!</p>}

      <button id="copy_clipboard" onClick={handleClick}>Save Clipboard</button>
    </>
  )
}

 

Playwrightで権限の追加と実装

Playwrightでクリップボードの読み書きを行う場合、 browser context に対して権限の追加が必要です。
https://playwright.dev/docs/api/class-browsercontext#browser-context-grant-permissions

そこで、テストコードでは、権限を追加しつつクリップボードの中身を検証します。

  test('クリップボードにコピーされていること', async ({ context, page }) => {
    // 権限を追加する
    context.grantPermissions([
      'clipboard-write',  // ブラウザの操作で必要
      'clipboard-read',   // テストでの検証で必要
    ])

    await page.goto('http://localhost:8000/playwright/react/clipboard')

    // クリップボードへコピー
    await page.locator('#copy_clipboard').click()

    // コピー後のメッセージが表示されているか確認
    await expect(page.locator('#result')).toHaveText('saved!')

    // クリップボードの中身を取得して検証
    const result = await page.evaluate(async () => {
      return await navigator.clipboard.readText()
    })
    expect(result).toEqual('data from clipboard')
  })

 

参考:Playwrightによる、クリップボードへの書き込み・読み取りのエミュレートについて

現時点ではショートカットキーのみ対応しており、一連の操作をいい感じにするメソッドは無いようです。
[Feature]: Copy & Paste Clipboard emulation · Issue #8114 · microsoft/playwright

 

Reactの挙動を検証

ここまでも一部Reactでの実装を行っていましたが、ここからはReactの機能やライブラリを使ったときの検証をしていきます。

 

stateの変更の確認

Reactの useState を使って、画面上の表示が変わるようなときも検証できるかをみてみます。

 

Reactの実装

ボタンを押したら、画面のカウンターが +1 されるReactコンポーネントを用意します。

import {useState} from "react";

export const Increment = () => {
  const [count, setCount] = useState<number>(0)

  return (
    <>
      <p>Counter: <span id="counter">{count}</span></p>
      <button id="increment" onClick={() => setCount(count + 1)}>Increment</button>
    </>
  )
}

 

Playwrightの実装

Webアプリの検証と同様な書き方で、state変更も検証できます。

  test('React stateの確認ができること', async ({ page }) => {
    await page.goto('http://localhost:8000/playwright/react/state')

    // クリック前の状態を確認
    await expect(page.locator('#counter')).toHaveText('0')

    // クリック
    await page.locator('#increment').click()

    // クリック後の状態を確認
    await expect(page.locator('#counter')).toHaveText('1')
  })

 

i18n対応

Reactでi18n対応するときも検証できるか試してみます。

なお、今回は @shopify/react-i18n を使ってi18n化対応をしてみます。
quilt/README.md at main · Shopify/quilt

 

Reactの実装

ライブラリのREADMEに従い、コンポーネントを作成します。

まずはi18n化したいコンポーネントを囲むためのコンポーネントを用意します。

import {I18nManager, I18nContext} from "@shopify/react-i18n";
import {I18nText} from "./I18nText";

export const I18nMain = () => {
  const locale = navigator.language

  const i18nManager = new I18nManager({
    locale
  })

  return (
    <>
      <I18nContext.Provider value={i18nManager}>
        <I18nText />
      </I18nContext.Provider>
    </>
  )
}

 
続いて、 I18nText コンポーネントで、実際にi18n対応をします。

ブラウザの localeja の場合は シナノゴールド を、そうでない場合は shinano gold を表示します。

import {useI18n} from "@shopify/react-i18n";

const ja = {
  "Apple": {
    "text": 'シナノゴールド'
  }
}

const en: typeof ja = {
  "Apple": {
    "text": 'shinano gold'
  }
}

export const I18nText = () => {
  const [i18n] = useI18n({
    id: 'Apple',
    translations(locale) {
      if (locale === 'ja') {
        return ja
      }
      return en
    }
  })

  return (
    <p id="result">{i18n.translate('Apple.text')}</p>
  )
}

 

Playwrightの実装

Playwrightでブラウザの locale を設定するには、

  • browser fixtureの newContext() メソッドで、適切な locale をセット
  • その状態で新しい contextpage を使う

とすれば良さそうです。
https://playwright.dev/docs/api/class-browser?_highlight=newcontext#browser-new-context

test('ブラウザが日本語の場合、日本語で表示されること', async ({ browser }) => {
  const context = await browser.newContext({
    locale: 'ja'
  })

  const page = await context.newPage()

  await page.goto('http://localhost:8000/playwright/react/i18n')

  await expect(page.locator('#result')).toHaveText('シナノゴールド')
})

test('ブラウザが英語の場合、英語で表示されること', async ({ browser }) => {
  const context = await browser.newContext({
    locale: 'en'
  })

  const page = await context.newPage()

  await page.goto('http://localhost:8000/playwright/react/i18n')

  await expect(page.locator('#result')).toHaveText('shinano gold')
})

 

MUIとの組み合わせ

ReactでUIを作る場合、UIライブラリを使うこともあります。

そこで今回は、MUIのコンポーネントの中でも動きがあるものに対してPlaywrightで検証できるか試してみます。

なお、MUIのドキュメントに従い、MUIをインストールしておきます。
https://mui.com/material-ui/getting-started/installation/

$ npm install @mui/material @emotion/react @emotion/styled

 

Snackbar

通知を表示するようなコンポーネントです。
React Snackbar component - Material UI

 

Reactの実装

MUIのSnackbarのサンプルにある Simple snackbars をベースに、 id などを付与しました。

ボタンを押したらSnackbarを表示します。また、閉じるボタンをクリックすると、Snackbarが閉じます。

import {Button, IconButton, Snackbar} from "@mui/material";
import {SyntheticEvent, useState} from "react";
import CloseIcon from '@mui/icons-material/Close'

export const MuiSnackbar = () => {
  const [open, setOpen] = useState<boolean>(false)

  const handleClick = () => {
    setOpen(true)
  }

  const handleClose = (event: SyntheticEvent | Event, reason?: string) => {
    if (reason === 'clickaway') {
      return
    }

    setOpen(false)
  }

  const action= (
    <IconButton
        id="close_snackbar"
        size="small"
        aria-label="close"
        color="inherit"
        onClick={handleClose}
      >
        <CloseIcon fontSize="small" />
      </IconButton>
  )

  return (
    <>
      <Button id="show_snackbar" onClick={handleClick}>Show Snackbar</Button>

      <Snackbar
        id="snackbar"
        open={open}
        autoHideDuration={6000}
        onClose={handleClose}
        message="Hello snackbar"
        action={action}
      />
    </>
  )
}

 

Playwrightの実装

Snackbarの表示と閉じるを検証します。

test('MUI Snackbarの動作確認ができること', async ({ page }) => {
  await page.goto('http://localhost:8000/playwright/react/mui')

  // snackbarが表示できること
  await page.locator('#show_snackbar').click()
  await expect(page.locator('#snackbar')).toHaveText('Hello snackbar')

  // snackbarが閉じること
  await page.locator('#close_snackbar').click()
  await expect(page.locator('#snackbar')).toHaveCount(0)
})

 

いわゆるモーダルのコンポーネントです。
React Modal component - Material UI

 

Reactの実装

MUIのサンプル Basic modal をベースに、ボタンを押したらモーダルが開くようにしています。

export function MuiModal() {
  const [open, setOpen] = useState(false)
  const handleOpen = () => setOpen(true)
  const handleClose = () => setOpen(false)

  return (
    <div>
      <Button id="show_modal" onClick={handleOpen}>Open modal</Button>
      <Modal
        open={open}
        onClose={handleClose}
        aria-labelledby="modal-modal-title"
        aria-describedby="modal-modal-description"
      >
        <Box id="modal_content" sx={style}>
          Hello Modal
        </Box>
      </Modal>
    </div>
  )
}

 

Playwrightの実装(キーを押してモーダルを閉じる)

Basic modalには閉じるボタンがなく、モーダル以外の場所をクリックすることで、モーダルが閉じるようになっています。

そこで、Playwrightでは page.keyboard.press() メソッドで特定のキーを押すことができることを利用し、Escapeキーを押したらモーダルが閉じることを検証します。
https://playwright.dev/docs/api/class-keyboard

test('MUI Modalの動作確認ができること', async ({ page }) => {
  await page.goto('http://localhost:8000/playwright/react/mui')

  // Modalが表示されること
  await page.locator('#show_modal').click()
  await expect(page.locator('#modal_content')).toHaveText('Hello Modal')

  // Modalが閉じること
  // 特定のelementをclickできないので、Escキーで閉じる
  await page.keyboard.press('Escape')
  
  await expect(page.locator('#modal_content')).not.toBeVisible()
  await expect(page.locator('#modal_content')).toHaveCount(0)
})

 

DataGrid への対応は容易ではなさそう

DataGridは MUI X なため、別途インストールします。

npm install @mui/x-data-grid

 
DataGridには色々な機能があり複雑なため、

  • Playwrightでテストコードを自動生成
  • 自動生成されたテストコードが動くか

を試してみます。

 
以下は自動生成されたコードに加え、最後にDataGridの内容を確認できるようスクリーンショットの撮影も追加しています。
https://playwright.dev/docs/screenshots

test('test', async ({ page }) => {
  await page.goto('http://localhost:8000/playwright/react/mui-datagrid');
  await page.getByRole('button', { name: 'Menu' }).click();
  await page.getByRole('menuitem', { name: 'Filter' }).click();
  await page.getByRole('combobox', { name: 'Operator' }).selectOption('>=');
  await page.getByPlaceholder('Filter value').click();
  await page.getByPlaceholder('Filter value').fill('30');
  await page.getByPlaceholder('Filter value').press('Enter');
  await page.getByRole('columnheader', { name: 'Last name' }).getByRole('button', { name: 'Sort' }).click();
  await page.getByRole('button', { name: 'Go to next page' }).click();
  await page.getByRole('button', { name: 'Go to previous page' }).click();

  // スクショを撮って確認
  await page.screenshot({ path: 'screenshot.png' });
});

 
このテストコードを動かしてみたところ、以下の行で waiting となり、最終的にテストが失敗しました。

await page.getByRole('button', { name: 'Menu' }).click()

内容をあらためて見ると、「DataGridのどの列のメニューをクリックしているか」がはっきりしません。

おそらくDataGridを解析していけばテストコードが書けるとは思います。

ただ、解析するには時間がかかりそうなことから、今回は「容易ではない」という結論にしておきます。

 
なお、テストを一括で流した時にテストが失敗しないよう、 test.skip() しておきます。
https://playwright.dev/docs/api/class-test#test-skip-3

test.skip(true, 'DataGridの解析が必要なのでskipする')

 

React Hook Formとの組み合わせ

Reactでフォームを扱う場合、フォームライブラリが便利です。

今回は React Hook Form を使ってフォームを作成します。
https://react-hook-form.com/

インストールしておきます。

$ npm install react-hook-form

 
ここでは、フォームの要素ごとに

  • フォームの要素への値を設定
  • フォームをsubmitしたときの値を検証

を行ってみます。

 
なお、Reactの実装は長くなってしまうため、記事での記載は省略します。詳細は後述のソースコードをご確認ください。

また、Reactでフォームを作ったとしてもDjangoと組み合わせる場合は CSRF tokenをフォームに入れる必要があります。今回は以下を参考に CSRFToken コンポーネントを作成しました。
python - How to use csrf_token in Django RESTful API and React? - Stack Overflow

 

MUI TextField について

TextFieldは type="text" なinput要素を作ります。
https://mui.com/material-ui/react-text-field/

 

TextFieldへ入力し、検証する

TextFieldへの入力はLocatorオブジェクトの fill() メソッドを利用します。

また、フォームをsubmitしたときの値を検証するには、Pageオブジェクトの waitForRequest() メソッドを使って Request オブジェクトを取得し、検証を行います。

test.beforeEach(async ({ page }) => {
  await page.goto('http://localhost:8000/playwright/react/mui')
})

test('TextFieldに入力した値がリクエストにあること', async ({ page }) => {
  // TextField
  await page.locator('#mui_textfield').fill('シナノゴールド')

  // 送信ボタンをクリックした時に発生する、axios.post先へのリクエストをwaitする
  const requestPromise = page.waitForRequest('http://localhost:8000/api/playwright/mui/')

  // 送信ボタンをクリック
  await page.locator('#submit').click()

  const request = await requestPromise
  expect(request.postDataJSON()).toEqual({
    muiCheck: false,
    muiTextSelect: '',
    muiSelect: '',
    muiTextField: 'シナノゴールド',
  })
})

 

TextFieldへ追記し、検証する

上記の通り fill() メソッドを使うとTextFieldの値が差し替わります。

もし、値の末尾に追記したい場合は type() メソッドを使うと良さそうです。
https://playwright.dev/docs/api/class-locator#locator-type

test('TextFieldに追記した値がリクエストにあること', async ({ page }) => {
  const textField = page.locator('#mui_textfield')
  await textField.fill('王林')

  // typeで追記
  await textField.type('秋映')

  const requestPromise = page.waitForRequest('http://localhost:8000/api/playwright/mui/')

  await page.locator('#submit').click()

  const request = await requestPromise
  expect(request.postDataJSON()).toEqual({
    muiCheck: false,
    muiTextSelect: '',
    muiSelect: '',
    muiTextField: '王林秋映',
  })
})

 

TextFieldの値をクリアし、検証する

値をクリアする場合は、 clear() メソッドが使えます。
https://playwright.dev/docs/api/class-locator#locator-clear

test('TextFieldをクリアした値がリクエストにあること', async ({ page }) => {
  await page.locator('#mui_textfield').clear()

  const requestPromise = page.waitForRequest('http://localhost:8000/api/playwright/mui/')

  await page.locator('#submit').click()

  const request = await requestPromise
  expect(request.postDataJSON()).toEqual({
    muiCheck: false,
    muiTextSelect: '',
    muiSelect: '',
  })
})

 

MUI Radio Group を検証する

いわゆるラジオボタンを作成します。
https://mui.com/material-ui/react-radio-button/

Playwrightでは、選択したいラジオボタンclick() すればよさそうです。

test('Radioで選択した値がリクエストにあること', async ({ page }) => {
  await page.locator('#mui_radio_red').click()

  const requestPromise = page.waitForRequest('http://localhost:8000/api/playwright/mui/')

  await page.locator('#submit').click()

  const request = await requestPromise
  expect(request.postDataJSON()).toEqual({
    muiCheck: false,
    muiTextSelect: '',
    muiSelect: '',
    muiRadio: 'red',
  })
})

 

MUI Select を検証する

いわゆるドロップダウンです。
https://mui.com/material-ui/react-select/

 
ドロップダウンで値を選択するには、

  1. input の要素をクリック
  2. 表示される選択肢をクリック

に2段階で操作する必要があります。

test('Selectで選択した値がリクエストにあること', async ({ page }) => {
  // セレクトボックスをクリックした後、ドロップダウンのものをクリックする
  await page.locator('#mui_select').click()
  await page.locator('#mui_select_household').click()

  const requestPromise = page.waitForRequest('http://localhost:8000/api/playwright/mui/')

  await page.locator('#submit').click()

  const request = await requestPromise
  expect(request.postDataJSON()).toEqual({
    muiCheck: false,
    muiTextSelect: '',
    muiSelect: 'household',
  })
})

 

MUI TextField select を検証する

MUIでドロップダウンを実現するもう一つの方法としては、 TextFieldの select propを使う方法があります。
https://mui.com/material-ui/react-text-field/#select

なお、Playwrightによる操作の流れは、MUI Selectと同じになります。

test('TextField select で選択した値がリクエストにあること', async ({ page }) => {
  // セレクトボックスをクリックした後、選びたいものをクリックする
  await page.locator('#mui_textfield_select').click()
  await page.locator('#mui_textfield_select_farmersMarket').click()

  const requestPromise = page.waitForRequest('http://localhost:8000/api/playwright/mui/')

  await page.locator('#submit').click()

  const request = await requestPromise
  expect(request.postDataJSON()).toEqual({
    muiCheck: false,
    muiTextSelect: 'farmersMarket',
    muiSelect: '',
  })
})

 

MUI Checkbox を検証する

いわゆるチェックボックスです。
https://mui.com/material-ui/react-checkbox/

チェックボックスの場合は、 setChecked() メソッドを使うことで、 truefalse を明示的に設定できます。
https://playwright.dev/docs/api/class-locator#locator-set-checked

test('Checkboxのチェックがリクエストにあること', async ({ page }) => {
  await page.locator('#mui_checkbox').setChecked(true)

  const requestPromise = page.waitForRequest('http://localhost:8000/api/playwright/mui/')

  await page.locator('#submit').click()

  const request = await requestPromise
  expect(request.postDataJSON()).toEqual({
    muiCheck: true,
    muiTextSelect: '',
    muiSelect: '',
  })
})

 

MUI TextArea Autosize を検証する

TextAreaで高さを自動調整してくれるコンポーネントです。
https://mui.com/material-ui/react-textarea-autosize/

TextAreaで改行を入れる場合は、 \n を入れてあげると良いです。

test('TextAreaの改行がリクエストにあること', async ({ page }) => {
  await page.locator('#mui_textarea').fill('改行前\n改行後')

  const requestPromise = page.waitForRequest('http://localhost:8000/api/playwright/mui/')

  await page.locator('#submit').click()

  const request = await requestPromise
  expect(request.postDataJSON()).toEqual({
    muiCheck: false,
    muiTextSelect: '',
    muiSelect: '',
    muiTextAreaAutosize: '改行前\n改行後'
  })
})

 

MUI X DataTimePickerは、headlessではうまく検証できず

日時選択をするコンポーネントとして、 MUI X には DateTimePicker があります。
https://mui.com/x/react-date-pickers/getting-started/

DateTimePickerには input タグがあるため、直接入力することができます。

ただ、Playwrightでは、ブラウザを表示する場合はテストがパスするのですが、表示しない(ヘッドレスモード)とテストが失敗します。

test('うまくいかない', async ({ page }) => {
  await page.goto('http://localhost:8000/playwright/react/mui')
  
  const datetimePicker = page.locator('#mui_datetime_picker')
  await datetimePicker.fill('2023/01/07 17:01:02')

  const requestPromise = page.waitForRequest('http://localhost:8000/api/playwright/mui/')

  await page.locator('#submit').click()

  const request = await requestPromise
  expect(request.postDataJSON()).toEqual({
    muiCheck: false,
    muiTextSelect: '',
    muiSelect: '',
    muiDateTimePicker: '2023-01-07T08:01:02.000Z'
  })
})

 
ヘッドレスの場合、ここで失敗します。inputでの入力ができないようです。

await datetimePicker.fill('2023/01/07 17:01:02')

 
スクリーンショットを撮ったところ、ヘッドレスの場合はDateTimePickerの日付選択ダイアログが表示されてしまっています。

また、ダイアログの形もヘッドレスとそうでない場合とで異なっていました。

表示する場合

 
ヘッドレスの場合

 
これ以上の追求には時間がかかりそうだったため、今のところは headless ではうまくいかないという結論にします。

 

Playwrightの便利な機能について

ここまででDjangoやReactでの挙動をみてきました。

ここからはPlaywrightの便利な機能について記載します。

 

スクリーンショットを撮る

PlaywrightではPageオブジェクトの screenshot() メソッドで、ブラウザで表示している内容をスクリーンショットとして残せます。
https://playwright.dev/docs/api/class-page#page-screenshot

 
MUI DataGridの検証でもあった通り、以下のように使います。

await page.screenshot({ path: 'screenshot.png' })

 

ビデオを撮る

今回は試す機会がありませんでしたが、Playwrightの挙動についてビデオに残すこともできます。
https://playwright.dev/docs/videos

 

.envファイルの内容を環境変数に反映する

上記のBasic認証のテストコードでは、認証に使うユーザーとパスワードをテストコードにハードコーディングしていました。

ただ、現実的にはハードコーディングするのではなく、環境変数などから値を読み込んでテストを書きたいです。

 
Playwrightでは、 dotenv パッケージを使って .env ファイルの内容を環境変数に反映することができます。
https://playwright.dev/docs/test-parameterize#env-files

 
例えば、 .env ファイルに

LOCATOR_URL=http://localhost:8000/playwright/locator

と、アクセス先のURLを定義した場合、テストコードではこんな感じで読み込んで使えます。

test.describe('.envファイルからの読み込みを確認', () => {
  test('.envファイルに記載されたURLにアクセスしていること', async ({ page }) => {
    await page.goto(process.env.LOCATOR_URL)

    const result = page.locator('#id_locator')

    await expect(result).toHaveText('by id')
  })
})

 

HTTPリクエスト/レスポンスの確認

ここまでの例でも出てきていましたが、PlaywrightではHTTPリクエスト/レスポンスを監視して、内容を検証できます。
https://playwright.dev/docs/network#network-events

ここでは、HTTPリクエスト/レスポンスの検証について、それぞれためしてみます。

 

例:バックエンドにaxiosでリクエストしたときの内容を確認

この記事では、フォームでの検証を使っていました。再掲します。

const requestPromise = page.waitForRequest('http://localhost:8000/api/playwright/mui/')

await page.locator('#submit').click()

const request = await requestPromise
expect(request.postDataJSON()).toEqual({
  muiCheck: false,
  muiTextSelect: '',
  muiSelect: '',
  muiRadio: 'red',
})

 
ここでは waitForRequest を使ってリクエストを監視しています。
https://playwright.dev/docs/api/class-page#page-wait-for-request

監視した内容は await することで取り出し、検証することができます。

const request = await requestPromise

 
リクエストの中身については、 Request オブジェクトのドキュメントに記載があります。
https://playwright.dev/docs/api/class-request

なお、レスポンスの内容も response() メソッドから取り出せます。
https://playwright.dev/docs/api/class-request#request-response

 

例:新しいタブを開いたときのHTTPレスポンスの中身を確認

新しいタブを開いたときに、そのタブを描画するためのHTTPレスポンスの中身を確認してみます。

新しいタブの場合、 page fixtureとは別のページになるため、 page.waitForResponse() が使えません。
https://playwright.dev/docs/api/class-page#page-wait-for-response

そこで、ブラウザ全体で監視できる context.on('response') を使います。
https://playwright.dev/docs/api/class-browsercontext#browser-context-event-response

test('新しいタブのURLやレスポンスヘッダの中身を確認できること', async ({ page, context }) => {
  await page.goto('http://localhost:8000/playwright/open-tab')

  // responseが発生したときのイベントを設定する
  context.on('response', async response => {
    // リクエストが飛んだ先の検証
    expect(response.url()).toEqual('http://localhost:8000/playwright/new-tab')
    
    // ヘッダの検証
    const headers = response.headers()
    expect(headers['content-type']).toEqual('text/html; charset=utf-8')
  })

  await page.locator('#new_tab').click()
})

 
レスポンスの中身については、 Response オブジェクトのドキュメントに記載があります。
https://playwright.dev/docs/api/class-response

 

APIレスポンスの差し替え

Playwrightではネットワークに介在してリクエスト/レスポンスの差し替えができます。
https://playwright.dev/docs/network

そのため、「E2Eテスト環境にデータが入ってしまっているため、データがない場合の表示が確認しづらい」という場合にもPlaywrightだけで対応できます。

 
今回は、DjangoAPIのレスポンスを1件もデータが取得できなかったように差し替え、その時のReactの表示を確認してみます。
https://playwright.dev/docs/network#modify-responses

指定した route に対し、 fulfill メソッドで差し替え後のデータを設定すれば良いです。

test('taskが1件も存在しない時は、存在しない旨を表示すること', async ({ page }) => {
  // レスポンスが0件になるよう差し替え
  await page.route('http://localhost:8000/api/tasks/', async route => {
    // fulfill()にて、差し替え後のデータを設定
    route.fulfill({
      status: 404,
      body: JSON.stringify({})
    })
  })

  await page.goto('http://localhost:8000/tasks/')

  const message = page.locator('#message')
  await expect(message).toHaveCount(1)
  await expect(message).toHaveText('task not found')
})

 
なお、レスポンスの差し替えについては、以下の記事にある通り差し替えできるものとできないものがあります。
Playwright 1.29の新機能 Backend API Mockingは request.fetchでは効かないよ。 - Qiita

 

その他ドキュメントなど

今回はPlaywrightの操作や検証を中心に書きました。

ただ、Playwrightにはまだまだ機能があります。幸いなことに、Playwrightはドキュメントが充実しています。

まずは DocsAPI のドキュメントを見れば良いと思います。

 
日本語の記事については、以下が参考になりました。

 
E2Eテストの考え方などは以下が参考になりました。

 

ソースコード

Githubに上げました。

Django + Reactアプリ側です。

 
Playwright側です。