테스트란 무엇인가?
애플리케이션이 복잡해질 수록 변경을 어떻게 대응할지 전략을 짜는게 매우 중요해집니다. 이전 챕터에서는 변경에 조금 더 쉽게 대응할 수 있도록하는 객체지향 설계의 중요성과 그 방법에 대해 다루었는데요.
이번 챕터에서는 테스트 기술에 대해서 다루려고 합니다.
테스트는 왜 필요할까요?
•
만들어진 코드에 확신을 가질 수 있게 해줍니다.
•
변화에 유연하게 대처할 수 있는 자신감을 갖게 해줍니다.
테스트가 잘 구성되어 있다면 기존 코드에 변경이 발생하더라도 기존 기능이 여전히 잘 동작하는지 쉽게 검증할 수 있습니다.
그럼 테스트는 어떻게 해야 좋을까요?
테스트 설계
내 계좌에서 다른 사람에게 돈을 보내는 송금 서비스가 있다고 생각 해보겠습니다. 실제로는 이것보다 훨씬 복잡한 아키텍처를 가지겠지만 간단하게 5개 구간으로 나누어 보았는데요.
1.
Client: 사용자가 이용하는 App/Web
2.
Front Server: Client와 통신하는 Server (HTTP, gRPC, ... )
3.
Core Server: 서비스별 Server (HTTP, gRPC, ... )
4.
VAN: 금융 시스템과 통신하기 위한 중계 시스템
5.
Finance: 은행, 증권사, 카드사 등 금융 시스템
해당 시스템을 테스트한다고 하면 어떻게 할 수 있을까요? Client에 해당하는 Web, App에서 받는 사람의 은행을 선택하고 계좌번호, 보낼 금액을 입력한 뒤 송금 버튼을 누르면 될겁니다.
그렇게 테스트를 했더니 [알 수 없는 오류가 발생했습니다.]라는 메시지가 뜬다면 어떻게 해야할까요? 문제를 찾기 위해 Client부터 Front Server, Core Server, VAN 그리고 Finance에 해당하는 모든 구역에 대해 점검해야 합니다.
원인은 정말 다양할 수 있습니다. 사용자의 입력 값 오류, 잘못 작성된 서버/클라이언트의 비즈니스 로직, DB 질의하는 SQL의 오류 등등..
즉, 문제가 발생한 지점을 명확히 파악하기가 어렵습니다.
그렇기에 각 테스트는 영향받는 범위를 최소한으로 쪼개어 독립적으로 수행되어야 합니다.
단위 테스트
테스트를 쪼갠다면 어떤 기준으로 쪼개야 할까요? 일반적으로는 기능 단위로 테스트를 쉽게 쪼갤 수 있습니다. 사용자에게 입력을 받는 기능, 계좌번호 유효성을 검사하는 기능, 잔액을 검증하는 기능, 데이터베이스에 기록하는 기능, ...
이렇게 쪼개어 수행하는 테스트를 단위 테스트(Unit Test)라고 부릅니다. 우리는 왜 단위 테스트를 해야할까요? 단위 테스트는 정말 작은 단위로 이루어진 테스트이기 때문에 정말 빠른 속도로 기능을 테스트해볼 수 있습니다. 그리고 기존의 코드가 변경되거나 버그가 발생했을 때 문제가 발생한 지점을 쉽게 찾을 수 있도록 도와줍니다.
단위 테스트 외에도 여러가지 테스트가 있는데 부르는 사람마다 용어와 의미가 미묘하게 다르기도 합니다. 아래 글은 각 테스트를 phase 별로 Unit Test / Programmer Test / Integration Test로 구분하고 그 차이에 대해 설명해주고 있습니다.
테스트 결과의 일관성
모든 테스트는 테스트를 수행할 때마다 동일한 결과를 보여주어야만 합니다. 예를 들어 DB에 데이터가 제대로 적재되는지 검증하는 테스트가 있다고 가정해보겠습니다. 테스트를 어떻게 수행할 수 있을까요?
class DaoTest {
/*..*/
fun daoTest() {
// 1. DB에 넣은 데이터 생성
// 2. DB에 데이터 삽입/적재
// 3. DB에 해당 데이터가 존재하는지 검증 OR DB의 RowCount가 1증가했는지 검증
}
/*..*/
}
Kotlin
복사
코드 레벨로 표현한다면 대략 이렇게 될 수 있을 것 같은데요. 해당 테스트는 테스트를 수행할 때마다 동일한 결과를 보여주고 있을까요? 마냥 확신하기는 어렵습니다.
1.
데이터를 적재하는 로직은 정상인데, 데이터베이스에 중복 데이터가 이미 존재하여 적재되지 않음
2.
RowCount가 1개 증가하기를 기대했는데 다른 곳에서 DB의 INSERT, DELETE가 발생하여 다르게 동작함
그럼 어떻게 해야 좋을까요? 정말 간단하게는 각 테스트를 수행할 때 마다 DB를 초기화하고, 로직을 수행한 뒤 검증하면 될 것 같네요.
JVM 진영에는 JUnit이라는 테스트 자동화 프레임워크가 존재합니다. 이를 이용한다면 테스트를 수행할 때마다 DB를 초기화하는 등 테스트 로직에서 공통된 부분을 자동화할 수 있습니다.
DI와 테스트
DI를 이용한다면 강결합으로 인해 발생하는 문제를 최소화할 수 있습니다.
여기에 더불어 Interface를 이용한 DI를 하게된다면 mock Instance를 만들어 테스트에 활용하는 등 독립적인 테스트를 수행할 수 있기도 합니다. 다만 요즘은 mockito를 비롯한 Mock 프레임워크가 상당히 발전해있기 때문에 굳이 Interface를 이용하지 않더라도 구현 Class를 이용해 곧장 mock instance를 만들 수 있기도 합니다.
테스트 관련 용어
•
reference: ISTQB, IEEE1044, ISO 24765, Egler63, ISO 24765
용어 | 의미 |
Test Case | 단일 테스트에 대한 입력, 실행 조건, 동작 결과를 정의한 단위 (특정 Method에 대한 테스트) |
Test Suite | 테스트 케이스의 묶음 (특정 Class에 대한 테스트) |
Test Harness | 테스트를 위해 작성된 코드와 데이터 |
Test Driver | 테스트에 필요한 모듈/컴포넌트를 제어하거나 호출하는 단위 |
Test Stub | 테스트에 필요한 모듈/컴포넌트가 구현되지 않았을 때 테스트를 위해 작성되는 dummy/mock |
Decision Test | 결정 포인트(Decision/Branch Point) 모두 커버하는 것을 목적으로하는 테스트 |
White-Box Test | 애플리케이션의 내부 동작을 검증하는 테스트 기법 |
Black-Box Test | 애플리케이션의 내부 동작과 무관하게 입력에 따른 출력값을 검증하는 테스트 기법 |
Boundary Value Analysis | 입력값, 출력값에 대한 범위를 검증할 때 그 범위의 경계에 해당하는 값으로 검증하는 테스트 기법 |
Equivalence partitioning | 입력값, 출력값에 대한 범위를 검증할 때 그 범위에 해당하는 모든 조건을 검증하는 테스트 기법 |
Experience-based Test | 객관적으로 명시하기는 어렵지만 발생할 수 있는 오류에 대해서 경험을 기반으로 하여 검증하는 테스트 기법 |
Test Coverage | 테스트가 얼마나 충분한지 객관적으로 나타내는 지표, LIne Coverage / Statement Coverage / Decision Coverage / Condition Coverage / ... |