📝

Terraformのchildモジュールでは基本的に(機能|統合)テストは不要だと考えている話

に公開

結論

terraformのモジュールの機能テストは、不要だと思っている。
ただし、これは以下の条件を満たす場合に限る。

  1. リポジトリ内で閉じた(privateな)childモジュール[1]
  2. モジュールが変更されたら、その利用箇所(rootモジュール)ですぐにplan/applyが走るCICDがセットアップされている

背景

過去、2021 ~ 2025年頃までterraformのモジュールを使うにあたり、terratestを使って自動テストを行ってきた。このterratestは端的に言うとそのモジュールを使って実際に terraform init terraform plan terraform apply terraform destroy を行い、正常に成功するかをテストするためのもので、基本的には期待通りに機能していた。

しかし、やり続けるうちに疑問が湧いてきた。
terraformでは、childモジュールを使わないrootモジュールだけの場合は基本的にテストを書かない(しない)。
なぜなら、実際にインフラへ変更を加える前に変更内容が terraform plan で可視化されるためだ。
基本的に構築されたインフラ要素そのものの設定が期待通りかはアプリケーションを動かしてみないとわからないので、terraformに期待されるのはインフラ要素が意図通りの数・設定で作られるか、になる。
そしてそれはplanで十分なことが99%であるため、機能テストはやりすぎと考えられている。

ではなぜ(child)モジュールの場合だけテストをするか。
これには明確な理由があって、childモジュールの更新タイミングでrootモジュール側でplanされない/できないことが多いからだ。
例えば次のような広く使われているpublicなモジュールではモジュールの更新タイミングと利用側でのplanタイミングはズレる。

こういった場合は、更新内容に問題がないかを確認するために機能テストは行ったほうが良い。

しかし、モジュールの宣言箇所とその利用rootモジュールが同一リポジトリの場合、つまり source = "./../relative/path"のようにローカルパス形式でモジュールを参照している場合、かつ、モジュールの更新後に必ずCICDでplan, applyされる場合、モジュールの機能テストはrootモジュール側のplan, applyで代替できることになる。

機能テストをやめたい理由は他にもある。
それは機能テストの開発・運用コストが低くないことだ。
それがaws, もしくはdatadogのようなサービスのリソースを含む場合、機能テストでは実際にそれらを実際のアカウント/orgで作ることになる。
それらアカウントの用意は当然必要になるし、サービス側の5xxもしくは名前の衝突のような問題でリソースがアカウント上に残り手動でのclean upが必要になるケースがある。

つまるところ、AWSやGoogle Cloudなど実際のクラウドサービスを絡めた機能テストはできるできないで言えばできるものの、コストが高いということが言える。
それでも有効性が高いのであればやる価値があるが、前述の通り有効性が低いケースでは、投資対効果的にやらない、という判断が妥当。最近はそう考えるようになった。

備考

ローカルパス形式でモジュールを参照している場合、かつ、モジュールの更新後に必ずCICDでplan, applyされる場合、と書いたが、実はこれが難しいケースがある。
リポジトリ内のどのファイルが変更されても必ずplan, applyが走るようになっている場合は問題ないが、モノレポ(= たくさんのrootモジュールが1つのリポジトリ内にある)のterraformリポジトリでそれをやると不要にplan, applyが走りコストが膨大になり、不要な待ち時間も発生する。
そこでgithub actionsの on.pathsなどを使ってどのディレクトリ以下が変更されたらplan, applyするかを指定するのだが、複数のrootモジュール間で共有される(child)モジュールが多数になってくると、モジュール間の依存関係を手動で管理するのは非常に大変になる。

そこで作ったのがpaths-filterアクションのための次のスクリプト。

実際のところ、以下が最後の鍵となって、機能テストを書かない決断をした。

generate-terraform-ci-filters.sh
#!/usr/bin/env bash

set -euo pipefail
# set -x # デバッグ用


if [ "$#" -ne 1 ]; then
  echo '引数としてGitリポジトリのrootディレクトリのパスを指定してください' 1>&2
  exit 1
fi

git_root_path=$1


############################################
# local pathモジュールの依存関係からyamlを生成
############################################

