GigaViewer for Web における Flaky Test に対する取り組み

こんにちは。Webアプリケーションエンジニアの id:handat です。

この記事は『Inside GigaViewer for Apps』連載9回目の記事です。 今回は「GigaViewer for Apps」のバックエンドも担う 「GigaViewer for Web」(以下 GigaViewer) のサーバサイドでのテストの対する取り組みについて紹介します。

GigaViewerでは、Flaky Testが発生が課題となっていました。
Flaky Testとはアプリケーションの実装やテストコードを変更していないにも関わらず、実行するごとに結果が変わり、一定の確率で失敗してしまうテストのことを指します。

GigaViewerのリポジトリは大規模なモノレポで構成されており、並列実行するジョブの実行時間を合計するとバックエンドのテストだけでも1回のCI実行に延べ1時間以上を要します。 開発人数も多いためCIが1日で100回以上実行されることもあり、Flaky Testは開発者の生産性や、CIの実行コストに悪影響を及ぼしています。

この記事では前半でGigaViewerの特徴やテスト方針を踏まえてFlaky Test発生しやすい背景を説明し、後半ではFlaky Testに対してチームとしてどのような取り組みをしているのかを紹介します。

GigaViewer 特有の事情

サービス毎の機能の差

GigaViewerの特徴の一つに、各サービスごとのカスタマイズ性の高さがあります。 実装の多くは連載第4回で紹介されたFeature Flagを使用して処理を分岐していますが、より複雑なサービスごとの要件を満たすためにはFeature Flagだけでは満たせない場合もあります。

サービスごとの要件の違いの例として、ポイントの利用範囲が挙げられます。

あるサービスでは、Web版で購入したポイントをアプリ版で利用したり、逆にアプリ版で購入したポイントをWeb版で利用したりと、異なるプラットフォームで購入したポイントが相互利用できます。 一方、別のサービスでは、Web版で購入したポイントはWeb版でのみ、アプリ版で購入したポイントはアプリ版でのみ利用可能で、ポイントの相互利用はできません。

このようにサービスによって異なる実装する時はサービスごとの設定値を参照したり、次の実装例のようにサービスごとのモジュールを作成して別処理に分けるようにしています。

# GigaViewerのコード上ではサービス(サイト)のインスタンスを`$media`という変数で表しています

# 汎用のPointServiceクラス
my $common_class = 'App::Service::Point';

# `App::Service::Point::{MediaName}`のような規則に沿うサービス固有のクラスが存在するときはそれを使用する
my $point_class  = load_media_or_common_module($common_class, $media);

# 固有クラスのポイント消費処理を呼び出す
$point_class->consume($media, $user, $use_point);

ランダム値を多用するテスト方針

すべての機能とすべてのサービスに対して網羅的なテストを行おうとすると、テストの実行時間やCIの実行コストが膨大になってしまいます。 並列実行することで所要時間の短縮を図っていますが、並列実行でも実行コストは減らすことはできません。

そこで私たちのチームではランダムなデータや値を生成するヘルパー関数を用いてテストを行っています。 毎回網羅的なテストを実行せずとも、期待する動作を示したり、将来の機能追加やコード変更時のデグレードを検知するというテストコードの目的は果たせると考えているからです。

具体的な例として、ユーザーアカウントを作成とポイントを付与するヘルパー関数の実装を見てみましょう。引数を渡さなかった場合にはランダムな値をデフォルトとして設定しています。

# GigaViewerのコード上ではサービス(サイト)のインスタンスを`$media`という変数で表しています

# ユーザーアカウントを作成するテストヘルパーの例
sub create_email_user_account {
    args my $media        => 'Giga::Media',
        my $email_address => { isa => 'Str',                                         default => random_email_address },
        my $password      => { isa => 'Str',                                         default => random_alnum(64) },
        my $status        => { isa => 'Giga::Feature::UserAccount::Account::Status', default => 'active' },
        ;

    # ユーザーアカウントを作成する処理

    return $user_account;    # ユーザー
}

# ポイントを付与するテストヘルパーの例
sub add_point {
    args my $user_account => 'Giga::Feature::UserAccount::Account',
        my $amount        => 'PositiveInt',
        my $platform      => { isa => 'Giga::Types::Platform', default => one_of('web', 'app') },
        ;

    # ポイントを加算する処理

    return $point;           # 追加したポイントのモデル
}

