tofucodes diary

にほんごのほう

DeployerでColofulBoxにLaravelアプリケーションをデプロイする

対象読者

  • ColorfulBoxにPHPアプリケーションをデプロイしたい
  • GitなどでLaravelアプリケーションのソースコードをバージョン管理してる
  • composerを利用しててvendorディレクトリはバージョン管理対象から外してる

ColorfulBoxの制限

ColorfulBoxはいわゆる共用サーバです。VPSや専用サーバとは違って他のユーザに影響を与えないようにいくつか制限があります。

さくらインターネットなど他の共用サーバを利用したことがないのでこれらが一般的なのか分かりませんが、ColorfulBoxには現状気づいてるだけでも以下の制限が存在しています。

  1. CLIPHPバージョンはcPanelからは設定できない
  2. php.iniのallow_url_fopen=Offなのでcomposer installとかできない
  3. サーバにrsyncがインストールされてないのでrsyncできない

これらの制約がある中でも、これから紹介する方法を利用すれば、ローカル開発環境からコマンド1つでデプロイすることが可能になります。


ちなみに、CLIPHPバージョンを変更する方法は以下の記事で解説しています。 tofucodes.hatenablog.jp

Deployerとは

deployer.org

人気のフレームワークをサポートしているPHP製のデプロイツールです。 LaravelやCakePHP、他にもたくさんのPHPフレームワークがサポートされています。 他のデプロイツールとの比較はできませんが、数行のスクリプトでLaravelのアプリケーションがデプロイできて感動しました。

方法

🛠Deployerのインストール

$ curl -LO https://deployer.org/deployer.phar
$ mv deployer.phar /usr/local/bin/dep
$ chmod +x /usr/local/bin/dep

👨‍🍳Laravel用のレシピを利用してDeployerのスクリプトを作成

$ dep init -t Laravel

📝作成されるdeploy.phpを編集(解説は後述します)

<?php
namespace Deployer;

require 'recipe/laravel.php';

set('application', 'アプリ名');

set('repository', 'リポジトリのURL');

set('git_tty', true);

set('writable_mode', 'chmod');

// 1.
set('bin/php', '/home/ユーザ名/bin/php');

// 2.
set('bin/composer', function () {
    if (commandExist('composer')) {
        $composer = locateBinaryPath('composer');
    }
    return '{{bin/php}} -d allow_url_fopen=1 ' . $composer;
});

host('ホスト名')
    ->set('deploy_path', 'デプロイ先のパス');

after('deploy:failed', 'deploy:unlock');

before('deploy:symlink', 'artisan:migrate');

🚢デプロイ実行

$ dep deploy [-vvv]

解説

1. bin/php

set('bin/php', '/home/ユーザ名/bin/php');

ColofulBoxサーバのCLIPHPバージョンはデフォルト5.6となっていて最新版のLaravelをダウンロードすることができません。そのためDeployerが利用するCLIPHPをバージョン7.1や7.2にする必要があります。

私はシステムのPHP7.1をユーザのbinディレクトリにコピーしてPATHを通して利用しているため上記のような設定になっていますが、バージョンさえ合っていればパスはどこでもいいと思います。

2. bin/composer

set('bin/composer', function () {
    if (commandExist('composer')) {
        $composer = locateBinaryPath('composer');
    }
    return '{{bin/php}} -d allow_url_fopen=1 ' . $composer;
});

制限2.で記載した通り、ColorfulBoxサーバではphp.iniのallow_url_fopen=Offになってる都合上、単純にcomposer installを実行することができません。そこでcomposer install時に-d allow_url_fopen=1をつけることで一時的にallow_url_fopen=Onの状態を作っています。またcomposer install時のphpに関しても先ほど2.で指定したbin/phpが利用されるようにします。

ColorfulBoxでCLIのPHPのバージョンを変更する方法

www.colorfulbox.jp

