프로그램 테스트와 오류 감지 및 처리 (assert문)

소프트웨어가 실제로 예상대로 작동하는지 여부를 결정하는 프로세스를 흔히 소프트웨어 테스트(검증)라고 한다.

살짝 먼저 보니까 디버그를 공부했던 내용과 비슷한것 같다.

https://guhonga.tistory.com/51

 

디버깅 전략과 통합 디버거 사용

이 주제를 공부하는 것으로 복잡한 프로그램에도 문제를 진단하고 해결하는 능력이 향상되기를..  디버깅이 필요할 때 오류는 일반적으로 구문 오류와 의미 오류(논리 오류)라는 두 가지 범주

guhonga.tistory.com

 

 

 

단위 테스트

프로그램을 작은 조각으로 테스트하는 것이다.

작은 함수나 클래스를 작성할 때 마다 즉시 컴파일하고 테스트하는 방식으로 생각하자. 그러면 마지막 테스트 이후 변경해야 할 점이 적어진다.

 

이렇게 코드의 "단위"가 올바른지 확인하기 위해 코드의 작은 부분을 개별적으로 테스트하는 것을 단위 테스트 라고 한다.

자주 컴파일하는 습관을 들이자.

 

 

 

하지만 프로그램이 점점 길어질수록 이런 방식은 더이상 힘들다. 이런 방식은 각 클래스에 통합하기 전에 테스트할 때 어울리는 듯 하다.

이를 보완할 수 있는 방법 중 하나로 비공식 테스트가 있다.

거창한건 아니고, 그냥 코드 단위(함수, 클래스)를 작성한 후 방금 추가된 단위를 따로 테스트하는 코드를 작성해서 테스트가 통과되면 테스트를 지우는 것이다. 

이 방식은 내가 유니티로 게임 만들때 동작 하나하나 마다 로그를 출력하게 했던것과 비슷한 느낌인듯 하다.

 

 

 

 

 

테스트 보존

임시 테스트는 분명 좋고 괜찮은 방법이지만, 어느 시점에 문득 동일한 코드를 다시 테스트하고 싶을 수도 있다는 사실을 고려하지 않는다.

 

예를들어 새로운 기능을 추가한 후, 이미 작동하고 있던 기능이 여전히 잘 작동하는지 확인하고 싶을 때가 있다.

이런 경우 임시 테스트 코드를 삭제하는 대신, 테스트를 testVowel() 함수로 이동시키자.

더보기
bool isLowerVowel(char c)
{
    switch (c)
    {
    case 'a':
    case 'e':
    case 'i':
    case 'o':
    case 'u':
        return true;
    default:
        return false;
    }
}

// Not called from anywhere right now
// But here if you want to retest things later
void testVowel()
{
    std::cout << isLowerVowel('a') << '\n'; // temporary test code, should produce 1
    std::cout << isLowerVowel('q') << '\n'; // temporary test code, should produce 0
}

int main()
{
    return 0;
}

 

 

 

 

 

테스트 기능 자동화

위의 테스트 보존은 실행 시 결과를 수동으로 확인해야 하는 단점이 있다.

자동화는 테스트와 예상 결과를 모두 포함하고 이를 비교하는 테스트 함수를 작성하는 방식이다.

더보기
bool isLowerVowel(char c)
{
    switch (c)
    {
    case 'a':
    case 'e':
    case 'i':
    case 'o':
    case 'u':
        return true;
    default:
        return false;
    }
}

// returns the number of the test that failed, or 0 if all tests passed
int testVowel()
{
    if (!isLowerVowel('a')) return 1;
    if (isLowerVowel('q')) return 2;

    return 0;
}

int main()
{
    int result { testVowel() };
    if (result != 0)
        std::cout << "testVowel() test " << result << " failed.\n";
    else
        std::cout << "testVowel() tests passed.\n";

    return 0;
}

이런 테스트 방식은 코드 기능을 추가하는 와중에 혹시 코드가 손상되지 않았는지 확인하고 돌아가서 이전 코드를 수정할 때 유용하다.

(점점 귀찮아지는데 결국 이 모든 점을 보완한 assert문을 사용하게 된다.)

 

 

이런 테스트를 했음에도 오류가 발생하는 것은 피할 수 없다. 주로 의미론적 오류들이 발생한다.

오류의 정도가 심해서 프로그램이 계속 작동할 수 없는 경우, 이를 복구 불가능한 오류(치명적 오류)라고 하는데 이럴때는 halt 문을 사용해서 종료시킬 수 있다. ex)  std::exit(1)

 

 

그렇다고 무작정 종료시키면 사용자가 어떤 이유때문에 종료됐는지 알지 못하니 별도의 오류 텍스트를 출력해줘야 한다.

