본문으로 건너뛰기
고급약 40분

경로 테스트와 데이터 흐름 테스트 — 실현 불가능 경로를 걸러내고 정의-사용 쌍으로 설계한다

기저 경로를 뽑는 절차는 모듈 3에서 배웠다. 여기서는 실제 코드에서 부딪히는 문제를 다룬다. 실현 불가능 경로를 걸러내고 루프를 유한한 케이스로 줄이는 법, 그리고 제어 흐름만으로는 보이지 않는 결함을 잡는 데이터 흐름 테스트 — 변수의 정의-사용(DU) 쌍을 뽑아 all-defs·all-uses·all-du-paths 기준으로 케이스를 설계하고, dd·ur·dk 같은 데이터 흐름 이상을 찾아내는 법을 익힌다.

  • #경로테스트
  • #데이터흐름테스트
  • #DU쌍
  • #순환복잡도
  • #화이트박스

개념

이미 아는 것부터 정리한다

모듈 3(레슨 10)에서 순환 복잡도 V(G) = E - N + 2 로 독립 경로의 개수를 구하고, 제어 흐름 그래프에서 기저 경로 집합을 뽑는 절차를 익혔다. 이 레슨은 그 절차를 다시 설명하지 않는다. 대신 그 절차를 실제 코드에 적용했을 때 반드시 부딪히는 세 가지 문제부터 시작한다.

  1. 뽑아낸 기저 경로 중 실행이 불가능한 경로 가 섞여 있다
  2. 루프가 있으면 경로의 수가 사실상 무한해진다
  3. 모든 경로를 다 지나도 변수가 잘못 쓰인 결함 은 잡히지 않는다

앞의 둘은 경로 테스트를 실무에서 쓸 수 있게 만드는 문제이고, 셋째가 데이터 흐름 테스트가 필요한 이유다.

실현 불가능 경로(infeasible path)

제어 흐름 그래프는 조건들 사이의 관계를 모른다. 그래서 그래프상으로는 존재하지만 어떤 입력으로도 탈 수 없는 경로가 나온다.

int classify(int score) {
    String grade = "";
    if (score >= 90) { grade = "A"; }          // 조건 1
    if (score < 60)  { grade = "F"; }          // 조건 2
    if (grade.equals("A") && score < 60) {     // 조건 3 — 절대 참이 될 수 없다
        log.warn("모순 상태");
    }
    return grade.length();
}

V(G)는 4를 넘지만, 조건 1이 참이면서 조건 2도 참인 경로는 존재하지 않는다. 이런 경로를 붙들고 "테스트 케이스를 못 만들겠다"고 시간을 쓰는 것이 초보자가 가장 많이 하는 낭비다.

판정 절차는 이렇다.

단계할 일결과
1경로를 따라가며 각 분기의 조건을 수식으로 모은다score >= 90 ∧ score < 60
2모은 조건이 동시에 참이 되는 입력이 있는지 본다없다
3없으면 실현 불가능으로 표시하고 근거를 기록한다경로 P3: infeasible
4실현 불가능 경로가 많으면 코드 냄새로 보고한다조건 3은 죽은 코드 후보

4단계가 QA의 진짜 산출물이다. 실현 불가능 경로가 많다는 것은 대개 도달할 수 없는 방어 코드 이거나 조건이 중복 이라는 뜻이고, 둘 다 리뷰에서 지적할 거리다. 100% 경로 커버리지를 달성할 수 없다는 사실 자체는 문제가 아니다 — 안전 규격에서도 구조 커버리지 미달성 부분은 "왜 도달 불가능한지"를 정당화 문서로 대신한다(모듈 35).

루프를 유한하게 자르기

루프가 하나만 있어도 반복 횟수만큼 경로가 늘어난다. 실무에서는 경계 값으로 자른다.

루프 유형테스트할 반복 횟수
단순 루프(최대 n회)0회(진입 안 함), 1회, 2회, 임의의 m회(1 < m < n), n-1회, n회, n+1회(가능하면)
중첩 루프안쪽 루프부터: 바깥은 최소로 고정하고 안쪽을 위 기준으로 → 바깥으로 확장
연결 루프서로 독립이면 각각 따로, 의존하면 중첩 루프처럼
비구조적 루프테스트 설계 대상이 아니라 리팩터링 대상 으로 보고한다