ColorfulBoxというレンタルサーバーを使ってPHPのアプリケーションを公開してみました。

レンタルサーバーというものを人生で初めて触れているもので勝手がどうにも分からず、PHPのバージョン変更するだけでもだいぶ手こずってしまいました。

分かってしまえば簡単なことだったので自責の念を込めつつ共有します。

結論

  • WebサーバーのPHPバージョン設定とCLIPHPバージョンは別物
  • レンタルサーバーの都合上CLIPHP実体を変えることはできない
  • PATHの順番を弄って自分のホームディレクトリに配置したPHPを利用するようにする

2種類のPHPバージョン

そもそもPHPのバージョンと一口にいっても以下の2種類を考慮する必要があるみたいです。

  • WebサーバのPHPバージョン(つまりアプリケーションのランタイムで使われるPHP?)
  • CLIPHPバージョン

前者のPHPバージョンについてはColorfulBoxの管理画面であるcPanel上で変更できるようになっています。

help-cf.colorfulbox.jp

help.colorfulbox.jp

CLIPHPはというと以下のようにPHP 5.6.40がデフォルトで使われていて、このバージョンではcomposerやLaravelのコマンドで色々困ることが出てきます。

$ ll /usr/local/bin/
total 56
lrwxrwxrwx 1 root root    37 Dec 28 17:57 ea-php53 -> /opt/cpanel/ea-php53/root/usr/bin/php
lrwxrwxrwx 1 root root    37 Dec 28 17:57 ea-php54 -> /opt/cpanel/ea-php54/root/usr/bin/php
lrwxrwxrwx 1 root root    37 Dec 28 16:59 ea-php55 -> /opt/cpanel/ea-php55/root/usr/bin/php
lrwxrwxrwx 1 root root    37 Dec 28 16:59 ea-php56 -> /opt/cpanel/ea-php56/root/usr/bin/php
lrwxrwxrwx 1 root root    37 Dec 28 16:59 ea-php70 -> /opt/cpanel/ea-php70/root/usr/bin/php
lrwxrwxrwx 1 root root    37 Dec 28 17:57 ea-php71 -> /opt/cpanel/ea-php71/root/usr/bin/php
lrwxrwxrwx 1 root root    37 Dec 28 17:57 ea-php72 -> /opt/cpanel/ea-php72/root/usr/bin/php
-rwxr-xr-x 1 root root 28264 Aug 10  2017 lsphp
-rwxr-xr-x 1 root root 28264 Aug 10  2017 php

$ /usr/local/bin/php -v
ea-php-cli Copyright 2017 cPanel, Inc.
PHP 5.6.40 (cli) (built: Jan 24 2019 18:26:19)
Copyright (c) 1997-2016 The PHP Group
Zend Engine v2.6.0, Copyright (c) 1998-2016 Zend Technologies
    with the ionCube PHP Loader v4.7.5, Copyright (c) 2002-2014, by ionCube Ltd.

かといって/usr/local/bin/phpの実体を他のバージョンにすることもレンタルサーバーの都合上、無理ですよね。

色々と試行錯誤して諦めかけたその時に、神の啓示が...(笑)

(結局はものすごい簡単なことで対応することができました)

方法

まずは/usr/local/bin/の中にあるPHPの一覧から自分の好きなバージョンを$HOME/bin/にコピーします。(例ではPHP 7.1)

$ cp /usr/local/bin/ea-php71 $HOME/bin/php #もしくは$HOME/.local/bin/php

次に$HOME/.bash_profileを編集します。

# .bash_profile

# Get the aliases and functions
if [ -f ~/.bashrc ]; then
        . ~/.bashrc
fi

# User specific environment and startup programs

# PATH=$PATH:$HOME/.local/bin:$HOME/bin  # 元々はこれ
PATH=$HOME/.local/bin:$HOME/bin:$PATH  # こちらに変更

export PATH