また、テスト内のランダムな要素は値やデータだけでなく、多くのテストでは複数あるサービスの中からランダムな1つのサービスを選択してテストを実施しています。 すべてのメディアからランダムで1サービスを選択して実行するテストや特定のFeature Flagを持つサービスの中からランダムで1サービスを選択して実行するテストが多く実装されています。

Flaky Testが発生する背景

このようにランダムなデータやサービスを用いたテストで効率化する一方で、ランダム性が高いがゆえにFlaky Testが生まれてしまうことがあります。 次のテストコードは、Flaky Testが発生するテストコードの例です。

# GigaViewerのコード上ではサービス(サイト)のインスタンスを`$media`という変数で表しています

# Web版でマンガを購入するテスト
sub purchase_manga: Tests {
    subtest 'ポイントを持っていればマンガを購入できる' => sub {
        # 会員登録機能と購入機能を持つサービスの中からランダムで1サービスを選択する
        my $media = media_with_features('UserAccount', 'Purchase');
        
        # ポイントで購入できるマンガを作成
        my $manga = create_manga(
            media => $media,
            title => 'テストマンガ',
            price => 100,
        );
        
        # ポイントを持つユーザーを作成
        my $user_account = create_email_user_account(
            media => $media,
            email_address => 'gigaviewer@hatena.ne.jp',
        );
        
        # ポイントを付与する
        my $point = add_point(
            user_account => $user_account,
            amount       => 100,
        );
        
        # Web版でマンガを購入できるかのテスト (購入できれば1、できなければ0が返る)
        my $result = Giga::Feature::Shop::Service::Purchase->purchase(
            media        => $media,
            user_account => $user_account,
            manga        => $manga,
            platform     => 'web',
        );
        
        is $result, 1, 'マンガを購入できる';
    }
}

上記のテストでは、ポイントが付与されたプラットフォームを指定していません。 このテストを、Web版とアプリ版でポイントを相互利用できないサービスに対して実行するとどうなるでしょうか?

add_pointで付与されたポイントがアプリ版で付与されていたら、Web版で利用できるポイントがないので購入できないという結果になってしまいます。 これが、GigaViewerでのFlaky Testが発生しやすい要因の一つです。

Flaky Testに対する取り組み

Flaky Testと付き合う

GigaViewerで発生するFlaky Testは多くの場合はアプリケーションの実装に問題はなく、上記の例のようにテストしたいコンポーネント以外の処理で確率的に意図しない挙動をしていることが主な原因でした。

GigaViewerでは、以下の3つの理由からFlaky Testの発生を許容しています。

  1. アプリケーションの特性やテスト方針上、完全にFlaky Testを排除することは難しい。
  2. Flaky Testによって CI が失敗した場合でも再実行するだけで、根本的な解決に向けたアクションが取られることは少ない。
  3. 現在までに、障害対応の振り返りでFlaky Testがリスクとして挙げられたことはない。

Flaky Testを許容する具体的な方法としては、CIでテストを2 回実行するという仕組みを導入しています。 1回目で失敗したテストのみ2回目を実行し、2回目で成功したテストはGFlaky Testとして扱いテストに通過したと扱い、2回目のテストで失敗したテストが存在する場合のみ、CIのステータスを失敗として扱うようにしています。

回数 テスト対象 すべてのテストが成功 失敗したテストがある
1回目 すべてのテスト CI成功 2回目を実行
2回目 1回目で失敗したテスト CI成功 CI失敗

Flaky Testと向き合う

しかし、本来Flaky Testは本当に問題がある箇所を見落としてしまうリスクが高まるため、看過できない問題です。

そこで、Flaky Testを改善していく取り組みとして、検知したFlaky Testをissueに起票しており、より活発に改善活動が行われるような工夫も行っています。

テスト単位でグルーピングして起票する

Flaky Testが発生した場合はテスト単位でissueを起票しています。 issueのタイトルにテスト名を指定することで、どのテストでFlaky Testが発生しているのかを一覧で確認できます。

同じテストで2度目のFlaky Testが発生した場合は、既存のissueにコメントとして追加することでコメント数がそのテストにおけるFlaky Testの発生回数となり、テストごとの発生回数も可視化することができます。

Flaky Testが起票される様子

issueの本文に失敗時の出力や再現に必要な情報をまとめる

issueの本文に失敗時の出力やどのような状況で失敗したのかの情報を記録し、改善活動を行いやすくしています。

起票されたFlaky Testのissueの例

