คลาส Application เป็นหนึ่งใน Component บนแอนดรอยด์ที่นักพัฒนาสามารถสร้างขึ้นมาเองเพื่อทำงานบางอย่างเพิ่มได้ จึงทำให้แอปส่วนใหญ่มักจะสร้างคลาส Application ขึ้นมาเอง

class AwesomeApplication: Application() {
    override fun onCreate() {
        super.onCreate()
        // Initialize what you want when application is starting
    }
}

ที่ต้องทำแบบนี้ก็เพราะว่าคลาสดังกล่าวเป็นหนึ่งในจุดเริ่มต้นสำหรับการทำงานของแอป จึงเหมาะกับการกำหนดค่าเริ่มต้นให้กับการทำงานต่าง ๆ อย่าง SDK หรือ Library บางตัว รวมไปถึงการทำงานที่เกี่ยวข้องกับคลาสตัวนี้โดยตรง

ยกตัวอย่างเช่น เจ้าของบล็อกต้องการให้ออกไปหน้าล็อกอินเมื่อผู้ใช้ไม่ได้ใช้งานนานเกิน 5 นาที

ตัวอย่างต่อไปนี้เป็นการจำลองการทำงานในการทำงานจริงเพื่อเป็นจุดเริ่มต้นสำหรับเนื้อหาหลักในบทความนี้

จึงทำให้เจ้าของบล็อกดัก Lifecycle ของ Activity ทั้งหมดภายในแอปผ่าน ActivityLifecycleCallbacks ของ Application แบบนี้ได้เลย

class AwesomeApplication : Application() {
    private val activityTracker = ActivityTracker()
    
    override fun onCreate() {
        super.onCreate()
        activityTracker.init(this)
    }
}

class ActivityTracker {
    fun init(application: Application) {
        application.registerActivityLifecycleCallbacks(lifecycleCallbacks)
    }

    private val lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
        override fun onActivityCreated(activity: Activity, bundle: Bundle?) { /* ... */ }
        override fun onActivityStarted(activity: Activity) { /* ... */ }
        override fun onActivityResumed(activity: Activity) { /* ... */ }
        override fun onActivityPaused(activity: Activity) { /* ... */ }
        override fun onActivityStopped(activity: Activity) { /* ... */ }
        override fun onActivitySaveInstanceState(activity: Activity, bundle: Bundle) { /* ... */ }
        override fun onActivityDestroyed(activity: Activity) { /* ... */ }
    }
}

สำหรับ ActivityTracker ถูกสร้างขึ้นมาเพื่อดัก Lifecycle ของ Activity เท่านั้น ส่วน​ Timer สำหรับจับเวลาการใช้งานแอปก็จะมีคลาสอีกตัวคอยจัดการให้แทน โดยให้มีชื่อคลาสว่า UserSessionTimer

class UserSessionTimer {
    fun onUserActive() { /* ... */ }
    fun onUserInactive() { /* ... */ }
}

class ActivityTracker {
    /* ... */
    private val timer = UserSessionTimer()
    
    private val lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks {
        override fun onActivityStarted(activity: Activity) {
            timer.onUserActive()
        }

        override fun onActivityStopped(activity: Activity) {
            timer.onUserInactive()
        }
        /* ... */
    }
}
อาจจะมีกรณีอื่นที่ใช้ในการคำนวณนอกเหนือจาก Lifecycle ของ Activity ด้วย จึงสร้างเป็นคลาส UserSessionTimer เพื่อให้นำไปใช้งานที่อื่นนอกเหนือจาก ActivityTracker ได้

เอาล่ะ ตัวอย่างทั้งหมดพร้อมสำหรับเนื้อหาหลักในบทความนี้แล้ว

ถึงเวลาเขียน UI Test

แล้วนักพัฒนาจะมั่นใจได้อย่างไรว่าการทำงานของคลาส Activity Tracker และ UserSessionTimer สามารถทำงานได้ถูกต้องล่ะ? ก็ต้องเขียน Integration Test ด้วย UI Test ยังไงล่ะ!

โดยจะใช้ Test Double ใน UserSessionTimer เพื่อตรวจสอบว่าทำงานได้ถูกต้องจริง

สมมติว่าใช้เป็น Mock ละกัน

แล้วนักพัฒนาจะส่ง Mock ของ UserSessionTimer เข้าไปแทนที่ตัวเดิมที่อยู่ใน ActivityTracker ด้วยวิธีไหนล่ะ?

ก็ต้องเพิ่ม Dependency Injection เข้าไปยังไงล่ะ

ในการทำ Dependency Injection บนแอนดรอยด์จะนิยมใช้ Hilt หรือ Koin ซึ่งในบทความนี้จะเจาะจงไปที่ Koin ส่วนรายละเอียดของ Hilt จะแยกเป็นอีกบทความ

ต้องบอกก่อนว่า Koin เป็น Service Locator, ไม่ใช่ Dependency Injection แต่ขอเหมารวมเพื่อความเข้าใจง่าย

ถ้าคุณเลือก Koin

ดูรายละเอียดเกี่ยวกับการใช้งาน Koin ได้ที่นี่

เราก็จะต้องทำให้ UserSessionTimer ที่เป็น Class Member ใน ActivityTracker รองรับ Dependency Injection ด้วยการย้ายไปอยู่ใน Constructor แทนก่อน

