DeNA Testing Blog

Make Testing Fun, Smart, and Delighting End-Users

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の鉄則

治安維持のためにCI/CDサービスを活用しておこなったこと

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

10/21(木)にiOS Test TeaTime #3を開催しました。

その時の私の登壇資料は次のとおりです。

資料では不足しているであろう情報もあるので、本稿ではその点も補いつつ説明していきたいと思います。

はじめに

SWETメンバーとして、プロジェクトに関わるときは「CI/CDサービス」が問題なく動いているかどうかを確認することはよくあります。

CI/CDサービスが文字通り継続的に動いているのであれば「プロジェクトのコードが手元でも動かせる」「コードの状態がある程度わかる」という状態ともいえます。

そのため、これはプロジェクトの状況を把握してなにをするべきかを判断するためには重要な指標の1つともいえます。

その中で最初にチェックする箇所の例としては、次のようなものがあります。

  • どのようなことを実行しているか
    • 自動テストがあって実行されているか
  • どのようなタイミングで実行しているか
    • 継続的に動かせているか
  • ビルドの成功率はどういった状態か
    • 仮に失敗をある程度していてもすぐ直せているか

本稿では、私が今回関わったプロジェクトの「関わった頃の状況」とそれを元にどのような理由からどのようなことをおこなっていったかについて次から説明をしていきます。

関わった頃の状況

本稿で話す関わったプロジェクトではiOS周りをメインで関わりました。 そのプロジェクトで利用しているCI/CDサービスはBitriseになります。

Bitriseの詳細は本稿では説明をしませんが、Bitriseでは実行するものを「ワークフロー」という単位で用意します。 その「ワークフロー」は特定の機能をおこなうことができる「ステップ」と呼ばれるものを組み合わせて作ります。

このBitriseの関わった頃の利用状況としては次のようなものでした。

  • Q:どのようなことを実行しているか
    • 一般的なワークフロー(ビルド、テスト、App Store Connectへのアップロード)は揃っている
    • コードにテストケースは一定あるが「テスト」のワークフローはNightly実行のみ
  • Q:どのようなタイミングで実行しているか
    • 継続的に動かせているのは一部であり、次のような感じになっていた
      • PR単位:Dangerのみ実行しており、他は実行していない
        • Dangerは本プロジェクトの他リポジトリと併せてCircleCIに移行しました
      • Push時(特定ブランチ):アプリのビルドと配布
      • Nightly(1日1回):テスト
  • Q:ワークフローの実行結果の成功率はどのぐらいか
    • アプリのビルドにおいて、マージ後に直せているがテストにおいては失敗したまま放置傾向になっている

上記のように、PR単位で何かしらのワークフローは動かしておらずコードのチェックは行えていない状態ではありました。

この状態の理由

関わった頃はまだ「開発者がそこまで多くなく、みんながある程度プロダクトについて理解している状態」という前提がありました。

そのような前提がある中なので、

  • Bitriseの運用に対してコストをあまりかけられない
  • 「ビルド」や「テスト」が失敗するようなコードであっても、原因に気づきやすくコードを直すコストはそこまで高くない

結果として、この時点ではBitriseは「アプリの配布用」の用途ぐらいになっていました。

今後もこのような体制であれば一定問題がおきないかもしれませんが、関わった時点で人が増えつつある時期でもありました。 このまま進んでいくと、次のような問題が起こるであろうと思われました。

今後起こるであろう問題

関わる人が増えてくると、次のような問題が発生していきます。

  • 手元で「ビルドができない」「テストが通らない」といったケースが増える
    • 自分が関係していないコードのことが多くなり、原因の特定コストが一定かかる
  • 誰もがマージできるためビルドできないコードが一定発生し「検証をするためのアプリ」のビルドが検証当日になっても出来ていないといったことが起こる

この手のことがある程度増えて、結果として「コミュニケーションコスト」「失敗の原因の特定コスト」「修正コスト」といったいくつものコストが必要になってしまうということが起こりえます。

この状態が今後起こることを想定し、Bitriseをもう少し活用し「コードの品質」をある程度担保できるようにするのが良いだろうと判断しました。

そこで、「今後に向けた対応」として次を検討しました。

  • Step1:PR時点で「ビルド」「テスト」のワークフローを動かす
  • Step2:「ビルド」「テスト」のワークフローが失敗したものはマージできないようにする

これらをBitriseやGitHubなどで設定することはかんたんです。 しかし、実際に利用され続けなければ意味がありません。

そこで、この「Step1」と「Step2」をおこなった場合の課題などについて関係者にヒアリングをおこないました。 その結果として次の課題がわかりました。

  • 課題1:実行時間がかかりすぎて待っていることができない
    • 特に「テスト」の実行時間が長い
  • 課題2:必要性を強くは感じていない
    • 現時点ではそこまで問題が起きていないと考えているため

実行時間がかかりすぎてもこれらを行おうとするかはプロダクトをとりまく状況にも依存します。 この時点では、スピード優先してリリースしていくという形とも言えました。

しかし「課題1」「課題2」において何かしらのアプローチをおこなうことは今後を考えると必要だろうと判断しました。 それらについて次に説明をしていきます。

課題1:実行時間について

Bitrise Insightsという機能を利用して実際に「テスト」にかかっていた時間を出すと次のとおりです。

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

この画像をみたら分かるとおりですが、

  • テストの実行時間は40分以上
  • 成功率は高くない

PR時において40分以上も結果を待つのは厳しく、1度失敗して修正するとすぐに1時間以上の時間が発生するためPR時に設定したくない理由も分かります。

また成功率の低さは手元で全テストを実行するのが面倒という側面もあったと考えられます。 本稿では詳細は割愛しますが、このプロジェクトではモジュール単位で分割しており、モジュール単位でテスト実行できるものの、それら全てのテスト実行がXcodeからできるようになっていませんでした。 ※なお、この課題については全実行できるような仕組みを別途チームメンバーが用意してくれました。

このような状況下では、テストが壊れやすくその上に壊れたテストが乗ってしまい、さらに直しづらいというループが出来てしまいます。

これらを解消するには「実行時間」をできる限り短くする必要があるといえます。

課題2:必要性の認識

「定期的にコードのチェックをしてなくても問題があまり起きていない」状況ではありますが、将来的には炎上リスクがあると言えます。

必要性を認識してもらうためには「治安の良い状態に慣れてもらう」「現状を認識できるようにする」といった状態にして、このような環境が当たり前と思ってもらうことです。

  • 「Bitriseの治安の良い状態(グリーンが当たり前)」を作って、その環境に慣れてもらう
  • 「Bitriseの状態の可視化」をおこないコードの状態を常にわかるようにする

このような状況を当たり前にすることで、なにか問題が発生したときに以前と比べて「問題が起きた」というのを強く感じてもらえるようにするというのがあります。

おこなったことの内容

上述した「課題1」「課題2」を解決するために行なったことについて次のとおりです。

  • 前提としてやる必要のあること(まずはキレイにすること)
    • (1)Bitriseのワークフローの現状把握と整理
  • 「課題1」へのアプローチ
    • (1)Bitriseのワークフローの現状把握と整理(一部)
    • (2)実行時間の短縮
  • 「課題2」へのアプローチ
    • (3)情報を追えるようにするための可視化
    • (4)安定運用するための対応

(1)Bitriseのワークフローの現状把握と整理

既存のワークフローの把握と整理

BitriseはWebからワークフローを簡単に作れるため、ワークフローやワークフロー内のステップは増えやすく誰が作ったか分かりづらいという問題があります*1

結果として次のようなことがしばしば起きますし、実際に起きていました。

  • 「使っていないワークフロー、ステップ」が残されたままになることがある
    • 例えば、調査用に作った一時的なものは放置されやすい
  • ワークフロー名に統一性がなかったり、行なっていることとワークフロー名があっていない

そこで、次の方針をもとに整理整頓を進めました

  • 必要のないものは削除
    • これでステップが削除されると実行時間の削減にもつながる
  • ドキュメント化
    • ルール化したほうが良いものについてはルールについて明記
    • ワークフローやトリガーなどBirise利用に関する情報をドキュメントにまとめ、適宜更新

この方針のもとにおこなったこととしては、次のようなものです。

  • (1)メンバーにヒアリング
    • 使っていないワークフロー、ステップの削除
  • (2)ワークフローで行なっていることをチェック
    • 別名のワークフローだけど中身は同じというのもあったため整理
    • 使っているステップをチェックし必要なければ削除
  • (3)ワークフローの作り方についてルール化
    • ワークフロー名の命名規則
    • ユーティリティワークフローの用意と利用
  • (4)今後のために整理した内容は全てドキュメント化

ヒアリングして削除できたものはそこまで多くなく、実際はワークフローならびにステップ、そしてステップがよんでいる先(たとえばfastlaneのlane)をチェックして、本当に必要なのかを確認しつつ進めました。

次図は実際のドキュメントの目次ですが、最終的にはこのようなドキュメントを用意することで、これから利用する人も現状把握がある程度しやすいようにしています。

これらによって現状のBitriseのワークフローなどの状態がわかるようになりました。 ここからがスタートとも言えます。

また、これらは地道な作業ですがこれだけでも多少の実行時間の削減に繋がっています。

既存のワークフローの失敗を直す

あまり定期的に動かしてないこともあり「テスト」のワークフローは失敗していました。

壊れた状態のままにしておくと、他の壊れたコードも追加されていってさらに直すのが大変になります。 結果として、放置されてしまうということはよくあることです。

あまり動かしていなかったこの「テスト」では次のような問題が起きていました。

  • 例1)Compile errorになっている
  • 例2)期待値が少し変更されていた(例えば表示文言が変わったとか)
  • 例3)手元では動くがBitriseの環境依存で落ちる
  • 例4)Xcode12 x XcodeGenで起きたCycle Inside App問題

例1)や例2)では落ちている箇所を伝えて、直してもらえれば大丈夫です。 しかし例3)や例4)は調査、対応コストが一定かかるためどうしても放置されてしまいがちです。

そこで例1)や例2)はSWETメンバーで対応はしつつも、少したったらプロジェクトメンバーに伝えて直してもらうように依頼しました。 例3)や例4)についてはSWETメンバー側でおもに関わって対応をしました。

対応をした結果、1度All Greenになりましたが再度テストが失敗することは何度も起きました。 しかし、例3)や例4)のようなコストが一定かかるものは何度も起きるわけではありません。

何度も対応を続けることで、All Greenにするための修正コストもそこまでかからないようになってきました。

(2)実行時間の短縮

「課題1」のアプローチは分かりやすく、実行時間をどれだけ減らせるかになります。

PR時にワークフローが動いている状態を当たり前にするためには、この状態が良いなと思ってもらわなくてはなりません。 そのためには、実行時間は大きな課題でした。

そこで、多少ワークフローでおこなっていることがわかりづらくなっても、実行時間を減らすことを最優先としました。

実行時間の短縮

まずは実行時間がかかっているテストをどうにかするところからはじめました。

当初のテストのワークフローの例としては次のような感じでした。 ※実際のワークフローはもう少しステップが多いです。 f:id:swet-blog:20211104113459p:plain

この状態における実行時間短縮に向けた対応として次をおこないました。

  • (1)並列化ができる箇所(ステップC)は並列化
  • (2)並列化しても共通して利用されるステップ(C以外のステップ)で時間を減らせる箇所がないかの調査と対応

ワークフローの並列化

Bitriseでのワークフローの並列化は公式のステップを利用します。 実際に用意した並列化したワークフローの図を元に説明をしていきます。

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

上の図を元に説明をしていきます。 ワークフロー1がメインのワークフローとなります。 ワークフロー2〜4までがワークフロー1きっかけで動く他のワークフローです。

ワークフロー1では「Bitrise Start Build」ステップを利用して他に動かすワークフローを指定します。 また、他のワークフローが動いている間にワークフロー1でもテストを実行します。

