DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

macOSのCopy-on-Write機能を使ってディスクを節約した話

こんにちは、SWETでCI/CDチームの前田( @mad_p )です。 SWETではCI/CDチームの一員として、Jenkins運用のサポートや、CI/CD回りのノウハウ蓄積・研究をしています。

はじめに

先日開催されましたCEDEC 2022にて、Gitリポジトリの肥大化に対応した事例を発表しました。これはそのフォローアップ記事となります。以前に出した記事の続編でもあります。

発表資料は次の場所に置いていますので、参照してみてください。

Gitリポジトリの肥大化問題

前提となっている課題をおさらいしておきます。 Gitリポジトリは、コミットを重ねることで大きくなっていきます。 大きくなると、クローンにかかる時間や、クローン後の.gitを保存するためのディスク容量が多く必要になります。 その結果、CIの実行時間・CIマシンのディスク不足などの心配事が増えます。 特にディスク不足に関しては、.gitの容量とチェックアウトしたファイルの容量のダブルでディスク消費が増えるので注意が必要です。

Jenkinsマシン上では、同一のリポジトリをジョブ別の複数フォルダにクローンすることになります。 多くのジョブで活用されるリポジトリは、ディスク容量不足の原因となりやすいです。 今回の事例で節約対象としたJenkinsマシンでは、20か所以上にクローンされているリポジトリもあります。

今回紹介する事例について

今回紹介する事例の背景を少し説明したいと思います。 対象のJenkinsマシンは、あるモバイルゲームタイトルのCIを支えているものです。

そのゲームはリリースから数年経っていて、 毎月のイベントごとにアプリ更新とキャラクターの追加があります。 この月次のリリースに対応して、ソースコードやアセットのブランチを管理しています。

プロジェクト内の大きなリポジトリのトップ4はこんな感じです(クローン後の.gitの容量)。

  • アセット(22GB)
  • アセットバンドル(LFS利用、16GB)1
  • アセットソース(15GB)
  • アプリ(3.4GB)

一番大きいアセットリポジトリを使ったジョブが一番種類が多く、 大きさ×ジョブ数で、ディスクを多く必要とします。

Jenkinsマシンはオンプレ・クラウド合わせて15台くらいで運用しています。 macノードが、慢性的なディスク容量不足に悩まされていて、 ここをなんとかしたいというのが動機です。

すでに紹介した対応方法と効果

前回のブログ記事で説明した方法を使って節約できた容量を紹介します。 ある1台のmacノードでの成果を調べてみました。

リファレンスを活用したクローンでは、.gitの、ディスク容量とダウンロード時間を節約できました。

リファレンスの活用

よく使うリポジトリの上位5つくらいを、リファレンスとして使うためのミラーとして用意しておきます。 定期的にフェッチするようにして、ある程度最新の状態になるように保っています。 Jenkinsのジョブでクローンするとき、 ミラーをリファレンスとして使うようにオプションを設定します。 これによって、20個以上の.gitをひとつにまとめることができました。

リファレンス設定のファイルを数えて調べたところ、 容量×参照数で、820GB程度の必要量を60GBにまとめることができています。 元々シャロークローンで足りていた部分もあると思うので、 実質の節約量は引き算した760GBとはいかず半分くらいかもしれません。 思っていたよりたくさん節約していたことがわかりました。

LFSローカルストレージの共有化では、 .git/lfsのディスク容量とダウンロード時間を節約できています。

LFSローカルストレージ共有

こちらは、現状での節約量を調べるのは難しかったので、 導入当時の記録を調べました。 複数フォルダの合計で90GB程度だったものが、まとめて20GBになったとありました。

copy-on-write機能を使ってディスクを節約した事例

それでは、本題のcopy-on-write活用について説明しましょう。

次の問題: チェックアウトしたファイルの容量が大きい

.gitの容量問題が解決したのですが、まだディスク残量が厳しい状況でした。 次の要因としては、チェックアウトしたワーキングツリーの容量が大きそうです。

どんなデータがJenkinsマシンのディスク内で容量を使っているかを調査してみました。 その結果、ジョブ内で参照するアセットデータが大きいとわかりました。 特に、キャラクターのモデルやモーションのデータが大きいです。 スパースチェックアウトが使えないかな、と思いましたが、 キャラクターデータを部分的に扱うジョブというのはなく、 うまくいかなさそうです。 サウンドデータもリポジトリ容量は大きいですが、 ジョブは少なくディスク容量としてはそれほど気にならないです。 すべてのジョブがリードオンリーとは限らない使い方、ということもわかりました。

容量を使っているのは、キャラクターやモーションのデータということがわかりました。 アセットリポジトリからチェックアウトしたフォルダツリーには、 ファイルが4万個くらい入っていて、25GBくらいの容量になります。 これがジョブごとに別の場所にあり、全部で20組くらい、 合計すると500GB程度になっています。

多くのデータが重複している状態

このように多数のコピーを持っているのは、Jenkinsマシンのうち、 macノードだけということもわかりました。 macOS特有の機能を使ってもいいから、なんとかしたいという思いがありました。

同一内容のファイルがたくさんコピーされているなら、まとめることができそうです。 macOSのAPFSというファイルシステムには、 同一内容のファイルのディスク領域だけを共有する機能があります。 copy-on-write動作をするので、リードオンリーと限らない場合でも使えそうです。

同一のディスクブロックをまとめられたら、 全体がこの図のようにできると思います。 これが理想の形です。

データ重複を減らした理想形

モデル/モーションデータの性質

ここで、対象となるモデル/モーションのデータがどんな性質を持っているかを考えてみます。

モデルとモーションは、開発が進むにつれて、 新規キャラクターのデータが追加されます。 一方で、すでに登場しているキャラクターのデータが修正されたり モーションが追加されることは、ゼロではないにしても、それほど多くはありません。 先に述べたように、これらのデータは月次リリースに対応してブランチ管理されています。 登場済みのキャラクターは、ブランチを切りかえても内容が変わらないことがほとんどです。

ジョブでは、このブランチ名をパラメーターとして処理を行うことが多いです。 毎月、その月のリリースのブランチだけを処理するわけではなく、 何か月か先のブランチに対してもCIを行っています。 このため、ジョブは実行するたびに別のブランチをチェックアウトしています。 具体的にどのファイルが上書きされるのかを予見することは難しいです。

UNIXのリンク機能

同一内容のファイルがたくさんあるときに、まとめるテクニックとして、 UNIXで古来から使われていた方法にリンクというものがあります。 macOSもUNIXの一種なので同じものが使えます。

ハードリンク、シンボリックリンクという2種類があり、 いずれも、データがリードオンリーの場合によく使われてきました。 macOSのcopy-on-write機能と比較するために、ちょっと復習してみます。

今回の用途をふまえて、次の2点に注意して比較してみましょう。

  • 書きかえたときに、共有されている他のファイルに変更が反映されるか
  • Gitにコミット、Gitからチェックアウトしたときにどうなるか

リンクの説明をするために、ファイルシステム内のデータ構造についてみてみましょう。 UNIXやLinux、macOSのファイルシステムは、だいたいこんな風になっています。

inodeの仕組み

ファイルの実体は「inode」というデータ構造で管理されています。 ファイルのメタデータとディスクブロックの番号がここに記録されています。 実際のファイルひとつにinodeひとつが対応している感じです。

ファイルの内容は複数のブロックに分割して保存されています。 そのブロックの番号がinodeに記録されています。 じゃあファイル名はどこにあるんだ、というと、ディレクトリに入っています。 ディレクトリも実はファイルの一種です。 内容がファイル名とinode番号の対応表になっています。

ハードリンクは、ひとつのinodeに複数のファイル名をつける機能です。 ln コマンドで作ることができます。

ハードリンクの仕組み

ひとつのファイルに2つの名前があるという状態になります。 片方を書きかえるともう一方にも変更が反映されます。 むしろ、反映されることを狙いたい場合に使われる方法です。

Gitは、「このファイルとこのファイルがハードリンクされている」というのを調べないので、 コミットしたりチェックアウトしたりすると、別のファイルとして扱われます。