このように編集することで/usr/local/bin/phpよりも$HOME/bin/phpが先に探索されるようになるので、先ほどコピーしたPHPを利用することができるようになります。

$ which php
~/bin/php

$ php -v
PHP 7.1.26 (cli) (built: Jan 24 2019 17:47:13) ( NTS )
Copyright (c) 1997-2018 The PHP Group
Zend Engine v3.1.0, Copyright (c) 1998-2018 Zend Technologies
    with the ionCube PHP Loader (enabled) + Intrusion Protection from ioncube24.com (unconfigured) v10.2.4, Copyright (c) 2002-2018, by ionCube Ltd.

追記

書いてて思いついたんですけど、エイリアスとかでも対応できたかも?

$ alias php=/usr/local/bin/ea-php72

$ php -v
PHP 7.2.14 (cli) (built: Jan 24 2019 17:28:26) ( NTS )
Copyright (c) 1997-2018 The PHP Group
Zend Engine v3.2.0, Copyright (c) 1998-2018 Zend Technologies
    with the ionCube PHP Loader (enabled) + Intrusion Protection from ioncube24.com (unconfigured) v10.2.4, Copyright (c) 2002-2018, by ionCube Ltd.
    with Zend OPcache v7.2.14, Copyright (c) 1999-2018, by Zend Technologies

IQKeyboardManagerで親Viewが異なるUITextFieldを兄弟とみなす方法

github.com

課題

Qiitaで紹介されていて使ってみたIQKeyboardManager、すごい便利ですね。

IQKeyboardManagerは、デフォルトでは画像のように別の親Viewに属しているUITextFieldなどを兄弟と見なしてくれません。(つまりToolbarの↑↓でUITextFieldを移動できません。)

f:id:tofucodes:20190117154326p:plain
2つのUITextFieldは兄弟にならない

原因

IQKeyboardManagerのソースコードを見てみます。 UITextField兄弟間をToolbarの↑↓で移動するロジックは、兄弟をresponderViews()という関数で探索していることが分かります。

@objc public var canGoNext: Bool {
    //Getting all responder view's.
    if let textFields = responderViews() {
        if let  textFieldRetain = _textFieldView {
            //Getting index of current textField.
            if let index = textFields.index(of: textFieldRetain) {
                
                //If it is not first textField. then it's previous object canBecomeFirstResponder.
                if index < textFields.count-1 {
                    return true
                }
            }
        }
    }
    return false
}

responderViews()はまず、全てのsuperViewからtoolbarPreviousNextAllowedClassesの配列に含まれるクラスと一致するものを探索します。

もし一致するsuperViewが見つかった場合は、そのViewの全ての子Viewから兄弟を探索し、Viewの配列として返却します。

もし一致するsuperViewが1つも見つからなかった場合は、対象のUITextFieldと同じ階層にある兄弟だけを探索して返却します。