class UserSessionTimer { /* ... */ }

class ActivityTracker(val timer: UserSessionTimer) { /* ... */ }

จากนั้นก็สร้าง Koin Module สำหรับคลาสทั้ง 2 ตัวนี้ขึ้นมา

val appModule = module {
    single { UserSessionTimer() }
    single { ActivityTracker(get()) }
}

และในคลาส Application ก็กำหนดค่าเริ่มต้นให้กับ Koin และสร้าง ActivityTracker ผ่าน Koin แบบนี้แทน

class AwesomeApplication : Application() {
    private val activityTracker: ActivityTracker by inject()

    override fun onCreate() {
        super.onCreate()
        startKoin {
            modules(appModule)
        }
        activityTracker.init(this)
    }
}
นักพัฒนาสามารถใช้ Jetpack App Startup แล้วย้ายคำสั่ง startKoin { } ไปไว้ใน Initializer ของ Library ดังกล่าวได้เช่นกัน

และใน UI Test ก็จะให้ Koin ทำการ Reload Module ใหม่ เพื่อส่ง Mock ของ UserSessionTimer แทนของเดิม

@RunWith(AndroidJUnit4::class)
class UserSessionUiTest {
    @MockK
    lateinit var userSessionTimer: UserSessionTimer

    @Before
    fun setup() {
    	MockKAnnotations.init(this)
        val injectedModules = module {
            single { userSessionTimer }
            single { ActivityTracker(get()) }
        }
        unloadKoinModules(appModules)
        loadKoinModules(listOf(appModules, injectedModules))
        /* ... */
    }
    
    @Test
    fun case1() { /* ... */ }
    
    @Test
    fun case2() { /* ... */ }
    
    @Test
    fun caseN() { /* ... */ }
}
@Mockk และ MockKAnnotations.init(...) เป็นคำสั่งจาก Library ที่ชื่อ MockK เพื่อช่วยให้ง่ายต่อการสร้าง Test Double บน Kotlin โดยใส่ไว้ในโค้ดตัวอย่างเพื่อให้ผู้อ่านเห็นการสร้าง Mock สำหรับ UserSessionTimer ด้วย

เพียงเท่านี้ นักพัฒนาก็ส่ง Mock ของ UserSessionTimer ในตอนที่ UI Test ทำงานได้แล้วสิ?

อาจจะดูเหมือนผ่านไปได้ด้วยดี แต่เดี๋ยวก่อน ยังมีบางอย่างที่ผู้อ่านต้องรู้เกี่ยวกับการทำงานของ UI Test อยู่นะ

การทำงานของ Application Class ตอนที่ UI Test ทำงาน

ในการสร้าง UI Test ใด ๆ บนแอนดรอยด์ เมื่อใดก็ตามที่ UI Test เริ่มทำงาน Application จะถูกสร้างขึ้นมาเพียงครั้งเดียวเพื่อรันเทสทั้งหมด

รวมไปถึง Content Provider ด้วยเช่นกัน

นั่นหมายความว่าใน @Before ที่นักพัฒนาจะส่ง Mock ของ UserSessionTimer ผ่าน Dependency Injection จะเกิดขึ้นหลังจากตอนที่คลาส Application เริ่มทำงานแล้ว

จึงทำให้ข้างในคลาส Application จะยังถือ ActivityTracker ที่ทำงานตามปกติอยู่ ไม่ใช่ ActivityTracker ที่มี Mock ของ UserSessionTimer อยู่ข้างใน

ใช้ Custom Getter เพื่อส่ง Test Double เข้ามาแทน

จากเดิมที่ UserSessionTimer จะถูกส่งผ่าน Constructor ก็ให้ประกาศเป็น Class Member แล้วใช้เป็น Custom Getter แทน เพื่อทำให้ Dependency Injection สามารถส่ง Test Double เข้ามาในภายหลังได้

// UserSessionTimer.kt
class UserSessionTimer { /* ... */ }

// ActivityTracker.kt
import org.koin.core.component.KoinComponent
import org.koin.core.component.get

class ActivityTracker: KoinComponent { 
    private val timer: UserSessionTimer
        get() = get()
}

เป็นอันเสร็จเรียบร้อย

ด้วยวิธีนี้ก็จะทำให้ UserSessionTimer ใน ActivityTracker ได้เป็น Test Double ในตอนที่รัน UI Test โดยนักพัฒนาสามารถกำหนด Test Double ใน Dependency Inecjtion ตามที่ต้องการไว้ใน @Before หรือ @Test ก็ได้ และจะใช้ Test Double ร่วมกันสำหรับทุก Test Case ก็ได้ หรือแยกคนละตัวก็ทำได้เช่นกัน

ซึ่งวิธีดังกล่าวจะไม่ส่งผลกับการทำงานของแอปตามปกติอีกด้วย จึงไม่ต้องกังวลว่าแอปจะทำงานไม่ถูกต้องเพียงเพราะอยากจะเขียน UI Test

และผู้อ่านคนใดอยากเห็นโค้ดตัวอย่างที่ใช้ในบทความนี้ สามารถเข้าไปดูกันใน GitHub ได้เลยจ้า

GitHub - akexorcist/android-application-class-test-double: Inject test double in application class with Hilt/Koin
Inject test double in application class with Hilt/Koin - GitHub - akexorcist/android-application-class-test-double: Inject test double in application class with Hilt/Koin