티스토리 뷰
1. 목표
버그로부터 안전한 코드를 위해 널리 사용되는 실용적인 테크닉인 testing에 대해서 다뤄보고자 한다.
- testing의 가치를 이해하고 test-first programming에 대해서 알아볼 것이다.
- input과 output space에 따라 메소드를 구획화 하여 test suite를 디자인할것이다.
- code coverage를 측정하여 test suite를 평가하고 언제 blackbox testing과 whitebox testing을 사용해야 하는지, unit test와 integration test, automated regression testing을 사용해야 하는지 알아 볼 것이다.
* Test suite란 ?
개별 테스트 케이스들을 하나로 묶은 것이다.
2. 소프트웨어 테스팅이 어려운 이유
Testing은 Validation이라는 더 일반적인 프로세스의 한가지 예이다.
Validation의 목적은 프로그램 안의 문제를 발견하여 프로그램의 정확성을 증가시키는것이다.
Validation은 다음과 같은 3가지를 포함한다.
- 프로그램에 대한 Formal reasoning (verification, 확인). Verification은 프로그램이 정확하다는 공식적인 증거를 구성한다. Verification을 손으로 직접하는것은 매우 귀찮기 때문에 verification을 위한 자동화된 툴들은 활발한 연구분야중 하다이다. 그럼에도 작고 치명적인 부분은 공식적으로 verified될수 있다. (운영체제의 스케쥴러나 가상머신에서 바이트코드 인터프리터나 운영체제의 파일 시스템과 같이)
- Code review. 누군가 내가 작성한 코드를 읽어보고 추리하는 것은 버그를 발견하기 좋은 방법이다.
- Testing. 신중하게 선택된 input에 대해서 프로그램을 실행하고 결과를 확인한다
최고의 validation으로도 소프트웨어에서 완벽한 퀄리티를 달성하기는 매우 어렵다. 소프트웨어가 만들어지고 난 다음 의 버그인 일반적인 잔여 불량률 per kloc(천줄의 소스코드 당)을 본다면
- 1 -10 결함 / kloc : 일반적인 산업 소프트웨어
- 0.1 - 1 결함 / kloc : 고품질의 validation. 자바 라이브러리가 이 정로 수준의 정확함을 가지고 있을것으로 추정,
- 0.01 - 0.1 결함 / kloc : 최고의 안전이 중요한 validation. 나사와 우주항공 기업들이 이 수준에 도달가능하다.
대규모의 큰 시스템의 경우, 일반적인 회사에서의 백만줄의 소스코드를 작성하면 천개의 버그는 놓쳤다는 말이다.
그럼 왜 이렇게 소프트웨어 테스팅이 어려울까
하드웨어에서의 테스팅과 달리 소프트웨어 세계에서 테스트가 어려우며 잘 작동하지 않는 몇가지 접근 방식이 있다.
- Exhaustive testing. 완벽한 테스팅은 불가능하다. 완전 철저하게 테스트 케이스의 공간을 커버하기에는 너무 크다. 예를들어 32비트 소수점 곱하기 연산을 테스트 한다고 하면 2^64승의 테스트 케이스가 있는데 이 모두를 커버하는것은 불가능하다.
- Haphazard testing. 일단 실행시켜보고 제대로 작동하는 지 보는 테스트는 버그가 너무 많아 임의로 선택한 input이 성공하는것 보다 실패하는것이 더 쉬운 경우가 아니고서는 버그를 찾기 힘들다. 프로그램의 정확성에 대한 확신을 증가시켜 주지 못한다.
- Random or statistical testing은 소프트웨어에서 잘 작동하지 않는다. 다른 엔지니어링 분야에서는 소량의 샘플을 무작위로 테스트하여 전체 생산에 대한 결함률을 추정할수 있다. 이렇게 추정한 결함률은 결함률이 연속적이고 균일하게 일어날 것이라고 가정한다. 하지만 소프트웨어에서는 이것이 불가능한 이유가 소프트웨어에서는 연속적이고 균일하게 일어나는 것이 아니라 불연속적이고 상황마다 따로따로 일어나기 때문이다. 전반적으로 잘 작동하다가 갑자기 한 시점에서 결함이 발견 된다. 예시로 펜티엄 FDIV 버그는 90억개의 분할중에 하나정도에 영향을 미쳤다. 자세한 글은 다음 링크 참조 (https://johngrib.github.io/wiki/pentium-fdiv-bug/) 메모리를 벗어나거나 스택오버플로우나 수에서 overflow가 일어나는 것도 갑자기 일어나는 것이다. 물리적인 시스템에서 시스템이 실패 지점에 접근 하고 있다는 가시적인 증거가 있거나 실패 지점근처에서 확률적으로 실패가 분포되거나 하는 것과는 다르다.
3. Test-first programming
테스팅을 하기위해서의 기본 자세는, 코딩할때에는 목표가 프로그램이 작동하도록 하는 것이지만, 테스터로서는 프로그램이 실패하도록 만들어야 한다. 가능한한 발견할 수 있는 취약한 점들을 발견하여 제거될수 있도록 해야만 한다.
일찍 테스트하고 자주해야하며, 코드를 개발하면서 테스트 하는 것이 테스트 해야하는 코드가 끝까지 쌓일때 까지 내버려 두는 것보다 훨씬 낫다.
Test-first programming에서는, 코드를 작성하기 전에 테스트를 작성한다. single function을 개발하는것은 다음 순서와 같다.
1. function에 대해서 자세한 설명 (specification)을 적는다.
2. 그러한 specification을 실행하는 테스트를 작성한다
3. 실제 코드를 작성한다. 코드가 작성한 테스트를 통과하면, 완료되는 것이다.
specification은 함수의 input과 output에 대해서 묘사한다.
코드에서 specification은 메소드 시그니쳐와 그위에 무엇을 하는지가 적힌 주석으로 이루어져 있다.
테스트를 작성하는 것은 specification을 이해하는 좋은 방법이다. specification 또한 부정확하고 모호하는 등 버그가 있을수 있다. 그래서 테스트를 작성하는 것은 이러한 문제를 조기에 발견할 수 있도록 해준다.
(1) 테스트 케이스를 Partitioning을 통해서 고르기
좋은 test suite(하나의 메소드를 테스트 하기 위한 테스트 메소드인 테스트 케이스 들을 하나로 묶은 것)를 만드는 것은 도전적이고 흥미로운 디자인 문제이다. 우리는 빠르게 실행할 수 있을 만큼 작지만 프로그램을 검증하기에는 충분히 큰 테스트 케이스를 선택하려고 한다. 이러기 위해서는 input space를 subdomain으로 나누어야 한다. 각각의 subdomain들이 완전히 input space를 커버할수 있게끔 해서 모든 input이 적어도 하나의 subdomain에 놓일 수 있다. 각각의 subdomain에서 테스트 케이스를 고르고, 그것이 우리의 test suite가 되는 것이다.
subdomain을 나눌때, input space를 비슷한 input들의 set로 구획화 하여야 한다는 것이다.(Partitioning) 그러면 각 set의 하나의 대표를 사용할 수 있을 것이다. 이러한 접근 방식은 서로 비슷하지 않은 테스트 케이스를 선택하여 랜덤 테스팅이 도달 할 수 없는 input space의 일부를 테스트 하도록 하여 제한된 테스팅 자원을 최대한 활용할 수 있다. 만약 테스트가 output space의 서로 다른 부분을 탐색하도록 보장 해야하는 경우에는 output space를 subdomain으로 구획화 할수도 있지만 대부분 input space를 partition하는 것으로 충분하다.
예) 자바의 BigInteger 클래스의 multiply 메소드
곱셈은 2개의 input으로 하나의 output을 만든다고 생각해야한다.
따라서 2차원의 input space가 있기 때문에, a,b를 곱셈이 어떻게 작용하는지를 생각하여 다음과 같이 나눠야 한다.
- a, b 모두 양수
- a, b 모두 음수
- a가 양수 b가 음수
- b가 양수 a가 음수
- a또는 b가 0, -1, 1일경우
또한 0, 1, -1과 같은 특별한 케이스 또한 체크 해줘야한다.
마찬가지로 테스터로써, 버그를 찾을때 BigInteger를 implement한 사람이 값이 큰 경우를 제외하고는 int나 long을 통해서 내부적으로 계싼을 하는것이 아닌지 체크해야 한다. 따라서 당연하게도 가장 큰 long 타입보다 큰 수를 테스태 해봐야한다.
- a나 b가 작을경우
- a또는 b의 절대 값이 Long.MAX_VALUE (대략 2^63)보다 클때를 체크 해줘야한다.
위의 조건들을 모두 따져 본다면, 다음과 같이 49가지의 경우로 분류 가능하다.
포인트는 위와 같이 구획화한 것을 완전히 커버 하기 위해서 테스트 케이스를 고르는 것이다.
(2) Partition에서 Boundaries를 포함
파티션의 경계선(Boundaries)을 포함 해야하는데, 버그는 subdomain 사이의 경계에서 종종 발생하기 때문이다.
- 0은 양수와 음수의 경계
- int와 double 타입과 같은 수 타입에 있어서의 최댓값과 최솟값
- 컬렉션 타입에 대한 빈 값들
- 컬렉션의 첫번째와 마지막 요소
왜 버그들은 경계선에서 자주 발생할까? 한가지 이유는 프로그러머가 한끝차이 실수(off-by-one mistake)를 하기 때문이다. >대신 >=와 같은것을 사용하고 1대신 0으로 초기화 하는 경우가 그렇다. 또 다른 이유는 몇 바운더리들은 코드에서 특별한 경우로 처리해야하기 때문이다. 또한 int 타입의 수가 최댓값을 넘으면 갑자기 음수가 되는것 처럼 경계의 값들이 코드 동작에서 불연속적인 장소가 될 수 있기 때문이다.
따라서 경계에 있는 값들을 partition의 subdomain으로 포함시키는 것이 중요하다.
또 다른 예로서 int x int -> int를 보자.
구획화는 다음과 같이 할 수 있다.
- a > b
- a =b
- a < b
또한 a의 값에 따라
- a = 0
- a < 0
- a > 0
- a = integer 최대값
- a = integer 최소값
또한 b의 값에 따라
- b = 0
- b < 0
- b > 0
- b= integer 최대값
- b= integer 최소값
그러면 이 모든 것들을 커버하는 테스트 값을 골라보자
- (1, 2) : a < b, a > 0, b > 0 커버
- (-1, -3) : a > b, a < 0, b < 0 커버
- (0, 0) : a = b, a = 0, b = 0 커버
- (Integer.MIN_VALUE, Integer.MAX_VALUE) : a < b, a = minint, b = maxint 커버
- (Integer.MAX_VALUE, Integer.MIN_VALUE) : a > b, a = maxint, b = minint 커버
(3) partition을 커버하기 위한 두 극단
input space를 구획화 하고 난 다음, 우리는 test suite가 얼마나 철저할지를 고를 수 있다.
- 카데시안 곱 (Cartesian product) : 특정 파티션 dimension에서 모든 가능한 조합은 하나의 테스트 케이스로 커버가능하다. 위의 BigInteger의 multiply()메소드 경우 49개의 테스트 케이스를 준다. int 곱셈의 경우는 3 x 5 x 5로 75가지의 테스트 케이스가 있다. multiply()경우는 아니지만, int 곱셈에서와 같이 고려해야하는 케이스들 중 중복되는 것이 있을 수도 있다.
- 각 파트를 cover하는것 : 각 차원의 모든 부분은 최소한 하나의 테스트 케이스로 커버 되지만, 반드시 모든 조합이 필요한 것은 아니다. 이 방식으로 접근한다면, 잘 고르면 최대 5개의 테스트 케이스 정도로 적은 test suite가 될수 있다. 위의 예에서 5개의 테스트 케이스를 고를 수 있었다.
우리는 중종 이 극단 사이에서 판단하여 어느정도 타협을 하며 whitebox testing과 blackbox testing과 code coverage tool에 영향을 받는다.
4. Blackbox 테스팅과 Whitebox 테스팅
Blackbox 테스팅이란 함수의 implementation이 아닌 specification만 보고 테스트 케이스를 고르는 것을 말한다.
위의 multiply() 예에서 볼 수 있듯, 함수의 실제 코드를 보지 않고 구획화를 하고 바운더리를 찾았다.
(소프트웨어의 내부 구조나 작동원리를 모르는 상태에서 소프트웨어의 동작을 검사하는 방법)
Whitebox 테스팅이란 함수가 실제로 어떻게 implement될것인지에 대한 지식으로 테스트 케이스를 정하는 것을 말한다.
예를들어 만약 implementation이 input에 따라 각각 다른 알고리즘을 선택한다면 이러한 영역에 따라서 구획화를 해야하는 것이다. 만약 implementation이 이전 input을 기억하는 내부의 캐시를 유지하는 경우, 반복되는 input을 테스트 해야한다. (응용 프로그램의 내부 구조와 동작을 검사하는 소프트웨어 테스트 방법)
whitebox 테스팅을 수행할 때, spec에 의해서 명확하게 요구하지 않는 특정한 implementation 행동을 테스트 케이스에 포함시켜서는 안된다.
예를들어 spec에 "input의 형식이 잘못된 경우 예외를 발생한다"라고 표기된 경우, 단지 현재의 implementation이 NullPointerException을 발생시킨다고 NullPointerException을 특정해서 테스트가 체크해서는 안된다. 이 경우 specification는 어떤 예외도 던질수 있도록 허용하기 때문에, implementor의 자유를 보호하기 위해서 테스트 케이스도 일반적이여야 한다.
5. Documenting Your Testing Strategy
위와 같은 방식으로 테스트를 어떻게 하는지를 문서화 할수 있다 (주석 처리)
6. Coverage
test suite를 판단하는 한가지 방법으로 프로그램을 얼마나 철저하게 실행하는 지를 묻는 것이다. 이것을 coverage라고 한다. 다음은 세가지 일반적인 커버리지의 종류이다. (테스트 케이스가 얼마나 충족되었는지를 나타내는 지표)
- Statement coverage : 모든 statement가 테스트 케이스에 의해서 실행되는가
- Branch coverage : 모든 if와 while문에 대해서, true, false 모든 경우가 테스트 케이스에 의해서 다루어 지는가
- Path coverage : branch로의 가능한 모든 조합들, 즉 프로그램에서 모든 경로가 테스트 케이스로 다루어 지는가
Statement < Branch < Path 순으로 더 강하며 테스트가 많이 필요하다. 일반적으로 100퍼센트 statement coverage가 일반적인 목표이지만, 도달할수 없는 defensive 코드때문에 거의 달성하지 못한다.
100퍼센트 branch coverage는 매우 바람직하며 안전에 중요한 코드의 경우는 더욱 높은 기준을 가지고 있다.
하지만 path coverage 100퍼센트는 불가능한데, 기하급수적인 크기의 test suite를 필요하기 때문이다.
테스팅에 대한 표준 접근방식은 test suite가 적절한 statement coverage에 도달할 때까지 테스트를 추가하는 것이다. 그래야 도달할수 있는 모든 statement를 적어도 하나의 테스트 케이스에서 실행될수 있기 때문이다. 실제로 statement coverage는 test suite로 실행되는 각 statement의 수를 계산하는 code coverage tool로 측정된다.
7. Unit Testing과 Stub
Unit test : 테스트가 잘된 프로그램경우, 각 모듈(메소드나 클래스)에 대해서 테스트를 가지고 있는데, 각 개별 모듈을 가능하다면 고립된 곳에서 테스트하는 테스트를 말한다.
고립시켜 테스트 하는것은 디버깅하는데 더 쉽고, 테스트가 실패하더라도 버그가 그 모듈안에 있다는 것을 더 확신할 수 있다.
Integration test : unit test의 반대로, 모듈의 조합을 테스트 하거나 전체 프로그램을 테스트 하는 것이다. 만약 할수 있는게 integration test뿐이라면, 테스트가 실패했을때 버그가 어디있는지를 찾아야 한다. 그래도 Integration test는 중요한데, 모듈 사이의 커넥션에서 프로그램이 fail할수 있기 때문이다. 예를들어 만약 한 모듈이 다른 모듈에서 실제로 얻는 것과 다른 input을 기대할 수 있다, 하지만 철저한 unit test를 통해서 버그를 더 찾기 쉬워지고 각 모듈의 정확성이 높아진다.
stub : mock object라고도 한다. 큰 시스템을 만들때 중요한 기술이다.
8. Automated Testing과 Regression Testing
Automated Testing : 테스트를 돌리고 결과를 확인하는것을 자동으로 하는것. 예를들어 자바의 JUnit같은 테스팅 프레임워크 경우 자동으로 test suite를 생성하도록 돕는다. 테스트를 돌아가기 쉽게 만들어주지만, 그래도 좋은 테스트 케이스를 생각해 내는것은 내가 해야한다. 자동으로 테스트 생성하는 것은 어려운 문제이고 지금도 연구중인 부분이다.
Regression Testing : 코드를 수정하고 변경하고 난 후에 테스트를 다시 작동시키는것은 메우 중요한데, 각각의 변화 후에 모든 테스트를 돌리는 것을 regression testing이라 말한다.
버그를 찾알 때마다, 버그를 유발한 input을 automated test suite에 테스트 케이스로 추가해야 한다. 이러한 테스트 케이스를 regression test라고 한다. regression test를 저장하는것은 버그를 다시 일으키는 것으로부터 막아준다.
test-first debugging : 버그가 발생하면, 그 버그를 발생한 테스트 케이스를 즉시 적어 test suite에 즉시 추가한다. 버그를 찾아서 고쳤으면, 모든 테스트 케이스는 패스할것이고 그 버그에 대한 디버깅과 regression test가 끝난 것이다.
실제로는 automated testing 과 regression testing의 조합이 거의 항상 사용된다. Automated regression testing이 현대 소프트웨어 엔지니어링에서의 best practice이다.
reference :
https://mangkyu.tistory.com/143
https://blog.naver.com/PostView.nhn?isHttpsRedirect=true&blogId=netrance&logNo=110184403382
'Books & Lectures > Software Construction in Java(MIT 6.005)' 카테고리의 다른 글
2. Code Review (0) | 2022.01.06 |
---|---|
1. Static Checking (0) | 2022.01.05 |
- Total
- Today
- Yesterday