./output-terraform-local-path-dependencies.sh ../ > terraform-ci-filters-local-paths.yml

############################################
# ルートモジュールのパスを列挙する
############################################

root_module_paths=$(
  find $git_root_path -name .terraform.lock.hcl ! -path '*/.terraform/*' | # lockファイルのあるディレクトリを探し
    xargs dirname | # 結果からファイル名を取り去って
    sort # 最後ソートして仕上げ
)

{
  for root_module_path in ${root_module_paths[@]}; do
    root_module_relative_path=$(realpath --relative-to="$git_root_path" "$root_module_path")
    cat <<-EOF
$root_module_relative_path:
  - $root_module_relative_path/**/*.tf
  - $root_module_relative_path/**/.terraform.lock.hcl
EOF
  done
} > terraform-ci-filters-base.yml

# yqの習熟度が低いのでここはかなり適当で最適じゃない処理をしている可能性が高い
# あとで余裕があるか知見が溜まったら改善すること
yq eval-all \
  'select(fileIndex == 0) *+ select(fileIndex == 1)' \
  terraform-ci-filters-base.yml \
  terraform-ci-filters-local-paths.yml \
  > terraform-ci-filters.yml
output-terraform-local-path-dependencies.sh
#!/usr/bin/env bash

# 各terraform rootモジュールが依存する[ローカルパスモジュール](https://developer.hashicorp.com/terraform/language/modules/sources#local-paths)を
# すべて出力するスクリプト。
# すべてなので、チャイルドモジュール(= ローカルパスモジュール)が
# 更にローカルパスモジュールのチャイルドモジュールを持っていた場合、
# それも出力される。
#
# 注意点:
#   このスクリプトはモジュールの相互参照を考慮していません。
#   もしそういった構造を対象にした場合、無限ループに入ります。
#   ただ、対処自体はそれほど難しくはないので
#   (処理済みモジュールのマーカーかリストを使えば良い)
#   必要になったら処理を追加しましょう

set -euo pipefail
# set -x # デバッグ用


if [ "$#" -ne 1 ]; then
  echo '引数としてGitリポジトリのrootディレクトリを指定してください' 1>&2
  exit 1
fi

git_root_path=$1

############################################
# ルートモジュールのパスを列挙する
############################################

root_module_paths=$(
  find $git_root_path -name .terraform.lock.hcl ! -path '*/.terraform/*' | # lockファイルのあるディレクトリを探し
    xargs dirname | # 結果からファイル名を取り去って
    sort # 最後ソートして仕上げ
)


############################################
# 再帰的に見ていって、2階層以上も含めた
# すべての依存local path moduleを列挙する関数を定義
############################################

# 対象のrootモジュールに含まれるlocal pathモジュールのsourceパスを再帰的にすべて列挙する関数
list_paths_of_child_local_path_module_recursively () {
  root_module_path=$1 # 対象のrootモジュールのパスを引数として渡す

  {
    child_paths=$(list_paths_of_child_local_path_module_shallow $root_module_path)
    for child_path in ${child_paths[@]}; do
      echo "$child_path"
    done

    for child_path in ${child_paths[@]}; do
      grandchild_or_more_paths=$(list_paths_of_child_local_path_module_recursively $(realpath $root_module_path/$child_path))
      # ↑ realpathを使っているのは、 ./abc/def/../ghi みたいなのを ./abc/ghi にするため
      for grandchild_or_more_path in ${grandchild_or_more_paths[@]}; do
        realpath --relative-to=$root_module_path "$root_module_path/$child_path/$grandchild_or_more_path"
      done
    done
  } | sort | uniq # 重複排除しつつ出力
}

