Help us understand the problem. What is going on with this article?

Rails minitest 使い方(具体例あり) - Mock編

こんにちは。@koshi_life です。
2018年末からRubyを書きはじめて、テストの書き方について調べた内容を備忘します。

モデルとか画面描画を挟まないロジックならテスト書きやすいので積極的にテストコードを書きたい派です。
プロダクト、チーム状況にはよって敢えてテスト書かない意思決定をするのも全然OKだと思いますが、

「敢えて書かない」と「書けないから書かない」のは違うので、ちゃんとテストが書ける技術は身につけておきたいものです。

Rails 標準のテストフレームワーク minitest を使ってみました。

前提

  • Ruby 2.6.0
  • Rails 5.2.2

テスト対象モジュール

ユーザ間でお金の貸し借りを行えるアプリをイメージして、新規でRailsアプリを作ります。(DB設定は割愛)

$ rails new hello-rails --skip-active-record
$ rails g model bank_account account_id amount:Integer

テスト対象モジュールとして、
ユーザが借りている借金を貸した人全員に全額返却する機能
Tasks::DebtManager.clear_all を作りました。

lib/tasks/debt_manager.rb
# coding: utf-8

# 借金マネージャー
class Tasks::DebtManager

    # 借金を借りている全員に対して返済する。
    def self.clear_all (account_id, payment_api_client)
        # <API利用> 借金リストを取得します。
        debts = payment_api_client.list_debts(account_id)

        # 借りている1人1人に返却します。
        debts.each do |debt|
            # 送金APIに必要なパラメーターを準備します。
            from = account_id
            to = debt[:account_id]
            amount = debt[:amount]

            Rails.logger.info("===== #{from} さんが #{to} さんに借りているお金(#{amount}円)を精算します。 =====")

            # <API利用> 送金します。
            result = payment_api_client.send_money(from, to, amount)

            # 処理成否に応じて残高(DB)を更新します。
            if result[:result]
                # 成功パターンなら、送金額をDBに反映します。(トランザクション処理は割愛)
                from_bank = BankAccount.find_by(account_id: from)
                to_bank = BankAccount.find_by(account_id: to)
                from_bank.amount -= amount
                to_bank.amount += amount

                # DB内容を更新
                from_bank.save()
                to_bank.save()

                Rails.logger.info("借金を返済しました。")
            else
                # 失敗パターンなら、DBは更新しない。
                Rails.logger.error("残高不足のため借金の返済ができませんでした。")
            end
        end
    end
end

このコードのポイントは、引数の本モジュール外の機能 payment_api_client<API利用>のコメント箇所で用いている部分です。

payment_api_client は Tasks::DebtManager.clear_all の単体テストでは担保すべき範囲外なので、 Mock を利用して、Tasks::DebtManager.clear_all のロジックが意図した通り振る舞うかテストコードを書いて確認していきます。

テストコード

test/tasks/debt_manager_test.rb
# coding: utf-8

require 'minitest/autorun'

# DB内 初期化モジュール, DBは mongoDB (mongoid) です。
module ModelCleaner
    # Test Data
    BANK_ACCOUNTS = [
        BankAccount.new(account_id: 'acc1', amount: 600),
        BankAccount.new(account_id: 'acc2', amount: 1000),
        BankAccount.new(account_id: 'acc3', amount: 2000),
    ]
    def initialize()
        Rails.logger.info("ModelCleaner.initialize() is called.")
        # Drop Collections
        BankAccount.collection.drop()
        # Insert Data
        BANK_ACCOUNTS.each { |ba| ba.save!()}
    end
    module_function :initialize
end


# テストクラス
class DebtManagerTest < ActiveSupport::TestCase
    # DBを初期化します。
    ModelCleaner.initialize()

    test "2人から借金中。1人目は送金成功、2人目で残高不足になる" do
        # Mock準備 payment_api_client で返却する値を確認ケースに沿って定義します。
        list_debts_res1 = [
            { account_id: 'acc2', amount: 500 },
            { account_id: 'acc3', amount: 1000 },
        ]
        send_money_res1 = { result: true }
        send_money_res2 = { result: false }

        # Mockを定義します。
        payment_api_mock = MiniTest::Mock.new
        payment_api_mock.expect(:list_debts, list_debts_res1, ['acc1'])
        payment_api_mock.expect(:send_money, send_money_res1, ['acc1', 'acc2', 500])
        payment_api_mock.expect(:send_money, send_money_res2, ['acc1', 'acc3', 1000])

        # 検証対象メソッド実行します。
        Tasks::DebtManager.clear_all('acc1', payment_api_mock)

        # Mockに送信されたデータを検証します。
        payment_api_mock.verify

        # 送金後のDB内が期待値通りか検証します。
        ba1 = BankAccount.find_by(account_id: 'acc1')
        assert_equal(100, ba1.amount.to_i)
        ba2 = BankAccount.find_by(account_id: 'acc2')
        assert_equal(1500, ba2.amount.to_i)
        ba3 = BankAccount.find_by(account_id: 'acc3')
        assert_equal(2000, ba3.amount.to_i)
    end

end

ポイントは MiniTest::Mock#expect 関数の使い方かと。
公式ドキュメントにあるように、

MiniTest::Mock#expect(name, retval, args = []) ⇒ Object

第一引数にコールされるメソッド名
第二引数に返却値
第三引数にメソッドコール時に指定する引数
を指定します。

payment_api_mock.expect(:send_money, send_money_res1, ['acc1', 'acc2', 500])
payment_api_mock.expect(:send_money, send_money_res2, ['acc1', 'acc3', 1000])

同じメソッドを複数回呼ぶ想定なら、上記のように呼ばれる順番でexpect関数で登録します。

テスト実行結果

$ rails test test/tasks/debt_manager_test.rb 
Running via Spring preloader in process 6760
I, [2019-02-01T20:06:40.389554 #6760]  INFO -- : ModelCleaner.initialize() is called.
Run options: --seed 57956

# Running:

I, [2019-02-01T20:06:40.481603 #6760]  INFO -- : --------------------------------------------------
I, [2019-02-01T20:06:40.481768 #6760]  INFO -- : DebtManagerTest: test_2人から借金中。1人目は送金成功、2人目で残高不足になる
I, [2019-02-01T20:06:40.481856 #6760]  INFO -- : --------------------------------------------------
I, [2019-02-01T20:06:40.485079 #6760]  INFO -- : ===== acc1 さんが acc2 さんに借りているお金(500円)を精算します。 =====
I, [2019-02-01T20:06:40.495268 #6760]  INFO -- : 借金を返済しました。
I, [2019-02-01T20:06:40.495409 #6760]  INFO -- : ===== acc1 さんが acc3 さんに借りているお金(1000円)を精算します。 =====
E, [2019-02-01T20:06:40.495508 #6760] ERROR -- : 残高不足のため借金の返済ができませんでした。
.

Finished in 0.024644s, 40.5778 runs/s, 121.7335 assertions/s.
1 runs, 3 assertions, 0 failures, 0 errors, 0 skips

これで、Mock使いになれた気がします。Stub編もまたいつか。

参考

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away