そして、ワークフロー1にある「Bitrise Wait for Buildステップ」で他のワークフローが終わるのを待機します。 また、ここで他のワークフローの成果物をまとめてテストケース数などを計算するようにしています。

ステップの実行時間のチェック

上記の並列化は「テスト」においては効果がありますが「ビルド」は並列化できません。 そこで、共通で利用するようなステップがどれぐらい時間がかかっているかをチェックしました。

ここの実行時間が削減できれば、ワークフロー全体に効果があります。

調べた結果として次の3つのステップで多少時間がかかっていました。

  • (1)git cloneステップ
  • (2)fastlaneステップ
  • (3)Cacheステップ

1つ目はgit cloneステップで、Repositoryの肥大化によってcloneをすると2〜3分もかかっている状態でした。

公式のステップはPR時にdepthの設定が解除される作りだったため、独自にステップを作るかこの公式ステップにPRを出すかを検討しましたが、4.0.27バージョン頃から対応されるようになったため、depthを指定するようにしました。

これはgit cloneステップの「Checkout options」にある「Limit fetching to the specified number of commits」で指定できます。 これにより2〜3分かかっていたのが20秒前後にまで削減されました。

2つ目はfastlaneステップです。

fastlaneを使ってビルドやテストをするためのセットアップをおこなうlaneを用意していて、それをBitriseでよんでいました。 Pluginfileに指定してある独自のプラグインも含めgem install時のdependencyが多くインストールに多少時間がかかっている状態でした。

これらはキャッシュに乗せておくことで、その時間は緩和されますがfastlaneはバージョンアップ頻度も多く、ある程度バージョンアップする必要もあります。

しかし、このセットアップ処理自体は別にfastlaneを使わないといけないわけではありませんでした。 そこで、今回はfastlaneを使わない形に変更しました。

最後はCacheステップ(Pull、Push)です。

ビルドを早くするためになんでもCacheしたいという気持ちは出がちですが、Cacheするものが増えてくると、どうしてもCacheのPullとPushに時間がかかってしまいます。

そこで、必要なもののみキャッシュをするようにしました。

また、プロジェクトで利用しているGemfileに記載されていたgemについて整理整頓をおこない使っていないものは全て削除しました。

ビルドマシンのスペックの変更

上述したように色々と実行時間を短縮させるための行為をおこないました。

しかし、最後はある意味「金の弾丸」です。

BitriseはGen2を今年(2021年)から提供しはじめました。 Gen2についての情報については次のブログを参考にしてください。

BitriseがGen2を提供したことにより、利用するビルドマシンのスペックがさらによくなりました。 現在、利用しているGen2のビルドマシン(Elite XL)と今まで利用していたGen1のEliteを比べると次のとおりです。

  • Gen1 Elite:4vCPU@3.5GHz、8GB RAM
  • Gen2 Elite XL:12vCPU@3.2GHz、54GB RAM

このスペックの変更により、アプリのビルドは40%程度実行時間が削減されました。 また、テストにおいてはビルドほどではありませんがある程度の実行時間が削減されました。

テストの実行時間の短縮結果

これらの対応の結果、どの程度実行時間が短縮したかについて説明します。

直近4週間の結果(Bitrise Insightsより)は次のとおりです。 これは全てのテスト実行(PR時、PUSH時)での結果になります。

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

以前の結果(41分22秒)と比べて30%ほどになっています。 当初予定していた時間は10分程度ですが、一応達成できたと言えます。

実行時間を削減する案は他にもあるのですが、まずは慣れる場を用意することを優先するためこの時間で一旦はOKとしました。

(3)情報の可視化

Bitriseでいろいろと実行するようになったこともあり、Bitriseの実行履歴から情報を追えます。 しかし、Bitriseの実行履歴を見て情報を追うのはなかなか面倒です。

情報をあとからおいやすくすることで、問題が起きたときに分かりやすくしました。 そこで次のような情報の可視化をおこないました。

  • GitHub Commit Statusの利用による可視化
  • ビルド結果の可視化

GitHub Commit Statusの活用

デフォルトのBitriseの機能ではCommit Statusに対する情報は成功、失敗といった情報ぐらいです。 そこであとからCommit Statusを見たときにどのような状態だったかが分かるようにしたいというのがありました。

そこでワークフローの種類によって、Commit Statusにそれぞれ結果を反映させるようにしました。 f:id:swet-blog:20211104113427p:plain

上記の図にあるように単にワークフローの成否だけでなく、テストケース数やカバレッジ率も設定するようにしています。 これにより、例えばある時点から特定のワークフローが失敗しているとか、そのときに特定のテストケースが失敗しているのか、Compile Errorなのかといったことまで分かるようになっています。

情報の可視化

Bitriseで実行したワークフローの結果を、SWETメンバーが開発し導入しているCIAnalyzerを利用して下図のように可視化をおこなっています。

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

これにより、ビルドの状態(ワークフローやステップ単位での実行時間やビルドの成功率など)を後からでも簡単に確認できるようになりました。 ただし、この手の可視化をおこなったとしても見てくれるとは限りません。

そこで、定期的に関係者に状況をシェアするようにしていました。 例えば「xx月と比べてテストの実行時間がyy%削減されました」とか「最近xxというワークフローの実行時間が少しずつ上昇しているようです」などです。

また、すでに紹介していますがBitriseでもInsightsという機能を提供しています。 この機能でも同様に履歴がわかるようになっていますし、ステップ単位での実行時間も分かります。 最近リリースしたばかりで、どのプランでも30日間の無料アクセスを提供しているとのことなので、1度見てみると良いと思います。

(4)安定運用するための対応

CI/CDを安定的に運用するためには上記の対応も重要なことです。

しかし、実行時間が短くなってPRのたびに動いたとしても、ビルドが失敗した場合に対応をせずに放置してしまうという状態はよくありません。

失敗した際に放置してしまう理由はいくつかあります。

  • 失敗していることに気づいていない
    • 単にSlackの通知チャンネルに通知しているだけでは通知数の多さでスルーされてしまうことはあります
  • 失敗した原因の対応コストが高い
    • 上述したとおり環境起因で落ちる場合は対応コストがどうしてもある程度かかってしまいます

そこで安定運用するためにおこなったことについて次に説明をしていきます。

期間限定の門番

最初に述べた「Step2」の対応が出来ない間(システム的に守れない間)の一定期間はBitriseに対する門番となりました。

門番としておこなったこととしては、おもに次のとおりです。

  • ワークフローが失敗したときの初期調査
  • Bitrise周り全般の相談窓口

失敗したときの初期調査においては、より失敗を見つけやすいようにSlackの通知周りの整理整頓もおこないました。 これについては後述します。

失敗を見つけたらBitriseのログを調査し、原因を追求した上で「対応をおこなう」「対応方法を伝える」のどちらかを行いました。

これを繰り返しおこないつつ、少しずつ自分で対応をするという範囲を狭めていきました。

失敗を知ってもらい対応してもらうための工夫

失敗した場合に、それに気づいてもらう必要があります。 そこで、何かしら問題が起きたときにすぐに気づけるような状態にしておきます。

  • Slack通知の整理整頓
  • 失敗時におけるSlack通知先の変更

Slack通知は多くなりがちです。 通知する箇所の整理や通知内容について整理整頓をおこないました。 表示する内容も変更し、テスト結果や実行時間なども表示するようにしました。

次に失敗時におけるSlackでのメンションです。 当初は失敗した場合にSlackのユーザーグループにメンションをするというのもおこないましたが、PR単位も含めると失敗することはよくあることです。

結果、メンションをしすぎてしまい逆に放置されてしまう原因になってしまいます。

そこで、ワークフローが失敗してはいけないような「デフォルトブランチ」や「リリースブランチ」においてだけ、失敗した時点で開発メンバーがメインで利用しているチャンネルにSlack通知をするようにしました。

これで、問題に気づきやすくなりました。

最終的には問題が起こらないようにする必要があります。 通知をおこなうのではなく、そもそも「失敗したものがマージされないようにする」のが望ましいです。 しかし、この段階でその設定までおこなうのはまだ時期尚早なため、まずは知ってもらうというのを優先しました。

おこなった結果

行いたかった「Step1」と「Step2」は次のとおりです。

  • Step1:PR時点で「ビルド」「テスト」のワークフローを動かす
  • Step2:「ビルド」「テスト」のワークフローが失敗したものはマージできないようにする

これらがどうなったかについて次に説明していきます。

Step1に対する結果

実行時間の短縮により「課題1」は解決しました。 そこで、PRに対してもワークフローを実行するようにしました。

ただし、一度に「ビルド」と「テスト」を設定するのではなく、まずは「ビルド」を動くようにしました。 PR時に「ビルド」を動かすようになってある程度たってから、追加で「テスト」についても動くように設定しました。

Step2の対応と結果

「Step1」が達成した段階時点では、まだ「Step2」はそこまで必要と認識されていない状況でした。 ある程度、今の状態が慣れるのを待ちつつタイミングを待つこととしました。

ブランチに失敗したままのコードがマージされるケースは、この時点でもたまに起きていました。 それでも、まだStep2についてはおこなえていない状態でした。

しかし、実際に検証前にビルドができないといったような「誰もがこの状態は良くない」と思える事件が発生した時点で、今後の予防として「Branch Protection Rule」の導入を提案しました*2

この時点ではPR時に「ビルド」「テスト」が動くようになってからある程度たっている状態でした。 これらのワークフローの実行時間があまりかかってないことを共有し、導入することになりました。

Branch Protection Ruleの導入

一度に導入するとなにかあったときの対応コストは多くなります。 その結果として導入をやめようという流れにもなりかねないため、少しずつ設定を追加するようにしました。

まずは次の設定を入れました。

  • PRの向き先のブランチ:「リリースブランチ」
    • 「Require status checks to pass before merging」をオン
      • ビルドを必須
        • GitHub Commit Statusをそれぞれ独自におこなっているので設定はかんたん

設定を入れた状態にし、なにかしら問題が起きたら即時対応をするようにしました。 例えば、Bitrise起因でビルドが出来ないなどの対応があります。

上記の設定を導入し、ある程度たったのちに次のように拡大をしていきました。

  • PRの向き先のブランチを拡大:「リリースブランチ」「デフォルトブランチ」
    • 「Require status checks to pass before merging」
      • ビルドを必須
      • テストを必須

この設定を入れて、慣れてくるようになるとエンジニアメンバーからも提案が出てくるようになりました。 そこで、次の設定を入れることにしました。

  • Require pull request reviews before merging
    • レビュー必須(approve 1件以上)

これにより「ビルド」「テスト」ができないもの、コードレビューがされていないものは基本マージができなくなり、事故が起こることは減りました。

これで当初予定していた「Step1」と「Step2」のそれぞれの対応が終わりました。

おわりに

求める姿を想定し、そこに進めていくのは必ずしも一直線とは限りません。

その状態自体が本当に自分たちにとって良いのかも分からないこともしばしばありますし、その状態にすることによる大変さがあるかもしれないと思うかもしれません。

今回は、上述したように少しずつ対応を進めていきました。 そして、少しずつメンバーに慣れていってもらうようにしました。

その結果として、当初予定していた「求める姿」になりました。

この状態がベストではなくまだ先の「求める姿」があると思っています。 また、プロジェクトの状態が変わっていけばまた変えるべき箇所も出てくるとは思います。

今後も現在の状況を見ながら、「求める姿」を考えて対応を進めていければと思います。

最後に、上記のような活動に興味を持ってくれた方、一緒に働いてみたいなと思ってくれた方。 現在、SWETはメンバーを募集中ですのでぜひ応募ください。

*1:Bitriseではリポジトリにあるbitrise.ymlを正として利用する設定がありますが、弊社の場合はGitHub:eを利用しており本機能をそのまま利用できません。