シンボリックリンクは、 ln コマンドに -s オプションをつけると作ることができます。

シンボリックリンクの仕組み

fileBのディスクブロックには「 fileA 」と書いてあって、 これをファイル名と見なしてfileAをさがし出し、 その内容をfileBの内容とする、という仕組みになっています。 片方を書きかえると、もう一方にも変更が反映されます。

Gitでは、シンボリックリンクを認識して、シンボリックリンクとしてコミットします。 チェックアウトするとシンボリックリンクとして作成されます。

以上の2つのリンク方式は、片方を書きかえるともう一方にも反映されるので、 今回対象とするJenkinsのジョブのように、 不用意に変更が反映されると困るという使い方には適していません。

macOSのcopy-on-write機能

macOSにある、copy-on-write機能の仕組みは、 これらのハードリンク、シンボリックリンクと比べてみると、理解しやすいです。

cp コマンドに -c オプションをつけると、この方法でファイルをコピーできます。

copy-on-writeで複製した状態

新しくinodeが作られ、ファイルの所有者やタイムスタンプなどは、独立して管理されます。 inodeから指しているディスク領域だけが、fileAとfileBで共有されます。

書きかえは片方にだけ反映されます。 ディスク領域を共有しているファイルの片方を書きかえると、 書き込みが発生した部分だけに、新たにディスクブロックが割り当てられます。

copy-on-writeの動作

図では、fileAの末尾に「 hoge 」と追記した状態を表わしています。 fileAとfileBで共有していたディスクブロックのうち、 末尾に対応するブロックだけが新しいブロックにコピーされて、 hoge が追記されます。

この動作は、「write」するときに「copy」するので、 「copy-on-write」と呼ばれています。 この新しいブロックは、fileAからだけ参照されます。 結果として、fileAとfileBは別の内容のファイルになりますが、 内容が同じ部分ではディスク領域を共有している、ということになります。

このような動作は、今回の用途にはぴったりです。

予備実験、効果見積り

copy-on-writeの仕組みを使って、ディスクの節約はできそうでしょうか?

別のディレクトリにクローンした、同じ内容のファイルをまとめたとして、 次のチェックアウトで上書きされて、別のディスク領域に戻ってしまわないでしょうか? ブランチを切りかえても内容が変わらない場合、 ディスク領域の共有が維持されることを期待します。

また、copy-on-writeで領域をまとめるとき、 どれとどれが同一内容のファイルであるか特定できるでしょうか? フォルダツリーが同じ構造をしていれば、 同じファイル名のもの同士を対応させて調べられます。 前述した、リファレンス用にミラーしているフォルダを使って、 ツリー同士を比較できそうです。

節約できそうか調べるため、予備実験と効果見積りをしました。 ブランチを切りかえてチェックアウトしてみて、 更新のないファイルが上書きされるか調べました。 ls コマンドに -i オプションをつけると、inode番号を表示できます。 inode番号とタイムスタンプを調べると、上書きされないことがわかりました。

たくさんコピーがあると言いましたが、 どれくらい同一内容のファイルがあるのかも調べて、 効果の見積りをしました。 ミラーとジョブのツリーをつき合わせて内容を比較しました。 100万個くらいあるファイルのうち97%程度が、 対応するミラーのファイルと同一内容であり、 全体としては500GB程度を節約できそうだ、ということがわかりました。

また、この調査だけで10時間以上がかかってしまいました。 ディスク領域をまとめる処理を一度に全部やろうとすると、 24時間で終わらなさそう、現実的でない、ということもわかりました。

copy-on-writeにどんなリスクがあるのかもこの時点で検討しています。

  • duなどのファイル容量調査ツールでは、別のinodeは別のディスク領域を使っていると計算される
    • 「duで測定したディレクトリごとのディスク容量の合計」と「dfで見たディスク容量」が一致しなくなる
    • copy-on-write活用によってどれくらいの節約になったか、効果測定が難しい
      • 2つのファイルが共有しているかは、後述のapfs-clone-checkerで調べられる
  • 領域共有が解除されるような操作によって急にディスク領域が必要になる
    • ディスクの残り容量が十分にあると思っていたら、急にディスクフルになってしまう可能性がなくはない
    • バックアップは正常に取れるが、リストアしようとしたらディスクに入り切らないという可能性もある

実装

予備実験の結果をふまえて、ディスク領域を共有するジョブを作成しました。 20個あるツリーのうち、1日に2個ずつ選んで処理します。 10日間で全ツリーに対して1回ずつ処理が終わり、 その後はまた最初から処理される、ということを狙っています。

ミラーにあるツリーを比較元として、ファイル内容が同一であるか調べます。 内容が同じで、かつ共有処理がまだされていなければ、 ミラーのファイルを cp -c して、処理対象のファイルの代わりに置きます。 これをたくさんのツリーに対して順に行えば、 「ミラーのディスク領域を共有したツリー」が増えていって、 いつか全部のディスク領域が共有できるというわけです。 どれくらいのファイルを処理したか、統計情報も出力するようにしました。

「2つのファイルが、すでにディスク領域を共有しているかどうか」は、 調べられるツールがあったので、これを使っています。

試験運用

このように作ったジョブを、1か月程度運用してみた結果です。 ディスク領域が共有できているのは、96万ファイル中39万ファイル、約200GB(40%程度)、ということがわかりました。

未共有 共有済 共有不可 合計
ファイル数 513,539 388,005 59,176 960,720
容量(GB) 273.28 201.60 11.22 486.10

予備実験のときの見積りの半分くらいしか効果が出ていないです。これはなぜなんでしょう?

トラブルシューティング

効果が出ない理由として2つを予想しました。

  • ジョブの中で、予備的に一度消してから、チェックアウトし直している
  • 別ブランチをチェックアウトするときに書きかえられているファイルが多い

これを調べるために、ファイルが書きかえられた瞬間を見張りました。 1分に1回、lsコマンドでファイルのタイムスタンプを表示します。 ファイルが書きかわった時刻が分かるので、 そのときに実行されていたJenkinsジョブと、 ジョブの中のどのコマンドだったかを調べました。

こうやって調べた結果、わかったことを元に対策していきます。

予備的なクリア

「予備的に全クリア」しているジョブはなさそうでした。

ブランチを削除するツール

ひとつ原因がわかったのは、「ブランチを削除するツール」が悪さをしていたことです。 ブランチを消すとき、現在のワーキングツリーがそのブランチにいると、 git branch コマンドが、エラーになってしまいます。 これを回避するために、 「とりあえずmasterブランチを1回チェックアウトする」という動作をしていました。 このmasterブランチがとても古く、 多くのファイルが上書きされていました。

対策は簡単です。 masterをチェックアウトする代わりに、 git checkout --detach HEAD というコマンドを実行すればよいです。 タグをチェックアウトしたときと同様の状態になり、安全にブランチを削除できます。

アプリビルド

別の原因として、アプリビルドのジョブがありました。 アプリは、チュートリアルで必要なアセットのみを選んでビルドする必要があります。 必要ないアセットは、いったんチェックアウトした後、削除する処理になっていました。 対策として、このジョブはディスク領域の共有をあきらめて処理対象から外しました。

git reset --hard の挙動

もうひとつ面白かった原因は、ワーキングツリーをブランチの内容に合わせるため、 git reset --hard コマンドを使っていたことでした。 cp -c したファイルと置きかえた直後に git reset すると、 内容が同一かどうか調べず、常に上書きされてしまうことがわかりました。 git checkout では、内容が同一の場合には上書きされません。

対策としては、一度 git status を行っておけば、 git reset しても上書きされないことがわかりました。2

その他の工夫

そのほかに、ツリーを単位として、処理済みかどうかを素早く予想する方法を思いついたので、 日次のジョブに工夫を入れました。 古くから存在していて、更新されそうもないキャラのモデルファイルをひとつ選びます。 処理対象ツリーごとに、このファイルが共有処理済みかどうか調べます。 共有されていなければ、そのツリー全体がおそらく未処理なので、優先して処理します。 このファイルが共有済みであれば、ツリー全体はおそらく処理済みですが、確実とはいえないので、 低優先度ながら処理するようにしました。