改善活動を行いやすくするためには、local環境で再現できることが重要です。しかし、Flaky Testは不安定に失敗するため、再現が難しいことがあります。

そこで、Flaky Testが発生した条件を再現するためのコマンドを用意し、コピーするだけで再現できるように工夫しています。 GigaViewerでは、テスト実行時のシード値や実行時刻などが不安定さの要因となりやすいため、そのような情報を環境変数として指定することで、CI実行時と同等の条件でテストを実行可能にしています。

# ※`T2_DATETIME_NOW`/`T2_MEDIA_NAME`はGigaViewerのテストで使用している環境変数です。

$ docker-compose exec app \
env T2_RAND_SEED="1746754392457009" T2_DATETIME_NOW="2025-05-09T10:33:13.610+09:00" T2_MEDIA_NAME="" \
carton exec -- prove -lvr t/Flaky/sample1.t
t/Flaky/sample1.t .. 
# Seeded srand with seed '1746754392457009' from environment variable.
# 
# main->consistent
ok 1 - 安定しているテスト {
    ok 1 - 1が得られる
    1..1
}
# 
# main->flaky
not ok 2 - 不安定なテスト {
    not ok 1 - 1が得られる
    # Failed test '1が得られる'
    # at t/Flaky/sample1.t line 15.
    # +-----+----+-------+
    # | GOT | OP | CHECK |
    # +-----+----+-------+
    # | 0   | eq | 1     |
    # +-----+----+-------+
    1..1
}

# Failed test '不安定なテスト'
# at t/Flaky/sample1.t line 16.
LAST_FAILED_TEST_DATETIME=2025-05-09T10:33:13.610+09:00
1..2
# Looks like you failed 1 test of 2.
Dubious, test returned 1 (wstat 256, 0x100)
Failed 1/2 subtests 

Test Summary Report
-------------------
t/Flaky/sample1.t (Wstat: 256 (exited 1) Tests: 2 Failed: 1)
  Failed test:  2
  Non-zero exit status: 1
Files=1, Tests=2,  2 wallclock secs ( 0.01 usr  0.01 sys +  0.62 cusr  0.56 csys =  1.20 CPU)
Result: FAIL

改善活動が活発になると、新たな課題も見えてきました。Flaky Testが改善された後でも、改善前のコミットから枝分かれした feature ブランチがあると、既に修正済みのFlaky Testを検知して不要な issue を起票してしまうことがありました。

そこで、Flaky Testが発生しているブランチが、デフォルトブランチなのか、またはfeatureブランチなのかを区別できるラベル付けを行っています。 こうすることで、デフォルトブランチで発生しているFlaky Testのみを改善が必要なテストとして区別しています。

一定期間再発してないテストはクローズする

一時的に発生していたFlaky Testや、issueに気付かれないまま修正されたFlaky Testが残っていることがあります。 これらのissueはノイズになってしまうので、Flaky Testが一定期間発生していないissueはクローズするようにしています。

Flaky Testのクローズにはactions/staleを利用しており、 最後のコメントから7日間経過時に「7日間失敗してない」のラベルを付与し、その後さらに7日間コメントがなければクローズされます。

jobs:
  close-issues:
    runs-on: ubuntu-latest
    permissions:
      issues: write
    steps:
      - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
        with:
          days-before-issue-stale: 7
          days-before-pr-stale: -1
          days-before-issue-close: 7
          days-before-pr-close: -1
          stale-issue-label: "7日間失敗してない"
          repo-token: ${{ secrets.GITHUB_TOKEN }}
          stale-issue-message: "このテストは7日間失敗してないため、7日後にクローズされます。「自動で閉じないで」ラベルをつけることでクローズされなくなります。"
          close-issue-message: "14日間失敗がなかったので自動で閉じられました。"
          exempt-issue-labels: "自動で閉じないで"
          operations-per-run: 200

終わりに

ここまで、GigaViewer for Web におけるFlaky Testへの取り組みを紹介してきました。 プロダクトの性質上、Flaky Testが発生することは避けられないため短期的にはリスクは許容しつつ、Flaky Testを管理・改善していくことで中長期的なリスクも軽減できています。 連載企画『Inside GigaViewer for Apps』では、このような GigaViewer に関する仕組みをアプリ側・バックエンド側の双方の視点からお届けしていますのでほかの記事もぜひチェックしてみてください!

id:handat 2022年12月中途入社。マンガメディア開発チームでWebアプリケーションエンジニアを務める。