*2:Branch Protection Ruleを用いることで、PRのコードがCI/CDサービスで成功していない場合は特定ブランチにマージさせないといった設定できます。

8年ぶりの新卒としてSWETにjoinした話

はじめまして、2021年に新卒としてSWETに加わったIKです。 今回はSWETに入って感じたこと、思ったことなどを新卒目線で書いていきたいと思います。

IKってどんな新卒?

学生時代にVim scriptばかり書いていた新卒です。 主に、ソフトウェアのメンテナンスに興味をもち、OSSにパッチを送る活動をしていました。

パッチを送った主なリポジトリ

また、VimConf2019に登壇し、以下の発表をしました。

しかし、一般的なWebシステムやアプリ開発には疎い、そんな状態で入社しました。

どうしてSWETへの配属を志望したのか

私は学生時代、1からソフトウェアを作ることよりも、ある程度形になったソフトウェアのメンテナンスに参加することの方が多かった傾向にあります。 そこで行ったメンテナンスの内容を以下に示します。

  • 具体的なメンテナンスの内容
    • バグの修正
    • feature requestに応える
    • パフォーマンスの改善
    • Pull Requestのレビュー
    • 既存のドキュメントの改善

ソフトウェアのメンテナンスに参加することで、メンテナンスのフェーズは1からソフトウェアを作成するフェーズよりも遥かに長いこと、メンテナンスを継続することはとても大変であることを思い知りました。 その経験から、ソフトウェアのメンテナンスの負担を減らすこと、開発時に発生する手戻りを減らすことはソフトウェア開発が行われる限り常に要求されることだと考え、それらに興味を持つようになります。 その私の考えがSWETのミッションとマッチしていると考えたため、私はSWETへの参加を志望しました。

SWETのミッション

ソフトウェアテストを起点とし、DeNAのサービス全般の品質向上とエンジニアの開発生産性向上に対して貢献していくことをミッションとする

SWETに配属されてどんな仕事をしているのか

Unityプロジェクトに導入するための静的解析器の開発をしています。 C#向けの既存の静的解析器はいくつかOSSとして公開されていますが、以下の理由により自作する必要があります。

  • 社内独自フレームワークに起因する問題を検査できない
  • 既存の静的解析器のルールでは、検出したい問題を見逃してしまうことがある

では、自作の静的解析器を作成するために行った/行っているフローを以下に示します。

  1. 診断の対象を決める
    • ECMA334やMicrosoft社のドキュメントを参考に、C#の構文を調べながら、診断の対象になるものを決めて、洗い出す(ここが一番大変)
  2. 仕様をデシジョンテーブルにまとめ、テストケースを洗い出す
  3. 作成したデシジョンテーブルをもとにテストコードを1件ずつ追加
  4. 静的解析器を実装し、テストを通す
  5. 3に戻る

静的解析器を開発プロジェクトで使ってもらうためには、適用する開発プロジェクトの人の信頼、すなわち静的解析器に対して好印象を持って貰う必要があります。 そのため、正確に静的解析ができているかを検証し、証明するためにテストを書くことは重要です。

要約すると、テストのノウハウを学びつつ、静的解析器を実装する仕事をしています。

SWETの人たちが、新卒目線でどう見えるか

ミスを未然に防ぐことや手戻りを防ぐことに意識をおき、そういった可能性がある箇所に対しての改善を妥協しない人が多い印象です。 特にメンターから教わったことの中から印象に残ったものを以下に示します。

テストメソッドの命名をしっかり意識する

SWETにjoinする前の自分は、テストメソッドの命名が雑でした。極端な例を出すと、goodcaseとかbadcaseなどと言った命名をしていました。

FizzBuzzを使った例

[TestMethod]
public void GoodCase()
{
    var actual = FizzBuzz(3);
    Assert.Equal("Fizz", actual);
}

このように、テストメソッドの命名が雑だと以下のようなデメリットがあります。

  • テストが失敗したとき、失敗したメソッド名やメッセージを見ただけでは失敗箇所の特定が行えず、深くコードを追う必要がある

そこで、テスト対象メソッド名_テスト対象に与える条件_期待する結果のような命名を意識しました。

[TestMethod]
public void FizzBuzz_入力値が3_Fizzを返す()
{
    var actual = FizzBuzz(3);
    Assert.Equal("Fizz", actual);
}

これにより、上記のデメリットを解消できます。

テストの無いリファクタリングはリファクタリングとはいわない

この言葉は自分にとても刺さりました。 リファクタリングとは、プログラムの外部から見た振る舞いを変えずに、内部構造を変えることだとした上で話しを進めます。

テストを書き、実行することで、リファクタリングを試みた際に発生したリグレッションを検知できます。 しかし、当時の自分は自動テストを実施せずに目視のみの確認でリファクタリングを完了としていました。 そのため、抜け漏れが発生し、リグレッションを起こしてしまったことが多々ありました。

リグレッションを放置していると、ボディーブローのように後々効いて、気づいたときには巨大なバグになっていることも少なくありません。 こういった状態を作らない、妥協しない姿勢をSWETの方から見習い、吸収していきたいと思いました。

新卒でSWETに入って良かったと感じたこと

テストケースの考え方や、テスト自動化の導入方法は、どんなソフトウェア開発にも必要です。 また、テストを導入するためには、テストを導入しやすい環境を作らなければなりません。

これらのテクニックは、これからも陳腐化することはないスキルだと自分は確信しています。 そういった潰しの効くスキルを基盤として身につけながら業務に携わることができてとても良かったと感じています。

また、こういったテスト技術を基盤として様々なソフトウェアに関わることで、様々なソフトウェアの知識を吸収ができるのも良さだと考えています。

終わりに

新卒でSWETに入って、うまく仕事を遂行できるか不安ではありましたが、毎日新しい発見がありとても学びになっています。 また自分の施策が、ソフトウェア開発を促進するための施策に関われていることを実感でき、今の仕事にとてもやりがいを感じています。 新卒でSWETに入ったことを自分は正解だと感じており、今後もSWETで学んだことを様々なソフトウェア開発の促進のために生かしていきたいと考えています。

SWETは中途しか採用していないイメージを持たれている方が多いのかも知れませんが、新卒で入ることも可能です。SWETは学びが多い部署だと思っているので、新卒でSWETに入る選択もありだと思います。 もしSWETに興味を持った方がいれば、是非お話しましょう!

大きなGitリポジトリをクローンするときの工夫を図解します

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

はじめに

Gitリポジトリをクローンすると、ローカルフォルダにはそのリポジトリの全体がダウンロードされ .git というフォルダに格納されます。ブランチをチェックアウトすると、ブランチ内のファイルがワーキングツリーとして展開されます。この様子を図にするとこのようになります。

クローン+チェックアウト

この .git とワーキングツリーの使うディスク容量を節約しようというのが今回のお話です。特にJenkinsにおいて、大きめのGitリポジトリをクローンしてくる場合に課題があり、いろいろ工夫してみたので、その結果を紹介します。同じCI/CDチームの加瀬による記事「大規模リポジトリで高速にgit cloneするテクニック」と内容的に重なる部分もあるので、そちらの記事も参考にしてください。

今回、Jenkinsでの設定方法を多めに解説していますが、紹介する工夫はJenkins以外でも使えるものです。図を見て仕組みを理解しておくだけでも、後で役に立つと思います。

Gitリポジトリは大きくなっていく

アプリコードやアセットのリポジトリは、開発が進むにしたがって大きくなっていきます。これはゲーム開発では特に顕著です。とあるタイトルのアセットリポジトリは、そのままクローンすると .git が17GiB、チェックアウトした部分も含めると45GiB程度になっています。これは日々大きくなっていきます。

大きくなる原因

リポジトリ内のオブジェクトを簡略化して図にすると以下のようになります。丸がコミット、四角がファイルに対応します。

リポジトリ内のオブジェクト

リポジトリが時間とともに大きくなっていくのは、以下のような要因によるものです。それぞれに対して、何か対策があるか見てみましょう。

  • コミットされるファイル数が多くなる
    • これは必要だからコミットされているので、仕方がありません
  • コミットされるファイルが大きい
    • これの対策としては、大きいファイル(画像、音声データなど)をGit LFSに置くことで、リポジトリ内のオブジェクトとしては保存しない、という方法があります。LFSについては後でもう少し詳しく見てみます
  • 歴史が長くなる
    • これも長く開発が続いていくと増えるものなので、仕方がありません

コミットされるファイル数が多くなる方向、歴史が長くなる方向の2つの軸を意識しておくと、この後紹介する工夫の理解がしやすくなると思います。

大きくなると困ること

Gitリポジトリが大きくなると様々な問題が発生します。

  • クローンに時間がかかり、待ち時間やJenkinsジョブ実行時間が長くなる
  • クローンしたフォルダのディスク使用量が多くなり、ディスク枯渇が発生しやすくなる

これに加え、Jenkinsエージェント特有の事情として以下があり、巨大なリポジトリのクローンによる困り事は増幅されることになります。

  • Jenkinsではジョブごとにワークスペースが分離される。同じリポジトリを使うジョブが複数個あると、ひとつのリポジトリでも複数回別のフォルダにクローンされる。その回数分ディスク容量を必要とする
  • ワークスペースは、特に明示しない限り、次回の実行に備えて保存される。次回実行時にリポジトリのフェッチの負荷が低くなるというメリットとなる(クローン済の .git を使って差分だけを取ってくればよいため)。一方で、取っておく分のディスクを消費する

Gitリポジトリからローカルフォルダへのダウンロード量や通信量を減らすことで、これらの困り事を軽減できます。クローン時、チェックアウト時、LFSの順で、どのような工夫ができるかの方法を紹介します。また、その結果どのように節約できるのかを図にして見ていきましょう。

GitHub公式ブログ

ここで取り挙げる節約方法はGitHubブログの以下の記事でも解説されています。図が多くわかりやすいので、参照してみてください。本記事中の図も、この公式ブログの図を真似しています(わかりやすさのため、本記事ではツリーオブジェクトなど一部省略しています)。

クローン時のディスク・ダウンロード節約方法

大きめのリポジトリをクローンする場合のコツについて説明します。

git clone では通常、前述したオブジェクトの全体をダウンロードしてきて、 .git/objects 配下に置きます。以下では、この全部を取得するのではなく、一部を取得するためのテクニックを紹介します。これによって、クローン後のディスク使用量だけでなく、クローン時のネットワーク転送量(すなわち時間)も節約できます。

シャロークローン(shallow clone)

最新版だけを取ってきて、過去の歴史を取ってこない方法です。取得したい歴史の長さ(深さ)をdepthというパラメータで指定できます。通常は1でよいでしょう。シャロークローンの動作を図にすると以下のようになります。

シャロークローン

歴史の一番新しいほうからdepth分だけをサーバーから取得し、 .git 内に置きます。取得されない部分を図中では点線で表現しています。最新情報だけ取ってくるので、取得するオブジェクト数を節約できます。これは大きくなるリポジトリを歴史の長さ方向に限定して取得することに相当します。

シャロークローンのやりかた

  • CLIによる方法
    • git clone 時に --depth オプションで取得したい歴史の深さを指定する
      • git clone --depth=1 git@github.com:org/repo.git .
  • Jenkinsfileによる方法
    • checkout ステップに CloneOption を渡して shallowtruedepth を指定
checkout([$class: 'GitSCM',
    extensions: [[$class: 'CloneOption',
        shallow: true,  // ← shallow cloneを指定
        depth: 1,  // ← depthを指定
        timeout: 60]],
    branches: [[name: "feature/mybranch"]],
    gitTool: 'Default',
    userRemoteConfigs: [[url: 'git@github.com:org/repo.git']]])
  • JenkinsのFreestyleジョブでの設定方法
    • 「ソースコード管理」の「追加処理」で「Advanced clone behaviors」を追加し、shallow cloneをチェック、shallow clone depthを入力

