059. (Getting Started with Test-Driven Development) 9. 테스트 범위와 종류

9. 테스트 범위와 종류

9.1. 테스트 범위

일반적인 웹 애플리케이션은 아래와 같은 구성 요소를 가진다.

웹 어플리케이션의 일반적인 구성 요소

하나의 기능이 올바르게 동작하려면 컨트롤러, 서비스, 모델과 같은 자바 코드와 프레임워크 설정에 문제가 없어야 한다.

또한, 브라우저에서 실행되는 Javascript, HTML, CSS가 정상 동작해야하고 DB의 테이블도 올바르게 생성되어 있어야 한다.

이러한 상황에서 개발자가 코드를 조금씩 수정할 때마다 모든 범위를 다 테스트해야한다면 많은 비용이 소모된다.

반대로 일부분만 정상임을 확인했다고 바로 출시해서도 안된다.

이처럼 테스트의 범위는 테스트의 목적과 수행하는 사람에 따라 달라진다.

아래 그림을 참고해보자.

테스트 범위에 따른 테스트 종류

이번 포스팅에서는 테스트 범위와 그에 따른 테스트 종류에 대해 알아보도록 하자.

9.1.1. 기능 테스트와 E2E 테스트

기능 테스트(Functional Testing) 는 사용자 입장에서 시스템이 제공하는 기능이 올바르게 동작하는지 확인한다.

기능 테스트를 수행하려면 시스템을 구동하고 사용하는데 필요한 모든 구성 요소가 필요하다.

참고 예를 들어 회원 가입 기능을 테스트하려면 웹 서버, 데이터베이스, 웹 브라우저가 필요하다.

기능 테스트는 사용자가 직접 사용하는 웹 브라우저나 모바일 앱부터 시작해서 데이터베이스나 외부 서비스에 이르기까지 모든 구성 요소를 하나로 엮어서 진행한다.

이는 사용자가 만나는 끝인 브라우저와 서비스 구성요소의 끝인 데이터베이스까지 모든 구성 요소를 논리적으로 완전한 하나의 기능으로 다룬다.

이처럼 기능 테스트는 끝에서 끝까지 검사하기 때문에 E2E(End to end) 테스트로도 볼 수 있다.

QA 조직에서 수행하는 테스트가 주로 기능 테스트이며, 이때 테스트는 시스템이 필요로 하는 데이터를 입력하고 결과가 올바른지 확인한다.

9.1.2. 통합 테스트

통합 테스트(Integration Test) 는 시스템의 각 구성 요소가 올바르게 연동되는지 확인한다.

기능 테스트가 사용자 입장에서 테스트하는 데 반해, 통합 테스트는 소프트웨어의 코드를 직접 테스트한다.

모바일 앱을 예로 들면 기능 테스트는 앱을 통해 가입 기능을 테스트한다면 통합 테스트는 서버의 회원 가입 코드를 직접 테스트하는 식이다.

일반적인 웹 애플리케이션은 프레임워크, 라이브러리, 데이터베이스, 구현한 코드가 주요 통합 테스트 대상이다.

9.1.3. 단위 테스트

단위 테스트(Unit Testing) 는 개별 코드나 컴포넌트가 기대한대로 동작하는지 확인한다.

단위 테스트는 하나의 클래스나 하나의 메서드와 같은 작은 범위를 테스트한다.

일부 의존 대상은 Stub이나 Mock을 이용해서 대역으로 대체한다.

9.1.4. 테스트 범위간 차이

