필자는 최근에 진행중인 프로젝트를 리팩터링 하였다. 기본 패키지, 파일 네이밍부터 핵심 로직 수정까지 거의 모든 파일을 수정할 정도로 많은 작업을 수행했다. 그렇다 보니, 내가 수정한 작업이 정말 이전과 같이 잘 동작하는지 파악하기가 정말 어려웠고, 시간도 많이 걸렸다. 수정한 로직에서 문제가 발생하여, 디버깅을 하면서 문제를 탐색하고 고민하는 시간이 정말 많았다.
테스트에 대한 시간 투자가 아까워, 계속해서 미루었던 나는, 이번에 테스트에 대해 공부해보고, 실제 프로젝트에 도입하려고 한다.
테스트를 왜 작성해야 할까?
QA보다 더 훨씬 효율적으로 점검할 수 있다.
- 필요할 때 마다, 규모가 커지면 커질 수록 QA에 의존하는 것은 힘든 일이다.
- 자동화된 테스트는 앱의 확장가능성을 더욱 높여준다.
좋은 설계를 촉진한다.
- 테스트를 더 용이하기 위해 지키는 규칙들, 단일책임원칙(SRP), 개방폐쇄원칙(OCP)를 적용할 수록, 테스트도 용이해지고, 더 좋은 설계를 가능하게 한다.
생산적이다.
- 아키텍처의 변경이나 리팩토링시 많은 수정을 요구한다.
- 테스트가 구현이 잘 되어 있을 때, 확신을 가지고 수정할 수 있다.
개발 속도
- 테스트를 구현하지 않으면, 초반 개발 시간을 줄일 수 있으나, 나중에 설계와 구현이 마쳤을 때 버그가 발생하면 훨씬 더 많은 시간이 든다.
협업을 촉진한다.
- 테스트는 하나의 문서와도 같다. 테스트를 통해, 함수나 API의 원작자의 의도와 기능을 알 수 있다.
- 코드의 원작자가 아니더라도, 코드를 수정할 수 있다.
- 더 효율적인 코드 리뷰를 가능하게 한다.
테스트의 종류는 어떤 것이 있을까?
테스트를 잘 작성하려면?
테스트 예시를 통해 알아보자!
1-1. 종속성 추가하기
plugins {
id 'de.mannodermaus.android-junit5' version "1.9.3.0"
}
defaultConfig {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
dependencies {
// Junit5
testImplementation "org.junit.jupiter:junit-jupiter-api:5.9.3"
testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:5.9.3"
testImplementation "org.junit.jupiter:junit-jupiter-params:5.9.3"
// AssertK
testImplementation "com.willowtreeapps.assertk:assertk:0.26.1"
}
Unit Test를 위해, 사용할 라이브러리는 Junit5와 assetk이다. 위와 같이 종속성을 추가한다.
Junit5는 Junit4보다 확장된 기능을 제공한다. AssertK는 Assertion을 위해 사용되며, 가독성이 뛰어나다.
1 - 2. 테스트 파일 생성
1-x) 테스트 어노테이션
테스트에 앞서, 테스트 기본 어노테이션에 대해 소개한다.
class ExampleUnitTest {
@BeforeEach
fun setUp(){
}
@AfterEach
fun tearDown(){
}
@Test
fun addition_isCorrect() {
assertThat(2 + 2).isEqualTo(4)
}
}
- @BeforeEach는 모든 테스트 실행 전에 호출되는 테스트이며, 주로 초기화 작업을 수행하거나, 테스트 환경을 설정하는데 사용한다.
- @AfterEach는 모든 테스트 실행 후에 호출되는 테스트이며, 주로 테스트 이후 리소스 해제를 위해 사용된다.
- @Test는 테스트할 메소드를 명시하는데 사용된다.
1-4. 테스트 구현
해당 테스트 어노테이션을 통해, 다음과 같은 테스트 케이스를 구현할 수 있다.
internal class ShoppingCartTest{
private lateinit var shoppingCart: ShoppingCart
@BeforeEach
fun setUp() {
shoppingCart = ShoppingCart()
}
@Test
fun `여러 개의 상품을 더했을 때, 총 가격이 일치해야 한다`(){
// GIVEN
val product = Product(
id = 1,
name = "아이스크림",
price = 40.0
)
shoppingCart.addProduct(product, 4)
// ACTION
val totalCost = shoppingCart.getTotalCost()
// ASSERTION
assertThat(totalCost).isEqualTo(160.0)
}
}
위의 테스트 케이스를 살펴보면,
- 테스트 전, ShoppingCart를 초기화 하고, 테스트에서 ShoppingCart에 상품의 총 합을 검증하고 있다.
- 백틱을 사용하여, 자연어로 테스트 함수명을 작성하였다.
- 테스트는 가독성이 중요하여, 함수명이 길어도, 잘 읽히는 것이 중요하다.
- GIVEN, ACTION, ASSERTION으로 구분하였다.
- GIVEN : 테스트 환경과 사전 조건을 설정한다. 위 코드에서는 객체를 생성하고, 초기 상태를 설정하였다.
- ACTION : 테스트를 수행한다. 위 코드에서는 특정 메소드를 호출하고, 입력하였다.
- ASSEERTION : 결과를 검증한다. 위 코드에서는 예상결과와 실제 결과를 비교하고 있다.
1-5. 알파 테스트
Junit5에서는 다양한 어노테이션을 통해, 여러 조건에서 용이하게 테스트할 수 있다. 아래는 몇가지 예시를 소개한다.
1. ReapeatedTest
internal class ShoppingCartTest{
private lateinit var shoppingCart: ShoppingCart
@BeforeEach
fun setUp() {
shoppingCart = ShoppingCart()
}
@RepeatedTest(100)
fun `음수 갯수의 상품을 100번 추가했을 때, 모두 예외를 발생해야 한다`(){
// GIVEN
val product = Product(
id = 1,
name = "마카롱",
price = 4.0
)
// ASSERTION && ACTION
assertFailure {
shoppingCart.addProduct(product, -4)
}
}
}
@RepeatedTest는 테스트를 입력한 수만큼 반복한다. 이러한 테스트 어노테이션은 Flaky Test에서 사용될 수 있다.
FlakyTest는 테스트 코드가 실행될 때, 때로는 통과하고 때로는 실패하는 테스트를 의미한다.
전반적으로 문제에 대한 원인을 찾기 힘들 때, RepeatedTest를 통해 해결할 수 있다.
2. ParameterizedTest
internal class ShoppingCartTest{
private lateinit var shoppingCart: ShoppingCart
@BeforeEach
fun setUp() {
shoppingCart = ShoppingCart()
}
@ParameterizedTest
@ValueSource(
ints = [1, 2, 3, 4, 5]
)
fun `여러 개의 상품을 1 ~ 5 번 더했을 때, 매번 총 가격이 일치해야 한다`(quantity: Int){
// GIVEN
val product = Product(
id = 1,
name = "아이스크림",
price = 40.0
)
shoppingCart.addProduct(product, quantity)
// ACTION
val totalCost = shoppingCart.getTotalCost()
// ASSERTION
assertThat(totalCost).isEqualTo(40.0 * quantity)
}
}
@ParameterizedTest는 매개변수를 받는 테스트 함수에서 사용될 수 있다. 추가적으로 @ValueSource 어노테이션을 통해, 원하는 입력을 매개변수로 제공할 수 있다.
internal class ShoppingCartTest{
private lateinit var shoppingCart: ShoppingCart
@BeforeEach
fun setUp() {
shoppingCart = ShoppingCart()
}
@ParameterizedTest
@CsvSource(
"4,20.0",
"3,15.0",
"6,30.0"
)
fun `여러 개의 상품을 quantity번 더했을 때, 매번 총 가격이 expectedPriceSum과 일치해야 한다`(
quantity: Int,
expectedPriceSum: Double
){
// GIVEN
val product = Product(
id = 1,
name = "아이스크림",
price = 5.0
)
shoppingCart.addProduct(product, quantity)
// ACTION
val totalCost = shoppingCart.getTotalCost()
// ASSERTION
assertThat(totalCost).isEqualTo(expectedPriceSum)
}
}
만약, 입력이 여러 개라면, @CsvSource 어노테이션을 통해, csv형식으로 입력을 매개변수로 제공할 수 있다.
1-6. Private function test
만약, 테스트하고자 하는 함수가 다음과 같은, Private function인 경우는 어떻게 할까?
class ShoppingCart {
private val validProductIds = listOf(1, 2, 3, 4, 5)
private val items = mutableListOf<Product>()
private fun isValidProduct(product: Product): Boolean {
return product.price >= 0.0 && product.id in validProductIds
}
}
앞에 private을 public으로 변경하고, @OpenForTesting(테스트 시에만, Public) 어노테이션을 작성할 수도 있다. 하지만, 이는 권장하지 않는 방법이다.
다른 Public 함수를 통해, 간접적으로 Private을 검증하는 방법이 올바른 방법이다.
필자는 다음과 같이, 공개 함수인, addProudct() 에 private function을 호출하여, 간접적으로 검증하였다.
class ShoppingCart {
private val validProductIds = listOf(1, 2, 3, 4, 5)
private val items = mutableListOf<Product>()
fun addProduct(product: Product, quantity: Int) {
if(quantity < 0) {
throw IllegalArgumentException("Quantity can't be negative")
}
repeat(quantity) {
if(isValidProduct(product)) { // private function 호출
items.add(product) // 유효한 상품만 추가
}
}
}
private fun isValidProduct(product: Product): Boolean {
return product.price >= 0.0 && product.id in validProductIds
}
}
internal class ShoppingCartTest{
private lateinit var shoppingCart: ShoppingCart
@BeforeEach
fun setUp() {
shoppingCart = ShoppingCart()
}
@Test
fun `존재하지 않는 상품인 경우, isInvalidProduct 함수는 Invalid를 반환해야 한다`(){
// GIVEN
val product = Product(
id = 1231,
name = "아이스크림",
price = 5.0
)
shoppingCart.addProduct(product, 5)
// ACTION
val totalCost = shoppingCart.getTotalCost()
// ASSERTION
assertThat(totalCost).isEqualTo(0.0)
}
}
마지막으로
이번 포스팅에서는 테스트 소개와 테스트 예제를 알아보았다. 더 나은, 1퍼센트라도 더 나은 프로젝트를 위해 테스트는 필요하다고 생각한다. 특히, QA도 없는 프로젝트인 경우는 더욱더. 더 나은 프로젝트를 구현하기 위해, 테스트를 배워 진행중인 프로젝트에 적용하고 싶다!
해당 코드는 아래의 저장소에서 확인할 수 있습니다.
https://github.com/jeongjaino/EveryAndroid3/tree/main/TestingCourse/TestingCourse3