シャロークローンの欠点

シャロークローンには以下のような欠点があります。歴史に対する操作を使うことがわかっている場合は利用しないほうがいいかもしれません。

  • シャロークローンで取ってきていると、歴史をあやつる作業はできなくなる
    • git log で過去のコミットを見る
    • 過去のコミットをチェックアウトする
    • git diff で差分を見る
    • マージする
  • 公式ブログの説明によると、シャロークローン後のフェッチで、場合によっては結局過去の歴史を全部取ってしまうような場合もあるとのこと
    • 一度クローンした場所で、追加でフェッチをするような用途にはシャロークローンは向かない。つまりJenkins向きではない。一方でCircleCIなどクローンした結果を一度しか使わない環境では有効

なお、いったんはシャロークローンしたものの明示的に全部を取り直したいという場合は、 git fetch--unshallow オプションをつけます。

  • git fetch --unshallow

パーシャルクローン(partial clone)

コミット内の各ファイルは、そのメタ情報(ツリーオブジェクト)とファイル実体(ブロブオブジェクト)から成っています(上述のGitHubブログを参照)。パーシャルクローンは、ブロブやツリーオブジェクトを必要な部分のみ取得します。ローカルには、必要に応じてオブジェクトを取得するという情報(下の図では中空の四角で表現)が記録され、checkoutやdiffなど、実際に必要になった時点でオンデマンドにブロブオブジェクトがダウンロードされます。

パーシャルクローン

パーシャルクローンは操作に必要な要素のみを取得するので、歴史の長さ×ファイル数を2次元的な広がりと見て、必要な部分だけを取得することに相当します。この図はクローン後、いくつか操作して必要なものが取得された状況を表現しています。

パーシャルクローンのやりかた

ここではブロブを必要な部分のみ取得し、ツリーはすべて取る方法(ブロブレスクローン)を紹介します。

  • CLIによる方法
    • git clone 時に、 --filter オプションでブロブを取得しないことを指定する
      • git clone --filter=blob:none git@github.com:org/repo .
  • Jenkinsfileによる方法
    • checkout ステップには機能がない。 sh ステップでCLIのgitを利用する
  • Freestyleジョブでの設定方法
    • 同様にシェルスクリプトからgitを利用

パーシャルクローンの欠点

パーシャルクローンには以下のような欠点があります。

  • git diff など、ファイルの内容を必要とする操作をすると、オンデマンドでダウンロードされる
    • その時点でネットワーク接続が必要であることと、ダウンロード時間がかかることに注意

リファレンスリポジトリ(reference repository)の活用

Jenkinsでは複数のジョブから同一のリポジトリ(アプリソースやアセットなど)を参照することが多くあります。その場合、ジョブごとに別のフォルダに同じリポジトリをクローンしてくることになります。2回クローンした状態を図にするとこうなります。

複数回のフォルダにクローン

このとき、 .git はフォルダAとBの下にそれぞれ作成され、歴史を記述したオブジェクトはそれぞれの下に保存されます。ブランチを切ったりコミットしたり、特定のブランチだけフェッチすると、内容は完全に同一とはならないのですが、サーバーからコピーした分は同一のものがディスク上で別の場所に複数回保管されていることになります。特定のリポジトリに関するジョブが十数個にもなると、その数だけ複製を持つことになります。

Gitにはすでにクローン済の .git/objects を参照する機能があります。これを使うと、次の図のようになります。

リファレンスクローン

B/.git/objects の下に、「 A/.git/objects を見ろ」というファイルが記録され、そちらも参照してくれるようになります。AになくてBに必要なオブジェクトはサーバーからダウンロードされ、Bの .git/objects の下に保存されます(このため、Aの情報が多少古くても問題ありません)。これによって、Bの初回クローンは圧倒的に速くなり、ディスクも大きく節約できます。このとき、フォルダBから見てフォルダAを「リファレンス」と呼びます。リファレンスを利用したクローンを、この記事では「リファレンスクローン」と呼ぶことにしましょう。

リファレンスクローンのやりかた

フォルダBにクローンする時点で、すでにフォルダAにクローン済である場合、Aのパスをリファレンスとして指定できます。

  • CLIによる方法
    • git clone 時に、 --reference または --reference-if-able オプションでリファレンスとして使うクローン済みのフォルダを指定する
    • git clone --reference-if-able=/path/to/A git@github.com:org/repo ./B
      • --reference で指定するとリファレンスが存在しなければエラーとなる
      • --reference-if-able では存在しなければ無視される(警告のみ)
    • サブモジュールの取得にリファレンスを使いたい場合は、サブモジュールごとに指定する
      • git submodule update --init --reference=/path/to/A -- submodule/path
  • Jenkinsfileによる方法
    • CloneOptionreference を指定できる。リファレンスが存在しないと無視される(エラーメッセージが出るが処理は続行される)
checkout([$class: 'GitSCM',
    extensions: [[$class: 'CloneOption',
        reference: "/path/to/A", // ← リファレンスのパスを指定
        timeout: 60]],
    branches: [[name: "feature/mybranch"]],
    gitTool: 'Default',
    userRemoteConfigs: [[url: 'git@github.com:org/repo.git']]])
  • Freestyleジョブでの設定方法
    • 「Advanced clone behaviors」→「リファレンスリポジトリのパス」に設定する。存在しないと無視される

通常どおりにクローンしたフォルダに対して、後からリファレンスを加えることもできます。

  • .git/objects/info/alternates というファイルを作成し、リファレンスの .git/objects のフルパスを書き込む
    • サブモジュールの場合は .git/modules/<submodule_path>/objects/info/alternates
  • alternates 作成後、以下のコマンドを実行すると、リファレンスに存在するオブジェクト(重複分)を削除できる
    • git repack -a -d -l

リファレンスクローンの注意点

AをリファレンスとしてBをクローンした後に、A側でオブジェクトが削除されると、Bから参照しようとしたときにオブジェクトが見つからずにエラーとなります。 git pull などの操作をきっかけとして、A側で git gc が自動実行されることがあり、これが発生します。フォルダAをまるごと削除した場合も同様です。 その場合、以下のようなエラーにぶつかるでしょう。

  • fatal: bad object HEAD
  • fatal: bad object <commit_hash>
  • fatal: unable to read <blob_hash>

一度こうなってしまうと修復はなかなか大変なため、リファレンスクローンは事情をよく知って使う必要があります。 git help clone--reference の項目を見ると、 --shared と同様の注意が必要と書いてあり、その注意書きはこうです。要約すると「A側でブランチが削除されるとB側で必要なものが消され、Bが壊れる場合がある。何をやっているかよく理解して使いなさい」ということです。

NOTE: this is a possibly dangerous operation; do not use it unless you understand what it does. If you clone your repository using this option and then delete branches (or use any other Git command that makes any existing commit unreferenced) in the source repository, some objects may become unreferenced (or dangling). These objects may be removed by normal Git operations (such as git commit) which automatically call git maintenance run --auto. (See git-maintenance(1).) If these objects are removed and were referenced by the cloned repository, then the cloned repository will become corrupt.

ローカルミラー活用法

これらの問題を回避するために、リファレンスで指す先はリファレンスで使われることだけに特化したミラーフォルダとして用意するのがよいと思います。組織的に運用しないと安定した稼動は難しいかもしれません。以下に、自分達のプラクティスを紹介しますので、参考にしてみてください。

  • ローカルマシン(Jenkinsエージェントなどごと)にリファレンス用途のクローン(以下ミラー)を作成する
export MIRROR_DIR=/path/to/mirror/dir
mkdir -p $MIRROR_DIR
git clone --no-checkout git@github.com:org/repo $MIRROR_DIR/repo
(cd $MIRROR_DIR/repo; git config gc.pruneExpire never)
  • ミラーでは git config gc.pruneExpire never しておくことで、自動gcによるオブジェクトの削除が起こらないように設定する
  • 環境変数 MIRROR_DIR でミラー群の位置を指すよう、 .bash_profile や、Jenkinsのノード管理で定義しておく
  • ミラーはベア(bare)リポジトリとしてクローンするか、 --no-checkout で作るとワーキングツリー分のディスクを節約できる
  • 定期的にフェッチするジョブをしかけておく
    • .git/gc.log に「too many unreachable loose objects」が出力されるが、これは無視してよい。リファレンスする側のフォルダで使っているオブジェクトがloose objectsとして検出されることが多い
    • ミラーにtmpオブジェクトが残ってしまったような場合など、どうしてもgcやpruneコマンドを使いたい場合は、以下のオプションをつける
      • git prune --expire=never; git gc --no-prune
  • クローン時は --reference-if-able=$MIRROR_DIR/repo オプションをつける

リファレンスクローンを利用する場合、サーバーから取得する歴史はリファレンスにないオブジェクトのみになります。このため、シャロークローンやパーシャルクローンを利用しなくても十分な節約になります。プロジェクトで利用する全部のリポジトリについてミラーを作る必要はなく、複数の場所に何度もクローンされやすいリポジトリを選んで設定するだけで、大きな効果を得ることができるでしょう。

チェックアウト時のディスク節約方法

ここまで、クローンのときの工夫を見てきました。続いてチェックアウトのときの工夫を紹介します。

ファイル数が多くなったリポジトリは、チェックアウトした結果のワーキングツリーもかなりディスクを使うことがあります。通常のチェックアウトの様子を図に示します。チェックアウト時にはHEADから参照されるすべてのファイルのコピーがワーキングツリーとしてコピーされます。

チェックアウト

スパースチェックアウト(sparse checkout)

必要とするファイルがリポジトリ内の一部だけである場合、例えば特定のシェルスクリプト数個だけの場合、リポジトリ全体ではなく、一部のフォルダやファイルだけをチェックアウトすることで、ワーキングツリーを小さくできます。これをスパースチェックアウトと呼びます。

スパースチェックアウト

スパースチェックアウトはファイル数方向を限定してワーキングツリーを小さくすることに相当します。歴史オブジェクトは全部ローカルに持ってきているので、歴史をあやつる作業もできます。逆に言えば、歴史の取得や .git の容量は節約していません。

スパースチェックアウトについては、こちらのブログ記事が参考になります。

スパースチェックアウトのやりかた

  • CLIによる方法 (上記ブログ記事も参照してください)
    • git clone 時に --sparse オプションでスパースチェックアウトであることを、 --no-checkout オプションで初回のチェックアウトしないことを指定する
    • クローン後 git sparse-checkout add でチェックアウトしたいフォルダを指定する
    • 必要なフォルダやファイルを指定するにはパターンも使える。パターンの書き方の詳細は git help sparse-checkout を参照
git clone --sparse --no-checkout git@github.com:org/repo .
cd repo
git sparse-checkout add want_folder1 want_folder2
git checkout
  • Jenkinsfileによる方法
checkout([$class: 'GitSCM',
     extensions: [[$class: 'SparseCheckoutPaths',
         sparseCheckoutPaths: // checkoutしたいフォルダ
             [[path: "want_folder1"],
              [path: "want_folder2"]]
     ]],
     branches: [[name: "feature/mybranch"]],
     gitTool: 'Default',
     userRemoteConfigs: [[url: 'git@github.com:org/repo.git']]])
  • Freestyleジョブでの設定方法
    • ソースコード管理の「追加の処理」から「Sparse checkout paths」に設定

前述のパーシャルクローンと組み合わせて使うと、さらにディスクと通信時間を節約できます。

パーシャルクローン+スパースチェックアウト

このように組合せて使うと、ファイル数方向、歴史の長さ方向の両軸で限定したことに相当します。

LFS (Large File Storage) の活用

Gitリポジトリに大きいバイナリファイルをコミットしていくと、その歴史で .git は大きくなってしまいます。また、GitHubにはひとつのファイルの大きさは100MiBまでという制限があるため、これを超える大きさのファイルはそもそもコミットできません。ゲームのアセットなど、画像や音声ファイルを多く扱うリポジトリでは、ここで説明するLFSを利用するのがよいでしょう。

