ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 소프트웨어 테스트 품질
    DEV/ETC 2024. 7. 8. 18:36

    테스트 품질을 검증하기 위해 테스트 커버리지 도구를 이용하여 테스트가 커버한 코드와 커버하지 못한 코드가 얼마나 되는지 확인하고, 해당 테스트가 얼마나 유용한지 계산해보자.

    그리고 품질에 초점을 둔 소프트웨어 개발 프로세스인 TDD와 BDD에 대해 알아보자.

     

    1. 테스트 커버리지 측정하기

    테스트 커버리지는 그 자체로 코드의 품질을 어느정도 보장한다. 하지만 높은 테스트 커버리지가 테스트의 질을 보장하지는 않는다. 훌륭한 개발자는 테스트를 실행하여 얻어 낸 기계적인 백분율 수치 이상을 볼 수 있어야 한다.

     

    1-1. 테스트 커버리지란 ?

    테스트 커버리지를 계산하는 데 다양한 지표를 사용할 수 있다. 가장 기본적인 지표는 테스트 묶음을 실행하는 동안 호출되는 애플리케이션의 메서드나 코드 줄의 수를 가지고 나타낸 백분율이다. 또 다른 지표로 테스트가 호출하는 메서드를 추적해서 집계할 수 있다.

     

    즉, 테스트가 충분한가를 나타내는 지표로 소프트웨어 테스트를 진행했을 때 코드 자체가 얼마나 실행되었냐는 것이다.

     

    일반적으로 화이트박스 테스트를 활용하면 더 높은 테스트 커버리지를 얻을 수 있다. 더 많은 메서드에 접근할 수 있을뿐더러 각 메서드에 대한 입력과 보조객체의 동작을 제어할 수 있기 때문이다.

     

    블랙박스 테스트로 높은 코드 커버리지를 달성하지 못했다면, 보통은 더 많은 테스트가 필요하다는 뜻이다.

    애플리케이션에 아직 테스트하지 않은 부분이 있을 것이다. 혹은 비즈니스 목표에 기여하지 않는 불필요한 로직이 있을 수 있다. 두 경우 모두 실제 원인을 찾기위해 추가적인 분석이 필요하다.

     

    1-2. 코드 커버리지를 측정하는 도구

    코드 커버리지 도구는 JUnit과 통합이 잘되어 있다. IntelliJ를 활용하면 코드 커버리지를 편리하게 집계할 수 있다.

     

    이와 같이 Run 'CalculatorTest' with Coverage를 클릭하면 리포트가 나타난다. 

     

    com.study.junit.ch01 패키지 수준에서 클래스와 라인별로 테스트가 얼마나 커버됐는지 확인할 수 있다.

    설정에서 커버리지 수준을 설정할 수 있다. 아래는 Calculator 클래스로 코드 커버리지를 설정한것이다.

    커버리지 리포트의 좌측 버튼중에는 HTML 형식의 리포트로 생성할 수 있는 버튼도 있다. 나는 생략하고 넘어간다.

     

    2. 테스트 하기 쉬운 코드 작성하기

    테스트하기 쉬운 코드란, 가독성이 좋고 테스트하기 편하도록 가능한 소스 코드를 단순하게 작성하는것이다.

     

    2-1. public API는 정보 제공자와 정보 사용자 간의 계약이다.

    : 하위 호환성을 제공하는 소프트웨어를 만들 때의 원칙은 public 메서드의 시그니처를 변경하면 안된다는 것이다.

    public 메서드의 시그니처를 변경했다면 애플리케이션이나 단위 테스트의 메서드 호출을 전부 변경해야 한다.

    즉, public메서드는 일반적으로 서로의 존재를 알지 못하는 컴포넌트, 오픈소스 프로젝트, 상용 제품 등 애플리케이션의 연결지점이 되므로 되도록 API 명세를 변경하지 않는것이 좋다.

     

    2-2. 의존성 줄이기

    : 단위 테스트는 코드를 격리된 상태에서 검증한다는 점을 명심해야한다. 테스트하기 쉬운 코드를 작성하려면 의존성을 최대한 줄여야한다. 클래스가 인스턴스화되고 특정한 상태로 설정해야 하는 다른 클래스에 많이 의존하는 경우, 테스트하기가 매우 복잡해지며 복잡한 모의 객체를 만들어야 할 수도 있다. 의존성을 줄이려면 메서드를 분리해야한다. 특히 객체를 인스턴스화 하는 메서드와 비즈니스 로직을 갖고 있는 메서드를 분리하는 것이 중요하다.

    public class Vehicle {
        Driver d = new Driver();
        boolean hasDriver = true;
    
        private void setHasDriver(boolean hasDriver) {
            this.hasDriver = hasDriver;
        }
    }

     

    Vehicle 객체가 생성될 때 Driver 객체도 같이 만들어진다.

    두가지 객체가 강하게 결합되어 있고 Vehicle 클래스가 Driver클래스에 의존하는 문제가 있다.

     

    이럴 때는 Driver 객체를 Vehicle 클래스에 전달하는 방식으로 해결할 수 있다.

    public class Vehicle {
        Driver d;
        boolean hasDriver = true;
        
        Vehicle(Driver d) {
            this.d = d;
        }
    
        private void setHasDriver(boolean hasDriver) {
            this.hasDriver = hasDriver;
        }
    }

    이렇게 수정하면 모의 Driver객체를 생성하고 Vehicle 객체 생성시 모의 객체를 Vehicle에 전달할 수 있다.

    이를 의존성 주입(그중에서도 생성자 주입)이라고 한다.

     

    2-3. 간단한 생성자 만들어 보기

    : 더 나은 테스트 커버리지를 위해서는 더 많은 테스트 케이스가 필요하다. 각 테스트 케이스에서는 다음과 같은 일이 일어난다.

     

    1) 테스트할 클래스를 인스턴스화 한다.

    2) 클래스를 특정 상태로 설정한다.

    3) 작업을 수행한다.

    4) 클래스의 최종 상태를 검증한다.

     

    public class Car {
        private int maxSpeed;
        
        Car() {
            this.maxSpeed = 180;
        }
    }

    이 예제는 1,2 가 같이 수행하는 것으로 클래스를 미리 정의한 상태로 인스턴스화 하기 때문에 좋지 못한 예제이다.

    유지보수나 테스트가 어려우니 클래스를 특정상태로 설정하는 것은 별도의 작업으로 분리해야한다.

    public class Car {
        private int maxSpeed;
    	
        //세터 메서드를 사용하여 클래스를 특정 상태로 설정
        public void setMaxSpeed(int maxSpeed) {
            this.maxSpeed = maxSpeed;
        }
    }

     

    2-4. 데메테르 법칙 따르기

    : 최소 지식의 법칙으로 알려져 있는데, 클래스는 알아야 할 만큼의 정보만 가져가야 한다는 것이다.

    public class Car {
        private Driver driver;
    
        Car(Context context) {
            this.driver = context.getDriver();
        }
    }

    이 예제는 데메테르 법칙을 위반했다. Context객체는 드라이브에 대한 정보를 포함하고 있다.

    Car 클래스가 Context객체에 getDriver 메서드가 있다는것을 알아야하므로 데메테르 법칙을 위반하고 있다.

     

    때문에 데메테르 법칙을 적용하여 메서드나 생성자에 정확히 필요한 참조만 전달해야한다.

    Car(Driver driver) {
    	this.driver = driver;
    }

    객체를 요구하되 객체 안에서 다시 찾지 않으며 현재 애플리케이션에 꼭 필요한 객체만 요청한다.

    이것이 데메테르 법칙의 핵심이다.

     

    2-5. 숨은 의존성과 전역 상태 피하기

    : 전역 상태는 매우 주의해서 관리해야 한다. 전역 상태를 공유하는 것은 때때로 의도하지 않은 결과를 만들어낸다.

     

    2-6. 제네릭 메서드 사용하기

    : 팩터리 메서드와 같은 정적 메서드는 유용하지만, 정적 메서드만 사용한다면 메서드 호출이 거의 항상 컴파일 타임에 결정되므로 코드를  유연하게 만드는 연결 지점이 없어진다. 이런 경우에는 하나의 객체가 둘 이상의 IS-A 관계를 갖도록 만드는 다형성을 활용한다면 호출할 메서드가 컴파일 타임에 결정되지 않도록 만들 수 있다. 다형성을 활용하여 애플리케이션 코드를 테스트 코드로 대체해 특정한 코드를 테스트해 볼 수 있다.

     

    정적 코드를 남발하거나, 애플리케이션 개발 시 다형성을 활용하지 못하면 애플리케이션뿐만 아니라 테스트에도 문제가 생긴다. 코드를 재사용하지 않는다는 뜻이기 때문이다. (코드 중복 문제)

     

    결론적으로 파라미터에 구체적 타입을 명시해야하는 정적 유틸 메서드가 있다면 반드시 제네릭을 사용해야한다.

    public static Set union(Set s1, Set s2) {
        Set result = new HashSet(s1);
        result.addAll(s2);
        return result;
    }

    해당 메서드를 타입 체크로부터 안전하게 하고 경고를 없애려면 제네릭 메서드를 사용하자.

    public static <E> Set<E> union(Set<E> s1, Set<E> s2) {
        Set<E> result = new HashSet<>(s1);
        result.addAll(s2);
        return result;
    }

     

    2-7. 상속보다는 합성 활용하기

    : 개발자들은 코드 재사용을 위해 상속을 활용하지만 상속보다는 합성이 테스트하기 쉽다. 런타임에 상속 구조를 변경할 수는 없지만 객체를 다르게 합성하는 것이 가능하기 때문이다.  상속은 하위 클래스가 상위 클래스의 서브타입일 때 고려하는 것이 좋다. 클래스 A와 B가 있을 때 연관 관계를 생각해보자.

    IS-A 관계라면 상속을, HAS-A 관계라면 합성을 사용하는 것이 바람직하다.

     

    2-8. 분기문보다는 다형성 활용하기

    클래스가 너무 복잡하면 인스턴스화가 어려울 수 있다. 복잡도를 낮추기 위해서는 switch나 if문으로 길게 늘어진 분기문을 만들지 않으면 된다.

    public class DocumentPrinter {
    
        private Document document;
    
        public DocumentPrinter(Document document) {
            this.document = document;
        }
    
        public void printDocument() {
            switch (document.getDocumentType()) {
                case WORD_DOCUMENT:
                    printWORDDocument();
                    break;
                case PDF_DOCUMENT:
                    printPDFDocument();
                    break;
                case TEXT_DOCUMENT:
                    printTextDocument();
                    break;
                default:
                    printBinaryDocument();
                    break;
            }
        }
    
        private void printBinaryDocument() {
        }
    
        private void printTextDocument() {
        }
    
        private void printPDFDocument() {
        }
    
        private void printWORDDocument() {
        }
    }

    이 예제는 테스트와 유지보수가 모두 어렵다. 새 documentType이 추가된다면 새 case문자 print 메서드가 추가될 것이다.

    분기문이 길고 복잡해진다면 다형성을 활용해보자. 객체를 여러 작은 클래스로 나누어 길고 복잡한 분기문을 대체해준다.

    public class DocumentPrinter {
        public void printDocument(Document document) {
            document.printDocument();
        }
    }
    
    public abstract class Document {
        public abstract void printDocument();
    }
    
    public class PDFDocument extends Document {
        public void printDocument() {
            printPDFDocument();
        }
    
        private void printPDFDocument() {
        }
    }
    
    public class TextDocument extends Document {
        public void printDocument() {
            printTextDocument();
        }
    
        private void printTextDocument() {
        }
    }
    
    public class WordDocument extends Document {
        public void printDocument() {
            printWORDDocument();
        }
    
        private void printWORDDocument() {
        }
    }

     

    Document 추상 클래스를 상속받은 각각의 XDocument 클래스는 printDocument 추상 메서드를 각자의 로직으로 재정의한다. DocumentPrint 클래스의 printDocument(Document) 메서드가 호출되면 Document클래스에서 printDocument 메서드를 호출하는 것으로 작업을 위임한다.

     

    다형성을 활용하면 수행해야 하는 코드가 런타임에서 결정되므로 복잡한 분기문이 필요 없어지고, 소스 코드를 이해하고 테스트하기 쉬워진다.

    3. TDD (테스트 주도 개발)

    개발자가 테스트를 먼저 작성한 다음 테스트를 통과하는 코드를 작성하는 프로그래밍 기법.

    코드를 작성한 다음에는 코드를 검사하고 난잡한 부분을 정리하거나 코드의 질을 높이기위해 리팩터링한다.

     

    3-1. 개발 주기에 적응하기

     

    • 통념적인 개발주기
    코드를 작성한다 > 테스트한다 > 반복한다

     

    • TDD를 따르는 주기
    테스트한다 > 코드를 작성한다 > 반복한다

     

    TDD에서의 테스트는 설계를 주도하고, 메서드의 첫 번째 클라이언트가 된다.

     

    이러한 TDD 장점은 다음과 같다.

     

    - 목적이 분명한 코드를 작성할 수 있고, 개발자는 애플리케이션이 필요로 하는 것을 정확하게 개발했다는 확신을 얻을 수 있다. 코드를 설계하는 데 테스트를 사용할 수 있다.

     

    - 새로운 기능을 더 빨리 적용할 수 있다. 테스트는 개발자가 의도한대로 코드를 구현하게 유도하는 힘이 있다.

     

    - 테스트는 정상적으로 작동하는 기존 코드에 버그가 생기는 것을 방지할 수 있다.

     

    - 테스트는 개발 문서 역할을 한다. 테스트를 따르는 것은 소스 코드가 해결해야 하는 문제를 이해하는 것과 같다.

     

    3-2. TDD 2단계 수행하기

     

    위 주기에서 하나의 절차가 빠져있는데, 실제 TDD는 다음과 같이 진행된다.

    테스트한다 > 코드를 작성한다 > 리팩터링한다 > 반복한다

     

    리팩터링은 소프트웨어의 외적 동작을 바꾸지 않고 내부적인 구조만 개선함으로써 시스템을 변경하는 과정을 말한다.

    이때 외적 동작이 바뀌지 않았다는것을 증명하기 위해 테스트를 사용한다.

     

    TDD의 핵심 원리는 다음과 같다.

    • 새 코드를 작성하기 전에 실패하는 테스트를 먼저 작성한다.
    • 테스트를 통과하는 가장 단순한 코드를 작성한다.

    4. BDD (행위 주도 개발)

    댄 노스가 주장한 BDD는 비즈니스 요구 사항을 직접적으로 만족하는 IT솔루션을 만드는 데에 집중한다.

    BDD의 철학은 비즈니스 전략, 요구사항, 목표가 개발을 주도하며 이것들이 시나리오로 구체화 된 다음에 IT 솔루션이 만들어진다는 것이다. TDD가 품질 좋은 소프트웨어를 만드는데 기여한다면 BDD는 사용자의 문제를 직접적으로 해결하는 소프트웨어를 만드는데 기여한다.

     

    소프트웨어에 비즈니스적 가치를 제공하는 것은 바로 동작하는 기능이다.

    기능은 비즈니스적 목표를 달성하기 위해 필요한 실체적이고 전달 가능한 서비스를 말한다. 비즈니스 목표를 설정하기 위해 비즈니스 분석가는 고객과 협의하여 비즈니스 목표를 달성할 수 있는 소프트웨어의 기능을 결정한다.

     

    '고객이 목적지로 가는 최적의 경로' 기능을 제공한다면 이 기능을 스토리로 잘게 쪼개야한다.

    예를 들면 '최소 환승 경로 찾기' 또는 '최단 시간 경로 찾기' 등으로 쪼갤 수 있다.

    스토리는 구체적인 사례로 설명할 수 있어야 하며 구체적인 사례는 각 스토리의 인수 기준이 된다.

    인수 테스트는 Given, When, Then 키워드를 사용하여 작성한다.

    Given : XX 회사에서 운항하는 항공편에서
    When : 5월 15일부터 20일 사이에 부쿠레슈티에서 뉴욕으로 가능 가장 빠른 항공편을 찾는다면
    Then : 부쿠레슈티 - 프랑크푸르트 - 뉴욕을 잇는 최단 경로를 보여준다.

    5. 돌연변이 테스트 수행하기

    단위테스트부터 인수테스트까지 다양한 테스트를 진행하고 코드 커버리지가 100%에 가깝다면 완벽한 소스일까?

    코드 커버리지가 완벽한 작동을 보장하진 않으므로, 여전히 테스트가 충분하지 않을 수 있다.

    이를 확인할 수 있는 가장 간단한 방법은 테스트에서 JUnit 단언문을 삭제하는 것이다.

     

    돌연변이 테스트는 새 테스트를 설계하고 기존 테스트의 품질을 평가하는 데 사용한다.

    기본적인 아이디어는 프로그램을 '조금' 수정하는 것으로, 조금씩 변경된 프로그램들을 돌연변이라고 부른다.

    돌연변이는 +를 -로 바꾸는 등 기존 연산자를 다른 연산자로 바꾸거나 if와 else의 내용을 바꾸는 등 일부 조건을 뒤집어 돌연변이 연산으로 만든다. 만약 돌연변이 테스트가 통과한다면 테스트가 잘못된 것으로 간주할 수 있다.

     

    이렇게 돌연변이 테스트는 테스트의 신뢰성을 높이거나, 테스트 데이터의 약점을 찾을 수 있으며, 실행중에 코드의 약점을 찾을 수도 있다. 해당 테스트 역시 화이트박스 테스트의 일종이다.

    6. 개발 주기 내에서 테스트하기

    일반적인 개발 주기를 구분하고, 각 개발 주기에서 적용되는 테스트는 다음과 같다.

     

    - 개발 : 말그대로 개발자가 개발 후 형상관리 시스템에 커밋하는 단계

    • 단위 테스트 : 비즈니스 로직에 대해 단위테스트 (실제 운영환경과 분리하여 실행할 수 있는 테스트)

     

    - 통합 : 다른 팀에서 개발한 컴포넌트까지 포함하여 애플리케이션을 빌드하고 여러 컴포넌트가 함께 잘 동작하는지 확인하는 단계, 자동화 하는 것이 중요(지속적 통합)

    • 단위 테스트 + 기능 테스트 : 자동화된 빌드를 수행하여 애플리케이션을 패키징하고 배포한 다음 단위 테스트와 기능 테스트를 수행한다. 시스템이나 구성 요소가 요구 사항을 잘 지키고 있는지 평가한다. 접근 환경에 따라 일부 기능만 테스트 가능한 경우도 있다.

     

    - 인수/부하 테스트 : 프로젝트에서 리소스가 얼마나 사용 가능한지에 따라 하나 또는 두 단계로 나눠짐.

    부하 테스트 단계에서는 애플리케이션에 부하를 주어 사이즈나 응답시간과 관련하여 적절하게 확장하는지 확인한다.

    인수 단계는 프로젝트의 고객이 시스템을 인수하는 단계로, 사용자의 피드백을 받을 수 있도록 가능한 자주 배포하는 것이 권장된다.

    • 단위 테스트 + 기능 테스트 + 부하 테스트 : 통합 단계에서 실행한 것과 동일한 테스트를 수행한다. 소프트웨어의 성능과 견고함을 확인하기 위해서 부하 테스트를 추가적으로 실행하기도 한다.

     

    - 예비 운영 : 실제 운영 배포 전에 수행하는 마지막 검증 단계로 선택적으로 진행한다.

    • 단위 테스트 + 기능 테스트 + 부하 테스트 :  인수 단계에서 실행한 테스트를 이 단계에서도 실행함으로써 완전성을 검증한다.

     

    지속적인 회귀 테스트 ?

    더보기

    테스트는 대부분 현재 개발 시점을 대상으로 만들어진다. 신규 기능을 추가했다면 그에 맞는 새로운 테스트를 작성하는 것이 당연하다. 테스트를 하며 새 기능이 다른 기능과 잘 어울리는지, 사용자가 만족할 것인지를 알 수 있다.

     

    신규 기능은 기존 기능이 만들어 놓은 프로세스 위에 추가되는 경우가 많다. 두 기능이 각각 의도한 바를 제공할 수 있다면 좋겠지만, 때로는 새 기능과 함께  동작하도록 기존 소스를 변경해야 할수도 있다. 이 땐 기존 기능이 수정된 로직으로도 잘 작동되는지 확인해야한다.

     

    JUnit이 가지고 있는 강력한 장점은 테스트를 쉽게 자동화할 수 있다는 것이다. 메서드가 변경되면 변경된 메서드를 바로 테스트할 수 있으며, 실패한 테스트가 있다면 모든 테스트가 통과될 때까지 소스 코드를 다시 수정하거나 테스트를 수정할 수 있다.

     

    이렇게 회귀 테스트는 추가된 변경 사항 때문에 기존 기능이 망가지는 것을 방지한다. 모든 종류의 테스트가 회귀 테스트로 사용될 수 있다. 그러므로 소스코드를 변경한 다음에는 단위 테스트를 실행하는 것이 가장 먼저 해야할 일이다.

     

    댓글

Designed by Tistory.