ほぼAnsibleで管理している自社クラウドサービスのシステムテスト

# 概要

先日の「[自社クラウドサービスをAnsibleで作った話 recap](https://qiita.com/comefigo/items/1fc1061dd4519bfac0af)」で紹介した通りに自社クラウドサービスのライフサイクル管理はほぼすべてAnsibleが担っています。
当社サービスはシングルテナント構成で、顧客数が増えるのに比例して顧客環境(以下テナントとする)も増えます。
事業がスケールしていくのにつれ、それらのテナントを手作業で管理、メンテしていけるはずがないことから、リリース当初からAnsibleによる自動化で構築していました。現在も機能追加や基盤改善を行う際も原則Ansibleで実装しています。
しかし、テストはついこの間まで手動のままでした。

テストは定期的に基盤更新(設定変更、構成変更、機能追加)やパッチ適用(各種バージョンアップ)を実施しているので、新規もしくはバージョンアップしたテナントが正常に動作しているのかをテストシナリオに沿ってシステムテストを実施しています。

シナリオはサービスメニュー（新規契約、解約、オプション）に沿って構成されており、それらのシナリオを順（直列）にテストしていました。サービスメニューが増えるのにつれ、シナリオが増え、結果的にテストコストも増える未来しかないので、自動テストを導入しました。
Ansibleのシステムテストの話を聞かないので、手動テストからテスト自動化するのあたっての工夫や考え方などを以下にまとめます。皆さんの役に立てれば幸いです。

すべてを書き出したわけではないので、気になることや質問などがありましたらドシドシくださいませ。

# テストケース

サービスメニューに沿ったシナリオベースのテストケースになります。
ざっくりですが、以下のように新規契約、変更、解約の一連のライフサイクルがテストケースになっております。
また、EC2のタグ付けや監視の動作確認、バージョンアップ時の動作確認などの非機能要件も確認内容に含めています。


|No| ケース名                                                         |  確認内容 |
| --- | ---------------------------------------------------------------- |  ----------------- |
| 1 | 新規申込が確定したら、テナントAが作成され、正常に利用できる | ・テナントが正常に作成されること<br/>・サンプルデータが投入されていること<br/>・初期PWでログインできること<br/>・EC2のタグに〇〇が付与されていること<br/>・Zabbix ホストにテナントAが登録されていること |
| 2 | テナントAの契約を更新したら、変更値がテナントAに反映される               | ・ユーザ数が〇〇であること<br/>・〇〇オプションが利用できること |
| 3 | テナントAが解約されたら、対象の環境が利用できなくなり、データが削除される | ・テナントにアクセスできないこと<br/>・データが削除されていること<br/>・Zabbixホストが削除されていること |
| 4 | バージョンxxのテナントAをバージョンyyに更新し、各種機能が正常に利用できる | ・正常にサービスを利用できる。<br/>・異常なアラートが発報されていない<br/>・新規追加された機能が利用できる |
| 5 | テナントAの〇〇サービスを停止すると、▲▲アラートがZabbixから発報される | ・Slack XXXチャンネルに●●のアラートメッセージが受信されていること<br/>・Zabbixの障害一覧に該当の障害があること |
| 6 | テナントAの〇〇サービスを開始すると、▲▲アラートが解決される |・Slack XXXチャンネルに●●のアラートが解決したメッセージが受信されていること<br/>・Zabbixの当該の障害が解決されていること |

上記テストケース`No.1`で**作成されたテナントAに対して順にテスト**していました。
そうすると以下の問題を抱えていました。

- 途中で問題が発生した際に、すべてのテストを最初からやり直すことになる
- ケース数に応じてテスト時間も長くなる
- 複数人で同時実行しづらい

自動テスト化するのにあたり、以下のようにテスト種別ごとに**並列実行**できるようにケース間の依存関係を無くし、これらの問題を解決しました。

| No | テスト種別 |ケース名|確認内容(前述同様のため略)|
| --- | --- | --- | --- |
| 1 | 新規 | 新規テナントAが作成され、正常に利用できる | 略 |
| 2 | 更新 | 新規テナントBが作成され、正常に利用できる | 略 |
| 2-1 | - | テナントBの契約が更新されたら、変更値がテナントBに反映される | 略 |
| 3 | 解約 | 新規テナントCが作成され、正常に利用できる | 略 |
| 3-1 | - | テナントCが解約されたら、対象の環境が利用できなくなり、データが削除される | 略 |
| 4 | バージョンアップ | バージョンxxのテナントDを作成し、正常に利用できる | 略 |
| 4-1 | - | テナントDをバージョンyyに更新し、各種機能が正常に利用できる| 略 |

# 実装

すべてのテストケースが並列実行できるよう`AWS Codebuild`（[AWS Codebuild](https://aws.amazon.com/jp/codebuild/)の詳細は割愛します）を採用しました。
簡単なアーキテクチャは以下のようになっています。
テスト種別ごとに1つのCodebuilldプロジェクトになるようにしました。
Codebuilldでは`buildspec.yml`で定義されたコマンド(下図のbuildspec.ymlを参照)を順に処理していきます。

![qiita2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/30522/6ed729fc-590e-e769-f4d0-fe481e185f51.png)

## buildspec.ymlの解説

上図のbuildspec.ymlの実行順に合わせて以下で解説します。

- Codebuildの実行環境はローカルで開発する際のAnsibleの実行環境（AnsibleのDockerコンテナ）と同様のものを使用しているので、環境差異による問題を減らしています。
- Codebuild実行時にソースは[$CODEBUILD_SRC_DIR](https://docs.aws.amazon.com/ja_jp/codebuild/latest/userguide/build-env-ref-env-vars.html)にクローンされます。なのでローカル開発と同じディレクトリ構成になるようにシンボリックリンクを設け、実行環境の差異を無くしています。（手順1）
- テスト開始前にテスト対象のテナントに関連するすべてのリソース（Zabbixホスト、ログフォルダ、EC2、ルーティング）を初期化することで、ゴミデータによるテスト品質の低下を抑えるようにしています。（手順2）
- 各テストシナリオごとに必要な変数（テナントの初期設定値）をjsonファイルに変換し、各種処理実行時に外部変数として渡すようにしています。変数を変えるだけで振舞を変えられるように標準化を図っています。（手順3）
- 本番環境はAWX経由ですべての処理を実行しているので、最終的なテストはAWX経由で実行します。開発時はスピード重視のため、直接playbookを実行できるようにフラグ（`EXECUTE_ON_AWX`）で処理分岐できるようにしています。（手順4）
- 作成されたテスト用テナントが前提条件に合致した環境なのかを検証することで、テスト品質を担保しています。（手順5）
- 実際に業務で使用されているplaybookを実行します。環境変数に応じてAWX経由で実行します。実行環境は本番環境と同等になるためテスト品質を担保しています。（手順6）
- テストケースに沿って動作、設定確認します。（手順7）
- APIで設定値を確認できないものは、[GhostInspector](https://qiita.com/comefigo/items/c41c25c74158e0630759)によるUIテストでカバーしています。
- 一部デスクトップ用クライアントアプリケーションは別途Windows Power Automate Desktopでテスト実装中です。完全自動化できていない。

# 注意点

- すべてのテストを同時実行するのであれば、APIの同時実行制限があるクラウドサービスにおいて同時実行数の調整やエラー発生時のリトライの調整が必要  
  AWSでさえもAPIの同時実行制限がありますので、要注意！
- テスト環境内に貧弱なシステムがあることで単一障害点になり、テストが詰まる可能性があるので、API同時実行時と同様な対応が必要  
  例えば、性能が低い監視基盤があると多数のテストテナントの監視を捌けずに落ちたり、監視が遅延したりなどが発生する

# 工夫

- AWSを使用している弊社環境特有ですが、テナントをスポットインスタンスではなくオンデマンドインスタンスでテストしています。  
  スポットインスタンスだとAWSの都合によりテスト中にインスタンスの強制停止に遭遇し、思わぬエラーに見舞われる可能性を避けるため。
- 複数環境を想定したテストを実装すること。  
  例えば、テナントの削除ケースで予め2つ以上のテナントが存在し、そのうちの1つのテナントを削除し、もう一つのテナントが削除されないことも確認しているのでテストデータによるテスト不足を防ぐことができます。
- テストケースの項番をテストコード内にも記載することで、検索性を高めている
  テストからテストケースを作成できるのが一番理想

    ```yaml
    - name: "2-1 更新確認"
      ansible.builtin.assert:
        that:
          - a == b
    ```
- assertモジュールに`fail_msg:`を指定することでエラー発生時に原因となった値を表示するようにする
  エラー発生時に比較対象がどのような値なのかが表示されないので、明示的に定義する必要があります。
  **stringフィルター忘れずに！**

    ```yaml
    - name: "2-1 更新確認"
      ansible.builtin.assert:
        that:
          - a == b
        fail_msg: "a: {{ a | string }} , b: {{ b | string }}"
    ```
- localhost以外のホストをテスト対象にしている場合は、`any_errors_fatal: True`を設けることでサイレントエラーを無くす
  テストの実装方法次第ではあるが、`max_fail_percentage`の影響によっては、1部のホストでエラーが発生してもテストが完走してしまうリスクを無くす。
- （一定ではない）時間経過によって確認しなければならないテストの場合は、待ち時間を`pause`で固定するのではなく、ポーリングで状態確認(APIで状況を確認)することで、時間経過のブレによって実行時間の短縮や想定以上の時間を要したことによるエラーを防ぐことができます。
- Linuxのaliasを活用して、playbook実行時のコマンドを短縮化することで、実装者の負担を減らしています。
- すべての処理をAnsibleではなく、一部ShellScriptからAnsibleを実行していることもあります。適材適所。

# 効果

自動テスト化したことで得られた効果は以下の通りになります。

- 以前のテスト時間は準備を含めて1週間/1人。今は準備込みで3日/1人
- 心理的安全性を確保できたので、コード修正しても安心できる
- ちょっとした修正でも容易にテストできるので、品質の向上が得られた