각 테스트는 다음과 같은 차이가 있다.

  • 통합 테스트를 실행하려면 DB나 캐시 서버와 같은 연동 대상을 구성해야 한다.
    • 기능 테스트를 실행하려면 웹 서버를 구동하거나 모바일 앱을 폰에 설치해야 할 수도 있다.
    • 통합 테스트나 기능 테스트는 테스트 상황을 만들어내기 위해 많은 노력이 필요하다.
    • 반면, 단위 테스트는 테스트 코드를 빼면 따로 준비할 것이 없다.
  • 통합 테스트는 데이터베이스 연결, 소켓 통신, 스프링 컨테이너 초기화와 같이 테스트 실행 속도를 느리게 만드는 요인이 많다.
    • 기능 테스트는 추가로 브라우저나 앱을 구동하고 화면에 흐름에 따라 알맞은 상호 작용을 해야 한다.
    • 반면, 단위 테스트는 서버 구동이나 데이터베이스 없이 외부 의존성을 대역으로 처리하므로 실행 속도가 빠르다.
  • 통합 테스트나 기능 테스트로는 상황을 준비하거나 결과 확인이 어렵거나 불가능한 경우가 있다.
    • 외부 시스템과 연동해야하는 기능이 특히 어려운 경우가 많다.
    • 이런 경우에는 단위 테스트와 대역을 조합해서 상황을 만들고 결과를 확인해야 한다.

TDD 적용 여부와 상관없이 테스트 코드를 작성하는 개발자는 단위 테스트와 통합 테스트를 섞어서 작성한다.

어떤 테스트를 더 많이 작성해야 한다는 절대적인 규칙은 없지만 위 세 가지 차이점으로 인해 통합 테스트 코드 보다는 단위 테스트 코드를 더 많이 작성한다.

통합 테스트에는 많은 비용이 들지만, 아무리 많은 단위 테스트를 작성한다고 해도 각 구성 요소의 연동 여부를 확인해야하므로 결국 통합 테스트는 필요하다.

9.1.5. 테스트 범위에 따른 테스트 코드 개수와 시간

기능 테스트, 통합 테스트, 단위 테스트 등 모든 범위에 대해 테스트를 자동화하는 시도가 증가하고 있다.

테스트를 자동화하는 만큼 고품질의 소프트웨어를 더 빠르게 출시할 수 있기 때문이다.

참고 테스트를 자동화한다는 것은 결국 코드로 작성한 테스트를 실행한다는 것을 의미한다.

기능 테스트를 수행하려면 브라우저나 모바일 앱과 같은 클라이언트부터 데이터베이스까지 모든 환경이 갖춰줘야 하기에 자동화하거나 다양한 상황을 모사하기 힘들다.

이런 이유로 정기적으로 수행하는 기능 테스트는 정상적인 경우와 몇 가지 특수한 상황만 테스트 범위로 잡는다.

기능 테스트를 수행하기 위한 알맞은 도구가 없으면 기능 테스트 코드를 만들기 힘들 수도 있다.

통합 테스트는 기능 테스트에 비해 제약이 덜하며 시스템 내부 구성 요소에 대한 테스트도 가능하다.

통합 테스트는 기능 테스트에 비해 상대적으로 실행 시간이 짧고 상황을 보다 유연하게 구성할 수 있기때문에 보통 기능 테스트보다 통합 테스트를 더 많이 작성한다.

지금까지의 내용을 그림으로 나타내면 아래와 같다.

테스트 피라미드

한 번 더 정리해보자.

단위 테스트는 통합 테스트로도 만들기 힘든 상황을 쉽게 구성할 수 있으며, 더 작은 단위를 대상으로 테스트 코드를 만들고 더 다양한 상황을 다루기때문에 통합 테스트보다 단위 테스트를 더 많이 작성하게 된다.

기능 테스트나 통합 테스트에서 모든 예외 상황을 테스트하게 되면 굳이 단위 테스트에서 작성할 필요가 없으니 단위 테스트는 줄어들게 된다.

다만, 테스트 속도는 단위 테스트가 더욱 빠르기 때문에 가능하다면 단위 테스트에서 다양한 상황을 다루고, 통합 테스트나 기능 테스트는 주요 상황에 초점을 맞추는 것이 좋다.

테스트의 실행 시간은 피드백 속도와 연관이 있기때문에 가능한 빠른 시간 내에 테스트를 수행하는 것이 초점을 맞추어야 한다.

9.2. 외부 연동이 필요한 테스트 예

소프트웨어는 다양한 외부 연동이 필요하다.

