ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 단위 테스트 정리 2 - 좋은 단위 테스트의 4대 요소
    책책책 책을 읽읍시다/프로그래밍 2022. 7. 4. 23:16

     

    좋은 단위 테스트의 4대 요소


    회귀 방지, 리팩터링 내성, 빠른 피드백, 유지 보수성

    회귀 방지

    회귀 발생 가능성이 높은 코드 측정 지표

    • 테스트 중에 실행되는 코드의 양
    • 코드 복잡도
    • 코드의 도메인 유의성

    단순한 코드를 테스트하는 것은 가치가 거의 없다. 이러한 코드는 짧고, 비즈니스 로직을 많이 담고 있지도 않다. 단순한 코드를 다루는 테스트는 실수할 여지가 많지 않기 때문에 회귀 오류가 많이 생기지 않는다.

    우리가 작성하지 않은 코드(라이브러리, 프레임워크 그리고 프로젝트에서 쓰는 외부 시스템)도 중요하다. 이 코드는 작성한 코드만큼이나 소프트웨어 작동에 영향을 미친다. 최상의 보호를 위해서는 테스트가 해당 라이브러리, 프레임워크, 외부 시스템을 테스트 범주에 포함시켜서 소프트웨어가 이러한 의존성에 대해 검증이 올바른지 확인한다.

    리팩터링 내성

    테스트를 빨간색(실패)으로 바꾸지 않고 기본 애플리케이션 코드를 리팩터링할 수 있는지에 대한 척도다.

    리팩터링하고 코드도 잘 동작하지만, 테스트가 실패하는 상황을 거짓 양성(false positive)이라고 한다. 거짓 양성은 허위 경보다. 리팩터링 내성 지표에서 테스트 점수가 얼마나 잘 나오는지 평가하려면 테스트에서 얼마나 많이 거짓 양성이 발생하는지 살펴봐야 한다. 적을수록 좋다.

    테스트가 지속 가능한 성장을 하게 하는 메커니즘은 회귀 없이 주기적으로 리팩터링하고 새로운 기능을 추가할 수 있는 것이다. 여기에는 두 가지 장점이 있다.

    • 기존 기능이 고장 났을 때 테스트가 조기 경고를 제공한다. 운영 환경이었으면 문제를 처리하는데 훨씬 많은 노력이 든다.
    • 코드 변경이 회귀로 이어지지 않을 것이라고 확신하게 된다. 이러한 확신이 없으면 리팩터링 하는데 주저하게 되고 코드베이스가 나빠질 가능성이 높아진다.

    거짓 양성은 이 두 가지 이점을 모두 방해한다.

    • 테스트가 타당한 이유 없이 실패하면, 코드 문제에 대응하는 능력과 의지가 희석된다. 이내 타당한 실패도 무시하기 시작해 기능이 고장 나도 운영환경에 들어가게 된다.
    • 테스트 스위트에 대한 신뢰가 서서히 떨어지며, 더 이상 믿을 만한 안전망으로 인식하지 않는다. 신뢰가 부족하면 리팩터링이 줄어들고, 회귀를 피하려고 코드 변경을 최소화한다.

    테스트와 테스트 대상 시스템(SUT)의 구현 세부 사항이 많이 결합할수록 허위 경보가 더 많이 생긴다. 이럴 경우 해당 구현 세부사항에서 테스트를 분리해 해결한다. 테스트를 통해 SUT가 제공하는 최종 결과(관련된 절차가 아니라 식별할 수 있는 동작)를 검증하는지 확인해야 한다.

    오류 유형 표 기능
    작동 고장
    테스트 결과 테스트 통과 올바른 추론
    (참 음성)
    2종 오류
    (거짓 음성), 회귀 방지
    테스트 실패 1종 오류
    (거짓 양성), 리팩터링 내성
    올바른 추론
    (참 양성)

    이해하기 좋은 한 가지 방법은 독감 검사를 생각해보는 것이다. 독감 검사는 검사를 받는 사람이 독감에 걸렸을 때 긍정(positive)이다. 테스트의 맥락에서 긍정은 어떤 조건이 이제 사실임을 의미한다. 이러한 조건은 테스트가 반응하도록 작성자가 설정한 조건이다. 여기서는 독감의 존재가 조건에 해당한다. 반대로 독감이 없으면 독감 검사는 부정(negative)이 된다. 

     이제 독감 테스트가 얼마나 정확한지 평가할 때 거짓 양성 또는 거짓 음성과 같은 용어가 나타난다. 거짓 양성과 거짓 음성의 확률은 독감 테스트의 수준을 나타낸다. 즉, 확률이 낮을수록 테스트가 더 정확해진다.

    이 정확도는 좋은 단위 테스트의 처음 두 개의 특성에 대한 것이다. 회귀 방지와 리팩터링 내성은 테스트 스위트의 정확도를 극대화하는 것을 목표로 한다. 정확도 지표는 다음 두 가지 요소로 구성된다.

    • 테스트가 버그 있음을 얼마나 잘 나타내는가 : 거짓 음성(회귀 방지 영역) 제외
    • 테스트가 버그 없음을 얼마나 잘 나타내는가 : 거짓 양성(리팩터링 내성 영역) 제외

    테스트 정확도 = 신호(발견된 버그 수) / 소음(허위 경보 발생 수)

    빠른 피드백과 유지 보수성

    테스트가 빠르게 실행되면 코드에 결함이 생기자마자 버그에 대해 경고하기 시작할 정도로 피드백 루프를 대폭 줄여서, 버그를 수정하는 비용을 거의 0까지 줄일 수 있다.

    유지 보수성 지표는 유지비를 평가하며 두 가지 주요 요소로 구성된다.

    • 테스트가 얼마나 이해하기 어려운가 : 테스트는 코드 라인이 적을수록 더 읽기 쉽다. 작은 테스트는 필요할 때 변경하는 것도 쉽다. 테스트 코드의 품질은 제품 코드만큼 중요하다. 테스트를 작성할 때 절차를 생략하지 말라. 테스트 코드를 일급 시민(first-class citizen)으로 취급하라
    • 테스트가 얼마나 실행하기 어려운가 : 테스트가 프로세스 외부 종속성으로 작동하면, 데이터베이스 서버를 재부팅하고 네트워크 연결 문제를 해결하는 등 의존성을 상시 운영하는데 시간을 들여야 한다.

    이상적인 테스트

    가치 추정치 = [0…1] * [0…1] * [0…1] * [0…1]

    회귀 방지, 리팩터링 내성, 빠른 피드백, 유지 보수성 모두 1을 얻기는 불가능하다. 회귀 방지, 리팩터링 내성, 빠른 피드백은 상호 배타적이기 떄문이다. 셋 중 하나를 희생해야 나머지 둘을 최대로 할 수 있다.

    곱셈 원리 때문에 하나라도 0점이면 테스트는 가치가 없다. 몇가지 예를 들어보자.

    엔드 투 엔드 테스트

    많은 코드를 테스트하므로 회귀 방지를 훌륭히 해낸다. 거짓 양성에 면역이 돼 리팩터링 내성도 우수하다. 리팩터링은 올바르게 했다면 식별할 수 있는 동작을 변경하지 않으므로 엔드 투 엔드 테스트에 영향을 미치지 않는다. 다른 장점으로 특정 구현도 강요하지 않아 최종 사용자의 관점에서 기능이 어떻게 동작하는지만 볼 수 있다.

    그럼에도 불구하고 빠른 피드백을 받을 수 없다.

    간단한 테스트

    public class User
    {
        public string Name { get; set;}
    }
    
    [Fact]
    public void Test()
    { 
        var sut = new User();
    
        sut.Name = “John Smith”;
    
        Assert.Equal(“John Smith”, sut.Name);
    }

    매우 빠른 피드백을 제공하고, 거짓 양성이 생길 가능성이 상당히 낮기 때문에 리팩터링 내성도 우수하다. 그러나 기반 코드에 실수할 여지가 많지 않기 때문에 간단한 테스트는 회귀를 나타내지 않을 것이다. 이름만 바꿀 뿐 동어 반복 테스트(tautology test)를 불러온다. 이러한 테스트는 항상 통과하거나 검증이 무의미하기 때문에 어떤 것도 테스트한다고 할 수 없다.

    깨지기 쉬운 테스트

    실행이 빠르고 회귀를 잡을 가능성이 높지만 거짓 양성이 많은 테스트를 작성하기가 매우 쉽다. 이러한 테스트를 깨지기 쉬운 테스트(brittle test)라고 한다. 이는 리팩터링을 견디지 못하고, 해당 기능이 고장 났는지 여부와 관계없이 빨간색으로 바뀐다.

    public class UserRepository
    {
        public User GetById(int id)
        {
            /* … */
        }
    
        public string LastExecutedSqlStatement { get; set; }
    }
    
    [Fact]
    public void GetById_executes_correct_SQL_code()
    {
        var sut = new UserRepository();
    
        User user = sut.GetById(5);
    
        Assert.Equal(
            “Select * from dbo.[User] Where UserId = 5”,
            sut.LastExecutedSqlStatement);
    }

    개발자가 SQL 코드 생성을 엉망으로할 수 있고 UserID 대신 ID로 잘못 사용하거나할 때는 버그르 잡을 수 있다.

    그러나 다음과 같이 SQL문을 여러 가지 형태로 변형해도 결과는 모두 같다.

    select * from dbo.[User] where userId = 5

    select * from dbo.User where userId = 5

    select UserId, Name, Email from dbo.[User] where userId = 5

    이는 테스트가 SUT의 내부 구현 세부 사항에 결합되는 예다.(딥링크 테스트를 예로 쓰자)

     

    이상적인 테스트 : 결론

    세 가지 특성 중 리팩터링 내성을 포기할 수 없는데, 리팩터링 내성 여부는 대부분 이진 선택이기 떄문이다. 테스트에 리팩터링 내성이 있거나 없거나 둘 중 하나다. 중간 단계는 거의 없다. 반면에 회귀 방지와 빠른 피드백에 대한 지표는 조절이 가능하다.

     

    블랙박스 테스트와 화이트박스 테스트 간의 선택

    • 블랙박스 테스트(black-box testing)는 시스템의 내부 구조를 몰라도 시스템의 기능을 검사할 수 있는 소ㅡ트웨어 테스트 방법이다. 일반적으로 명세와 요구 사항, 즉 애플리케이션이 어떻게 해야 하는지가 아니라 무엇을 해야 하는지를 중심으로 구축된다.
    • 화이트박스 테스트(white-box testing)는 정반대다. 애플리케이션의 내부 작업을 검증하는 테스트 방식이며, 테스트는 요구 사항이나 명세가 아닌 소스 코드에서 파생된다.

    화이트박스 테스트가 더 철저한 편이지만, 코드의 특정 구현과 결합돼 있기 때문에 깨지기 쉽다.

      회귀 방지 리팩터링 내성
    화이트박스 테스트 좋음 나쁨
    블랙박스 테스트 나쁨 좋음

    리팩터링 내성은 타협할 수 없기 때문에 화이트박스 테스트 대신 블랙박스 테스트를 기본으로 선택하라. 모든 테스트(단위, 통합 엔드 투 엔드)가 시스템을 블랙박스로 보게 만들고 문제 영역에 의미 있는 동작을 확인하라. 테스트를 통해 비즈니스 요구 사항으로 거슬러 올라갈 수 없다면, 이는 테스트가 깨지기 쉬움을 나타낸다. 이 테스트를 재구성하거나 삭제하라. 기존 테스트 스위트로 두지 말라. 유일한 예외는 알고리즘 복잡도가 높은 유틸리티 코드를 다루는 경우다.

     

    팁 : 검증문을 작성할 때 제품 코드에 의존하지 말라. 테스트에서 별도의 리터럴과 상수 집합을 사용하라. 필요하면 리터럴과 상수를 복제하라. 테스트는 제품 코드와 독립적으로 검사점을 제공해야 한다. 그렇지 않으면, 이름만 바꿀 뿐 동어 반복 테스트(아무것도 검증하지 않고 무의미한 검증문만 있는 테스트)를 만들 위험이 있다.

    댓글

Designed by Tistory.