結果

このような改善をして、運用をさらに1か月続けた結果です。

未共有 共有済 共有不可 合計
ファイル数 81,236 773,185 15,440 869,861
容量(GB) 43.67 412.63 8.21 464.51

87万ファイル中の 77万ファイル約400GB(88%) のディスク領域を節約できました。 対策の中で処理対象から外したジョブもあるので、 見積り段階と比べると分母も減っています。

下のグラフは、効果の大きかった4台のマシンの空き容量監視画面です。

ディスク使用量の変化

導入前、200GB程度だった空き容量が、 試験運用のときは400GB程度、 改善後は500GB程度になっているのがわかります。 他の要因もあるので、 400GB空き容量が増えた、とはいきませんでしたが、 copy-on-writeの効果としては十分な節約ができたと思います。

当初の状態と比べてみましょう。

多くのデータが重複している状態

これが、こうなりました。

節約後の実際の状態

理想形との違いは、未処理のツリーが、10%くらいは常にありそう、ということです。

まとめ

Jenkinsのmacノード上で、チェックアウトしたデータの容量が多く、慢性的なディスク不足に悩んでいました。 容量の大きいデータを特定し、その性質を調べました。 copy-on-write機能が使えそうだと思いついて、 本当に使えるのか、予備実験をして効果を見積りました。 実装して試験運用すると、効果がいまいちでした。 その原因をさぐって対策した結果、見積りに近い効果が得られました。 gitコマンドの動作の違いなどがわかって面白かったです。

前回のブログで紹介した工夫と、今回紹介したブログの工夫を合わせて、 .gitの容量とチェックアウトしたファイルの容量の両面から、ディスク節約を行えました。

copy-on-write機能は、大きなフォルダ全体のコピーを素早く作りたいときなどにも役立ちます。 たとえば、自分の開発用マシン上で、すでにクローン済みのフォルダとは別にもうひとつクローンしたい場合に使えます。 ワーキングツリーと.gitをまとめて全部 cp -ac cloned_folder cloned_folder2 のようにコピーすると、 時間とディスク容量の両方を節約できます。 JenkinsやGitを使わない場合でも、便利な場面が多いと思います。 ぜひ活用してみてください。

宣伝

この記事を読んで「面白かった」「学びがあった」と思っていただけた方、よろしければTwitterやfacebook、はてなブックマークにてコメントをお願いします。

また DeNA 公式 Twitter アカウント @DeNAxTech では、Blog記事だけでなく色々な勉強会での登壇資料も発信してます。ぜひフォローして下さい!


  1. LFSについては前回の記事のLFS (Large File Storage) の活用の項で解説しています

  2. git statusで作成したインデックス情報とファイルの状態が異なる場合、git checkoutではファイルの内容が変わっているのか調べてくれるのに対し、git resetでは調べてくれないようです。インデックスにどんな情報が入っているかは、GitHubブログのこの記事の「Phase 1: refresh_index」の項に解説があります。inode番号も入っていますね。

夜開催のiOS Test Onlineを開催します!登壇者募集!

SWETグループのKuniwakです。iOSコミュニティの皆様にとってはお久しぶりです!

早速本題ですが、夜開催のiOS Test Onlineを始めます!iOS Test Onlineは、iOS Test NightやiOS Test TeaTimeと同じくiOSアプリのユニットテストやUIテスト、継続的インテグレーション(CI)などiOSアプリのテストにまつわる技術的な発表の場です。

「iOS Test 〜」系勉強会が多すぎる!と思った方向けに説明すると、それぞれのイベントは開催時間帯と開催形態で分かれているだけで発表内容や発表ボリュームは変わりません。

イベント 開催時間帯 開催形態
iOS Test Online オンライン
iOS Test Night オフライン
iOS Test TeaTime オンライン

さて、このiOS Test Onlineについて発表者を募集します!

もう一度言います。

iOS Test Onlineについて発表者を募集します!

発表内容は、たとえば「テストはじめて書いてみたよ」や「このテストツール便利だったよ」あるいは「私がテストを書かない理由」でも大歓迎です!発表したい方は次のGoogle Formからご応募ください!なお、開催日は発表者が集まれる日で調整する予定です。

iOS Test Online発表応募フォーム

過去の発表資料を参考にしたい方は次の過去の発表資料をご覧になるとよいでしょう:

皆様からの発表のご応募お待ちしております!

Isabelle/Isar勉強会を社内で開催しました

こんにちは、SWETグループの鈴木穂高(@hoddy3190)です。

SWETグループのメンバー向けにIsabelle/Isar勉強会を開催しました。本記事では、勉強会の概要の紹介と、勉強会の資料の公開をします。

もしよろしければご活用ください。

Isabelleとは

Isabelleは、定理証明支援ツールの1つです。 数学の授業で証明を解く時、暗記した定義や定義を引き出して仮定や結論をみて試行錯誤しながら適用して解いていっていたと思います。Isabelleを使うと、利用できる定理を提示してくれたり、自動で定理を適用して証明を進めてくれたり、誤った証明を指摘してくれたりします。

Isabelleの大きな特徴として強力な自動証明が挙げられます。自動証明機能の典型例であるSledgehammerは、証明したい論理式を与えれば自動で定理を適用して証明を解き進めてくれます。 公理系も選択可能です。よく使われるのはHOLですが、他にもFOLやZFなどを扱えます。 Isabelleの実績として、オペレーティングシステムの機能に関する証明に使われました。1

Isabelleには証明の記述方法が2種類あります。apply-scriptsとIsarです。 今回の勉強会で用いているのはIsarです。Isarは今何を証明しているのかを宣言して証明を書くためapply-scriptsより読みやすいという特徴があります。

業務でのIsabelleの用途

仕様と実装に正当性関係が成り立つことを証明するのに使えます。 仕様と実装の正当性関係とは、実装が仕様を満たしているかどうかの関係のことを言います。

私はSWETグループにて仕様の欠陥をなるべく早くみつける取り組みにチャレンジしています。 詳細はこちらの記事の「発表内容」のスライド資料または動画をご覧いただければと思いますが、簡単にいうと仕様を形式仕様記述するというアプローチで取り組んでいます。

正当性関係が成り立っていることは論理式で表せますので、仕様と実装が形式的に書かれていれば、実装が仕様を満たしているかをIsabelleのような定理証明支援ツールを使って証明ができます。私はOCamlで記述した仕様と実装をIsabelleが読める形にトランスパイルして、正当性関係が成り立つことを証明しました。

Isabelle/Isar勉強会の概要

正当性関係が成り立っているとはどういうことなのかは、Isabelleを使って証明を進めることで理解が深まります。 さらには証明の手順が分かると、正当性関係が成り立つことを証明されるのを見越して、仕様はこう書けばよい、仕様に対して実装はこう書けばよいといった勘所も養われます。 このようなメリットのため、SWET内でIsabelle/Isarを学ぶための勉強会を開催しました。

勉強会は毎週1時間リモートミーティングで開催しました。 参加メンバーの人数は5,6人です。 参加メンバー全員がIsabelleをapply-scriptsで使ったことがある方でした。 実はこの勉強会よりも前に、Isabelleの基本的な使い方や、仕様と実装をIsabelleで記述し正当性関係に関する証明のやり方を学ぶ別の勉強会があり全員そこの参加者でした。 ただ本資料はIsabelle初学者でも分かるように作っています。

1時間の勉強会の内容は、座学と練習問題です。 座学では毎回私が資料をベースに説明をし参加メンバーと質疑応答を行います。 その後用意してある練習問題を参加メンバーに解いてもらいます。 毎回平均して30分ほど座学、残り30分ほどは演習問題を解く時間でした。 時間内に解ききれなかった練習問題は可能であれば別途各自で時間をみつけて次回の勉強会までに解いてもらうようにしました。 勉強会は合計7回開催しました。

