본문 바로가기

기타

무엇을 단위 테스트할 것인가?(Right-BICEP)

이번에도 역시 JUnit을 활용한 단위 테스트 도서를 읽으면서 새로이 배운 점을 적어보려 한다.

 

메서드나 클래스의 코드에 있는 수많은 버그들을 단번에 찾아내기란 쉽지 않고 일일히 들여다본다고 해도 예상치 못한 곳에서 문제가 발생하기 마련이다. 경험이 쌓인다면 대략 어느 부분에서 버그가 나올 지 잘 알게 되겠지만 그렇지 않은 경우 Right-BICEP과 같은 원칙을 통해 테스트 대상을 좁혀나갈 수 있다.

Right, 결과가 올바른가?

https://echa.europa.eu/support/substance-identification/why-is-it-important-to-get-it-right-

테스트 코드는 무엇보다도 기대한 결과를 산출하는지 검증할 수 있어야 한다. 이는 아래와 같은 테스트 코드를 의미한다.

@Test
public void answerArithmeticMeanOfTwoNumbers(){
    ScoreCollection collection = new ScoreCollection();
    collection.add(() -> 5);
    collection.add(() -> 7);
    
    int actualResult = collection.arithmeticMean();
    
    assertThat(actualResult, equalTo(6));
}

5와 7을 가지고 있는 컬렉션의 평균을 구해서 이것이 6((5 + 7) / 2)라는 것을 단언을 통해 검증하고 있다. 만약 여기에 5와 7이 아닌 다른 값(더 크거나, 더 작은)을 넣어서 테스트해보는 등 테스트 환경의 변화를 꾀할 수 있다면 이는 테스트 대상 코드의 내용에 대해서 이해하고 있고 코드가 정상적으로 동작한다는 것을 알고 있다는 의미이기도 하다.

 

그렇지 못한다면 이는 해당 코드의 내용을 완전히 이해하지 못한 것이며 이를 이해하고 정상적으로 동작한다는 것을 알 수 있을 때까지 추가적인 개발은 보류하는 것이 좋다. 단위 테스트를 좀 더 중요하게 여긴다면 작성하는 모든 단위 테스트에 대해서 위처럼 코드에 대한 이해 + 정상적으로 동작하는지 확인 가능 여부를 일일히 확인하며 진행할 수도 있고 프로덕션 코드보다 테스트 코드 작성을 우선으로 하는 TDD를 적용할 수도 있을 것이다.

 

물론 모든 사항(코드의 모든 부분)에 대해서 알 수 있을 때까지 기다리지 않고 현재의 최선의 판단을 내린 후 추후 새로 알게 된 부분이 있다면 그때 개선사항을 반영하는 것도 좋은 방법이다.

Boundary Exception, 경계 조건은 맞는가?

https://www.gbnews.ch/setting-boundaries-in-life/

경계 조건이란 무엇일까? 도서에서는 다음과 같이 그 예를 제시하고 있다.

  • 모호하고 일관성 없는 입력 값(특수문자가 포함된 파일 이름 등)
  • 잘못된 양식의 데이터(도메인이 빠진 이메일 주소 등)
  • 수치적 오버플로우를 일으키는 계산(INT_MAX에 1을 더하기 등)
  • 비거나 빠진 값(0, 0.0, "", null 등)
  • 이성적인 기댓값을 훨씬 벗어나는 값(150세로 입력된 나이 값 등)
  • 중복을 허용해서는 안되는 자료구조에서 값의 중복
  • 정렬 알고리즘에 이미 정렬된 입력값을 넣는 경우(최악의 성능)
  • 요청에 대한 응답의 순서가 시간적으로 맞지 않는 경우

이로는 경계 조건이 무엇인지 명확히 알기 어렵기 때문에 추가적인 검색을 통해 다음과 같이 알 수 있었다.

정확히는 '경계 검사', '경계값 분석 테스트'라고 불리며 어떤 변수가 사용되기 전에 어떤 '경계'내에 위치하는지를 검사하여 메모리 취약점(버퍼 오버플로우 등)을 예방하는 데 그 목적이 있다. 이는 해당 입력값의 경계의 끝부분, 예를 들어 0부터 100까지 입력받는 메소드가 있을 때 해당 경계(0~100)의 양 끝 가장자리(boundary)에서 결함이 발견될 특성이 높다는 특성을 이용한 소프트웨어 기법이다.

https://en.wikipedia.org/wiki/Boundary-value_analysis

