엄코딩의 개발 일지

유닛테스트 코드를 작성한 이유

 

앱을 개발하다보면 코드가 예상대로 실행이 되는지 테스트가 필요합니다.

 

하지만 매번 수동으로 테스트하게 되면 인력 소모도 심하고, 수동으로 하다보니 매번 정확하지 않은 테스트가 수행될 수 있어 안정성이 떨어질 수 있습니다.

 

만약 자동화 테스트를 진행하게 된다면 인력 소모를 줄일 수 있고, 보다 정교하고 안정적인 테스트를 진행할 수 있다고 생각했습니다.

 

이 포스팅의 목적

 

이번 포스팅은 로컬 JVM 에서 실행할 수 있는 유닛 테스트 코드 작성입니다.

 

구글링한 자료를 바탕으로 Kotlin 언어로 작성해 보았습니다.

 

우선 위 이미지에서 테스트 환경 프로젝트 구조를 볼 수 있습니다. ( 샘플 예제 코드와는 관련이 없습니다. )

 

~androidTest/java/com.example.androidtest/ExampleInstrumentedTest는 실제 안드로이드 기기를 통한 테스트 방법이고,

 

~test/java/com.example.androidtest/ExampleUnitTest가 유닛테스트, 즉  이번 포스팅에서 다루게 될  로컬 JVM에서 실행할 수 있는 테스트 방법입니다.

 

 

build.gradle

apply plugin: 'com.android.application'

apply plugin: 'kotlin-android'

apply plugin: 'kotlin-android-extensions'

android {
    compileSdkVersion 28
    defaultConfig {
        applicationId "com.example.androidtest"
        minSdkVersion 24
        targetSdkVersion 28
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation"org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation 'com.android.support:appcompat-v7:28.0.0'
    implementation 'com.android.support.constraint:constraint-layout:1.1.3'

    // android test implementation
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}

 

values/strings

<resources>
    <string name="app_name">AndroidTest</string>
    <string name="coffee_price">Coffee: $%.1f</string>
    <string name="total_price">Total price: $%.1f</string>
    <string name="increment_label">+</string>
    <string name="decrement_label">-</string>
    <string name="default_coffee_count">0</string>
</resources>

 

acitivity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">
    <TextView
            android:id="@+id/coffee_price"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="20sp"
            android:text="@string/coffee_price"/>
    <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginTop="12dp"
            android:layout_marginBottom="12dp"
            android:gravity="center_vertical">
        <Button
                android:id="@+id/coffee_decrement"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:background="@android:color/transparent"
                android:text="@string/decrement_label"/>
        <TextView
                android:id="@+id/coffee_count"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginLeft="16dp"
                android:layout_marginRight="16dp"
                android:textSize="32sp"
                android:text="@string/default_coffee_count"/>
        <Button
                android:id="@+id/coffee_increment"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:background="@android:color/transparent"
                android:text="@string/increment_label"/>
    </LinearLayout>
    <TextView
            android:id="@+id/total_price"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="20sp"
            android:text="@string/total_price"/>
</LinearLayout>

 

MainActivity.class

import android.support.v7.app.AppCompatActivity
import android.os.Bundle
import android.os.PersistableBundle
import android.util.Log
import android.view.View
import kotlinx.android.synthetic.main.activity_main.*

class MainActivity : AppCompatActivity() {

    val COFFEE_COUNT = "coffee_count"
    val DEFAULT_COFFEE_PRICE = 5.0f

    private lateinit var mOrder : CoffeeOrder

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        mOrder = CoffeeOrder(DEFAULT_COFFEE_PRICE)

        coffee_price.text = String.format(getString(R.string.coffee_price),DEFAULT_COFFEE_PRICE)
        total_price.text = String.format(getString(R.string.total_price),0.0f)

        coffee_increment.setOnClickListener {
            mOrder.incrementCoffeeCount()
            updateCoffeeCount()
            updateTotalPrice()
        }

        coffee_decrement.setOnClickListener {
            mOrder.decrementCoffeeCount()
            updateCoffeeCount()
            updateTotalPrice()
        }
    }


    override fun onSaveInstanceState(outState: Bundle?) {
        super.onSaveInstanceState(outState)
        outState!!.putInt(COFFEE_COUNT,mOrder.mCoffeeCount)
    }

    override fun onRestoreInstanceState(savedInstanceState: Bundle?) {
        super.onRestoreInstanceState(savedInstanceState)
        if(savedInstanceState != null){
            mOrder.mCoffeeCount = savedInstanceState.getInt(COFFEE_COUNT)
            updateCoffeeCount()
            updateTotalPrice()
        }
    }

    private fun updateCoffeeCount(){
        coffee_count.text = ""+mOrder.mCoffeeCount

    }

    private fun updateTotalPrice(){
        total_price.text = String.format(getString(R.string.total_price),mOrder.mTotalPrice)
    }
}

 