LFSはファイルの実体を別のストレージに置き、その参照(SHA256ハッシュ)のみをコミットします。図にすると以下のようになります。 .git 内には参照しか入らないため、 git clone の段階ではファイルの実体はダウンロードされません。 git lfs pull により、必要なオブジェクトの実体がダウンロードされる仕組みです(通常はクローン直後のチェックアウトで自動的に行われます)。

Git LFS

Git LFSの設定は、リポジトリ作成直後から行うのがおすすめです。先に大きなオブジェクトをコミットして、後からLFS化しても、コミットしてしまった分は大きなオブジェクトが歴史に残ってしまうからです。以下の手順でLFSの設定ができます。

  • マシンにgit-lfsをインストールする
    • brew install git-lfs , apt install git-lfs など
  • アカウントごとにLFSを使うことを設定する
    • git lfs install
      • チェックアウト時に自動的にLFSからダウンロードされるようになります
  • LFSを導入したいリポジトリで、特定の拡張子のファイルをLFSに格納するよう設定する
    • git lfs track "*.psd" "*.ogg"
  • 通常どおりコミットしてプッシュすると、設定した拡張子のファイルは、実体がLFSに送られる
  • クローンした側ではチェックアウト時に自動的にLFSファイルが取得される。明示的に取得する場合は以下コマンドを実行する
    • git lfs pull (現在チェックアウトしているブランチのオブジェクトを取得し、ワーキングツリーに置く)

LFSの設定・動作ついては、以下の公式ドキュメントや記事が参考になります。

すでに運用中のリポジトリを後からLFS化したい場合、 git lfs migrate というコマンドが使えます。LFSのマイグレーションは手順が多いため、ここでは説明しません。以下のチュートリアルが参考になるでしょう。

LFSのディスク節約方法