대표적으로 단일증감연산자(a++, --b 등)의 잘못된 사용이나 Off-by-one 에러, 범위를 벗어난 배열의 인덱싱을 예로 들 수 있는데 100개의 원소를 가지는 배열의 100번째 원소 참조 등 이런 경계 부분에서 실수를 많이 하는 것을 생각할 수 있다. 이렇게 발생하는 문제를 Edge case라고도 하는데 대부분의 경우 잘 일어나지 않지만 아주 극단적인(extreme) 경우 내부 알고리즘이나 프로시저에서 발생할 수 있기 때문에 이들을 검사하기 위해 단위 테스트를 수행하게 되는 것이다.

 

즉 소프트웨어 로직에서 오류를 일으킬 수 있는 극단적인 입력값을 검사하라는 말로 요약할 수 있을 것이다. 위에서 작성한 두 수의 평균을 구하는 테스트의 경우 정수 대신 null값을 넣는다거나 자료형의 최댓값과 1을 더한 결과를 전달한다거나 아무런 수도 넣지 않아 0으로 나누게 되는 등의 단위 테스트를 수행함으로써 프로덕션 코드에서 발생할 수 있는 여러가지 문제에 대해 미리 인지하여 조건문이나 보호절(guard clause)을 넣어 이를 개선하도록 할 수 있다.

 

경계 조건에 대해서는 CORRECT라는 두문자어가 있는데 이는 다음과 같다.

  • Conformance(준수): 값이 기대한 양식을 준수하고 있는가?
  • Ordering(순서): 값의 집합이 적절하게 정렬되거나 정렬되지 않았나?
  • Range(범위): 이상적인 최솟값과 최댓값 안에 있는가?
  • Reference(참조): 코드 자체에서 통제할 수 없는 어떤 외부 참조를 포함하고 있는가?
  • Existence(존재): 값이 존재하는가? null이 아닌가? 0이 아닌가?
  • Cardinality(기수): 정확히 충분한 값들이 있는가
  • Time(시간): 모든것이 순서대로 정확한 시간에 일어나는가?

이 항목들에 대해 테스트하는 메소드를 작성하여 이를 위반했을 때 일어날 수 있는 일을 알아보는 것으로 추후 다른 포스트에서 다루겠다.

 

(참고자료)

공대위키 경계값 분석 테스트

위키피디아 경계 검사

 

Inverse Relationship, 역 관계를 검사할 수 있는가?

https://www.thoughtco.com/inversion-definition-1209968

때로는 논리적인 역 관계를 적용하여 행동을 검사할 수 있는데 예를 들어 직접 만든 제곱근 함수가 수학 라이브러리의 제곱근 함수가 계산한 결과와 일치하는지 확인함으로써 이 코드가 제대로 동작하는지를 검증하는 것이다. 종종 수학 계산에서 사용되며 곱셈의 결과를 나눗셈을 통해 반대로 원래 값으로 되돌리면서 제대로 연산되었는지 확인하는 등을 예로 들 수 있다.

Cross-check, 다른 수단을 활용하여 교차 검사할 수 있는가?

https://www.freepik.com/free-vector/check-cross-signs-paint-design_1136697.htm

이는 위의 역 관계를 검사하는 항목와 유사하지만 문제를 해결하는 방법, 해법을 여러개를 적용하여 결과값이 동일한지 검사한다는 점에서 차이가 있다. 모든 문제에는 특별한 경우가 아닌 경우 여러가지 해법이 존재하나 그 중 실제로 사용되는 것은 '1등' 해법만이 해당된다. 이 1등 해법을 고르는 데는 시간 복잡도나 공간 복잡도 혹은 기타 지표들에 의해 결정되겠지만 나머지 해법들은 그 지표를 만족하지 못할 뿐 문제를 해결하지 못한다는 것은 아니다.

 

예를 들어 비정렬된 데이터를 정렬하는 데에는 여러 정렬 알고리즘이 존재하고 그 중 가장 빠른 것만을 사용하기 마련이다. 가장 기초적인 버블 정렬부터 퀵 정렬까지 요구되는 시간 복잡도와 공간 복잡도는 천차만별이며 그에 따라 상황에 맞는 정렬을 사용하게 되는데 중요한 것은 어떤 정렬이던간에 정렬된 결과는 동일하다는 것이다.

 

