こんにちは、メルカリでフロントエンドエンジニアをしている @karszawa です。
本日はメルカリアドベントカレンダー2019の21日目の記事として「メルカリアプリのWebView向けWebアプリケーションの開発を支えるモック技術」というニッチ(?)な題目の話をさせていただきます。
メルカリアプリでは「取引画面」を代表として、意外と多くの場所で WebView が使われています。
WebView 向けの Web アプリケーション開発は動作環境が特殊で外部依存の多いという点で普通の Web アプリケーション開発よりややこしいです。たとえば、普通の Web アプリケーションはあるアプリが独自に定義した API(ネイティブ API)を呼ぶことはありません。この記事ではそういった難しさを持ったアプリケーションを開発する上で重要な モックの話 をします。
取引画面
まずはメルカリアプリで WebView が使われている箇所を紹介します。
メルカリは C2C のフリマアプリなので、取引が成立した後もやることがあります。
例えば、購入後に購入者と出品者がやりとりしたり、出品者が商品をコンビニなどに持ち込んで品物の発送をしたりします。
取引画面 はそういった取引後のやりとりを行うための画面で、専属のチーム(フロントエンド&バックエンド)で開発されています。
筆者もそのチームでフロントエンドを担当するエンジニアの一人です。
このページが WebView で開発されていることには大きく二つの理由があり、一つは開発初期に開発人員の観点から WebView を採用したらいつの間にか規模が大きくなり他に選択肢がなくなったという歴史的経緯、もう一つはパートナーの API とのやり取りが多くクイックな対応が必要だというビジネス的な経緯です。
この画面は WebView なので、Web アプリケーションと言ってもデバイス(iOS・Android)を使って確認をしないと、本当の意味ではテストになりません。ですが WebView 上での Web アプリケーション開発は、Google Chrome や Firefox などの現代的なブラウザでのリッチな開発環境に慣れた私達に満足できるものではありません。では単純にブラウザを使って開発をすれば良いのかというと、そう簡単には行きません。なぜなら WebView 向けの Web アプリケーションでは時としてアプリのネイティブ API を呼び出す必要があるからです。メルカリの場合は認証もアプリを通して行っているので、このままではテスト環境の API を呼び出すことすらできません。
そのような制約を持つ Web アプリケーションを開発するために、私達は次の2つのことを行っています。
- モックサーバーによる API のモック
- webpack によるネイティブ API のモック
一つずつ説明していきます。
モックサーバーによる API のモック
まずは API をモックするモックサーバーです。
一般的には Web アプリケーションの開発時にもバックエンドのサービスをローカルで立ち上げたり、ステージング環境の API に接続して開発を行うことが多いと思います。メルカリの場合はバックエンドはマイクロサービスで開発されており、それらをすべてローカルで実行するのは現実的ではありません。一方で、ステージング環境の API に接続してアプリケーションを開発する方は不可能ではありませんが、データの用意が大変だったりバックエンドの開発を待たなければ行けなかったりと不便な点も多いです。1
そこで私達が考えたのが、バックエンドの機能を持たずに偽物の値を返すだけのモックサーバーです。このサーバーは express
で開発されており、各エンドポイントには決められた値を返すだけの処理が実装されています。メルカリのバックエンドはマイクロサービスで開発されていますが、クライアントからアクセスするときはゲートウェイという単一のサービスを介すだけなので、一つのサーバーを立てれば API はモックできます。
コードは次の通りです。非常に単純かつローカルで実行可能性だけを考えれば良いので、バックエンドの知識がなくても簡単に開発できます。
/**
* 取引情報を取得するAPI
* オブジェクトは静的に定義してあるのでそれを返すだけ
*/
app.get('transactions/get', (req, res) => {
const [itemId] = pickValuesOrThrow(req.query, ['id']);
const data = {
id: 'abcxyz',
status: 'wait_shipping',
shipping_method: 10,
buyer_id: 12345,
seller_id: 67890,
...
};
res.json({
result: 'OK',
meta: {
requested: 1510301381
},
data,
});
});
データベースとスナップショットによるデータベースの初期化
あるエンドポイントが返す値を変更したいときもあります。たとえば、上で登場した API は取引の状態(「出荷待ち」とか「レビュー待ち」とか)を返しますが、取引画面はこの値によって見た目が大きく変わるので両方テストできる必要があります。
こういった状況のためにモックサーバーではデータベースを持っています。データベースと言っても、MySQL や PostgreSQL のようなミドルウェアではなく ただの JS オブジェクト です。モックサーバーはローカルの開発で使用されるだけなのでデータを永続化する必要がありませんし、むしろ永続化されたデータによって表示される画面が変わってしまうという事故は避けたいです。
次のソースコードは上述のエンドポイントを DB を使ったものに書き換えた例です。
/**
* `db`: データベースを表すオブジェクト
* テスト用のサーバーなので永続化もしないしインスタンスも一つで良い。
* つまりただのオブジェクトをデータベースとして使うことができる。
*/
const db = {
transactions: {
abcxyz: {
id: 'abcxyz',
status: 'wait_shipping',
shipping_method: 10
buyer_id: 12345,
seller_id: 67890,
...
}
}
};
/**
* 取引情報を取得するAPI
* オブジェクトは静的に定義してあるのでそれを返すだけ
*/
app.get('transactions/get', (req, res) => {
const [itemId] = pickValuesOrThrow(req.query, ['id']);
const data = db.items[itemId];
if (!data) {
res.status(404).send({ error: 'item not found' });
} else {
res.json({
result: 'OK',
meta: {
requested: 1510301381,
},
data,
});
}
});
これで API の動作を動的に変更できるようになりました。モックサーバーにはデータベースの変更点を列挙したもの(スナップショット)を適用するためのエンドポイントが存在していて、それを呼び出すことで外部から動的に動作を変更できます。DB を変更するために特殊なエンドポイントを作ることで、自動テストの際に簡単に所望のデータベースの状態を再現できます。
スナップショットを適用するエンドポイントは次のような形をしています。
/**
* データベースの状態を変えたいときは `snapshots/**` というモックサーバーを制御する
* ためのAPIを呼ぶ。実際に使用しているモックサーバーでは起動時に `SNAPSHOT` という
* 環境変数を設定してDBの状態を初期化することもできる。
*/
app.get('snapshots/rakuraku/wait_review', () => {
db.items['abcxyz'].shipping_method = 4;
db.items['abcxyz'].status = 'wait_review';
});
webpack によるネイティブ API のモック
モックサーバーは API をモックするための汎用的な手法で、普通の Web アプリケーションでも有効な手段です。しかし、取引画面は WebView のアプリケーションなので、「もう一つの API」について考えなければなりません。アプリ固有の API です。たとえばメルカリでは次のようなネイティブ API が Custom URL Scheme として定義されています。
名称 | 挙動 |
---|---|
openProductDetail |
商品詳細ページを開く |
openUserProfile |
ユーザーページを開く |
openURL |
与えられた URL の Web サイトをブラウザで開く |
openModalWebView |
新しい WebView で指定した URL を開く |
showProgress |
ローディングインジケータを表示する |
dismissProgress |
ローディングインジケータを消す |
これらの API はブラウザからはそのままでは呼び出せないためモックする必要があります。
また、モックする方法にも対象の API に応じて次の2つのパターンがあります。
- ブラウザの相当する処理に置き換える(
openURL
など) - 何もしない(実行されたことをコンソールに通知して呼ばれたことだけわかるようにする)(
openProductDetail
など)
幸いなことにこれらの処理は JavaScript の関数としてラップされていて、メルカリに複数ある WebView アプリケーションが共通で使えるようにライブラリ化されています。ライブラリは @mercari/js
という名前で npm のプライベートパッケージとして公開されているので npm install
でインストールができます。
さて、Custom URL Scheme によって定義されたネイティブ API のモックですが、私達のプロジェクトではファイルごとモック関数を定義したものに置き換えています。具体的には次の手順のとおりです。
まずネイティブ API に相当しブラウザで動作するような関数を定義したファイルを用意します。
// mock.mercari.js.nativeBridge.js
export const openProductDetail = (args: { itemId: string }) => {
console.log(`function: "nativeBridge.browser.js/openProductDetail" was called with args: ${args}`);
}
export const openURL = (url: string) => {
window.open(url, '_blank');
}
次に webpack の alias
で @mercari/js/nativeBridge
を参照したときに mock.mercari.js.nativeBridge.js
を見るように設定します。
// webpack.config.js
module.exports = {
alias: {
'@mercari/js/nativeBridge$': path.resolve(__dirname, 'mock.mercari.js.nativeBridge.js'),
}
...
};
以上でネイティブのコードを呼ぶときに代わりにブラウザのコードを呼ぶことができるようになります。
開発ではネイティブ API は必ずモックし本番では必ずネイティブ API を呼ぶので UserAgent を見て動的に処理を変えたりする必要はなく、ビルド時の設定で静的に解決できます。
まとめ
以上がメルカリの WebView 向けアプリケーションという少し特殊なアプリケーションをモックする工夫でした。
こういった特殊なことをしないとブラウザでの開発はできないわけですが、その手法を考えることもまた開発上の面白い課題だと思います。
取引画面を作っているチームではこの記事で話したことに加え、これを応用してE2Eテストを実施したり、本番の API を触れるようにするプロキシを作成したりといろいろな工夫を行っています。
弊チームでは WebView 向けの Web アプリケーション開発に並々ならぬ興味をお持ちのフロントエンドエンジニアを歓迎しています。
ご興味があればこちらからご連絡ください。
-
iOS や Android などのクライアントはこの方法で開発されています。↩