git lfs pull の動作は以下の2つに分けられます。

  • git lfs fetch
    • 現在のブランチで利用されているLFSオブジェクトをダウンロードし、ローカルストレージに置く
    • ローカルストレージは通常、クローンしたフォルダの .git/lfs (サブモジュールでは .git/modules/<submodule_path>/lfs
  • git lfs checkout
    • ローカルストレージからワーキングツリーにオブジェクトをコピーする

git lfs pullgit lfs fetch の後 git lfs checkout を行うことに相当します。

これを図にするとこうなります。 git lfs checkout でファイルがコピーされるため、LFSで管理されているファイルは、ローカルストレージ内とワーキングツリー内、通常2個ずつディスクに置かれることになります。

クローン後のLFSチェックアウト

ワーキングツリーのディスクを節約する

ワーキングツリー内のファイルは書き換えてコミットする使い方もあるので、 git lfs checkout では(ハードリンクなどではなく)ファイルをコピーすることが必要です。CIなど、read-onlyの作業しかしないことがわかっている場合には、コピー分のディスクを節約したくなります。

macOSではAPFSというファイルシステムの機能で、ファイルを複製するときに同じディスク領域を使い、ディスクを節約できます。書き込みが発生した時点でcopy-on-writeする動作になります(cpコマンドの -c オプション)。 git lfs dedup というコマンドを実行すると、LFSのローカルストレージとワーキングツリーを比較し、ファイル内容が同一のとき cp -c を使ってひとつにまとめるということをしてくれます。

ローカルストレージを節約する

ローカルストレージには過去のチェックアウトで使ったデータがたまっていくので、時々プルーン(おそうじ)したほうがよいかもしれません。とはいえ、よほど大きくなっていない限り、明示的にプルーンする必要はありません。

  • git lfs prune

ローカルストレージを共有する

同一のリポジトリをJenkinsの複数ジョブなどで複数回クローンすると、ローカルストレージはそれぞれの .git 下に作られます。つまり、クローンした回数分のオブジェクトがディスク上に保存されます。

複数フォルダとLFSローカルストレージ

ローカルストレージを変更し、フォルダAとBの両方で共有するよう設定すると、この重複分を減らすことができます。

  • git config --local lfs.storage /path/to/shared/storage
    • LFSのローカルストレージを特定のパスに設定します

同じリポジトリに対するクローンの各フォルダで、同一のパスを指定してこのコマンドを実行するとよいでしょう。

共有ローカルストレージ

この設定を使うと、大きくなりがちなLFSのローカルストレージを共有して、全体の容量を減らすことができます。なお、ストレージの共有を使っている場合は git lfs prune しないほうがよいです。図中のフォルダAで git lfs prune した場合、フォルダBからしか参照されていないオブジェクトは消されてしまいます(次回チェックアウト時にダウンロードし直されるので、エラーにはなりません)。

この設定をグローバルに行うこともできます。

  • git config --global lfs.storage /path/to/shared/storage

--global に設定すると $HOME/.gitconfig に設定が書き込まれ、そのアカウントでクローンするリポジトリすべてのLFSローカルストレージが同一の場所になります。これには以下のように利点・欠点がありますので、それに注意して利用するとよいでしょう。

  • 利点
    • クローンするたびにいちいちローカルストレージの設定をしなくてよい
    • サブモジュールでもLFSを使っている場合、すべてのサブモジュールで設定する必要がない
  • 欠点
    • 異なる元リポジトリに由来するオブジェクトが、ローカルストレージ内で混在する
      • 機密レベルの異なるリポジトリを扱う場合には、混在するとよくないかもしれない
      • なお、SHA256ハッシュを使っているので、別のオブジェクトが同名になってしまう(ハッシュの衝突)心配はない

まとめ

.git は気がつくと大きくなってしまうものです。うまく工夫することで、ディスク容量や転送時間を減らすことができます。歴史の長さとファイル数の増加どちらがより大きな要因であるか、また、クローンしたフォルダをどう使うかなどを考慮して、シャロークローンやパーシャルクローンなど適切な方法を選択しましょう。

Jenkinsエージェントのような環境では、同一のリポジトリを複数のフォルダにクローンすることが多く、 .git はその数ぶんだけ作られてしまいます。リファレンスクローンで共有したり、LFSのローカルストレージを共有すると、この「複数回」が原因となる重複を減らすことができるでしょう。

これらの工夫を使って、冒頭で例に出したタイトルでは、Jenkinsエージェントあたり300GiB程度のディスク容量を節約できました。いくつかのリポジトリが何度も何度もクローンされていたので、リファレンスクローンの効果が大きく250GiB程度の節約、LFSローカルストレージの共有が50GiB程度の節約になりました。スパースチェックアウトはまだ十分活用できていませんが、改善の余地があるのだと前向きに考えています。

タクシーアプリ「GO」Android版へ自動テストを導入するまでの道のり

こんにちは、Androidチームの田熊(fgfgtkm)と外山(sumio)です。SWETのAndroidチームでは、Androidのプロダクトに対して自動テストのサポートをしています。

この度株式会社Mobility Technologiesが提供するタクシーアプリ「GO」のAndroid版に対する、おおよそ2年間に渡る取り組みが終了しました。

本記事では、この取り組みで行ってきた次の2点を紹介したいと思います。

  • 「GO」のAndorid版に対してどのような自動テストを導入したか
  • 開発チームに自動テストを定着させるまでにやったこと

タクシーアプリ「GO」に対しての自動テスト導入

SWETのAndroidチームは「社内のAndroidエンジニアが自動テストを書くことを習慣化している」ことをゴールの1つとして、自動テストを普及させるための取り組みをしています。 その中でAndroidのテストナレッジの社内展開を目的としたハンズオンを実施してきました。

しかし、社内の多くのチームはテストが書きにくい設計になっているという課題を持っており、既存機能にテストを書くためには設計を改善する必要がありました。(参考:テストの社内普及のための取り組みとして、Androidテストハンズオンを実施しました

今回紹介する「GO」(旧サービス名:MOV)も例外ではありません。 ハンズオンではカバーしきれない現場の課題を解決して自動テスト導入を促進したい、そして「GO」をそのモデルケースにしたい、と考えたのが本取り組みのきっかけです。

それから約2年間、開発チームと協力しながら次の施策を実施しました。

  • コード改善 + ユニットテストの導入
  • スクリーンショットテスト

それぞれどのようなことをやってきたか、具体的にお話したいと思います。

コード改善 + ユニットテストの導入

当時の「GO」は歴史的経緯から一貫したアーキテクチャが定められておらず、多くの処理がFragmentやActivityに記述されていました。メイン機能となる地図を表示している画面(以降、地図Fragmentと呼びます)も同様で、配車に関連する複雑なロジックが地図Fragmentに実装されていました。

最初に行ったことは、この地図Fragmentに実装されたロジックを別クラスに切り出した上でユニットテストを書けるようにし、MVVMの構成にすることでした。以降、地図Fragmentや関連する画面クラスの改善を地道に進めていきました。

画面から切り出したほうが良い処理をピックアップし、画面からの分離⇔ユニットテストを実装する。そのサイクルを繰り返すことで、少しずつユニットテストが書けるコードを増やしていきました。 

こうしてテストコードが追加された結果、そのテストコードを参考に開発メンバー自身が自発的にユニットテストを実装してくれるようになりました。(具体的な数値については「自動テストを書くことを習慣化できたか?」のセクションで紹介します)

また、もともと開発チームでもMVVMアーキテクチャにしていきたいという思いがあったため、開発チーム主導でMVVM化がどんどん進んでいき、今ではほとんど全ての機能がMVVMアーキテクチャで実装されています。

改善した内容の一部はMOV Android版に対する「コード改善+テスト導入」の取り組みの紹介でもまとめていますので、是非ご参照ください。

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

ユニットテスト導入をある程度進めたタイミングで、一度リリース前検証で出た不具合チケットを整理しました。その結果、画面まで結合させないと発見が難しい不具合、ViewModel以下のレイヤのユニットテストでは発見の難しい不具合が高い割合で起票されていることがわかりました。

Androidアプリ開発においてこのような不具合はつきもので、問題を早期発見するためにUIテストを導入できないか考えました。そしてUIテスト実装のアイディアについて開発チームと議論を重ねた結果、スクリーンショットを活用したテスト(以降、スクリーンショットテストと呼びます)を導入することで合意しました。

SWETではスクリーンショットテストを導入するために、主に次の2つのことを実施しました。

  • スクリーンショットテストを書きやすくするための基盤を整備する
  • スクリーンショット一覧レポートの確認や画像差分の比較ができるようにCI/CDを整備

スクリーンショットテストを書きやすくするための基盤

スクリーンショットテストは画面が任意の状態になるようにデータをセットし、さらに非同期処理やレンダリング完了まで待った上でスクリーンショットを撮る必要があります。ユニットテストと比較するとテストコード側でセットアップする必要のあることが多くなり、ボイラープレートが増えてしまいます。

我々はできるだけスクリーンショットテストを書く大変さを軽減したいと考え、書きやすくするための基盤の実装に着手しました。そして開発チームと一緒に様々なパターンのスクリーンショットテストを実装しながら、機能をブラッシュアップしていきました。

この基盤は次のことを実現しています。

  • Junit5のExtensionとして提供され、テストに適用するだけで「GO」で安定してスクリーンショットテストを取得するために必要なセットアップが完了する(非同期処理の待ち合わせ設定など)
  • Activity・Fragment・DialogFragmentそれぞれの起動を簡潔に書けるようにする手段の提供
  • 画面単位・View単位など様々なスクリーンショット取得方法の提供
  • テストでよく使用されるEspressoのコードを汎用化

基盤の実装の一部はGOGO Screenshot Test for AndroidとしてOSS化しています。是非ご参照ください。

CI/CDの整備

取得したスクリーンショットは一覧化したレポートを作成することで、エンジニア以外のメンバーでもデザインのレビューがしやすくなります。 また、reg-suitといったツールを使えば、Visual Regression Testにも活用できます。 例えば、前回リリース時点のスクリーンショットと現在のバージョンのスクリーンショットを比較すれば、意図せぬUIの変更が含まれていないか検証できます。

Andorid スクリーンショットテスト 3つのプロダクトに導入する中で倒してきた課題より引用)

これらのことを実現するために、次のようなワークフローをBitriseを使って整備しました。このワークフローはマージやタグ追加のタイミングで実行されます。

  • Instrumentation Testを実行し、スクリーンショットを取得する
  • 取得したスクリーンショットの一覧レポートを作成する
  • 任意のバージョン間のスクリーンショットを比較してreg-suitのレポートを作成する

あわせて、不必要な差分が含まれないスクリーンショットを安定して取得できるように基盤の改善をしました。

スクリーンショットテストの導入および、スクリーンショットを安定して取るためにやったことはAndorid スクリーンショットテスト 3つのプロダクトに導入する中で倒してきた課題にもまとめています。是非こちらもご参照ください。

自動テストを書くことを習慣化できたか?

冒頭でSWETのAndroidチームは「社内のAndroidエンジニアが自動テストを書くことを習慣化している」ことをゴールの1つとしていると述べました。 それでは自動テスト導入の取り組みの結果、「GO」の開発チームにおいて自動テストを書くことを習慣化できたのでしょうか。

最近のリリース時点での自動テスト件数とカバレッジの推移は次のようになっています。

まずはLocal Testの推移です。Local Testには主に画面以外のユニットテストが含まれます。

次に、スクリーンショットテストが含まれるInstrumentation Testの推移です。スクリーンショットテストを導入したバージョン5.3.0から集計しています。

ユニットテスト・スクリーンショットテストともに各バージョンで増加しています。また、これらのテストはほとんどSWETメンバーによる実装ではなく、開発メンバー自身が実装したものです。取り組みを始めた当初はユニットテストが十数件ほどでしたが、現在ではコンスタントに自動テストが増えています。

この結果から、「GO」の開発チームにおいては自動テストを書くことを習慣化できたと考えています。

自動テストの習慣化が実現できたのは、開発チームの尽力によるところが大きいです。一方でSWETからも自動テストが定着するような働きかけを行ってきました。

次のセクションでは、自動テストを定着させるためにどのような取り組みを行ってきたかを紹介します。

自動テストがチームに根付くまで

導入したテストが効果を発揮するためには、テストの継続的運用が不可欠です。 そのためには、開発チーム自身がテストの追加・修正といったメンテナンスをし続ける必要があります。

  • テストを導入したものの、導入した担当者が異動を機にメンテナンスされなくなってしまった
  • テストを書く気持ちはあるけれども、ついついプロダクトの実装を優先してしまう

といった話は色々なところで耳にします。 開発チームのメンバーひとりひとりがテストを書けるようになり、更にその状態を継続するのは大変なことです。

幸い、「GO」開発チームでは自動テストを書くことを習慣化できました。 自らテストを書く習慣は「こうすれば身に付く」といった答えのあるものではありませんが、一例として「GO」開発チームにおける習慣化のためにSWETが働きかけてきたことを紹介します。

ユニットテスト

「コード改善 + ユニットテストの導入」で触れたようなテスタビリティ改善の取り組みによって、開発メンバーの書いたテストが少しずつ増えるようになりました。

この段階になってから重要なのがトラブル発生時のサポートです。 特に次のようなトラブルは、一歩間違うと未解決のまま放置されてしまうことになりがちです。

  • モックライブラリMockkで意図通りにモックできず、テストが失敗してしまう
  • CI動かしたときだけ、時々テストが失敗してしまう

失敗したテストが残ったままの状態だと「テストをオールグリーンの状態に保つ」という気持ちが折れてしまい、テストがメンテナンスされず放置されるきっかけになってしまいます。 そうなってしまわないように、Slackに常駐して、テスト関連のトラブルに気付いたときには積極的にサポートするようにしました。

現在では、このようなトラブルも開発メンバー自身で解決し、SWETのサポート無しでテストの追加・修正を継続できています。

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

一方スクリーンショットでは、先に触れたようなスクリーンショットテスト基盤やワークフローの整備だけでは、開発チームが自らテストを追加するまでには至りませんでした。 そこからテストを書く習慣が根付くまでの道のりは試行錯誤の連続でした。

モブプロでテストを書くハードルを下げる

当時は、新たに構築したテスト基盤の使い方とテストの書き方の説明会を実施し、その後のテストの追加は開発チームに任せようという考え方でいました。 ところが、説明会終了後もテストが増えないままだったため、テストを書くハードルをもっと下げる必要があるのではないか、と考えました。

そこで、更にテストを書くハードルを下げるために、とある画面のスクリーンショットを撮るテストをモブ・プログラミング(以降モブプロと表記します)で実装してみることにしました。
モブプロの役割分担は次の通りです。

  • ドライバー(プログラムを書く人):SWET
    • テスト基盤の使い方などを説明しながらコーディングしました
  • ナビゲーター(ドライバーに指示する人):開発メンバー
    • スクリーンショット対象画面を表示するために必要なデータのセットアップ方法を案内しました

このモブプロで、「シンプルなコードで目的の画面のスクリーンショットを撮る」方法が開発チームに伝わったという手応えがありました。

モブプロで開発メンバーにコーディングしてもらう

その後新機能のリリースが立て続けに発生し、長い間テストが追加されない状態でした。

大きなリリースが終わり、開発チームが一息ついた頃に「そろそろスクリーンショットテスト書いてみませんか」と呼び掛けてみたところ、 「前回のモブプロでは自分が手を動かさなかったので書き始められる自信がない」という意見を多数いただきました。

確かに、前回のモブプロでは主にSWETがドライバーをしていたため、開発メンバーがコードを書く機会はほとんど有りませんでした。 そこで、開発メンバーをドライバーに据えて、改めてモブプロを開催することにしました。

  • ドライバー:開発メンバー1名
  • ナビゲーター:SWETと、残りの開発メンバー
    • SWETはテスト基盤の使い方を案内しました
    • 残りの開発メンバーは、狙った画面を表示するための方法を案内しました

このモブプロではスクリーンショット撮影に必要な一通りのコードを全員に書いて欲しかったため、開発メンバー全員がドライバーを担当するまで毎週開催することにしました。 その甲斐あって、スクリーンショットテストが追加されるようになりました!
・・・が、しばらく経つとテストが追加されない状態に戻ってしまいました。

短期的なゴールを共有する

そうこうしている内に、私達が支援できる期限は残り3か月を切ってしまいました。 当初はテストで撮影したスクリーンショットをデザイナーさんにレビューしてもらう計画だったにもかかわらず、肝心のテストが増えないのでレビューしてもらう画像も無いという状況でした。

この状況を打開するため、開発チームに次のような提案をしてみました。

  • 次回リリースのデザインレビューでは、テストで撮ったスクリーンショットをデザイナーさんにレビューしてもらうようにしたい
  • デザインレビューまでに、次回リリースで追加・修正される画面だけで良いからスクリーンショットを撮れるようにしたい
  • スクリーンショットテストを1人で書くのはまだ不安があると思うので、引き続きモブプロで書けるだけ書いていきたい

有り難いことに、開発メンバーの皆さんに賛成してもらえたので、3巡目となるモププロを開催しました。 今回のモブプロは役割分担こそ2巡目と同じものの、次の点が異なっていました。

  • テスト完成の期限が明確になった
  • スクリーンショットを撮る対象画面の範囲が明確になった

期限と範囲が明確なため、モブプロだけではカバーしきれなかった画面のテストが次々と追加されていきました。 モブプロだけでは必要なスクリーンショットが撮れそうになかったため、デザインレビューの期限に間に合うよう頑張ってもらえたのだと思っています。

また、提案したときは意図していなかったのですが、対象に難易度の高い画面が含まれていた点が良い方向に作用しました。 モブプロで難易度の高い画面に取り組んだことで、意図通りの状態で対象画面(Fragment)を起動するスキルが格段に向上したのです。 合わせて、難易度の高い画面に対応するための改善を、テスト基盤に多数取り込むことができました。

スキルが向上してスクリーンショットテストを書く手間が減っていくと、デザインレビュー以外の方法でも便利に使えるという声が開発メンバーから聞かれるようになりました。

  • Pull Requestを出すときにスクリーンショットを貼り付けるのが楽になった
  • 複雑な操作をしないと出せない画面を確認するのが楽になった

最終的には、デザインレビュー当日に必要なスクリーンショットテストが全て揃い、色々な画面サイズ・解像度でのスクリーンショットをデザイナーさんに渡すことができました。 開発メンバーの皆さんには、私達が離脱した後も継続してスクリーンショットテストを活用してもらえています。

まとめ

「GO」のAndroid版にユニットテストとスクリーンショットテストを導入し、開発チームが自動テストを書く習慣が根付くまでに取り組んだ内容を紹介しました。

今回の取り組みを通して、次のような気付きを得ることができました。

  • ユニットテストのようなシンプルなテストは、最初に参考となるテストを追加することで他のメンバーも書き始めやすくなる
  • 一方で、スクリーンショットテストのような複雑なテストを書いてもらうには、基盤の導入だけでは不十分
  • テストの書き方を身につけてもらうには、当人がドライバーとなってモブプロを行うのが効果的
  • 目標や期限が決まるとモチベーションがアップする

その一方で、次のような幸運に恵まれた点も多かったと思います。

  • 開発メンバーの皆さんが、自発的に自動テスト実装やアーキテクチャの改善をしてくれたこと
  • 開発メンバーの皆さんが、デバッグが難しい複雑なトラブルでも解決できる高いスキルを持っていたこと
  • 開発メンバーの皆さんがいつも協力的だったこと
  • 私達の支援期間が終了するまでに何とかしたいという気持ちを全員が持っていたこと

他のチームで、ラッキーな面を差し引いたとしてもなおこの方法が通用するのかは分かりませんが、今回の経験で得られた学びはとても大きなものでした。 本記事が、皆さんがテスト導入に取り組むときのヒントになれば幸いです。

謝辞

Mobility Technologiesの「GO」Android版開発チームの皆さんには、本取り組みのために貴重な時間を割いていただきました。 また、UIテスト基盤のOSS化について快諾していただきました。改めて感謝いたします。

Unityプロジェクト向けRoslynアナライザの作りかた

SWETグループの長谷川(@nowsprinting)です。

Unity 2020.2以降、Unityエディタ上でRoslynアナライザによる静的解析 (static analysis) を実行可能になりました。 また、それ以前のバージョンで作られたUnityプロジェクトであっても、JetBrains RiderなどのC#向けIDE(統合開発環境)上でRoslynアナライザの実行がサポートされています。

静的解析を充実させることで、コンパイラだけではチェックしきれないようなバグや性能劣化の原因を早期に検出できます。 例えば弊社では、実行時に動的にインスタンス化されるクラスのコンストラクタがIL2CPPビルド時にストリップされないように [Preserve]アトリビュートの指定漏れを検出するアナライザを導入し、ショーストッパーとなりえる問題を早期発見できるようにしています。

通常、こうした問題の検出はシニアエンジニアによるコードレビューに頼られます。静的解析によってレビュー負荷を軽減できれば、より高度な問題に目を向けられるという副次的効果も見込めます。

本記事では、社内フレームワークやプロジェクト固有のルールに対応するカスタムアナライザを作る手順やTipsを紹介します。

Roslynアナライザとは

Roslyn(ろずりん)とは、C# 6.0から導入された.NETコンパイラプラットフォームの通称です。C#およびVisual Basic向けコンパイラのほか、コード生成API、コード解析APIが公開されています。

Roslynのコード解析APIでは、構文解析 (syntactic analysis) および意味解析 (semantic analysis) を行なうことができ、これを利用して様々なアナライザを自作できます。 これを、本稿ではRoslynアナライザもしくは単にアナライザと呼びます。

なお、一般的なUnityプロジェクトで利用できるオープンソースのアナライザもあり、例えば以下のものが知られています。

UnityプロジェクトでRoslynアナライザを使用する

Roslynアナライザの作り方に先立って、Unityプロジェクトでアナライザを使用する方法について述べます。

Unity 2020.2以降、Unityプロジェクト内のアナライザ(および依存する)DLLファイルを適切に設定することで、Unityエディタ上で静的解析が実行されるようになりました。しかし、実用するにはまだ使い勝手が良いとは言えません *1 *2 *3 *4 *5 *6 *7

一方で、多くの方がコーディングに使用されているC#向けIDE(具体的にはJetBrains Rider、Visual Studio、Visual Studio Code)では、プロジェクトの.csprojファイルに使用するアナライザを定義してあれば静的解析が実行されます。 アナライザによる診断がもっとも欲しいタイミングは、まさにIDEでコードを書いているときです。アナライザを導入するのであれば、IDEでの診断をメインに据えることをおすすめします。

まずUnityプロジェクトでのDLL設定を、続いて、その設定を.csprojに反映する方法を紹介します。

UnityプロジェクトでのDLL設定

Unityプロジェクト(2019.4で確認しています)に配置したDLLファイルはPlugin Inspectorウィンドウで設定を変更でき、その設定はDLLの.metaファイルに書き込まれます。

Plugin Inspectorウィンドウ

ProjectウィンドウでAssetsフォルダ下にあるアナライザのDLLを選択してPlugin Inspectorウィンドウを表示し、以下のように変更します。

  • Select platform for plugin下のチェックをすべてoff
  • Asset Labelsに RoslynAnalyzer を追加 *8

DLLがAssetsフォルダ下にない場合、Asset Labelsは設定できませんので注意してください。

アナライザ設定を.csprojファイルへ反映する

IDEが使用する.csprojファイルは、IDEに対応したプラグインパッケージ *9 によって自動生成されます。 以下のプラグインでは、前述のようにUnity 2020.2向けに設定されたアナライザDLLの設定を.csprojに反映してくれますので、Unityプロジェクト側で前述の設定を済ませるだけでIDEでも静的解析が実行されるようになります。

上記以外のIDEをお使いの場合、また、AdditionalFilesを使用するアナライザを使用する場合は、.csproj生成時にアナライザの定義を挿入する必要があります。 この設定をサポートしてくれるエディタ拡張がCysharpさんにより公開されていますので、こちらを利用してみてください。

github.com

重要度設定とサプレス設定

アナライザには、診断項目ごとにデフォルトの重要度 (severity) が設定されています。プロジェクトによって重要度を上げたい/下げたい場合、これを上書き設定できます。

UnityエディタおよびVisual Studioの場合、ルールセットファイルを使用します。設定方法は公式マニュアルの Roslyn analyzers and ruleset files を参照してください。 Riderの場合は、Preferences... を開き、Editor | Inspection Settings | Roslyn Analyzersの中で設定できます。

また、特定のコードにおいてのみ、診断の対象外にしたいケースもあります。

  • 対象となるクラスやメソッド定義に対して [SuppressMessage]アトリビュートをつけることで、そのクラス/メソッド内では指定した診断をサプレスできます
  • 対象となるコード行の前後に #pragma warning disable <DiagnosticID>#pragma warning restore <DiagnosticID> ディレクティブを書くことで、指定した診断をサプレスできます

なお、Riderにはインスペクションを行単位でサプレスできるコメント書式がありますが、Rider上であってもRoslynアナライザの診断に対しては無効です。 #pragma warning disable/restore ディレクティブを使用してください。

Roslynアナライザの作成(基礎編)

続いて、基本的なRoslynアナライザ作成の手順を紹介します。

プロジェクトの準備

Roslynアナライザは、.NETプロジェクトとして作成します。 .NET SDK コマンドラインツールのほか、.NETプロジェクトを扱うことのできるIDEが使用できます。

ただし、アナライザ用のプロジェクトテンプレートは、Windows版のVisual Studioでのみ提供されています。 ここではVisual Studio 2019でプロジェクトを作成する手順を紹介します *11

ひな形の作成

  1. Visual Studio Installerを起動し、インストール済みVisual Studio 2019 *12 の「変更」をクリック。「個別のコンポーネント」タブにある「.NET Compiler Platform SDK」を選択してインストールします
  2. Visual Studio 2019を起動し、「新しいプロジェクトの作成」をクリック。プロジェクトテンプレートとして「Analyzer with Code Fix (.NET Standard)」のC#のほうを選択します(同じ名称でVisual Basic向けもあるので注意)

これでアナライザのひな形ができました。 ひな形のソリューションは、例えば名称を HogeFugaAnalyzer としたとき、下記5つのプロジェクト (.csproj) で構成されています。

  • HogeFugaAnalyzer: アナライザ本体。この名称はソリューションと同じものです
  • HogeFugaAnalyzer.CodeFixes: アナライザで検出した問題のオートフィックス機能を提供するプロジェクトです。本記事では解説しません
  • HogeFugaAnalyzer.Package: アナライザ本体とCode FixをNuGetパッケージ (.nupkg) ファイルにパッケージングするプロジェクトです。Unityプロジェクト専用のアナライザではDLLファイルを直接扱うため使用しません
  • HogeFugaAnalyzer.Test: アナライザのユニットテストを記述するプロジェクトです
  • HogeFugaAnalyzer.Vsix: Visual Studio拡張機能としてアナライザをデバッグ実行するためのプロジェクトです。本記事では解説しません *13

Windows以外の環境でビルドする設定

以降の開発をWindows以外の環境で行なう場合、ひな形のままではビルドできないため、アナライザ本体の PackageID 属性を書き換えます。

HogeFugaAnalyzer.csprojを任意のエディタで開き、

<PackageId>*$(MSBuildProjectFullPath)*</PackageId>

の部分を、例えば

<PackageId>HogeFugaAnalyzer</PackageId>

に変更します。 ただし、この名称はNuGetパッケージ (.nupkg) の名前としてパッケージングプロジェクト (HogeFugaAnalyzer.Package.csproj) 内で定義されています。同時にHogeFugaAnalyzer.Packageプロジェクトは削除しましょう。

もしNuGetパッケージとして配布を予定しているのであれば、パッケージングにまつわる設定をアナライザ本体の.csprojに記述するか、もしくは本体のPackageIDを例えば下記のように変更することで回避できます。

<PackageId>HogeFugaAnalyzer.Diagnostic</PackageId>

テストプロジェクトの設定

生成されたひな形ではテストプロジェクトが.NET Core App 2.0向けに設定されているため、開発環境に合わせて変更します。

例えば.NET SDK 5.0で開発する場合、 HogeFugaAnalyzer.Test.csprojをエディタで開き、

<TargetFramework>netcoreapp2.0</TargetFramework>

の部分を

<TargetFramework>netcoreapp5.0</TargetFramework>

に変更します。

以上で、IDE上でビルドが成功する状態になります。

アナライザの動作解説

アナライザのひな形プロジェクトでは、コード上にあらわれる型の名称を検査し、小文字が含まれていれば警告するサンプルが実装されています。 これをもとに、簡単に診断の流れを解説します。

DiagnosticAnalyzer

アナライザは、DiagnosticAnalyzer を継承かつ [DiagnosticAnalyzer]アトリビュートがつけられたクラスとして定義されます。

DiagnosticAnalyzerを継承したクラスには、SupportedDiagnosticsプロパティおよび Initializeメソッドの実装が必要です。

SupportedDiagnostics

このアナライザが提供する診断内容 (DiagnosticDescriptor) を配列で返します。

DiagnosticDescriptor は、IDEのインスペクション設定で一覧表示され、重要度 (severity) を設定させるために使われます。 また、実際に問題を検出した際に表示されるメッセージもここに設定します。

ひな形では private static readonly DiagnosticDescriptor Rule を定義し、これを ImmutableArray でラップして返す実装になっています。

public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
{
    get
    {
        return ImmutableArray.Create(Rule);
    }
}
Initialize

Roslynコンパイラによってアナライザがロードされると呼ばれるメソッドです。

コンパイラは、ソースコードに対して、字句解析・構文解析・意味解析といったフェーズを経て中間言語 (Intermediate Language) を出力します。 アナライザは、その診断内容に適したフェーズでコンパイラからコールバックを受けて動作します。 Initializeメソッドでは、コールバックを受け取りたい契機を AnalysisContext に登録します。

ひな形では、シンボルの意味解析完了ごとに動作させたいメソッド AnalyzeSymbol() を、 RegisterSymbolAction() で登録しています。

public override void Initialize(AnalysisContext context)
{
    (snip)
    context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType);
}

