해당 스터디는 90DaysOfDevOps
https://github.com/MichaelCade/90DaysOfDevOps
를 기반으로 진행한 내용입니다.
Day 73 - Introducing the Terraform Test Framework
Terraform 1.6 및 OpenTofu 1.6 버전부터 테라폼 내에 기본적으로 내장된 테스트 프레임워크가 도입되었다.
이전에는 IaC의 통합 및 단위 테스트를 위해 TerraTest(Go 언어 기반)나 Kitchen-Terraform(Ruby 기반)과 같은 서드파티 도구를 사용해야 했지만, 이제는 terraform test 명령어를 통해 테라폼 고유 문법인 HCL만으로 테스트를 수행할 수 있게 되었다.
인프라 엔지니어 역시 소프트웨어 개발 프로세스의 일부이며, 작성한 코드가 의도대로 작동하는지 검증할 필요가 있다.
하지만 테라폼은 일반적인 애플리케이션 코드와는 테스트 방식이 다르다.
테라폼 자체의 내부 함수나 바이너리 동작은 우리가 테스트할 영역이 아니다. (HashiCorp의 몫)

그렇다면, 인프라 엔지니어는 어떤 영역을 검증해야할까?
기본 유효성(Basic Validity):
구문이 올바른가? 논리적으로 파싱 되는가?
인자타입이 맞고, 필수 속성이 존재하는가?
해결: 이는 기존의 terraform validate 명령어로 충분히 검증 가능하다.
단위 테스트(Unit Testing - Rendering):
주어진 입력값에 따라 Configuration이 예상대로 렌더링되는가?
조건부 로직이나 함수가 의도대로 작동하는가?
잘못된 입력이 들어왔을 때 적절히 차단하는가?
해결: 테라폼 테스트 프레임워크의 plan 모드를 주로 사용한다.
통합 테스트(Integration Testing - Functionality):
실제 배포된 인프라가 예상대로 작동하는가? (예: 웹사이트가 200 OK를 반환하는가?)
네트워크 라우팅이나 방화벽 규칙이 올바르게 적용되었는가?
해결: 테라폼 테스트 프레임워크의 apply 모드를 사용한다.
테스트 프레임워크는 새로운 언어(Go, Ruby 등)를 배울 필요 없이 기존 HCL 문법을 그대로 사용한다는 점이 가장 큰 장점이다.
파일 명명 규칙 및 위치
디렉터리: 보통 tests 폴더 내에 위치시킴.
확장자: *.tftest.hcl (또는 JSON 형식의 *.tftest.json)
기본 문법 구조
테스트 파일은 하나 이상의 run 블록으로 구성된다.
run "테스트_이름" {
command = plan # 또는 apply (기본값은 apply)
# 변수 주입 (Mocking Input)
variables {
input_var = "value"
}
# 검증 로직
assert {
condition = <조건식>
error_message = "실패 시 출력할 메시지"
}
}
run 블록: 테스트 실행 단위.
command: plan(빠름, 리소스 미생성) or apply(느림, 실제 리소스 생성)
assert 블록: 조건이 true면 통과, false면 실패 및 에러 메시지 출력.
프레젠테이션에서는 Azure Storage Account에 정적 웹사이트를 배포하는 시나리오를 코드로 구현하였다.
목표: Azure Storage Account 이름 규칙인 소문자만 가능, 특수문자 불가 규칙을 준수하도록 변환 로직을 검증
로직: 사용자 입력 -> 변환 (특수문자 제거 + "data001" 추가 + 소문자화) -> 기대 결과 (testwebsitenamedata001).
먼저, 우리가 검증하고자 하는 테라폼 코드이다.
사용자가 대충 입력한 값을 Azure가 허용하는 형식으로 강제 변환하는 로직이 들어있다.
# main.tf (테스트 대상 코드)
variable "website_name" {
type = string
}
locals {
# 입력값에서 특수문자 제거, 뒤에 data001 추가, 전체 소문자 변환
storage_account_name = lower(format("%sdata001", replace(var.website_name, "/[^a-zA-Z0-9]/", "")))
}
resource "azurerm_storage_account" "example" {
name = local.storage_account_name
# ... 기타 설정 ...
}
위 locals 블록의 storage_account_name은 다음 세 단계의 과정을 거쳐 값을 생성한다.
이 순서가 정확히 작동하는지 확인하는 것이 테스트의 목적이다.
replace(var.website_name, "/[^a-zA-Z0-9]/", ""): 정규식을 사용하여 입력 변수인 var.website_name에서 알파벳(a-z, A-Z)과 숫자(0-9)가 아닌 모든 문자(공백, 특수기호 등)를 제거한다.
format("%sdata001", ...): 1번에서 정리된 문자열 뒤에 data001이라는 접미사를 붙인다.
lower(...): 마지막으로 문자열 전체를 소문자로 변환한다.
(Azure Storage Account는 대문자를 허용하지 않기 때문)
이제 위 로직이 제대로 작동하는지 확인하는 테스트 코드를 작성한다.
# tests/unit.tftest.hcl (단위 테스트 코드)
run "sa_naming_convention_test" {
command = plan # 리소스를 실제 생성할 필요 없으므로 plan 사용
variables {
website_name = "Test Website Name" # 대문자와 공백이 포함된 입력
}
assert {
# 정규식을 사용하여 변환된 이름이 예상 패턴과 일치하는지 확인
condition = can(regex("^testwebsitenamedata001$", local.storage_account_name))
error_message = "스토리지 계정 이름이 예상대로 변환되지 않았습니다."
}
}
command = plan의 의미:
해당 테스트는 실제 클라우드 리소스를 생성할 필요가 없다.
테라폼이 내부적으로 계산(Plan)만 수행하여 local 값이 어떻게 계산되는지만 확인하면 되기 때문에 plan 모드를 사용하여 속도가 매우 빠르다.
테스트 시나리오 (입력 vs 기대값):
입력 (variables): "Test Website Name"이라는 값을 넣었다. 여기에는 대문자와 공백이 포함되어 있어, 그대로 쓰면 Azure에서 에러가 난다.
기대 결과: main.tf의 로직을 통과하면 공백이 사라지고, 소문자로 바뀌고, 접미사가 붙어 testwebsitenamedata001이 되어야 한다.
검증 방법 :
plan 단계가 끝나면 Terraform은 메모리 상에 local.storage_account_name값을 가지고 있다.
regex("^testwebsitenamedata001$", ...) 구문을 통해 계산된 값이 정확히 우리가 기대한 문자열과 일치하는지 비교한다.
일치하면 true를 반환하여 Pass, 일치하지 않으면 error_message를 출력하며 Fail 처리된다.
성공하는 케이스만 테스트하는 것은 반쪽짜리 테스트다.
해당 데모는 "잘못된 입력이 들어왔을 때, 내 코드가 이를 적절히 막아내는가?"를 확인하는 과정이다.
variable "website_name" {
type = string
# 입력값 제한 로직
validation {
condition = length(var.website_name) <= 17
error_message = "웹사이트 이름은 17자 이하여야 합니다."
}
}
condition: 사용자가 입력한 website_name의 길이가 17자보다 작거나 같은지(<=) 확인한다.
이유:
Azure Storage Account 이름은 최대 24자이다.
앞서 진행한 데모의 로직에서 뒤에 data001(7자)을 강제로 붙이기 때문에, 사용자가 입력할 수 있는 최대 글자 수는 24 - 7 = 17자가 된다.
기대 결과: 이 조건이 false가 되면 테라폼은 배포를 중단하고 error_message를 띄운다.
run "length_validation_test" {
command = plan
# 1. 고의로 잘못된 값 입력
variables {
website_name = "ThisNameIsWayTooLongForAzure" # 30자 정도 되는 긴 문자열
}
# 2. 실패 기대 선언
expect_failures = [
var.website_name
]
}
고의적인 오류 주입:
website_name 변수에 17자를 훌쩍 넘기는 긴 문자열을 입력했다.
정상적인 상황이라면 Terraform은 여기서 에러를 뱉어야 한다.
expect_failures의 역할:
보통 테스트 중에 에러가 나면 테스트는 실패로 간주된다.
하지만 해당 블록은 "에러가 나는 것이 정상이다"라고 테라폼에게 알려주는 것이다.
Terraform은 var.website_name의 validation 블록에서 에러가 발생하는지 지켜본다.
에러 발생 시: 유효성 검사가 잘 작동한다고 판단하여 테스트를 Pass 시킨다.
에러 미발생 시: 유효성 검사가 고장 났다라고 판단하여 테스트를 Fail 시킨다.
통합 테스트는 단위 테스트와 달리 실제 배포(apply)를 수행한다.
해당 과정은 코드상의 논리가 아니라 "실제 클라우드 환경에서의 동작을 검증"한다.
해당 테스트는 3단계의 run 블록이 순차적으로 실행되며 상태를 공유한다.
run "setup" {
command = apply
module {
source = "./tests/setup" # 난수 생성 모듈
}
}
목적: 매번 테스트할 때마다 고유한 이름을 쓰기 위해 난수를 생성한다.
방식: apply를 실행하여 random_integer 리소스를 실제로 생성하고, 그 결과값을 Output으로 저장해 둔다.
run "deploy_infrastructure" {
command = apply
variables {
# 1단계의 결과값 참조
website_name = "test-${run.setup.integer}"
}
}
데이터 연결: run.setup.integer를 통해 1단계에서 만든 난수를 가져온다.
입력값 완성: 입력값은 test-[난수값]이 된다.
실제 배포:
command = apply이므로, 테라폼은 Azure에 실제로 접속하여 리소스 그룹, 스토리지 계정, 컨테이너, 블롭(웹사이트 파일)을 모두 생성한다.
해당 과정이 에러 없이 끝나야 다음 단계로 넘어간다.
run "verify_website_200_ok" {
command = apply
variables {
# 2단계의 배포 결과(URL) 참조
target_url = run.deploy_infrastructure.homepage_url
}
# HTTP 요청 헬퍼 모듈
module {
source = "./tests/loader"
}
assert {
# 상태 코드 확인
condition = data.http.main.status_code == 200
error_message = "웹사이트 접속 실패. 코드: ${data.http.main.status_code}"
}
}
URL 획득: 2단계에서 배포된 스토리지 계정의 정적 웹사이트 URL(https://test123data001.web.core.windows.net)을 run.deploy_infrastructure.homepage_url로 가져와 target_url 변수에 넣는다.
HTTP 요청: ./tests/loader 모듈은 내부적으로 data "http" 리소스를 사용하여 해당 URL로 실제 GET 요청을 보낸다.
최종 검증 :
응답받은 HTTP 상태 코드가 200인지 확인한다.
200이면 웹사이트가 정상적으로 배포되어 접속 가능하다는 뜻이므로 테스트 Pass.
404나 403 등이 나오면 배포는 됐지만 설정이 잘못되었다는 뜻이므로 테스트 Fail.
따라서, 정리하자면 사용자가 터미널에서 terraform test를 입력하면 벌어지는 일은 다음과 같다.
Terraform은 tests 폴더를 스캔하여 파일 이름순(알파벳순)으로 정렬한다. (integration... -> unit...)
통합 테스트 파일 실행:
난수 생성 (apply) -> 생성된 난수로 리소스 배포 (apply) -> 배포된 URL로 접속 시도 (apply) -> 200 OK 확인.
테스트가 끝나면 생성했던 리소스들은 자동으로 destroy 되어 정리된다.
(테라폼 테스트의 기본 동작)
단위 테스트 파일 실행:
변환 로직 테스트 (Terraform plan): 메모리상에서 계산해보고 정규식 매칭 확인.
유효성 검사 테스트 (Terraform plan): 일부러 긴 값을 넣어보고 에러가 발생하는지 확인.
모든 과정이 문제없으면 "Success! All tests passed." 메시지를 출력한다.
테라폼 테스트 프레임워크 도입: 별도의 언어(Go, Ruby) 학습 없이 HCL만으로 단위 및 통합 테스트가 가능해졌다.
테스트의 목적 분리:
plan 모드는 로직 및 렌더링 검증 Unit Test
apply 모드는 실제 배포 및 기능 검증 Integration Test
네거티브 테스트 지원: expect_failures 블록을 통해 잘못된 입력에 대한 유효성 검사 로직까지 확실하게 테스트할 수 있다.
순차적 시나리오 구성: run 블록 간 데이터를 주고받으며 실제 인프라 배포부터 검증까지의 흐름을 자동화할 수 있다.
결론: IaC도 소프트웨어 개발의 일부인 만큼, terraform test를 도입하여 인프라 코드의 안정성과 신뢰성을 높여야 한다.