그 방법으로 std::cout이나 std::cerr를 사용하여 출력할 수 있는데, cout은 익숙한 반면 cerr는 뭘까 싶다.

 

이 차이를 이해하기 위해 애플리케이션의 두 가지 유형을 구분할 필요가 있다.

  • 대화형 애플리케이션 실행 후 사용자가 상호 작용하는 애플리케이션이다. 게임 및 음악 앱과 같은 대부분의 독립형 애플리케이션이 이 범주에 속한다.
  • 비 대화형 애플리케이션 작동하는 데 사용자 상호 작용이 필요하지 않은 애플리케이션이다. 이 프로그램의 출력은 다른 응용 프로그램의 입력으로 사용될 수 있다.

 

비 대화형에는 또 도구와 서비스로 나눌 수 있는데, 도구는 즉각적으로 결과를 생성하기 위해 실행된 다음 종료되는 것이고, 서비스는 백그라운드에서 지속적으로 자동으로 실행되는 것이다. 예를들어 바이러스 스캐너 같은 것이 있다.

 

 

이제 다시 돌아와서, std::cout은 대화형 프로그램에서 일반 사용자에게 표시되는 오류메세지의 경우 사용하고
(예시 : "입력이 잘못되었습니다")

 

std::cerr는 문제 진단에 도움되지만 일반 사용자는 알 필요 없는 로그 파일의 표시에 사용한다.
비 대화형 프로그램의 경우 std::cerr로만 오류를 출력한다.

(예시 : 파일x를 성공적으로 열었음, 인코딩 완료 등..)

 

 

 

 

 

assertion

assertion은 프로그램에 버그가 없는 한 참이 되는 표현식이다.

표현식이 true로 평가되면 assertion 문은 아무 작업도 수행하지 않는다.

반대로 조건식이 false로 평가되면 오류메세지가 표시되고 프로그램이 종료된다. (std::abort를 통해)

 

이를 통해 문제가 무엇인지, 코드의 어디에서 문제가 발생했는지 알 수 있다. 디버그에 아주 필수적이다.

기능은 assert 전처리기 매크로로 구현된다. assert()함수의 매개변수 안에 넣어 유효성을 검사하여 사용한다.

assert(found);

 

 

만약 found가 false값을 반환하면 아래 메세지를 출력한다.

Assertion failed: found, file C:\\VCProjects\\Test.cpp, line 34

 

 

아주 효율적인 디버깅 능력이다. 굿...

조금 더 나아가서 found외에 논리연산자를 사용하여 assert문의 이해를 도울 수 있다.

assert(found && "Car could not be found in database");

 

 

 

그런데 assert 이거 그냥 오류처리랑 똑같은거 아닌가? 싶다.

 

아주 유사하지만 조금 다른 점이 있다. 

assertion의 목표는 절대 발생해서는 안 되는 일을 문서화하여 프로그래밍 오류를 잡는 것이다. 

때문에 assertion오류로부터 회복이 불가능하다.(일어나선 안되는 일이 일어난 경우라서..)

코드에 assertion을 넣음으로써 프로그래머는 프로그램 실행 중 특정 조건이 항상 참이라고 기대하는 것을 문서화할 수 있다.

 

 

반면, 오류 처리는 릴리스 구성에서 발생할 수 있는(드물지만) 사례를 적절하게 처리하도록 설계됐다. 

이러한 오류는 복구할 수도 있고 복구할 수 없을 수도 있지만 항상 프로그램 사용자가 이러한 오류를 발견할 수 있다고 가정한다. (assert는 발견 못해도 알아서 프로그램 멈춤)

 

 

즉, 논리적으로 불가능해야 하는 사례를 문서화시키려면 assertion을 사용하자.

 

 

그렇다고 막 남용하면 안된다.

assert 자체가 항상 참일 경우에만 사용해야 하고, assert가 있든 없든 프로그램은 동일하게 실행되어야 한다.

만약 실행될 경우 예치기 않게 종료되므로 데이터 손상이 발생할 가능성이 없는 경우에만 사용한다.

 

 

 

이외에 static_assert라는 타입도 있다. 이것은 런타임이 아닌 컴파일 타임에 확인되는 assertion이다.

 

이것은 <cassert>헤더에 포함된 것이 아니라 그냥 키워드이므로, 헤더 없이 사용할 수 있다.

static_assert(sizeof(long) == 8, "long must be 8 bytes");
static_assert(sizeof(int) >= 4, "int must be at least 4 bytes");

int main()
{
	return 0;
}

 

컴파일 타임에 평가되는 것에서 눈치챘겠지만, static_assert는 컴파일러에 의해 평가되므로 조건은 상수 표현식이어야 한다.

또한 static이므로 전역이니까 코드 파일의 어느 곳에든 배치할 수 있다.