アクションには、シンボルのほかにもコンパイルの開始・終了、メソッド呼び出しなど多数のアクションが定義されており、自由に組合わせてアナライザを作ることができます。

ただし、各アクションの呼び出し順は保証されていません *14。たとえばsymbol actionの時点でsyntax tree actionの処理が完了していることに依存するようなアナライザは動作しない恐れがあります。

アクションに関する詳細は、Roslynのドキュメント Analyzer Actions Semantics を参照してください。

診断の実行

コールバックを受けるよう登録した private static void AnalyzeSymbol() が実際の診断を行なうメソッドです。 診断に必要な情報は引数で受け取れます。ここではシンボルの名前に小文字を含む場合、診断結果である Diagnostic を生成して返しています。

private static void AnalyzeSymbol(SymbolAnalysisContext context)
{
    var namedTypeSymbol = (INamedTypeSymbol)context.Symbol;

    if (namedTypeSymbol.Name.ToCharArray().Any(char.IsLower))
    {
        var diagnostic = Diagnostic.Create(
          Rule,
          namedTypeSymbol.Locations[0],
          namedTypeSymbol.Name);

        context.ReportDiagnostic(diagnostic);
    }
}

Diagnostic.Create() の引数は、DiagnosticDescriptorLocation、メッセージ挿入語句となっています。 この結果を受けたIDE上では Location で指示された位置にポップアップ等で DiagnosticDescriptor に定義されたメッセージが表示されます。

Riderでの診断結果表示例

以上のように、アナライザプロジェクトのひな形は、そのままでアナライザとして動作するものが生成されています。 アナライザの開発がはじめてであれば、この時点で一度Unityプロジェクトに組み込んでその振る舞いを確認することをおすすめします。

アナライザのテスト

ユニットテスト

アナライザ作成においても、できるだけ早い段階かつ小さい単位でユニットテストを行なうことは役に立ちます。 しかし、Roslynコード解析APIで提供されているテストAPIは、解析対象コードと期待する Diagnostic を引数に取って合否検証まで行なうという粒度の大きいものしかありません。 このAPIでは、複雑な診断を行なうアナライザのテストは困難です。

そこで、アナライザの実行と結果検証を分けて使えるフレームワークDena.CodeAnalysis.Testingを作成し、使用しています。 Dena.CodeAnalysis.Testingは、GitHubおよびNuGet Galleryで公開しています。

github.com

www.nuget.org

Dena.CodeAnalysis.Testingを使用するには、テストプロジェクトの.csprojに下記の定義を追加します。

<PackageReference Include="Dena.CodeAnalysis.Testing" Version="1.0.0" />

Dena.CodeAnalysis.Testingを用いたテストの書きかた

テストは次のように記述できます。

var analyzer = new YourAnalyzer();