대부분의 웹 애플리케이션은 데이터베이스와의 연동을 필요로 하며, HTTP를 이용한 통신도 증가하는 추세이고 카프카를 이용한 메시지 송수신도 늘어나고 있다.

이처럼 외부 연동 대상은 애플리케이션 내에서 쉽게 제어할 수 없기때문에 연동 대상이 늘어날수록 통합 테스트도 힘들어진다.

모든 외부 연동 대상을 통합 테스트에서 다룰 수 없지만, 일부 외부 대상은 어느 정도 수준에서 제어가 가능하다.

이 중에서 가장 일반적인 외부 연동인 데이터베이스 연동과 HTTP 연동을 위한 테스트 코드 작성 예제를 살펴보자.

9.2.1. 스프링 부트와 DB 통합 테스트

이전 포스팅에서 다루었던 회원 가입 프로그램에 대한 통합 테스트 예제를 먼저 살펴보자.

UserRegister 클래스와 관련 타입의 구조는 아래와 같다.

UserRegister 및 관련 타입

테스트 작성 요령이 목적이므로 각 인터페이스를 상속한 클래스는 간단하게 구현되었다.

예를 들어 SimpleWeakPasswordChecker는 암호 길이가 5가 안되면 약한 암호로 판정하고, VirtualEmailNotifier는 콘솔에 간단한 문자열을 출력하게 구현되었다.

사용한 기술은 스프링부트, JPA, MySQL로 가정한다.

현재 스프링에 등록된 Bean 객체는 아래와 같다.

  • UserRegister
  • SimpleWeakPasswordChecker
  • UserRepository : JPA를 이용해서 등록
  • VirtualEmailNotifier

위와 같은 상황에서 UserRegister를 테스트하는 코드는 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@SpringBootTest
class UserRegisterIntTest {

@Autowired
private lateinit var register: UserRegister
@Autowired
private lateinit var jdbcTemplate: JdbcTemplate

@Test
fun `동일ID가_이미_존재하면_익셉션`() {
// 상황 : INSERT 쿼리 실행
jdbcTemplate.update(
"insert into user values (?, ?, ?) " +
"on duplicate key update password = ?, email = ?",
"cbk", "pw", "cbk@cbk.com", "pw", "cbk@cbk.com"
)

// 실행, 결과 확인
assertThrows(DupIdException::class.java) {
register.register("cbk", "strongpw", "email@email.com")
}
}

@Test
fun `존재하지_않으면_저장함`() {
// 상황 : DELETE 쿼리 실행
jdbcTemplate.update(
"delete from user where id = ?", "cbk"
)

// 실행
register.register("cbk", "strongpw", "email@email.com")

// 결과 확인 : SELECT 퀄 ㅣ실행
val rowSet: SqlRowSet = jdbcTemplate.queryForRowSet(
"select * from user where id = ?", "cbk"
)
rowSet.next()
assertEquals("email@email.com", rowSet.getString("email"))
}
}

통합 테스트는 실제로 데이터베이스를 사용하며 동일한 테스트를 여러 번 수행하더라도 결과가 같게 나와야 한다.

따라서 테스트 코드에서 데이터베이스내 데이터를 알맞게 존재해야한다.

데이터가 존재하는 상황을 만들기 위해 데이터베이스에 데이터를 추가해야 하고, 존재하지않는 상황을 만들기 위해 데이터를 삭제해야 한다.

위 테스트를 단위 테스트로 작성한다면 아래와 같이 될 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
@DisplayName("이미 같은 ID가 존재하면 가입 실패")
fun dupIdExists() {
// 이미 같은 ID가 존재하는 상황 만들기
fakeRepository.save(
User(
id = "id",
password = "pw1",
email = "email@email.com"
)
)

assertThrows(DupIdException::class.java) {
userRegister.register("id", "pw2", "email")
}
}

통합 테스트와 단위 테스트는 실행 시간에도 차이가 있다.

스프링 부트를 이용한 통합 테스트는 테스트 메서드를 실행하기 전에 스프링 컨테이너를 생성하는 과정이 필요하다.