즉 퀵 정렬로 정렬된 데이터들이 정말 정렬된 것인지 의문이 든다면 버블 정렬로 정렬된 데이터와 비교하여 제대로 정렬된 것인지 검증하는 "교차 검증"을 할 수 있다는 것을 의미한다. 꼭 정렬만이 아니라 수식 계산 알고리즘이나 물건 집계 시스템같은 경우 외부 수학 라이브러리를 사용하여 알고리즘과 결과값이 동일한지 확인한다거나 각 시스템별로 동일한 집계 값을 가지는지 확인하는 등 교차적으로(cross) 확인하는 것이다.

Error Conditions, 오류 조건을 강제로 일어나게 할 수 있는가?

https://uxplanet.org/how-to-write-a-perfect-error-message-da1ca65a8f36

악조건, 오류 상황에서도 시스템이 제대로 동작하는지 확인하고자 한다면 이러한 오류들을 강제로 발생시켜야 한다. 단순히 유효하지 않은 매개변수부터 특정 네트워크 오류(400, 500번대 에러 등)까지 여러가지 제약사항들을 생각할 수 있는다. 가득 찬 메모리, 디스크 공간, 서버와 클라이언트 간 시간차, 네트워크 가용성, 시스템 로드율, 비디오 해상도 등 여러가지 시나리오로 오류가 발생할 수 있으며 이들을 실제로 발생시키지 않고도 시뮬레이션 할 수 있는 기법이 몇 번 언급했던 목 객체(Mock Object)를 통해서 수행될 수 있다.

 

중요한 것은 단위 테스트는 프로덕션 코드의 로직에 대한 커버리지만을 다루는 것이 아니라 예기치 못한 상황에서 발생할 수 있는 에러에 대해서도 미리 확인하고 대처할 수 있도록 하는 것이다.

Performance Characteristics, 성능 조건은 기준에 부합하는가?

https://thoughtsaroundwethepeople.wordpress.com/tag/performance-management/

프로그램을 개발하다 보면 변경사항이 적용된 이후 성능이 급격하게 떨어지거나 다른 문제가 발생하는 경우가 있는데 대부분의 프로그래머들은 자의적으로 이곳에서 병목 현상을 유발하고 있으리라 추측한다. 정확하지 않은 추측의 경우 불필요한 시간 낭비와 오히려 더 성능이 악화될 수도 있는 결과를 가져온다.

 

그렇기 때문에 성능 문제가 발생했을 경우 이에 즉각적으로 대응하기보다는 단위 테스트를 설계하여 어디에서 병목 현상이 발생하고 있으며 예상한 변경 사항으로 어떤 차이가 생겼는지 파악해야 한다. 이때 주의 사항은 다음과 같다.

  • 충분한 횟수만큼 실행: 타이밍, CPU 클록 등 미세한 요소에 의해 테스트 결과가 들쭉날쭉할 수 있다.
  • 반복하는 코드 부분 최적화 방지: 자바의 경우 JVM이 최적화를 수행하지 않도록 하여 테스트의 신뢰도를 높인다.
  • 최적화되지 않은 테스트는 일반 테스트와 분리: 추후 테스트 시 총 시간이 너무 오래 걸리지 않도록 한다.
  • 결과는 여러 요소에 의해 달라질 수 있음: 동일한 머신이라도 시스템 로드율 등에 따라 결과가 달라질 수 있다. 그러므로 성능 테스트 시에 사전 조건을 엄격하게 맞추는 것이 좋다.

성능은 테스트 환경에 따라서도 차이가 나기 때문에 시간에 엄격한 테스트의 경우 테스트 환경에 따라 테스트가 실패할 수도 있다. 동일한 코드가 좋은 하드웨어 자원을 가진 서버에서 1초에 1000번 실행된다고 할 때 일반적인 데스크톱 환경에서는 분명 그보다 못한 결과가 나올 것이다. 때문에 이런 성능 차이를 보완하기보다는 프로덕션 환경과 유사한 환경에서 테스트를 진행하는 것이 권장된다.

 

성능 측정을 적절하게 활용하는 방법은 변경 사항을 만들 때 기존의 성능을 기준점으로 활용하는 것이다. 새로 구현한 메소드의 로직이 기존의 성능에 비교했을때 상대적으로 빨라졌다면, 그리고 여러번 수행했음에도 개선된 것이 확실하다면 변경사항을 프로덕션 코드에 적용해도 좋을 것이다.

마치며

아직은 이론적인 부분이 많아 JUnit 코딩보다는 이론적인 내용을 읽고 블로그에 옮겨적는 일이 많다. 얼른 목 객체를 적용해봤으면 좋겠다.