/** Get all UITextField/UITextView siblings of textFieldView. */
private func responderViews()-> [UIView]? {
    
    var superConsideredView : UIView?

    //If find any consider responderView in it's upper hierarchy then will get deepResponderView.
    for disabledClass in toolbarPreviousNextAllowedClasses {
        
        superConsideredView = _textFieldView?.superviewOfClassType(disabledClass)
        
        if superConsideredView != nil {
            break
        }
    }

//If there is a superConsideredView in view's hierarchy, then fetching all it's subview that responds. No sorting for superConsideredView, it's by subView position.    (Enhancement ID: #22)
    if let view = superConsideredView {
        return view.deepResponderViews()
    } else {  //Otherwise fetching all the siblings
//...

つまり、兄弟にしたいUITextFieldを全て、toolbarPreviousNextAllowedClassesに含まれるクラスの子Viewにすれば、兄弟として判別してくれるというロジックのようです。

対策

まず、親View用のカスタムViewクラスを定義します。 今回はTextFieldsContainerViewという名前でクラスを定義しました。

import UIKit

/// Class for letting IQKeyboardManager treat all text fields/views whose parent is this container view
/// as a sibling so that it enables to handle all on its toolbar.
/// We don't need this class all the time but useful when we have text fields/views that have different parent.
/// See also: https://github.com/hackiftekhar/IQKeyboardManager/blob/master/IQKeyboardManagerSwift/IQKeyboardManager.swift#L1716-L1728
class TextFieldsContainerView: UIView {

}

次に、IQKeyboardManagerのtoolbarPreviousNextAllowedClassesに先ほど定義したTextFieldsContainerViewクラスを追加します。

IQKeyboardManager.shared.toolbarPreviousNextAllowedClasses.append(TextFieldsContainerView.self)

最後に、TextFieldsContainerViewクラスをstoryboard/xibやコードで各UITextFieldの親Viewクラスになるように実装します。

f:id:tofucodes:20190117154307p:plain
2つのUITextFieldは兄弟と見なされる

BitbucketのコードをHerokuに自動デプロイする方法が超簡単だった

最近仕事が暇すぎて業務時間中に個人プロジェクトのコード書いてコミットしてたりしたんですが

プライベートレポジトリじゃないんでGithubのContribution activity(草の下の方とかに出るやつ)を同僚や上司に見られたらあまり良くないよなぁと思い

GithubのオープンレポジトリからBitbucketのプライベートレポジトリに移行しました。

元々はGithubのレポジトリとHerokuを連携させて自動デプロイの設定をしてたのですが

BitbucketとHerokuの自動デプロイ連携を今回やってみたので備忘録的なやつ。

(まさかこの直後にGithubがプライベートレポジトリを無料解放するとは、この時の僕は知る由もなかった...)

blog.github.com

ではGithubのプライベートレポジトリが無料になったこの時代に、BitbucketとHerokuの自動デプロイ設定をしていきましょう。

ゴール

bitbucketにコミットすると自動的にpipelineがアプリケーションをビルドしてHerokuにデプロイする。

f:id:tofucodes:20190115225929p:plain
bitbucketのpipeline画面

f:id:tofucodes:20190115225940p:plain
pipeline #4の詳細画面

f:id:tofucodes:20190115230218p:plain
Heroku管理画面のactivity履歴

手順

  1. bitbucket-pipelines.yml の作成
  2. レポジトリに環境変数の設定

1. bitbucket-pipelines.yml の作成

f:id:tofucodes:20190115233442p:plain
bitbucketのUI上でpipeline作成

参考までにこちらが私の bitbucket-pipelines.yml です。

image: php:7.1.3

pipelines:
  default:
    - step:
        name: Deploy to production
        deployment: production
        caches:
          - composer
        script:
          - apt-get update && apt-get install -y unzip git
          - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
          - composer install
          - git push https://heroku:$HEROKU_API_KEY@git.heroku.com/$HEROKU_APP_NAME.git HEAD
  • bitbucketのデフォルトのPHPバージョンは7.1.1でしたが僕のプロジェクトにはPHP7.1.3が必要だったので7.1.3にしてます。
  • それが原因か不明ですが、以下のようなエラーになるのでapt-getでgitもインストールしてます。
+ git push https://heroku:$HEROKU_API_KEY@git.heroku.com/$HEROKU_APP_NAME.git HEAD
bash: git: command not found

2. レポジトリの環境変数を設定

f:id:tofucodes:20190115234702p:plain
bitbucketのUI上で環境変数の設定

試してませんが、Repositoryの環境変数ではなくて、その下にあるDeploymentsの変数の方でも動作するかもしれません(し、そちらの方が適切かもしれません)

Deploymentsの変数の説明

Variables that can only be used in deployments to a specific environment.

HEROKU_API_KEYの作成方法は公式ドキュメントを参照で。

devcenter.heroku.com

iPhoneのWalletアプリにオリジナルのPassを登録する方法+α

iPhoneのWalletアプリ便利ですよね。僕も最近はコンビニでの支払いや自動販売機、電車の改札もWalletで済ませています。(個人的には使ってないですがポンタカードがWalletに登録できることを発見した時はテンション上がりました)

今回はそんなWalletアプリにオリジナルのPassを登録する方法を簡単にまとめてみました。 この記事では以下の内容について簡単に触れます。

  • iPhoneのWalletアプリにオリジナルのPassを登録する方法
  • PassがiBeaconに近づいたらロック画面に通知を表示する方法
  • Pass(Wallet)にPush通知を送信する方法

developer.apple.com

オリジナルのPassを登録する

Passの作成方法

developer.apple.com

Passを作成するには最終的に.pkpassという拡張子のファイルを生成することになります。

1. Pass Type IDの作成

f:id:tofucodes:20180730232356p:plain

2. Pass用のデータフォルダ作成

適用な場所に以下の構造のフォルダを作成します。名前はなんでも大丈夫です。

Sample.pass/

3. pass.jsonの作成

先ほど作成したフォルダにpass.jsonというJSONファイルを作成します。Passの設定は全てこのpass.jsonに記載されます。

{
    ...
    "passTypeIdentifier" : "先ほど作成したPass Type ID",
    "teamIdentifier" : "DeveloperアカウントのTeam ID",
    ...
}

Team IDはMember Centerで確認できます。

f:id:tofucodes:20180730234119p:plain

その他のキーの一覧

Top-Level Keysは必須のキー、Lower-Level Keysは任意のキーのようです。

developer.apple.com

developer.apple.com

4. .pkpassファイルの生成

.pkpassの生成にはsignpassというコマンドラインツールが必要になります。まずはAppleのダウンロードリンクからsignpass用のXcodeプロジェクトをダウンロードします。

ダウンロードしたらXcodeでそのプロジェクトを開きビルドを行うことでsignpassコマンドラインツールがビルドされて利用できるようになります。

signpassを作成することができたら以下のようにPass用のフォルダを指定してコマンドを実行します。Sample.pkpassが生成されたら成功です。

$ ./signpass -p Sample.pass

あとは作成したSample.pkpassiPhoneにダウンロードするだけです。 ダウンロードの方法としては、EメールやSafariを使う方法や自分のiOSアプリを利用する方法などいくつかあります。 自分はアプリでやる方法しか試してないのですが、PassKitフレームワークを使って簡単に実装することができます。 その他の方法については以下のリンクをご参照ください。

developer.apple.com

Passのデザイン

developer.apple.com

Passには現在のところ以下の5つのタイプが存在しています。それぞれに可能なデザインが決まっているので、自分の作成するPassに合わせてデザインを決めます。例えば背景をかっこよくグラデーションにしたいと思ってもできるのはEvent ticketsだけ、というようなことがあったりするので注意です。

  1. Boarding passes
  2. Coupons
  3. Event tickets
  4. Store cards
  5. Generic

iBeaconに近づいたらロック画面に通知を表示する

Beaconと連携させることで例えば以下のような便利な機能を作ることができます。

  • お店に近づいたらクーポンをロック画面に表示する
  • 空港に到着したら航空券をロック画面に表示する
  • ジムのドアの前でジムのカードキーをロック画面に表示する

ちなみにiBeaconではなくロケーションを利用することでも可能ですが、ロケーションの設定は1つのPassに最大10個までしか登録できません。

一方beaconの設定はというと、UUIDは同じく10個までしか登録できないものの、beacon端末のmajorとminorの値を変更することで同じUUIDのbeacon端末を作成することができるため、実質10個以上のbeaconを検知することができるようになっています。

Wallet Developer Guide: Pass Design and Creation

詳細なドキュメントは上記のRelevance Information Displays Passes on the Lock Screenの項に記載されています。

pass.jsonに以下の設定を追加します。

...
"beacons": [
    {
      "proximityUUID":"iBeacon端末の識別子",
      "relevantText":"ロック画面に通知する時に表示するテキスト"
    }
],
...

以上だけです。iBeacon端末を持っていない人はMacをiBeacon端末にして動作確認できます。

私は以下の記事で紹介されているGithubのプロジェクトをそのまま利用させていただきました。(感謝)

dev.classmethod.jp

Pass(Wallet)にPush通知を送信する

Walletに登録済みのPassをPass提供者側から更新するためには、サーバからPush通知を送信する必要があります。

更新処理全体の流れは公式ドキュメントをご参照ください。

curlを例に送信方法は以下のようになります。

curl -v -d '{"aps":""}' -H "apns-topic:<Pass Type ID>" \
  -H "apns-expiration: 1" -H "apns-priority: 10" \
  --http2 --cert <path>/Certificates.pem:<password> \ 
  https://api.push.apple.com/3/device/<device token>

注意点は、--certで指定する証明書はAPNsの証明書ではなくPass Type IDを作るときに生成した証明書を利用することです。

私はずっとAPNsの証明書を使って全然Push通知が届かず無駄な時間を費やしてしまいました。。

その他にもWalletのPassへのPush通知独自の注意点などが以下のstackoverflowによくまとめられているのでご参考までに。

stackoverflow.com

KotlinConf 2018 - Shaping Your App's Architecture with Kotlin and Architecture Components

KotlinConf 2018のビデオでAndroidアプリの設計について面白いものがあったのでざっくりですがまとめてみました。

一昔前の設計のアプリをArchitecture ComponentsCoroutinesを用いて再設計している実際の経験に基づいた話でとても参考になりました。

www.youtube.com

全体設計

f:id:tofucodes:20181106083844p:plain

UI, Domain, Dataのレイヤードアーキテクチャ。これは最近まあよく見るので特に目新しさはないですね。

  • Data層:Repository, DataSource (Local/Remote)
  • Domain層:UseCase
  • UI層:ViewModel, Activity/View, XML

レイヤー間はCoroutinesでやり取りしてます。学習コストや利用の容易さからRxJavaではなくCoroutineを採択したようです。

f:id:tofucodes:20181106210703p:plain

f:id:tofucodes:20181106210645p:plain

Data層

f:id:tofucodes:20181106091855p:plain

Data層を構成するのは以下の3要素。

  • Remote DataSource:(HTTP)リクエストを構築してサーバからデータを取得
  • Local DataSource:ディスクにデータを保存
  • Repository:上記2種類のDataSourceを利用してデータの取得と保存; 任意でインメモリにデータをキャッシュ

インメモリキャッシュは専用のシングルトンクラスとか作ったりすることが多かったんですが、Repositoryでインメモリキャッシュを持つという設計は目から鱗でした。こうすることでレイヤードアーキテクチャの他の層からインメモリキャッシュを参照するようなコードも防止できるし理にかなってる気がします。ただRepositoryのプロパティに単純に持つとしたらRepositoryのライフサイクルがどうなってるのか気になります。

また設計にはあまり関係ないですが、生成されるオブジェクトの数を減らす(?)テクニックとしてinline classが紹介されていました。 下の写真の例では、1つのRepositoryに第2引数だけが異なるpostCommentという同名メソッドを定義する方法として、第2引数のLong型の変数をそれぞれ別のinline classにしています。 つまりプリミティブ型の変数に名前をつけて区別するようなものでしょうか(知らんけど)。

f:id:tofucodes:20181106084005p:plain

Domain層

f:id:tofucodes:20181106091833p:plain

UseCaseの責務は

f:id:tofucodes:20181106084046p:plain

  • チームのルールとして1つのUseCaseには1つだけのpublicメソッド、他は全てprivateメソッド
  • その1つのpublicメソッドはinvokeメソッドにして関数オブジェクトとして扱う
  • ユーザのログイン状況の確認などはUseCaseの責務(UseCaseがLoginRepositoryを扱う)

UI層

f:id:tofucodes:20181106213417p:plain

ViewModelの責務は

  • UIで表示されるデータを公開(LiveData)
  • ユーザアクションに基づいてUseCaseのアクションを実行
  • Coroutinesの開始とキャンセル

ViewModelはActivityとXMLの2箇所から参照される。 ViewModelとXMLの間のデータのやり取りはデータバインディングを用いる。

f:id:tofucodes:20181108211102p:plain

  • ViewModelは関連するいくつかのUseCaseや他の引数をコンストラクタで受け取ってimmutablityを保つ
  • ViewModelProvidersで生成するViewModelはデフォルトでは引数を受け取れないため、Factoryを拡張する
  • 全てのViewModelには対応するFactoryを作成する

動画では、ViewModel自身とstoryIDのimmutabilityを保つために同じActivityクラスが複数インスタンス存在するとしても、それぞれ別インスタンスのViewModelを持つ、というニュアンスのことを言ってる?ような気がするんだけど、これはViewModelのそもそもの目的からちょっと外れる気がするのでどういうことかよく理解できない。分かる人教えて欲しい。

f:id:tofucodes:20181106084134p:plain

  • Activityからの編集を防止するためにViewModelではMutableLiveDataをprivateにしてImutableなLiveDataを宣言して公開する
  • ImutableなLiveDataのgetterはMutableLiveDataをそのまま返却する

f:id:tofucodes:20181106084201p:plain

  • URLを開いたりトーストの表示などは、独自のEvent型のLiveDataで扱う
  • Event型を利用することで、再度observeされた時に重複してイベントが発火しないようにする
  • Event型の詳細については以下の別ブログ参照

medium.com

まとめ

  • よく見るレイヤードアーキテクチャ+Coroutinesを採択
  • インメモリキャッシュはRepositoryの責務
  • UseCaseは最小単位で実装して関数オブジェクトとして扱うといい感じ
  • ViewModelは独自のFactoryクラスを実装してUseCaseやその他データをコンストラクタで受け取ってimmutableに保つ
  • 一度きりのUIイベント等をViewModelで管理する手段として独自のEvent型のLiveDataを利用する

こんなところでしょうか。 動画の中では他にも、Data層のRepositoryがKotlinで書かれたDomain層からだけじゃなく古いJavaコードやUI層のコードからも参照される状況の対応策や、複雑なデータ構造のデータを生成するための拡張実装についてなど、有益そうな情報がたくさんありました。

XCTestでFirebase Realtime DatabaseのAPIをモックしてレスポンスを偽造する

仕事でFirebase Realtime Databaseを利用していてユニットテストを書くためにモックについて調べてみるとこちらの記事に出会いました。

medium.com

モック以外の内容も書かれており素晴らしい内容ですね。とても参考になります。

今回はもう少しお手軽にFirebase Realtime Databaseのレスポンスをモックする方法を考えてみました。

複雑なことせずになんとかならんのか、という方のお役に立てれば何よりです。

テスト対象コード(サンプル)

class FirebaseDatabaseClient {

    private let databaseReference: DatabaseReference

    init(with databaseReference: DatabaseReference) {
        self.databaseReference = databaseReference
    }

    func readSample(completion: @escaping ([String]) -> Void) {
        self.databaseReference
            .child("sample")
            .observeSingleEvent(of: .value) { snapshot in
                var sampleList: [String] = []
                // 省略...
                completion(sampleList)
        }
    }
}
  • DatabaseReferenceのインスタンスをコンストラクタで受け取る
  • "sample"というパスのデータをobserveSingleEventで1度だけ取得
  • レスポンスのsnapshotをごにょごにょしてcompletionハンドラに引き渡し実行

といったような実装になっています。

テストコード

import XCTest
import FirebaseDatabase
@testable import Sample_App

class FirebaseDatabaseClientTests: XCTestCase {

    func testReadSample() {
        let client = FirebaseDatabaseClient(with: MockDatabaseReference())
        let expectaion = expectation(description: #function)

        client.readSample { sampleList in
            XCTAssertTrue(sampleList.contains("something"))
            expectaion.fulfill()
        }
        wait(for: [expectaion], timeout: 0.5)
    }
}

// MARK: - Override Firebae Realtime Database

private class MockDatabaseReference: DatabaseReference {

    override func child(_ pathString: String) -> DatabaseReference {
        return self
    }

    override func observeSingleEvent(of eventType: DataEventType, with block: @escaping (DataSnapshot) -> Void) {
        let snapshot = MockSnapshot()
        DispatchQueue.global().async {
            block(snapshot)
        }
    }
}

private class MockSnapshot: DataSnapshot {

    override var value: Any? {
        return ["key_1": "value_1", "key_2": "value_2"]
    }
}

1つ1つ軽く解説します。

テストケース

    func testReadSample() {
        let client = FirebaseDatabaseClient(with: MockDatabaseReference())
        let expectaion = expectation(description: #function)

        client.readSample { sampleList in
            XCTAssertTrue(sampleList.contains("something"))
            expectaion.fulfill()
        }
        wait(for: [expectaion], timeout: 0.5)
    }

まずはテストケースから。1行目でテスト対象のクラスを作成しますが、その際に引数としてFirebaseのDatabaseReferenceクラスのモックオブジェクトを渡します。モックオブジェクト自体の解説は後述します。 またデータベースからのデータ取得は非同期であるはずなのでXCTestExpectationを用います。

DatabaseReferenceのモック

private class MockDatabaseReference: DatabaseReference {

    override func child(_ pathString: String) -> DatabaseReference {
        return self
    }

    override func observeSingleEvent(of eventType: DataEventType, with block: @escaping (DataSnapshot) -> Void) {
        let snapshot = MockSnapshot()
        DispatchQueue.global().async {
            block(snapshot)
        }
    }
}

こちらが先ほども出てきたFirebaseのRealtimeDatabaseのモックオブジェクトの定義です。今回はテスト対象のクラスでobserveSingleEventを利用しているのでその関数と、child関数もオーバーライドしています。

child関数をオーバーライドする理由としては、アプリコードの方でデータベースの特定のパスのデータを取得する場合にchild("path")というAPIが実行されると、テスト対象クラスのコンストラクタに引き渡したDatabaseReferenceオブジェクトとは別のインスタンスが使われてしまってモックすることができません。

なので今回はchildが何度実行されようともモックオブジェクトを返すようにして、どんなpathのデータを取得しようともモックのDatabaseReferenceクラスが利用されるようにしています。もしpathによって返却するデータや処理を変えたい場合は、以下のようにオーバライドメソッドの中でpathStringを見て別のオブジェクトを返却したりとかでできるかもしれません。

    override func child(_ pathString: String) -> DatabaseReference {
        switch (pathString) {
        case "hoge": return HogeDatabaseReference()
        case "fuga": return FugaDatabaseReference()
        }
    }

DataSnapshotのモック

private class MockSnapshot: DataSnapshot {

    override var value: Any? {
        return ["key_1": "value_1", "key_2": "value_2"]
    }
}

最後に実際のレスポンスのクラスであるDataSnapshotのモックオブジェクトの定義です。DataSnapshotのvalueプロパティは本来readonlyなのでそのままではvalueを自分好みにすることができません。そこでvalueのプロパティをオーバーライドして、テストで利用したいデータを返却します。

このモックオブジェクトを先ほどのDatabaseReferenceクラスのモックオブジェクトのobserveSingleEventで利用することで、Realtime Databaseから指定したデータが返却されているように偽造することできるようになります。