본문 바로가기
Topos Uranos

요약

컴파일되고 그럴듯한 무언가를 출력하는 프로그램이라고 해서 그것만으로 테스트된 것은 아니다. 이 수업은 방법에 따라 테스트하는 법을 가르친다. 명세에서 영역과 경계에 따라 테스트 케이스를 끌어내는 법, 그것들을 실패를 세고 보고하는 자동 테스트 프로그램으로 모으는 법, 프로그램이 올바르다면 일어날 수 없는 일을 assert 매크로로 확인하고 constexpr 함수가 계산해야 하는 값을 컴파일할 때 static_assert 선언으로 확인하는 법, 그리고 올바른 결과가 감추는 정의되지 않은 동작을 찾아내는 새니타이저를 켜고 테스트를 실행하는 법이다. 이 모든 것은 하나의 경고 위에 서 있다. 테스트는 오류가 있음을 보일 수는 있어도, 오류가 없음을 보일 수는 결코 없다.

학습 목표

  1. 함수의 명세에서 테스트 케이스를 끌어낸다. 곧 입력의 분할, 분할 사이의 경계, 특수한 경우를, 각각에서 사후 조건이 요구하는 결과와 함께 끌어낸다.
  2. 얻은 결과를 기대 결과와 비교하고, 멈추지 않고 실패를 하나하나 보고하며, 마지막에 요약과 그것을 반영하는 종료 코드로 끝나는 자동 테스트 프로그램을 작성한다.
  3. 사전 조건과 불변식을 확인하는 데 assert 매크로를 쓰고, -DNDEBUG 옵션으로 컴파일하면 왜 그것이 사라지는지, 왜 그 조건에 부수 효과가 있어서는 안 되는지 설명한다.
  4. static_assert 선언을 constexpr 함수와 함께 써서 컴파일할 때 결과를 테스트하고, 새니타이저를 켜고 테스트를 실행하여 결과에 드러나지 않는 정의되지 않은 동작을 찾아낸다.

테스트란 무엇인가

프로그램을 테스트한다는 것은 고른 입력으로 프로그램을 실행하고, 그것이 하는 일을 해야 했던 일과 비교하는 것이다. 각 테스트 케이스는 두 부분으로 이루어진다. 사전 조건을 만족하는 입력, 그리고 그 입력에 대해 사후 조건이 요구하는 결과이며, 이 결과는 미리 다른 길로(손으로, 다른 공식으로, 알려진 표로) 계산해 둔다. 기대 결과가 없으면 테스트가 아니라 그저 한 번의 실행이다. 출력을 보고 “그럴듯하다”고 여기는 것은 인상이지 비교가 아니다.

그런데 int 타입의 인자 하나를 받는 함수는 사십억 개가 넘는 입력을 받아들이고, 인자 두 개를 받는 함수는 약 1.8⋅10191.8 \cdot 10^{19} 쌍을 받아들인다. 어떤 테스트 모음도 그것을 모두 훑을 수는 없다. 그렇다면 테스트는 가능한 경우 가운데 아주 작은 일부만 살필 뿐이고, 테스트를 모두 통과했다는 사실에서 함수가 올바르다는 결론은 따라 나오지 않는다. 에츠허르 다익스트라는 1970년에 이렇게 말했다. 테스트는 오류가 있음을 보일 수는 있어도, 오류가 없음을 보일 수는 결코 없다. 이것은 “수리논리학 I” 강좌가 개별 경우의 확인과 증명 사이에 긋는 구별과 같다. 어떤 논리식이 몇몇 값에 대해 참임을 확인해도 모든 값에 대해 참임이 증명되지는 않지만, 증명(이 강좌에서는 올바른 반복문 설계 수업의 불변식)은 모든 경우를 한꺼번에 덮는다. 테스트는 그 추론을 대신하지 못한다. 테스트가 찾는 것은 추론이 놓친 오류이다.

여기에서 케이스를 고르는 기준이 나온다. 테스트가 정확성을 확증할 수 없다면, 그 가치는 오류를 찾는 데 있고, 좋은 테스트 케이스란 실패할 가능성이 가장 큰 케이스이다. 작성자가 잘 돌아갈 줄 알고 고른 케이스는 아무것도 알려 주지 않는다. 아래 프로그램은 비어 있지 않은 벡터의 가장 큰 원소를 돌려주어야 하는 함수로 이것을 보여 준다.

C++ 프로그램
#include <print>#include <vector>​// 데이터 목록의 가장 큰 원소.// 사전 조건: values는 비어 있지 않다.// 사후 조건: values의 가장 큰 원소를 돌려준다.int largest(const std::vector<int>& values){    int best{0};    for (const int v : values) {        if (v > best) {            best = v;        }    }    return best;}​int main(){    std::println("{}", largest({3, 9, 4}));    std::println("{}", largest({7}));    std::println("{}", largest({1, 2, 3, 4}));    std::println("{}", largest({-5, -2, -8}));}
출력
9740

처음 세 케이스는 누구나 먼저 쓰는 것이고, 함수는 그것들을 통과한다. 9, 7, 4는 모두 올바르다. 네 번째는 0을 내놓는데, 이는 벡터에 들어 있지도 않은 값이며, 돌려주어야 할 것은 −2-2 값이다. 오류는 9번째 줄에 있다. best 변수가 첫 원소가 아니라 0에서 시작하므로, 함수는 어떤 원소가 양수이거나 0일 때만 올바르다. 전형적인 케이스들은 아무도 고르지 않았는데도 이 성질을 공유하고 있었고, 네 번째 케이스는 일부러 그것을 깨뜨린다. 수정은 int best{values[0]}; 선언이며, 사전 조건이 이를 정당하게 만든다. 사전 조건이 values[0] 원소가 존재함을 보장하기 때문이다.