参加メンバーからのフィードバックとして次のようなものが得られました:

  • 簡単な定理から徐々に証明していく形だったので、以前の勉強会に付いていけてなくても何とかなったのが良かったです。
  • apply-scriptsのほうが慣れている分楽な気がします。一方、apply-scriptsは確かにIsabelleを動かさないと何をしているのかがわからないですが、Isarだと読むだけでわかりそうでそこはよさそうに思います。

Isabelle/Isar勉強会の資料と練習問題

Isabelleの特徴は先に述べたとおり強力な自動証明ですが、今回の練習問題では自動証明は極力使わないよう解答してもらいました。 基本的な定理を理解した上で、定理を使って少しずつゴールをrefineしていくことがIsabelle/Isarに慣れるためには必須だと思ったからです。 ですから、もし練習問題を解く際は極力自動証明を使わないことをお勧めします。

さいごに

実際に業務で出てくる論理式に対して、今回は初学者向けということもあり例題や練習問題はシンプルです。 焦らず少しずつできることを増やしていってもらえればよいと思います。 本資料や練習問題に興味を持ってくださる方がいたら嬉しいです。

それから、SWETで働きたいエンジニアを絶賛募集中です。ぜひ採用ページをご覧ください!

時間を加味したモデリング

こんにちは、SWETの鈴木穂高(@hoddy3190)です。

現在SWETチームにて仕様の欠陥をなるべく早くみつける取り組みにチャレンジしています。 欠陥をみつけるタイミングが早ければ早いほど、開発中の手戻りに伴うコストを抑えられます。 たとえば、仕様作成フェーズ、実装フェーズ、QAフェーズの順で開発が進んでいくときに、仕様の欠陥が実装フェーズやQAフェーズでみつかると実装やQAのやり直しを引き起こしかねません。 そうした大きな手戻りを抑えるために仕様の欠陥をなるべく仕様作成フェーズでみつけることを目指します。

対象領域に出てくる要素をモデリングすることは、仕様に潜む欠陥を開発の早い段階でみつけるための、有効な手段のひとつです。 要素には、開発者がこれから作るシステムや、そのシステムのユーザー、そのシステムと直接的または間接的に相互作用する外部のシステムが含まれます。 単に図を書くというモデリングの他に、プログラムのように動くモデルを作るという方法もあります。動くモデルを作るとモデリングする過程での気づきや、モデルを動かしてみた時の気づきが後工程での手戻りを減らすことにつながります。

以前私が書いた「仕様記述テクニック『Promotion』の紹介」ではAlloyを使っていましたが、 本記事ではプログラミング言語OCamlをモデリング言語として使って時間を加味したモデルを記述する方法を紹介します。 多くのモデリング対象の振る舞いは時間が関係するため、モデルの記述で時間を扱えると表現の幅が広がります。 なお、OCamlの説明はしません。

OCamlでのモデリング

今回はプロセスをモデリングします。プロセスは対象領域の要素の振る舞いを指す用語として用います。 モデリングのやり方はさまざまあります。今回は対象のプロセスをプロセスが取りうる状態と遷移で表現します。 プロセスの型は次のようにします:

type ('ev, 'ch, 'state) process =
  'state -> (('ev, 'ch, 'state) trans) list

process型で求められる状態の型は次のようにします:

type state =
  | State_A
  | State_B of int

状態変数はコンストラクタの引数を使って表現します。

遷移ラベルには、イベント同期、受信、τを用意します。ここでτという遷移ラベルのついた遷移は、プロセス内部で自動的に遷移します。 遷移の型は次のようにします:

type ('ev, 'ch, 'state) trans =
  | Tau of 'state
  | Ev of ('ev * 'state)
  | Recv of ('ch * ('state, 'ev) guard * ('ev -> 'state))

コンストラクタTauはτを表します。 コンストラクタEvはイベント同期を表します。イベント同期は、チャネルを通じて値をやりとりします。 コンストラクタRecvは受信を表します。受信は、チャネルを通じて値を受け取ります。受信の場合、事後状態は受信した値によって決まります。ガードを満たすかどうかも受信した値によって決まります。 受信によって起きるイベントは、受信した値を使ってイベント同期で表します。

trans型で求められるイベントの型は次のようにします:

type ev =
  | Event_A
  | Ch1 of int

イベント型に呼応してチャネル型を定義します。イベントが属するコンストラクタを表すための型です。 OCamlでは引数をとるコンストラクタそのものを値として使うことができないので、このような構成にしています。 trans型で求められるチャネルの型は次のようにします:

type ch =
  | Ch_Ch1
  | Ch_Ch2

ここまでを踏まえて次の状態遷移図で表されるATMをモデリングしてみましょう。

f:id:swet-blog:20220404134852p:plain

ATMの振る舞いは、カードを受け取り(in)、PINナンバーを受け取り(pin)、PINナンバーをチェックし(check)、引き出す額を受け取り(req)、額分のお金を出し(dispense)、カードを出します(out)。

これをモデリングすると次のようになります:

let atm_process : (ev, ch, atm_state) process = fun s ->
  match s with
  | A_S1 vals ->
      [
        (* in?c *)
        Recv
          ( Ch_In,
            (fun _ _ -> true),
            fun ev ->
              let vals' = make_vals'_1 vals ev in
              A_S2 vals' );
      ]
  | A_S2 vals ->
      [
        (* pin?p *)
        Recv
          ( Ch_Pin,
            (fun _ _ -> true),
            fun ev ->
              let vals' = make_vals'_2 vals ev in
              A_S3 vals' );
      ]
  | A_S3 vals ->
      let vals' = make_vals'_3 vals in
      [ (* check *) Ev (Check, A_S4 vals') ]
  | A_S4 vals ->
      [
        (* req?n *)
        Recv
          ( Ch_Req,
            (fun _ _ -> true),
            fun ev ->
              let vals' = make_vals'_4 vals ev in
              A_S5 vals' );
      ]
  | A_S5 vals ->
      let vals' = make_vals'_5 vals in
      [ (* dispense!n *) Ev (Dispense vals.req, A_S6 vals') ]
  | A_S6 vals ->
      let vals' = make_vals'_6 vals in
      [ (* out!c *) Ev (Out (get_card vals), A_S1 vals') ]

時間遷移の追加

次に、物理的な時間経過を表現できるように表現の枠組みを拡大します。ここでは時間オートマトンという理論にある考え方を使います。 時間オートマトンにおける状態は、ロケーションと時間代入関数の組で表現します。 ロケーションは、今まで「状態」と呼んでいたものです。時間代入関数はクロック変数と時間をマッピングしたものです。クロックは経過した時間を表します。 遷移には2種類あり、あるロケーションから別のロケーションに遷移する離散遷移と、時間経過による時間遷移があります。

時間を表す型を用意します:

type datetime = int

クロック変数の型を用意します:

type clock = T

時間制約式は、時間に関する条件を表した式です。ある時刻までに遷移するといった遷移の条件や、ある時刻を越えてこの状態に留まることはできないといった状態が満たすべき条件を表すのに用いられます。時間制約式は次のようにします:

type ('clock) cons =
  | True
  | Eq of 'clock * datetime
  | Le of 'clock * datetime
  | Ge of 'clock * datetime
  | Lt of 'clock * datetime
  | Gt of 'clock * datetime
  | Not of 'clock cons
  | Or of 'clock cons * 'clock cons
  | And of 'clock cons * 'clock cons

時間代入関数は次のようにします:

type 'clock v = ('clock, datetime) Hashtbl.t

時間代入関数は時間遷移や、クロック変数のリセットを伴う離散遷移で更新されます。

不変述語は状態が満たす時間制約です。不変述語は次のようにします:

type ('state, 'clock) inv = 'state -> ('clock cons)

遷移に時間制約を与えられるようにしたいので、遷移する際に満たすべき時間制約とリセット対象のクロック変数を指定できるようプロセスの型を次のように変更します:

type ('ev, 'ch, 'state, 'clock) process =
  'state ->
  'clock v ->
  ( 'clock cons * (* 遷移する際に満たすべき時間制約式 *)
    'clock list * (* リセットするクロック変数 *)
    ('ev, 'ch, 'state, 'clock) trans ) list

遷移も合わせて変更します:

type ('ev, 'ch, 'state, 'clock) trans =
  | Tau of 'state
  | Ev of ('ev * 'state)
  | Recv of ('ch * ('state, 'clock, 'ev) guard * ('ev -> 'state))

ATMの例(モデリング)

ここまでを踏まえてさきほどのATMの振る舞いに変更を加えたものをモデリングしてみましょう。

f:id:swet-blog:20220324180050p:plain

pinの受け取りに時間が30かかる場合はカードを出す振る舞いを加えました。 これをモデリングすると次のようになります:

let atm_process (ev, ch, atm_state, clock) process = fun s v ->
  match s with
  | A_S1 vals ->
      [
        ( True,
          [ T ],
          (* in?c *)
          Recv
            ( Ch_In,
              (fun _ _ _ -> true),
              fun ev ->
                let vals' = make_vals'_1 vals ev in
                A_S2 vals' ) );
      ]
  | A_S2 vals ->
      [
        ( Lt (T, 30),
          [],
          (* pin?p *)
          Recv
            ( Ch_Pin,
              (fun _ _ _ -> true),
              fun ev ->
                let vals' = make_vals'_2 vals ev in
                A_S3 vals' ) );
        (Eq (T, 30), [], Ev (Out (get_card vals), A_S1 vals));
      ]
  | A_S3 vals ->
      let vals' = make_vals'_3 vals in
      [ (True, [], (* check *) Ev (Check, A_S4 vals')) ]
  | A_S4 vals ->
      [
        ( True,
          [],
          (* req?n *)
          Recv
            ( Ch_Req,
              (fun _ _ _ -> true),
              fun ev ->
                let vals' = make_vals'_4 vals ev in
                A_S5 vals' ) );
      ]
  | A_S5 vals ->
      let vals' = make_vals'_5 vals in
      [ (True, [], (* dispense!n *) Ev (Dispense vals.req, A_S6 vals')) ]
  | A_S6 vals ->
      let vals' = make_vals'_6 vals in
      [ (True, [], (* out!c *) Ev (Out (get_card vals), A_S1 vals')) ]

モデリングしたプロセスをエンジンを使ってREPL上で動かしてみます。 エンジンは、どのイベントが起こるとどの状態になるのかを可視化します。 ひとつ前の状態に戻ったり、遷移の履歴をダンプしたりもできます。 不変述語を満たしているかのチェックもエンジン側で行っています。

f:id:swet-blog:20220324180234g:plain

メニュー[0]を選ぶと時間遷移をします。 メニュー[1]を選ぶとたどってきた状態とイベントをダンプします。 メニュー[2]を選ぶとひとつ前の状態に戻ります(undo)。 メニュー[3]を選ぶとundoで戻った操作をやり直します(redo)。 メニュー[4]以降を選ぶと離散遷移をします。

状態遷移図を見ながら意図どおりに動いているかチェックします。 上の図では、初期状態A_S1から、メニュー[4]を選択していき、A_S2、A_S3、A_S4、A_S5、A_S6、A_S1と遷移することを確認しています。 その後、A_S1からA_S2に遷移し、メニュー[0]を選択して時間を30進めた後、A_S1に遷移することを確認しています。

並行合成

一般にシステムはユーザーや外部ソフトウェアなどと相互作用をもちます。 しかし、相互作用をもつ複数の要素から構成されるシステムを欠陥なく設計し実装することは難しいものです。 複数の要素から構成されるシステムをモデリングして開発の早い段階で先のように動かしてみることは、手戻りを減らすことに寄与することが期待されます。

システムを動かすためには、まず相互作用する複数のプロセスをそれぞれモデリングします。そしてそれらを合成します。合成は、複数の振る舞いを組み合わせてできたシステムの振る舞いを決定します。 並行合成は、合成する2つのプロセスそれぞれで発生する遷移のうち、同期させたい遷移を指定して合成します。指定された遷移以外の遷移は独立して起こります。

2つのプロセスを受け取り、並行合成したプロセスを返す関数の型を次のように決めます:

val composit:
  ?sync_set_by_ch:('ch -> bool) ->
  ?eq_ev:('ev -> 'ev -> bool) ->
  ('ev, 'ch, 'p_state, 'clock) process (* プロセスP *) ->
  ('ev, 'ch, 'q_state, 'clock) process (* プロセスQ *) ->
  ('ev -> 'ch) (* 対応チャネル取得関数 *) -> 
  ('ev, 'ch, 'p_state * 'q_state, 'clock) process

プロセスPとプロセスQは合成対象のプロセスです。合成されたプロセスがとりうる状態は、プロセスPがとりうる状態とプロセスQがとりうる状態の組になります。合成されたプロセスがとりうる状態はp_stateとq_stateのタプルで表現します。 対応チャネル取得関数は、与えられたイベントが属するチャネルを返します。 sync_set_by_chで同期イベント集合を表現します。同期イベント集合には、合成する2つのプロセスの相互作用に関係するイベントを指定します。たとえば2つのプロセス間でチャネルを通じて値を送受信する場合、値のやりとりをするイベントを指定します。チャネルを受け取ってtrueを返す場合、そのチャネルに属するイベントはすべて同期イベント集合に含まれることを意味します。 eq_evは受け取った2つのイベント同期を比較し等しいかどうかを判定する関数です。2つのイベント同期が同期イベント集合に含まれているとき同期するのかどうかを判定するのに使います。

ATMの例(並行合成)

さきほどのATMのモデルは変えずに、新たにユーザーの振る舞いをモデリングして、ATMと並行合成します。

ユーザーの振る舞いを次のように定めます: f:id:swet-blog:20220324180131p:plain

さきほどのcomposit関数を用いてATMの振る舞いとユーザーの振る舞いを並行合成したものを動かしてみます。 同期イベント集合は次のようにします:

let sync_set_by_ch ch =
match ch with
| Ch_In -> true
| Ch_Pin -> true
| Ch_Req -> true
| Ch_Dispense -> true
| Ch_Out -> true
| _ -> false

ユーザーとATMを合成した振る舞いの状態遷移図は次のようになります: f:id:swet-blog:20220324180045p:plain

初期状態を(U_S1, A_S1)にして動かしてみます。

f:id:swet-blog:20220324182633g:plain

まとめ

調べたい部分に限定したモデリングは実システムの実装よりもずっと小さなコストでできますから、実装が終わって調べていた振る舞いを、仕様作成の段階で調べることができます。 ここで誤りをみつけることができれば手戻り避けることができるので、開発時間を大きく短縮できます。 動くモデルを作って動かせば、仕様書を読んだだけだと気づけなかった仕様の欠陥にも気づくことが期待できます。 また設計上の選択についてさまざまな可能性を実験的に調べるといったことも可能になります。そうすることでよりよいプロダクトやサービスを開発できる可能性が高まると考えています。

参考

この1年すすめていた「プロジェクトの健康状態の可視化と予防」と「自動テストの適用範囲の拡大」という施策についての話

SWETグループの平田(@tarappo)です。

早いもので2021年度もとうとう終わりをむかえようとしています。 ふりかえりということで、ここ1年ほどの間に私も関わって進めていた次の2つの施策についてかんたんに紹介したいと思います。

  • プロジェクトの健康状態の可視化と予防(dev-vital)
  • 自動テストの適用範囲の拡大

今回紹介するこれらの施策は、SWETメンバーの今までの経験などを元に議論した中で出てきた課題から決めています。

プロジェクトの健康状態の可視化と予防(dev-vital)

私がSWETに所属してある程度の期間がたちますが、いろいろなプロジェクトに関わってきました。 その中で感じたのは、あるプロジェクトで出会った課題は他のプロジェクトでも起きていたりするということです。

今までのSWETの取り組みはプロジェクトですでに起きた課題に対してアプローチをとることが一般的でした。 たとえば、すでにテスタビリティがよくないプロダクトをリファクタしてテストをかけるようにするとか、CI/CDサービスが動いていない状態を直すといったようなところからです。

プロジェクトの早い段階からSWETのメンバーが関わっていれば、上記のようなことをあとから行わずにすんでいたかもしれません。 しかし、弊社のプロジェクトの数・規模感・スピードなどを考えるとすべてに関わるのは無理があります。

そこでどうすれば、このようなことが起きないかについて検討をしました。 その中で出てきたのが、次のような仮説です。

  • プロジェクトにおけるある指標が他の指標に影響をあたえて、最終的にユーザーに見えるレベルでの問題となるのではないか?
  • 影響をあたえる指標の関係性がわかれば、将来起こりうる問題の予兆がわかりその結果として予防ができるのではないか?
  • そのようなプロジェクトの健康状態といえるような指標を可視化できればよいのではないか?

この仮説をもとに「プロジェクトの健康状態の可視化と予防」という施策を2021年度からはじめました。 この施策を「dev-vital」と呼んでいます。

この施策では、まずプロジェクトの健康状態に必要と考えられる指標がどういったものかについてメンバーで検討を何度もおこないました。 そして決めた指標をプロジェクトからとれるようにプロセスを整え、集めた情報を保存できるようにして可視化をする必要があります。

そこで、開発プロセスの中で蓄積される次のような3種類の情報をまず可視化できるように整備を進めました。 すべてのプロジェクトで一度におこなうのは困難であるため、まずは特定の1つのプロジェクトをターゲットとして進めました。

  • (1)CI/CDサービスから得られる「ビルド時間」や「ビルドの成功・失敗率」「テストケース数」
  • (2)GitHubから得られる「エンジニア数」や「PRなどの情報」
  • (3)不具合チケット(JIRAチケット)から得られる「不具合数」や「クローズされるまでの時間」

なお取得するための情報元として上記に紐づくのもありますが「アプリ、サーバのリリース頻度」なども取得しています。

CI/CDサービスからの情報

(1)の情報を可視化するためにメンバーが開発しているCI Analyzerを活用しています。

このツールについては「CI/CD Conference 2021」で登壇をしていますので次を見ていただければと思います。

データを得るためにもCI/CDサービスを活用してPR時などをふくめビルドを継続的におこなえるような環境であることが望ましいです。 そもそもCI/CDサービスが動いてなければ、コードに問題があるのかどうかすらわかりません。

その一環も含めて、弊社の「Pococha」というサービスでCI/CDサービスを活用できるようにいかに開発に組み込んでいくかという次のような改善を進めました。

また、CI Analyzerでテストケース結果を確認できるようにCI/CDサービスの成果物としてJUnit.xml形式のファイルが保存されている必要があります。 それらの対応も併せておこなっています。

これにより、CI/CDサービスからいろいろな情報が得られるようになっています。

GitHubからの情報

(2)の情報はGitHubのGraphQL APIを活用することで得ることができます。

データを得るためにはPR時に記載する情報の整理ができていることが望ましいです。 PRには「誰が担当したのか(assignees)」「どのマイルストーンでリリースするのか(milestone)」「どのような対応なのか(label)」などいろいろな情報を含めることができます。

これらの情報はPRを出した人自身で入力することが望ましいもののどうしても人によって入力内容に差がでてしまいます。 そこで次を活用しました。

  • PR Templateの活用
  • Dangerの利用による自動設定
  • Dangerの利用による未入力箇所の指摘

これらにより、PRからの情報はある程度統一されるようになり、データの取得ができるようになりました。

JIRAからの情報

(3)は不具合チケット(JIRAチケット)の活用です。

検証を担当する品質管理部のメンバーがいる場合、検証時に不具合があればJIRAに不具合チケットを起票するようになっています。

今までも、一部のプロジェクトではこの不具合チケットのデータはまとめてBigQueryに保存してデータポータルを使って可視化していました。 今回ターゲットとしたプロジェクトにおいては、その整備ができていなかったこともあり、保存し可視化できるようにしました。

dev-vitalの現時点のまとめ

このようにいろいろと情報を取れるようにするにはプロセス含め整えることが必要でした。 その上で、データを保存できるようにし可視化をすすめました。

今は上述したようないくつかの情報について可視化できるようになっています。 この可視化された情報からだけでもある程度の情報が得られる状態ではあります。

しかし、当初かかげている仮説は次のようにそれぞれの指標が影響を与えているというものです。 「プロジェクトにおける指標が他の指標に影響をあたえる」

一歩目をあるき出したとはいえ、この仮説に対して検証ができるフェーズについてはこれからというところになります。

今まで話したようなこの1年間でおこなえたことについては次の勉強会でメンバーが登壇をしますので是非聴講してもらえればと思います。

自動テストの適用範囲の拡大

SWETは今までいろいろな自動テストを実装し、運用してきました。 ユニットテストといったフィードバックの早い自動テストは重要ですし、全社的な教育や事業部との連携などは次のブログにあるように進めてきました。

これらは今後も続けていきますが、ユニットテストよりも結合度の高い自動テストも重要です。 SWETが取り組んできた自動テストとしては、次のようなものがあります。

  • (1)パフォーマンステスト
  • (2)スクリーンショットテスト

パフォーマンステスト

1つ目のパフォーマンステストは「Pococha」のiOSアプリで取り組んできました。

Pocochaはライブ配信サービスであり「パフォーマンス」という観点は非常に重要です。 たんに機能が動けばいいわけではなく、高負荷時においてもアプリがユーザーにとって問題なく使えることが求められます。

取り組みをはじめた時点では、パフォーマンス計測自体が実施できていませんでした。 そこでまず、パフォーマンスを計測できるようにして、それを継続的に確認できる状態にする必要がありました。

そのために、パフォーマンス計測を自動でおこなえるようにしました。 このパフォーマンス計測周りについてはPocochaでおこなった事例として次のような記事を書いています。 この記事時点ではおおがかりな基盤にはなっていますが、この時点ではこのぐらいの基盤を作って計測する必要性がありました。

そして、そこから年月がたって必要なことはかわってきてこの基盤についても見直しをすすめています。

パフォーマンス計測がどのレベルで必要かはプロダクトの性質やそのときの状況によって異なります。 しかし一切関係ないというプロダクトはないともいえます。

今では、パフォーマンスに関してはiOS、Androidともにプラットフォーム側がいろいろと機能を提供してくれるようになってきています。 たとえばiOS側では、MetricKitXcode OrganizerなどがWWDCで発表されています。 Android側では、Android Vitalsといったものもあります。

これらのようなプラットフォームが提供する機能を活用することも重要です。

iOSアプリにおけるパフォーマンス計測についての話は2022/3/24に開催した「iOS Test TeaTime #4」でメンバーが登壇しました。

毎年のように変化があるので是非まとまったこの資料を見てもらえると嬉しいかぎりです。

スクリーンショットテスト

2つ目がスクリーンショットテストです。 ここでいうスクリーンショットテストは、ある時点での画面をキャプチャしたものを検証に利用することを指しています。

スクリーンショットはいろいろなプロダクトで活用されています。 すぐにスクリーンショットがとれる状態になっていればよいですが、必ずしもそういうコードになっていないことはよくあります。 そこで、まずはスクリーンショットをとれるようにする必要があります。

Androidにおいて、それらについておこなったことについては2021年の「DeNA TechCon 2021」でメンバーが登壇しました。

この登壇から1年がたち、スクリーンショットの活用方法は広がってきました。 今、Pocochaでおこなっている活用方法については先日おこなわれた「DeNA TechCon 2022」でメンバーが登壇しました。

このようにいろいろな自動テストを活用できる場所で使えるようにしています。

おわりに

こんかいは2021年度におこなった施策についてかんたんに説明をしました。 こうして振り返ると、おこなったことについてはある程度アウトプットしていることがわかります。 今後もアウトプットしてくと思うので、是非ウォッチしてもらえればと思います。

さいごになりますが、私はこのたびSWETを卒業します。

入社してから、ずっとSWET(SWETがグループになる前から)としていろいろなことをおこなってきました。 そのすべてをブログにまとめようかと思いましたが、あまりにも長くなるので2021年度に私がかかわった施策についてかんたんに説明しました。

長い間、働けたのは今回説明した施策みたいな非常に魅力的な仕事に取り組めたことや関わってきたメンバーの魅力によると思っています。

SWETでは上述した施策以外にもいろいろなことをおこなっています。 次のサイトに本件以外の情報についても載せているので見てもらえればと思います。

非常に魅力的で面白い仕事が待っているので、是非とも興味がある方は次から応募していただければと思います。

DeNA TechCon 2022開催決定!今年もSWETメンバー登壇します!

SWETグループの井口(@hisa9chi)です。本記事では2022年3月に開催予定の弊社イベント DeNA TechCon 2022 に関してSWETグループ含め所属する品質管理部から5件の登壇が予定されております。

品質管理部ではDeNAのものづくりを支え、品質管理のスペシャリストとして、事業部とともにプロダクトの品質を作り込んでいっています。そのためにプロダクト開発の現場に深く入り込み、品質の担保とその改善活動を引っ張っていくことで、信頼性の高いプロダクトを素早く提供し続けます。さらに、日本のテスト業界を牽引していく存在となるために、標準化活動や高度なテスト技術を追求しており、得た技術やノウハウは内外問わず積極的に発信、共有しています。

そんな品質管理部の取り組みの一部を本イベントで発信しますので、是非とも皆様に聴講していただきたい思いで今回5件の登壇の見所を紹介させていただきます。

DeNA TechCon 2022 イベント情報

紹介セッション

今回、ご紹介するのは次の5つとなります。

Track 時間 タイトル 部門 登壇者
Talk C 16:00-16:30 CS×QAシナジー発揮!ユーザ体験向上ハンドブックのススメ QCグループ
CSチーム
柏倉 直樹(Naoki Kashiwagura)
小澤 直美(Naomi Ozawa)
Talk C 16:30-17:00 受身から攻めのQAへ!事業を成功に導くQAへの変革 QCグループ 前川 健二(Kenji Maekawa)
Talk B 17:30-18:30 Unity開発でのミスを未然に防ぐRoslynアナライザーのすゝめ SWETグループ 稲垣 和真(Kazuma Inagaki)
LT A 14:30-15:00 ゲーム開発のCI/CDを支えるJenkins運用 ~MacStadium 活用とディスク容量との闘い~ SWETグループ 井口 恒志(Hisashi Iguchi)
LT A 14:30-15:00 Pocochaにおけるスクリーンショットテストのレポート活用術 SWETグループ 外山 純生(Sumio Toyama)

CS×QAシナジー発揮!ユーザ体験向上ハンドブックのススメ

DeNAの品管は当たり前品質の担保だけではなく、サービスの価値を高めるための活動を積極的に実施しています。 たとえば、開発実装前に「本当にこの仕様でお客様が喜ぶのか?」の視点でレビューを実施するだけでなく、実装後の動作確認においても「この動きでお客様は満足するか?」を常に考えながらテストを実施し、企画・開発チームへ意見を伝えます。 この際に重要となるのが「QAメンバーのお客様目線をいかに強化するか」です。 今回のTechConでは、部門の枠を超えてCSチームと一緒に「お客様目線を強化する仕組み」を作った事例をお話しします。

受身から攻めのQAへ!事業を成功に導くQAへの変革

DeNAのヘルスケア事業領域では、開発上流での品質担保に課題があり、品質管理(テスト)にかかるコストが増加し、QCDバランスを保ったリリースが難しい領域でした。 そこで、サービスの企画が始まる上流工程からQAメンバーを参画させ、QA目線でのモノづくり強化策を実行してきました。 その結果、開発プロセス改善や開発品質向上だけでなくそのほかにも多くの成功を収めました。 今回はこれら成功事例をもとにテスト中心の受け身から、事業を成功に導くQA部隊への変革の歴史とともにリリースまでにQAがどのような活動を行なっているか紹介いたします。

Unity開発でのミスを未然に防ぐRoslynアナライザーのすゝめ

コードをビルドすることなく、ミスを検知できたらゲーム開発が楽になると思いませんか? 本セッションでは、.NETの静的解析器であるRoslynアナライザーのUnityプロジェクトへの導入と、カスタムルール実装のハードルを下げるためのノウハウを紹介いたします。 Roslynアナライザー活用のヒントとして役立ててくだされば幸いです。

ゲーム開発のCI/CDを支えるJenkins運用 ~MacStadium 活用とディスク容量との闘い~

オンプレmacやMacStadiumをビルドに活用して運用していると、どうしてもディスク容量不足という問題が発生します。 この問題は深刻であり、容量不足が発生するとビルドが途中で失敗してしまいます。 そのため、再ビルドや、ディスクの空き容量を増やすための調査と掃除という面倒な作業が増えてしまいます。 そこで我々がこのディスク容量不足問題に対して、どのように立ち向ったかをご紹介させていたきます。

Pocochaにおけるスクリーンショットテストのレポート活用術

ライブ配信サービスのPocochaでは、アプリのUIに関する問題の早期発見を目的として、自動でスクリーンショットを取得するテストを導入しています(参考:「Android スクリーンショットテスト 3つのプロダクトに導入する中で倒してきた課題」)。 その成果物となるスクリーンショットレポートの提供が始まると、当初想定していなかった活用アイデアが出てくるようなりました。

本LTでは、デザイナーやQAメンバーの課題解決にスクリーンショットレポートが活用された例をいくつか紹介します。スクリーンショットレポート活用のヒントとして役立てて下されば幸いです。

まとめ

今回は、SWETの所属する品質管理部による発表をご紹介させていただきましたが、他にもTechConではDeNAのさまざまなサービスに関する登壇があります。実際のプロダクト開発の現場で培ったノウハウが多く凝縮された内容となっております。色々と聴講していただいて、ノウハウの吸収や今抱えている問題などの解決の糸口となると幸いです。また、イベント当日はDiscordサーバーを立ち上げる予定ですので、登壇者・聴講者間でのコミュニケーションも可能となっております。ぜひとも、2022年3月17日はDeNA TechCon 2022で盛り上がりましょう。

参加登録はこちらからお願いいたします。

「テスタビリティの高いGoのAPIサーバを開発しよう」というハンズオンを公開しました

はじめに

SWETグループのGoチームの伊藤(@akito0107)です。 「テスタビリティの高いGoのAPIサーバを開発しよう」というタイトルでGoを用いてWeb APIを書くエンジニア向けのハンズオンを公開しました。 この記事ではハンズオンの内容と補足を紹介しようと思います。

ハンズオンのねらい

このハンズオンでは、APIサーバーを題材としてテスタビリティを担保した設計をするためには何が必要なのかを学んでもらうことを目的にしています。 特に、私自身がテスタビリティを考える上で重要だと考える、Dependency InversionやDependency Injection (DI), Test Doubleについて詳しく説明し、さらには自分で実装してもらう形をとっています。

Goでは他の言語と違い、デファクトのWeb Frameworkなどはありません(強いて言うなら標準がデファクトですが)。 そのため、Goで現実のプロダクトを開発する際には、必要に応じてライブラリ同士を組み合わせる、または自分たちで開発する必要があります。 その際の指標として役立つようなものを狙ってハンズオンを作成しました。

ハンズオンの内容

今回のハンズオンでは、Go言語を書いたことがある人や(言語問わずに)APIサーバーを実装したことがある人向けに、より「テスタビリティ」が高い設計にするためにはどうすればよいのか、といった内容を紹介しました。

以下がハンズオンとあわせて公開した資料となります。

speakerdeck.com

なお、Codelabの回答もrepository上に公開してありますので、もしわからないところがありましたら参照してください。

ハンズオンは[スライドを用いた講義]→[codelabを用いた実習]の流れを1Chapterとし、全部で3つのChapterから構成されております。 講義による知識のインプット + codelabで実際に手を動かすことによるアウトプットを体験することで、より実践的な技術を身に着けてもらうことを想定しております。

Codelabでは簡単なAPIサーバのサンプルアプリケーションを題材とし、テスタビリティが低い設計から高い設計へのリファクタを行っていきます。 その過程でテスタビリティの高い設計や、テストを書く際のテクニックなどが学べる構成になっています。

各チャプターは以下のような内容になっています。

  • Chapter1 テスタビリティについて
  • Chapter2 アーキテクチャについて
  • Chapter3 Test Doubleについて

以下にそれぞれのChapterの内容を簡単に説明します。

Chapter1 テスタビリティについて

最初のChapterでは、今回のハンズオンで一貫して対象とするテスタビリティの定義をしました。 テスタビリティには様々な定義があります。今回はソフトウェアテスト293の鉄則*1

テスト容易性とは可視性と操作性である

という定義を採用し、テスト対象システム(今回の場合はAPIサーバー)の可視性と操作性を上げるための設計について考えていきます。

f:id:swet-blog:20210909123728p:plain
Testabilityの定義について

Codelabではサンプルアプリケーションのビルドおよび次のChapter以降で行うリファクタの準備をしました。

Chapter2 アーキテクチャについて

このChapterではテスタビリティが高いアーキテクチャについて解説しました。 いくつか例はあるものの、APIサーバを実装する上で最も古典的な設計である3層アーキテクチャについて解説し、テスタビリティを担保する上で特に重要な概念であるDependency Inversion(依存関係逆転)について解説をしました。

f:id:swet-blog:20210909123918p:plain
3層アーキテクチャについて

CodelabではChapter1でビルドしたサンプルアプリケーションをリファクタし、3層アーキテクチャに直した上で、依存関係の整理ができるような設計にすることをゴールにしています。

Chapter3 Test Doubleについて

最後のChapterではTest Doubleについての紹介しました。mockやstubという言葉は聞いたことがあるかもしれませんが、それらの総称がTest Doubleです。 Test Doubleのメリットや分類を紹介し、最後にGoでの実装例を話しました。

f:id:swet-blog:20210909124059p:plain
Test Doubleについて

CodelabではChapter2でリファクタしたサンプルアプリケーションに対し、Test Doubleを用いたテストを追加していくということを行いました。

時間の都合上、Codelabを最後までやりきれなかった方もいらっしゃるかとは思いますが上記の通り資料は公開されており、また、Codelabの回答もrepository上に公開されていますので、時間のあるときに最後までチャレンジできるようになっています。

補足

以下にスライド・Codelabには盛り込めなかった話題について補足をしておきたいと思います。

結合度の高いテストについて

今回紹介したアーキテクチャは各レイヤーの結合度を下げ、各レイヤー単体でテストを書くことが簡単になるような点を目的として設計しています。 一方で、コードの記述量が増え、コードの見通しが悪くなる、といったデメリットもあげられるかもしれません。

講義の内部でも触れましたが、最近はミドルウェア(特にDocker)やライブラリの進化で、手元で簡単にDB等のインフラ環境が整えられるようになってきました。 Cloud Providerが提供しているコンポーネントも、以前であれば、手元に環境を再現するのが難しくテストを書こうとすると必然的にコード上でTest Doubleを用意する必要がありました。 しかし、現在ではAWSであればlocalstackや、GCPであれば公式が提供しているemulatorなどが出てきて、手元で環境を再現することが容易になってきました。 そのため、以前よりも結合度の高いテストを書くことは容易になってきており、相対的に単体のレイヤーで行うテストの重要性や必然性は薄れてきているかもしれません。

それでもあえて今回このある意味レガシーな設計を紹介したのはいくつかの意図があります。代表的なものを紹介します。

まず第一に、(これは当たり前ですが)いくらlocalで使えるコンポーネントが出てきたといっても全てがそろっているわけではないので、ある程度複雑なシステムを対象にする場合は、何かしらのTest Doubleを使う必要があるということ。

そして第二に、再現テストを書くための土台を作っておくということがあげられます。

再現テストとは、何かしらのバグが発生した際に、そのバグが発生するような状況を再現するために書くテストです。 再現テストを実装した上で、バグを修正し、テストが通ることを確認するというような使い方をします。 バグの発生原因を特定し、確実に修正されていることを確認できる手法で、これをやるかやらないかでバグ修正の際の安心感や効率が変わってくると感じています。

再現テストを実装する上では、例えばDBからエラーが帰ってきた場合など、特殊な状況・状態に依存することが多く、この状況・状態の再現は仮にエミュレータを使っていたとしても非常に難しくなる場合があります。 そういった場合でもTest Doubleなどを使い、システムの状態を簡単にいじれるようにしておくと簡単に再現テストを書くことができます。

実運用中にバグが発覚し、いざ再現テストを書こうと思ったときには外部環境に密結合でテストが書けないといった状況を防ぐためにも、やや冗長に感じられるかもしれませんが、外部から依存を注入できるような設計は今でも有効だと感じています。

より良いテストを書くために

Codelabで記述したテストコードは、繰り返しが多く冗長な実装になっていると感じるかもしれません。 本来は、テストコードを記述した後にテストコード自体のメンテナンス性を上げる必要があります。 GoであればTable Driven Testsなどを使うと良いかもしれないです。

以下の例はCodelabのChapter3のuserUsecaseのtestをTable Driven Testingを使うようにリファクタした例です。

func TestUser_Create2(t *testing.T) {
    cases := []struct {
        name      string
        mockFn    func(t *testing.T) *userRepositoryMock
        mock      *userRepositoryMock
        expectErr error
        in        *model.User
    }{
        {
            name: "success",
            mockFn: func(t *testing.T) *userRepositoryMock { // testing.Tをt.Run内で受け取る必要があるため関数でwrapしている
                return &userRepositoryMock{
                    findByEmailFn: func(ctx context.Context, queryer sqlx.QueryerContext, email string) (user *model.User, err error) {
                        if email != "test@dena.com" {
                            t.Errorf("email must be test@dena.com but %s", email)
                        }
                        return nil, apierr.ErrUserNotExists
                    },
                    createFn: func(ctx context.Context, execer sqlx.ExecerContext, m *model.User) error {
                        return nil
                    },
                }
            },
            in: &model.User{
                FirstName:    "test_first_name",
                LastName:     "test_last_name",
                Email:        "test@dena.com",
                PasswordHash: "aaa",
            },
        },
        {
            name: "return user from repository",
            mockFn: func(t *testing.T) *userRepositoryMock {
                return &userRepositoryMock{
                    findByEmailFn: func(ctx context.Context, queryer sqlx.QueryerContext, email string) (user *model.User, err error) {
                        return &model.User{}, nil
                    },
                    createFn: func(ctx context.Context, execer sqlx.ExecerContext, m *model.User) error {
                        return nil
                    },
                }
            },
            in: &model.User{
                FirstName:    "test_first_name",
                LastName:     "test_last_name",
                Email:        "test@dena.com",
                PasswordHash: "aaa",
            },
            expectErr: apierr.ErrEmailAlreadyExists,
        },
    }

    for _, c := range cases {
        t.Run(c.name, func(t *testing.T) {
            userUsecase := NewUser(c.mockFn(t), nil)
            err := userUsecase.Create(context.Background(), c.in)

            if err != c.expectErr {
                t.Errorf("expectErr is: %v but actual: %v", c.expectErr, err)
            }
        })
    }
}

まとめ

Goのハンズオンを公開し、その内容の紹介と補足をしました。 講義を受けていなくても、スライド、Codelabそれぞれで完結して内容を把握できると思いますので、興味がありましたらぜひご覧になってみてください。

DeNAではGo以外にもAndroidのTestに関するハンズオンを公開しております。 せひそちらも覗いてみていただければと思います。

最後に、SWETでは一緒に働いてくれる人を募集しています。下記職種で募集しているのでぜひご応募ください。

career.dena.jp

*1:セム ケイナー,ジャームズ バック,ブレット ペティコード. ソフトウェアテスト293の鉄則