0회와 1회가 가장 많은 결함을 낸다. 빈 리스트로 진입했을 때 초기화되지 않은 누적 변수가 그대로 반환되는 결함, 첫 반복에서만 다른 처리를 하는 코드의 경계 결함이 여기서 나온다.

데이터 흐름 테스트 — 제어가 아니라 값을 따라간다

경로 테스트는 "어디를 지나갔는가"를 본다. 데이터 흐름 테스트는 "어떤 값이 어디서 만들어져 어디서 쓰였는가" 를 본다. 변수 하나에 대해 코드의 각 지점은 셋 중 하나다.

표기이름뜻
d정의(definition)변수에 값이 대입된다. 선언·대입·입력·참조 전달
u사용(use)변수의 값을 읽는다
k소멸(kill)변수가 유효 범위를 벗어나거나 무효화된다

사용은 다시 둘로 나뉜다.

  • c-use(계산 사용) — 계산식·출력·인자 전달처럼 값이 계산에 쓰이는 경우. total = price * qty 의 price
  • p-use(술어 사용) — 조건식에 쓰여 제어 흐름을 가르는 경우. if (qty > 0) 의 qty

p-use를 따로 세는 이유는, 조건에 쓰인 변수는 양쪽 분기 모두로 이어지는 두 개의 DU 쌍을 만들기 때문 이다.

DU 쌍 뽑기 — 실제 코드로

 1  def settle(order, coupon, points):
 2      discount = 0                       # d(discount)
 3      if coupon is not None:             # p-use(coupon)
 4          discount = coupon.amount       # d(discount)
 5      total = order.amount - discount    # c-use(discount), c-use(order)
 6      if points > 0:                     # p-use(points)
 7          total = total - points         # c-use(total), d(total)
 8      if total < 0:                      # p-use(total)
 9          total = 0                      # d(total)
10      return total                       # c-use(total)

변수 discount 의 정의-사용 쌍을 표로 만든다. 정의 지점에서 사용 지점까지 가는 동안 그 변수가 다시 정의되지 않는 경로 만 유효한 DU 경로다(이것을 definition-clear path라고 부른다).

변수정의사용사용 종류정의-소멸 없는 경로이 쌍을 태우는 입력
discount25c-use2→3→5 (3이 거짓)coupon=None
discount45c-use4→5coupon 있음
total56p-use5→6항상
total57c-use5→6→7points>0
total58p-use5→6→8 (6이 거짓)points=0
total78p-use7→8points>0
total710c-use7→8→10 (8이 거짓)points>0, 결과 ≥ 0
total910c-use9→10결과가 음수가 되는 입력
total510c-use5→6→8→10 (둘 다 거짓)points=0, 금액 ≥ 0

2 → 5 쌍이 핵심이다. 쿠폰이 없을 때 discount = 0 으로 초기화한 값이 실제로 쓰이는지를 확인하는 케이스이고, 분기 커버리지만 채우면 이 쌍을 안 타고도 100%가 나올 수 있다. 4 → 5 쌍만 타는 테스트로 3번 줄의 참·거짓을 모두 채울 수 있기 때문이다.

데이터 흐름 커버리지 기준의 계층

기준요구 사항강도
all-defs각 정의에 대해 적어도 하나의 사용에 도달하는 경로를 최소 1회가장 약함
all-c-uses각 정의에서 모든 계산 사용으로 가는 경로를 1회 이상
all-p-uses각 정의에서 모든 술어 사용으로 가는 경로를 1회 이상(참·거짓 양쪽)
all-uses각 (정의, 사용) 쌍을 최소 1회실무 상한선
all-du-paths각 쌍에 대해 정의-소멸 없는 모든 경로 를 1회씩경로 폭발. 안전 규격 일부에서만

포함 관계를 알아 두면 협상이 쉬워진다. all-uses는 분기 커버리지를 포함하지 않는다. 예외 분기가 어떤 변수도 쓰지 않으면 all-uses를 100% 채워도 그 분기는 안 타진다. 그래서 실무에서는 분기 커버리지 + all-uses 를 같이 목표로 잡는다.

데이터 흐름 이상(anomaly) — 도구가 잡아 주는 것들

DU 쌍을 손으로 뽑는 것은 비싸다. 다행히 정의-사용 순서가 이상한 패턴 은 정적 분석기가 잡는다. 연속하는 두 동작의 조합으로 분류한다.

