본문으로 건너뛰기

테스트

컨트롤러를 어떻게 테스트하는지, 단위 테스트부터 실제 클러스터를 사용하는 E2E까지 단계별로 다룹니다.

단위 테스트

reconciler를 순수 함수에 가깝게 유지하면 API 호출 없이 로직을 검증할 수 있습니다. 핵심은 상태 계산 로직과 API 호출을 분리하는 것입니다.

// 로직만 분리
fn desired_configmap(obj: &MyResource) -> ConfigMap {
ConfigMap {
metadata: ObjectMeta {
name: Some(format!("{}-config", obj.name_any())),
namespace: obj.namespace(),
owner_references: Some(vec![obj.controller_owner_ref(&()).unwrap()]),
..Default::default()
},
data: Some(BTreeMap::from([
("key".into(), obj.spec.value.clone()),
])),
..Default::default()
}
}

#[test]
fn test_desired_configmap_name() {
let obj = MyResource::new("test", MySpec { value: "hello".into() });
let cm = desired_configmap(&obj);
assert_eq!(cm.metadata.name.unwrap(), "test-config");
}

#[test]
fn test_desired_configmap_owner_ref() {
let obj = MyResource::new("test", MySpec { value: "hello".into() });
let cm = desired_configmap(&obj);
let refs = cm.metadata.owner_references.unwrap();
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].kind, "MyResource");
}

상태 결정 로직(어떤 ConfigMap을 만들어야 하는지), 에러 분류 로직(일시적 vs 영구적), 조건 판단 로직(reconcile이 필요한지) 등을 단위 테스트로 검증합니다.

Mock 테스트 — tower-test

tower_test::mock::pair()로 가짜 HTTP 레이어를 만들어 Client에 주입합니다. 실제 Kubernetes 클러스터 없이 API 호출 시나리오를 테스트합니다.

ApiServerVerifier 패턴

use tower_test::mock;
use kube::Client;
use http::{Request, Response};
use hyper::Body;

#[tokio::test]
async fn test_reconcile_creates_configmap() {
let (mock_service, handle) = mock::pair::<Request<Body>, Response<Body>>();
let mock_client = Client::new(mock_service, "default");

// API 서버 역할을 하는 태스크
let api_server = tokio::spawn(async move {
// 첫 번째 요청: GET ConfigMap (404 → 없으므로 생성해야 함)
let (request, send_response) = handle.next_request().await.unwrap();
assert!(request.uri().path().contains("/configmaps/test-config"));
send_response.send_response(
Response::builder()
.status(404)
.body(Body::from(serde_json::to_vec(
&not_found_status() // 테스트 헬퍼 — 404 Status 객체 생성
).unwrap()))
.unwrap()
);

// 두 번째 요청: PATCH ConfigMap (생성)
let (request, send_response) = handle.next_request().await.unwrap();
assert_eq!(request.method(), http::Method::PATCH);
send_response.send_response(
Response::new(Body::from(serde_json::to_vec(
&configmap() // 테스트 헬퍼 — 예상 ConfigMap 객체 생성
).unwrap()))
);
});

let ctx = Arc::new(Context { client: mock_client });
let obj = Arc::new(test_resource()); // 테스트 헬퍼 — MyResource 테스트 객체 생성
let result = reconcile(obj, ctx).await;
assert!(result.is_ok());

api_server.await.unwrap();
}

한계

한계설명
순서 의존요청 순서를 정확히 맞춰야 합니다. 순서가 바뀌면 테스트가 실패합니다
설정 장황다중 요청 시나리오에서 mock 설정 코드가 길어집니다
watcher mockwatcher 스트림을 mock하려면 추가 설정이 필요합니다

mock 테스트는 reconciler가 올바른 API 호출을 하는지 검증할 때 유용합니다. 하지만 복잡한 시나리오에서는 통합 테스트가 더 적합합니다.

통합 테스트 — k3d

실제 Kubernetes 클러스터에서 컨트롤러를 실행하고 결과를 검증합니다. k3d는 가벼운 Kubernetes 클러스터를 로컬에서 실행합니다.

클러스터 준비

# k3d 클러스터 생성 (로드밸런서 불필요)
k3d cluster create test --no-lb

# CRD 등록
kubectl apply -f manifests/crd.yaml

테스트 코드

use kube::runtime::wait::await_condition;

#[tokio::test]
#[ignore] // CI에서만 실행
async fn test_reconcile_creates_resources() {
let client = Client::try_default().await.unwrap();
let api = Api::<MyResource>::namespaced(client.clone(), "default");

// 리소스 생성
api.create(&PostParams::default(), &test_resource()).await.unwrap();

// 상태 수렴 대기
let cond = await_condition(api.clone(), "test", |obj: Option<&MyResource>| {
obj.and_then(|o| o.status.as_ref())
.map(|s| s.phase == "Ready")
.unwrap_or(false)
});
tokio::time::timeout(Duration::from_secs(30), cond)
.await
.expect("timeout waiting for Ready")
.expect("watch error");

// 자식 리소스 확인
let cm_api = Api::<ConfigMap>::namespaced(client, "default");
let cm = cm_api.get("test-config").await.expect("ConfigMap not found");
assert_eq!(cm.data.unwrap()["key"], "expected-value");

// 정리
api.delete("test", &DeleteParams::default()).await.unwrap();
}

await_condition()은 watch 스트림을 열고 조건이 만족될 때까지 대기합니다. tokio::time::timeout()으로 감싸서 무한 대기를 방지합니다.

CI 설정 (GitHub Actions)

.github/workflows/integration.yml
jobs:
integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: AbsaOSS/k3d-action@v2
with:
cluster-name: test
- run: kubectl apply -f manifests/crd.yaml
- run: cargo test --test integration -- --ignored

E2E 테스트

컨트롤러를 Docker 이미지로 빌드하고 실제로 배포한 뒤 동작을 검증합니다. 통합 테스트와 달리 컨트롤러가 프로세스 내부가 아닌 Pod로 실행됩니다.

# 1. 이미지 빌드
docker build -t my-controller:test .

# 2. k3d 클러스터에 이미지 로드
k3d image import my-controller:test -c test

# 3. 컨트롤러 배포
kubectl apply -f manifests/deployment.yaml

# 4. 컨트롤러 준비 대기
kubectl wait --for=condition=available deployment/my-controller --timeout=60s

# 5. 테스트 리소스 적용
kubectl apply -f test-fixtures/

# 6. 상태 수렴 확인
kubectl wait --for=jsonpath='.status.phase'=Ready myresource/test --timeout=60s

E2E 테스트는 RBAC, 리소스 제한, health probe, graceful shutdown 등 배포 환경에서만 검증 가능한 항목을 확인합니다.

비교 정리

단계속도필요 환경검증 범위
단위밀리초없음상태 계산 로직
Mock없음API 호출 시나리오
통합k3dreconcile 흐름, CRD 등록
E2Ek3d + DockerRBAC, 배포, 전체 시스템

단위 테스트를 가장 많이 작성하고, mock과 통합 테스트로 핵심 시나리오를 검증하며, E2E는 배포 파이프라인에서만 실행하는 피라미드 구조가 일반적입니다.