CoffeeOrder.class

class CoffeeOrder(coffeePrice : Float) {
    var mCoffeePrice = coffeePrice
    var mCoffeeCount =  0
        get() = if(field < 0) 0 else field

    var mTotalPrice = 0f

    fun incrementCoffeeCount(){
        mCoffeeCount++
        calculateTotalPrice()
    }

    fun decrementCoffeeCount(){
        if( mCoffeeCount > 0){
            mCoffeeCount--
            calculateTotalPrice()
        }
    }

    fun calculateTotalPrice(){
        mTotalPrice = mCoffeePrice * mCoffeeCount
    }
}

 

CoffeeOrderTest.class

import junit.framework.Assert.assertEquals
import junit.framework.Assert.assertNotNull
import org.junit.Before
import org.junit.Test

class CoffeeOrderTest {
    private val PRICE_TEST = 5.0f
    private lateinit var mOrder : CoffeeOrder

    @Before
    fun setUp(){
        mOrder = CoffeeOrder(PRICE_TEST)
    }

    @Test
    fun orderIsNotNull(){
        assertNotNull(mOrder)
    }
    @Test
    fun orderDecrement(){
        mOrder.decrementCoffeeCount()
        assertEquals(0, mOrder.mCoffeeCount)

        mOrder.mCoffeeCount = 25
        mOrder.decrementCoffeeCount()
        assertEquals(24,mOrder.mCoffeeCount)
    }


    @Test
    fun orderIncrement(){
        mOrder.incrementCoffeeCount()
        assertEquals(1,mOrder.mCoffeeCount)

        mOrder.initCoffeeCount(25)
        mOrder.incrementCoffeeCount()
        assertEquals(26,mOrder.mCoffeeCount)

    }

    @Test
    fun orderTotalPrice(){
        assertEquals(0.0f, mOrder.mTotalPrice)

        mOrder.initCoffeeCount(25)
        assertEquals(PRICE_TEST*25, mOrder.mTotalPrice)
    }

    @Test
    fun orderSetCoffeeCount(){
        mOrder.initCoffeeCount(-1)
        assertEquals(0, mOrder.mCoffeeCount)

        mOrder.initCoffeeCount(25)
        assertEquals(25, mOrder.mCoffeeCount)
    }
}

 

실행

 

실행 결과

 

만약 기대값과 다른 결과가 나온다면?

위 이미지에서 볼 수 있듯이 코드 왼쪽에 빨간 아이콘이 보입니다.

 

( 해당 테스트코드가 실패했을 경우 빨간색. 성공했다면 초록색이 보일겁니다. )

 

각각의 @Test 어노테이션이 붙은 코드 블록에서 아이콘을 확인할 수 있습니다. 즉, 각 메소드마다 테스트를 해볼 수 있습니다.

 

위 이미지에서 기대값이 -1인데 실재 값이 0으로 나왔을 때 아래와 같은 에러 코드를 볼 수 있습니다.

 

 

이것저것 테스트해보고 있는데 유닛테스트를 더 자세하게 학습하고 싶어지네요!

 

읽어주셔서 감사합니다.

 

 

 

 

- 참고 자료

 

https://alexzh.com/2016/03/24/android-testing-unit-testing/