9.2.2. WireMock을 이용한 REST 클라이언트 테스트

통합 테스트하기 어려운 대상이 외부 서버이다.

이전 포스팅에서 다룬 예제인 외부 카드사 API를 다시 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
open class CardNumberValidator(
private val server: String
) {

open fun validate(cardNumber: String): CardValidity {
val httpClient = HttpClient.newHttpClient()
val request = HttpRequest.newBuilder()
.uri(URI.create("$server/card"))
.header("Content-Type", "text/plain")
.POST(BodyPublishers.ofString(cardNumber))
.timeout(Duration.ofSeconds(3))
.build()

return runCatching {
val response = httpClient.send(request, BodyHandlers.ofString())
return when (response.body()) {
"ok" -> CardValidity.VALID
"bad" -> CardValidity.INVALID
"expired" -> CardValidity.EXPIRED
"theft" -> CardValidity.THEFT
else -> CardValidity.UNKNOWN
}
}.onFailure { t ->
return when(t) {
is HttpTimeoutException -> CardValidity.TIMEOUT
is IOException,
is InterruptedException -> CardValidity.ERROR
else -> CardValidity.ERROR
}
}.getOrDefault(CardValidity.ERROR)
}
}

CardNumberValidator 클래스 자체를 테스트하려면 정해진 규칙에 맞게 통신할 수 있는 서버가 필요하다.

즉, 테스트를 위해 외부의 카드 정보 제공 API와 통신해야하는 데 원하는 상황을 쉽게 만들 수가 없다.

이때 WireMock을 이용하면 서버의 API를 Stub으로 대체할 수 있다.

아래는 WireMock을 이용한 테스트 코드의 예시이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class CardNumberValidatorTest {
private lateinit var wireMockServer: WireMockServer

@BeforeEach
fun setUp() {
wireMockServer = WireMockServer(Options().port(8089))
wireMockServer.start()
}

@Test
fun vaild() {
wireMockServer.stubFor(post(urlEqualTo("/card"))
.withRequestBody(equalTo("1234567890"))
.willReturn(aResponse()
.withHeader("Content-Type", "text/plain")
.withBody("ok")
)
)

val validator = CardNumberValidator("http://localhost:8089")
val validity = validator.validate("1234567890")

assertEquals(CardValidity.VALID, validity)
}

@Test
fun timeout() {
wireMockServer.stubFor(post(urlEqualTo("/card"))
.willReturn(aResponse()
.withFixedDelay(5000)
)
)

val validator = CardNumberValidator("http://localhost:8089")
val validity = validator.validate("1234567890")

assertEquals(CardValidity.TIMEOUT, validity)
}
}

WireMockServer는 HTTP 서버를 흉내 낸다. 아래와 같이 동작한다.

  • 테스트 실행 전에 WireMockServer를 시작하여 실제 HTTP 서버를 구동시킨다.
  • 테스트에서 WireMockServer의 동작을 기술한다.
  • HTTP 연동을 수행하는 테스트를 실행한다.
  • 테스트 실행 후 WireMockServer를 중지한다.

CardNumberValidator

9.2.3. 스프링 부트의 내장 서버를 이용한 API 기능 테스트

스프링 부트를 사용한다면 내장 톰캣을 이용해서 API에 대한 테스트를 JUnit 코드로 작성할 수 있다.

아래는 예제 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironmnet.RANDOM_PORT
)
class UserApiE2ETest {
@Authwired
private lateinit var restTemplate: TestRestTemplate

@Test
fun weakPwResponse() {
val reqBody = """
{
"id" : "id",
"pw" : "123",
"email" : "a@a.com",
""".trimIndent()

val request: RequestEntity<String> = RequestEntity
.post(URI.create("/users"))
.contentType(MediaType.APPLICATION_JSON_UTF8)
.body(reqBody)

val response: ResponseEntity<String> = restTemplate.exchange(
request,
String::class.java
)

assertEquals(HttpStatus.BAD_REQUEST, response.stateCode)
assertTrue(response.body.contains("WeakPasswordException"))
}
}