패턴뜻위험도대표 경고
ur정의 전에 사용버그use of uninitialized variable, Java의 "variable might not have been initialized"
dd정의하고 다시 정의(그 사이 사용 없음)의심dead store, value never read
dk정의하고 사용 없이 소멸의심unused variable, assigned but never used
du정의 후 사용정상—
ku소멸 후 사용버그use-after-free, use-after-move, 닫힌 커넥션 사용
kk소멸 후 소멸버그double free, double close
/* ku 와 kk — C에서 데이터 흐름 이상이 곧 크래시가 되는 예 */
char *buf = malloc(64);
strcpy(buf, "hello");
free(buf);              /* k */
printf("%s\n", buf);    /* u  → ku: use-after-free */
free(buf);              /* k  → kk: double free */
// dd — 대입이 즉시 덮어써진다. 대개 잘못된 분기 조건이나 병합 실수의 흔적이다
String status = "PENDING";
status = resolveStatus(order);   // 앞 줄의 값은 한 번도 읽히지 않았다

dd 와 dk 는 그 자체로는 동작을 깨뜨리지 않는다. 하지만 원래 쓰려던 변수를 잘못 썼거나, 있어야 할 분기가 빠졌다는 신호 인 경우가 많다. 리뷰에서 "이 대입은 왜 필요한가"를 물어야 하는 자리다.

JVM·JS·Python처럼 GC가 있는 환경에서는 ku·kk 가 메모리 문제로 나타나지 않지만, 자원 객체 에서 똑같은 모양으로 나타난다. 닫힌 Connection·FileStream을 다시 쓰거나, 두 번 닫는 코드가 그것이다. 레슨 4에서 이 계열을 동적 분석으로 잡는다.

언제 여기까지 하는가

DU 쌍을 표로 뽑는 작업은 비용이 크다. 전 코드에 적용하는 것이 아니라 대상을 고른다.

  • 상태 변수가 여러 분기에서 갱신되는 함수(위 예의 total 같은)
  • 계산 결과가 돈·재고·권한처럼 틀리면 되돌리기 어려운 값
  • 순환 복잡도가 높은데 분기 커버리지는 높게 나오는 함수 — 분기는 다 탔는데 결함이 계속 나오는 구간
  • 안전 규격에서 요구되는 단위(ISO 26262·IEC 61508의 기법 표에 데이터 흐름 분석이 포함된다)

실무에서 왜 필요한가

분기 커버리지 100%인 코드에서 계속 결함이 나오는 상황이 이 기법이 필요한 순간이다. "커버리지가 100%인데 왜 버그가 나오죠"라는 질문에 "커버리지는 실행 여부만 세니까요"라고 답하는 것과, "이 함수의 DU 쌍 9개 중 3개가 테스트되지 않습니다. 2번 줄에서 초기화한 discount가 실제로 쓰이는 경로를 타는 케이스가 없어요" 라고 답하는 것은 회의에서 전혀 다른 무게를 가진다.

실현 불가능 경로 판정은 또 다른 쓸모가 있다. 경로를 따라가다 "이 조건은 어떤 입력으로도 참이 될 수 없다"를 발견하면 그것은 테스트로 잡을 수 없는 결함을 설계 단계에서 잡은 것 이다. 죽은 코드, 중복 조건, 잘못된 부정 조건이 이 방식으로 드러난다.

실습 과제

과제 1 — 실현 불가능 경로 걸러내기 (20분)

아래 함수의 제어 흐름 그래프를 그리고 V(G)를 구한 뒤, 기저 경로를 뽑는다(절차는 모듈 3 레슨 10 참고).

String route(int amount, boolean isMember, boolean hasCoupon) {
    String plan = "STANDARD";
    if (amount > 100_000) plan = "PREMIUM";
    if (!isMember && hasCoupon) plan = "INVALID";
    if (plan.equals("PREMIUM") && !isMember) plan = "GUEST_PREMIUM";
    if (plan.equals("INVALID") && amount > 100_000) plan = "REVIEW";
    return plan;
}

각 경로에 대해 조건식을 모으고, 실현 가능/불가능을 판정한다. 실현 불가능한 경로가 있다면 그것이 코드의 어떤 문제를 가리키는지 한 문장으로 쓴다.

과제 2 — DU 쌍 표 만들기 (25분)