# 対象のrootモジュールに直接含まれるlocal pathモジュールのsourceパスを列挙する関数
list_paths_of_child_local_path_module_shallow () {
  root_module_path=$1 # 対象のrootモジュールのパスを引数として渡す

  {
    child_modules=$(
      cat $root_module_path/*.tf | # 直下のすべてのtfファイルから
      hcledit block list | # ブロック記述を列挙して
      { grep -i '^module' || true; } # その中でmoduleブロックに限定する
      # ↑ true周りのごちゃごちゃはgrep結果が0件でもexit statusを非0にしないための回避策
      # 詳細は https://stackoverflow.com/a/6550543/4006322
    )
    for child_module in ${child_modules[@]}; do
      cat $root_module_path/*.tf | # 直下のすべてのtfファイルから
        hcledit attribute get $child_module.source | # child moduleブロックのsource属性を取り出し
        sed -e 's/"//g' |  # 先頭と末尾の"を削除
        { grep '^\.' || true; }
        # ↑ local path moduleは必ず '.' から始まるので ( https://developer.hashicorp.com/terraform/language/modules/sources#local-paths )
        # それで絞り込む
    done
  } | sort | uniq # 重複排除しつつ出力
}


############################################
# ルートモジュールごとの依存ローカルパスモジュールから
# dorny/paths-filter 用の設定ファイル(yaml)を出力する
############################################

for root_module_path in ${root_module_paths[@]}; do
  paths=$(list_paths_of_child_local_path_module_recursively $root_module_path)

  if [ -z "$paths" ]; then
    echo "$(realpath --relative-to="$git_root_path" "$root_module_path"): []"
  else
    echo "$(realpath --relative-to="$git_root_path" "$root_module_path"):"
    list_paths_of_child_local_path_module_recursively $root_module_path | {
      while IFS= read -r path_of_local_path_module; do
        echo "  - $(realpath --relative-to="$git_root_path" "$root_module_path/$path_of_local_path_module")/**/*"
      done
    }
  fi
done

あとはこれで出力されたファイルを次のようにpaths-filterアクションとmatrixを組み合わせて使用する。

terraform-plan.yml
name: tfplan
on:
  pull_request:
    paths:
      - '**/*.tf'
      - '**/*.lock.hcl'

permissions: {}

jobs:
  # ジョブの依存関係・matrixの設計は
  # https://github.com/dorny/paths-filter/blob/v3.0.2/README.md#conditional-execution
  # の `Use change detection to configure matrix job` を参考にしました
  changes:
    runs-on: ubuntu-24.04
    timeout-minutes: 3
    permissions:
      contents: read # actions/checkoutのため
      pull-requests: read
    outputs:
      working_directory: ${{ steps.filter.outputs.changes }}
    steps:
      - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
      # ↑ terraform-ci-filters.yml を持ってくるため
      - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
        id: filter
        with:
          filters: .github/terraform-ci-filters.yml

  terraform-plan:
    needs: changes
    # https://github.com/bm-sms/jinzaibank-infra/actions/runs/12685956813
    # でのエラーを回避するため、 https://github.com/dorny/paths-filter/issues/66#issuecomment-778267385
    # を参考に以下のifを追記した
    if: ${{ needs.changes.outputs.working_directory != '[]' && needs.changes.outputs.working_directory != '' }}
    strategy:
      fail-fast: false # あるworking directoryで失敗しても他での実行は一旦続けてほしい
      matrix:
        working_directory: ${{ fromJSON(needs.changes.outputs.working_directory) }}
    timeout-minutes: 30 # なにかの問題で終わらなくなりコストが多くかかるのを避けるため指定 & planのtimeoutよりは長くしておく
    runs-on: ${{ matrix.working_directory == 'terraform/other/mysql-inside/development' && format('codebuild-xjb-dev-db-ops-github-actions-runner-{0}-{1}', github.run_id, github.run_attempt) || matrix.working_directory == 'terraform/other/mysql-inside/production' && format('codebuild-xjb-prd-db-ops-github-actions-runner-{0}-{1}', github.run_id, github.run_attempt) || 'ubuntu-24.04' }}
    permissions:
      id-token: write      # aws-actions/configure-aws-credentialsアクション用
      contents: read       # actions/checkoutアクション用
      pull-requests: write # tfcmtがPRに書き込むため
    defaults:
      run:
        working-directory: ${{ matrix.working_directory }}
    steps:
        ...
脚注
  1. childモジュールは他のモジュールからmodule { source = "...." }で参照される側のモジュール。rootモジュールはterraform init / plan / applyするディレクトリその場所のこと。詳細はModules overview | Terraform | HashiCorp Developer ↩︎

株式会社エス・エム・エス

Discussion