var diagnostics = await DiagnosticAnalyzerRunner.Run(
    analyzer,
    @"
public static class Foo
{
    public static void Bar()
    {
        System.Console.WriteLine(""Hello, World!"");
    }
}");

var actual = diagnostics
    .Where(x => x.Id != "CS1591") // Ignore "Missing XML comment for publicly visible type or member"
    .ToArray();

Assert.AreEqual(1, actual.Length);
Assert.AreEqual("YourAnalyzer0001", actual.First().Id);

この例では DiagnosticAnalyzerRunner.Run にアナライザのインスタンスと検証対象コードを渡し、診断結果を受け取った後に Assert で期待通りの結果であるかを検証しています。

アナライザの実行と Assert が分離されているため、最終的な診断結果だけでなく、アナライザ内の中間情報を検証するテストを書くこともできます。 また、アナライザをテストダブル(モック、スタブ、スパイ等)に置き換えてのテストも可能です。

ユニットテストを考えるとき、例えば INamedTypeSymbol などをテストデータとして生成できればよいのですが、Roslyn APIでは困難です。そのため上記のように診断対象コードをテストの入力データとしています。

最後の Assert はテスト対象や目的に応じて様々な書きかたがありますが、ひとつ注意すべき点があります。 上例では診断結果から CS1591 を取り除いた上で、件数および DIagnostic.Id を検証しています。 もしこれを直接

Assert.IsTrue(diagnostics.Any(x => x.Id == "YourAnalyzer0001"));

のように書いてしまうと、想定外の診断結果バグを見逃してしまったり(上例では2つ目の Diagnostic が無いことを確認できません)、返るはずの Diagnostic が返らなかったとき原因判明に時間がかかったりします(テストデータのミスによるコンパイルエラーは多々発生します)。 少々面倒に感じますが、関係ないものを明示的に取り除いてから Assert する手順を踏むことをおすすめします。

LocationAssert.HaveTheSpan

Dena.CodeAnalysis.Testingでは、補助的なツールとして LocationAssert.HaveTheSpan も提供しています。 次のように、 DiagnosticResult に含まれる Location が正しく設定されていることの検証を簡単に記述できます。

LocationAssert.HaveTheSpan(
    new LinePosition(7, 34),  // 期待する始点
    new LinePosition(7, 42),  // 期待する終点
    diagnostic.Location
);

なお、Location の行およびカラムはコードでは0オリジンで指定しますが、診断メッセージには1オリジンで(つまり+1して)表示されます。 1足すか引くかは混乱しがちなポイントですが、 LocationAssert.HaveTheSpan では次のようにアサートメッセージに両方の数値が並べて表示され、判別しやすくなっています。 また、メッセージはそのままテストコードのexpectedとしてコピー&ペーストできる書式となっています。

LocationAssert.HaveTheSpanの差分表示例

テストデータについてのTips

ユニットテストの効率を高めるため、テストで使用するデータ(アナライザにおいては診断対象コード)の質には気を配るべきです。 特に、実際の検証対象コードと乖離したデータでテストを書いてそれがパスしてしまうと、アナライザを実際のプロジェクトへと組み込んでから問題に気づくこととなり手戻りが大きくなります。

そのため、よほどシンプルなものを除き、テストデータは string ではなく個別のテキストファイルに定義することをおすすめします。 拡張子を.csにすることで、コンパイルエラーや警告をあらかじめ修正でき、テスト実行時に自作アナライザとは無関係の診断結果に煩わされることもなくなります。

複数のテストデータで同名のクラスを使用している場合、ファイルを小分けすることで使いまわしができます。逆に同名のクラスをあえて使い分けたい場合、テストケースごとに namespace を使い分けることで実現できます。

その他、以下の点にも注意してみてください。

  • メソッド呼び出しを検出するとき、それが拡張メソッドでないか。拡張メソッドは明示的に型が異なるため、テストデータも拡張メソッドとして記述しなければ検出できません
  • アトリビュートのクラス名に Attribute を付け忘れていないか。例えば、アトリビュートのクラス名が HogeAttribute でも Hoge でも、[Hoge] と記述できてしまいます。しかし、アナライザから見ると別物です

Unityプロジェクトに組み込んでのテストTips

アナライザをDLLにビルドし、それをUnityプロジェクトに組み込んでテストする際のTipsを紹介します。

極力ユニットテストで品質を上げておく

Unityプロジェクトに組み込んでからのテストではデバッグしづらく、また修正・再実行に時間もかかります。 大前提として、極力、ユニットテストでリアリティの高いテストデータ(診断対象コード)を使って品質を上げておくべきです。

dotnet buildコマンドを使用する

IDEでアナライザを実行する場合、IDEによってアナライザ自体がキャッシュされるため、何度も安定したテストを実行することは困難です。 そのため、初期の段階ではdotnetコマンドでインクリメンタルコンパイルを無効にした実行を試すことをおすすめします。

次のコマンドで実行できます。

$ dotnet build --no-incremental YOUR_PROJECT_NAME.csproj

ファイルロガーを使用する

アナライザをUnityプロジェクトに組み込むと、コンソール出力を観測できないため、いわゆるprintデバッグは不可能になります。 そのため、デバッグ困難な事象に突き当たってしまったら、早めにファイルロガーの仕組みを入れることをおすすめします。

なお、OSSなど外部のロガーソリューションを利用する場合、依存するDLLもすべてアナライザ本体同様 <Analyzer> ノードに書く必要がある点に注意してください。

Roslynアナライザの作成(応用編)

実用的なアナライザとして、冒頭で触れた「実行時に動的にインスタンス化されるクラスのコンストラクタがIL2CPPビルド時にストリップされないように [Preserve] アトリビュートの指定漏れを検出するアナライザ」の作成方法を紹介します。

このアナライザの実現には、二段階の処理が必要です。 まず、メソッドの呼び出し箇所でコールバックを受け、それが特定のメソッド呼び出しであるかを判断し型引数を取得します。 続いて、その型のコンストラクタに [Preserve] アトリビュートが定義されてるかを診断します *15

では、順に見ていきましょう。まず Initialize でメソッド呼び出し箇所のコールバックを登録します。

public override void Initialize(AnalysisContext context)
{
    context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
    context.EnableConcurrentExecution();

    context.RegisterOperationAction(
        AnalyzeAttributes,
        OperationKind.Invocation
    );
}

コールバックに渡される OperationAnalysisContext からは、呼び出しているメソッドの情報が取得できます。 ここでは、クラスを型引数として取り、実行時に動的にインスタンス化するクラスを登録しているメソッド呼び出しを検出します。

private void AnalyzeAttributes(OperationAnalysisContext context)
{
    var invocation = (IInvocationOperation)context.Operation;
    var methodSymbol = invocation.TargetMethod;

    if (methodSymbol.ContainingType.Name != "ServiceCollection")
        return;

    if (methodSymbol.Name != "AddSingleton")
        return;

    if (methodSymbol.TypeArguments.Count()==0)
        return;

特定のメソッド呼び出し(ここでは ServiceCollection.AddSingleton<>())であったならば、続いて型引数のコンストラクタについているアトリビュートを診断します。

    var injectedClass = methodSymbol.TypeArguments[0] as INamedTypeSymbol;
    var ctorShouldBePreserved = new List<IMethodSymbol>();

    foreach (var ctor in injectedClass.Constructors)
    {
        foreach (var attribute in ctor.GetAttributes())
        {
            if (attribute.AttributeClass?.Name == "PreserveAttribute")
            {
                ctorShouldBePreserved.Add(ctor);
            }
        }
    }

以上で、ServiceCollection.AddSingleton<>() に型引数として渡しているクラスのコンストラクタに [Preserve] アトリビュートがついているか否かを確認できました。

最後に、診断結果をコンパイラに渡します。

    if (ctorShouldBePreserved.Count == 0)
    {
        var diagnostic = Diagnostic.Create(
          CtorDoesNotHaveInject,  // [Preserve]付きコンストラクタが存在しない意のDiagnosticDescriptor
          location,               // 割愛していますが、呼び出し部分の型引数部分を指すLocation
          injectedClass.Name);
        context.ReportDiagnostic(diagnostic);
    }
}

コードでは割愛していますが、この診断結果の Location (primary location) には、本来の問題箇所である型引数のコンストラクタ定義箇所ではなく ServiceCollection.AddSingleton<>() 呼び出し位置(の型引数部分)を指定しています。

IDE上でアナライザが実行されるときはエディタタブで開かれているファイルを起点とした診断しか行われないため *16 *17、このアナライザは呼び出し側のファイルでしか機能せず、またそのファイル以外を指す診断結果は無効となるためです。

Preserveアトリビュートの指定漏れを検出するアナライザの診断例

まとめ

なかなか情報も少なく、ハードルが高い印象のRoslynアナライザです。しかし、導入してしまえばエンジニアへの負担なく問題を早期発見できる、とても効果の高いものです。

プロジェクトのコーディング規約やレビューでの観点のうち明文化されているものがあれば、それをアナライザにはできないか、検討してみてはいかがでしょうか。

最後に。以上のような活動に共感していただけた方、興味を持たれた方、一緒に働いてみようと思ってくれた方。 下記職種で採用しておりますので、ぜひご応募ください。お待ちしております。

https://career.dena.jp/job.phtml?job_code=1618career.dena.jp

*1:診断結果は、GUIではコンソールウィンドウ、CLI (Batch mode) ではビルドログに混ざって出力されます

*2:Unity 2020.2では、診断は当該ファイルのコンパイルもしくはReimportの契機でのみ実行されます(Unity 2020.3.4より、通常のコンパイルステップで動作するように修正されました)

*3:Unity 2020.2では、ルールセットファイルによる重要度の設定変更は、Reimportの契機でのみ反映されます(Unity 2021.1で修正されました)

*4:Unity 2020.2では、CLI実行ではルールセットファイルによる重要度設定は無効です(Unity 2021.1で修正されました)

*5:Packages/下のDLLには、アナライザとして識別させるためのラベル設定ができません

*6:Packages/下にアナライザとして設定したDLL(と.metaファイル)を配置しても、アナライザとして動作しません

*7:その他、Unity 2020.2時点のRoslynアナライザサポート状況はこちらにまとめてあります https://www.nowsprinting.com/entry/2021/04/18/200619

*8:ラベルは、ウィンドウ右下のしおり状アイコンをクリックすることで入力できます。アイコンが表示されていない場合は"Asset Labels"の文字をクリックすると表示されます

*9:Package Managerウィンドウでインポートおよびアップデートできます

*10:Visual Studio Code (VSCode) 用プラグインです。Visual Studio用ではないのでご注意ください

*11:その他の環境でアナライザプロジェクトを作成する場合は、 Roslyn SDK内にあるテンプレート https://github.com/dotnet/roslyn-sdk/tree/main/src/VisualStudio.Roslyn.SDK/Roslyn.SDK/ProjectTemplates/CSharp/Diagnostic もしくはテンプレートリポジトリ https://github.com/nowsprinting/RoslynAnalyzerTemplate を利用するか、こちらの記事を参考に設定してください https://zenn.dev/naminodarie/articles/32973a36fcbe99

*12:Community EditionでもRoslynアナライザは作成できますが、for macではできません

*13:Visual Studio 2019 v16.10ではデバッグ実行の手段が変わるため不要になるようです。参考 https://qiita.com/ryuix/items/36dabbf3c7e4e395e49e

*14:compilation start/endのような対になっているものの呼び出し順は保証されます。また、compilation end actionは必ず最後に1回実行されます

*15:あからじめSymbolActionでコンストラクタのアトリビュートを収集しておき、後でメソッド呼び出し箇所のコールバックで処理する、という二段構えの方法も考えられますが、2つの理由で採用していません。1つ目は、アクションの呼び出し順が未定義であること。2つ目は、IDE上で実行されるときはエディタタブで開かれているファイルを起点とした診断しか行われないため、型引数に使われている型が別ファイルにあるとSymbolActionが呼ばれないためです

*16:インスペクション機能はプロジェクト全体を診断してくれますが、この場合も実行単位はファイルごとのため同様の制限があります

*17:先に紹介したdotnet buildコマンドではこの制限を受けないため、診断されているはずなのにIDEに表示されないときはdotnet buildコマンドを試してみると原因にたどり着きやすいこともあります