과제 1의 plan 변수에 대해 본문 형식의 DU 쌍 표를 완성한다. 그다음:

  1. all-defs를 만족하는 최소 테스트 케이스 집합을 만든다
  2. all-uses를 만족하는 테스트 케이스 집합을 만든다
  3. 두 집합의 크기를 비교하고, all-defs만 충족했을 때 놓치는 쌍이 무엇인지 적는다
  4. 1에서 만든 집합으로 분기 커버리지를 계산한다. 100%가 되는가?

과제 3 — 이상 패턴 사냥 (15분)

자신이 관여하는 저장소에서 정적 분석 경고 중 dead store, unused variable, value never read, might not have been initialized 계열을 10건 모은다. 각각을 dd·dk·ur 로 분류하고, 그중 실제 결함의 흔적인 것 과 단순 정리 대상인 것 을 가른다. 실제 결함의 흔적으로 판정한 건은 그것을 재현하는 테스트 케이스를 한 개 쓴다.

자가 체크리스트

  • 기저 경로 중 실현 불가능 경로를 조건식 수집으로 판정할 수 있다
  • 실현 불가능 경로가 많다는 사실을 코드 문제의 신호로 보고할 수 있다
  • 루프를 0·1·2·m·n·n+1 기준으로 유한한 케이스로 줄일 수 있다
  • 코드의 각 지점을 d·u(c-use/p-use)·k로 표시할 수 있다
  • 변수 하나에 대해 definition-clear path 기준으로 DU 쌍 표를 만들 수 있다
  • all-defs·all-uses·all-du-paths의 강도 차이와 포함 관계를 설명할 수 있다
  • all-uses가 분기 커버리지를 포함하지 않는 이유를 예로 들 수 있다
  • ur·dd·dk·ku·kk 이상을 코드에서 식별하고 위험도를 판정할 수 있다
  • 데이터 흐름 테스트를 적용할 대상 코드를 기준을 갖고 고를 수 있다

흔한 실수

  • 실현 불가능 경로에 매달려 테스트 케이스를 만들려 한다. 조건식을 모아 모순을 확인하고, 근거를 기록한 뒤 다음으로 넘어간다.
  • 경로 커버리지 100%를 목표로 잡는다. 루프가 있으면 도달 불가능한 목표다. 기저 경로 + 루프 경계가 실무 목표다.
  • DU 쌍을 뽑으면서 중간 재정의를 무시한다. 정의에서 사용까지 그 변수가 다시 정의되면 그 경로는 유효한 DU 경로가 아니다.
  • p-use를 c-use와 같이 센다. 조건에 쓰인 변수는 참·거짓 양쪽 경로 각각이 별도의 쌍이다.
  • 정적 분석의 dead store 경고를 "스타일 문제"로 일괄 무시한다. 잘못된 변수에 대입한 흔적인 경우가 섞여 있다.
  • 모든 함수에 데이터 흐름 테스트를 적용하려 한다. 비용이 커서 금세 포기하게 된다. 돈·재고·권한을 다루는 상태 변수부터 고른다.

참고 자료

낯선 말 풀이

이 레슨에 나온 약어입니다. 본문에서도 처음 나올 때 점선으로 표시해 두었습니다 (제목과 링크 글자는 그대로 둡니다).

QA — 품질보증
만든 것이 쓸 만한지 확인하고, 애초에 덜 망가지게 만드는 방법을 정하는 일. "테스트"는 그 일의 일부다.
JVM — 자바 가상 머신
자바로 만든 프로그램을 돌려 주는 바탕 프로그램.
JS — 자바스크립트
웹 화면을 움직이게 만드는 프로그래밍 언어. 버튼을 눌렀을 때 일어나는 일을 적는다.
GC — 가비지 컬렉션
더 이상 쓰지 않는 메모리를 프로그램이 알아서 치우는 일.
ISO — 국제표준화기구
여러 나라가 모여 "이건 이렇게 하자"를 문서로 정하는 단체. 그 문서 자체를 번호로 부른다.
IEC — 국제전기기술위원회
전기·전자 분야의 국제 규격을 만드는 단체.
ISTQB — 국제 소프트웨어 테스팅 자격 위원회
테스터 자격 시험을 만들고 용어를 정리하는 국제 단체.
MC/DC — 변형 조건·결정 커버리지
조건이 여러 개 붙은 판단문에서, 각 조건이 혼자서 결과를 바꾸는 경우까지 모두 확인했는지 재는 기준.

용어집 전체 보기

이 레슨의 실습

실습은 별도 저장소에서 진행합니다. 새 탭으로 열립니다.