สร้าง Repository ใน MVVM บนแอนดรอยด์ให้เขียนเทสได้ง่ายกันเถอะ
ถ้าจะต้องเขียนแอปขึ้นมาใหม่ซักตัวหนึ่ง และต้องเลือก Structure Pattern ในโปรเจคนั้นๆ ส่วนใหญ่ก็คงจะเลือก MVVM กัน เพราะว่าเป็น Pattern ที่ค่อยข้างได้รับความนิยมและการสนับสนุนจากทีมพัฒนาแอนดรอยด์มากที่สุดเลยก็ว่าได้ และยิ่งนำ Clean Architecture เข้ามาใช้ด้วยแล้วก็ยิ่งทำให้โค้ดนั้นดูดีมากขึ้นไปอีก
ซึ่งบทความนี้ขอเน้นไปที่เรื่องราวของ Repository ซึ่งเป็น 1 ใน Layer สำคัญของการเขียน MVVM + Clean Architecture เลยก็ว่าได้ เพราะถ้ามัวแต่ใส่ใจกับ Layer บนๆ อย่าง Presentation Layer ก็อาจจะทำให้ MVVM + Clean Architecture ไปไม่ถึงฝั่งฝันเพราะตกม้าตายตอนเขียนเทสก็เป็นได้
บทสรุปโดยย่อของ Repository
Repository เป็นส่วนหนึ่งของ Data Layer ที่จะทำหน้าที่คอยรับส่งข้อมูลระหว่าง Data Source ใดๆ เพื่อส่งต่อให้กับ Layer ที่อยู่ข้างบนเพื่อนำไปใช้งาน โดย Data Source จะเป็นอะไรก็ได้ ไม่ว่า API, Database หรือแม้กระทั่ง Source File ต่างๆ ก็นับว่าเป็น Data Source กันทั้งสิ้น
โดย Data Source จะมีกี่ตัวก็ได้ และในการรับส่งข้อมูลกับ Data Source แต่ละตัว จะให้ Repository เป็นคนตัดสินใจทั้งหมด
ทำไมถึงไม่ควรมองข้าม Repository
เพราะ Repository เป็นหนึ่งใน Layer ที่สามารถเขียนเทสได้ง่าย เมื่อเทียบกับ Presentation Layer ที่ต้องทำ Instrumentation Test ในแอนดรอยด์เพื่อทำการเทส ในขณะที่ Repository สามารถเขียนเทสด้วย Unit Test ได้ทันที จึงไม่ต้องเสียเวลาในการสร้าง Environment สำหรับการเทสมากนัก
โครงสร้างของ Repository นั้นสำคัญไฉน
ปัญหาหลักสำหรับ Repository ก็คือ Data Source สามารถมีได้หลายตัว และมีวิธี Implement ที่ต่างกัน ดังนั้นการทำ Interface เพื่อกำหนดรูปแบบในการรับส่งข้อมูลให้เหมือนกันจึงช่วยลดโค้ดที่ผูกมัดกับ Data Source ตัวใดตัวหนึ่งเกินจำเป็น
ถ้า Data Source แต่ละตัวมี Data Holder ที่เหมือนกันก็คงไม่มีปัญหาอะไร แต่ถ้าต่างกันก็จะต้องสร้าง Data Holder กลางขึ้นมาเพื่อให้สามารถใช้ร่วมกันได้ระหว่างแต่ละ Data Source ด้วย (ถ้านึกไม่ออกว่า Data Holder คืออะไร ให้นึกถึงคลาส Response ของ Retrofit)
ยุ่งยากยังไง? ให้ลองดูโค้ดตัวอย่างนี้
interface ApiManager {
fun getProfile(id: String?): Response<Profile>
}
interface DatabaseManager {
fun getProfile(id: String?): Profile?
}
จะเห็นว่าข้อมูลที่ได้จาก API และ Database นั้นมีลักษณะไม่เหมือนกัน (ขึ้นอยู่วิธีการ Implement ของ Library) จึงต้องสร้าง Data Holder กลางขึ้นมาเพื่อใช้งานร่วมกัน และก็ต้องแลกด้วยการแปลงข้อมูลของ Data Source แต่ละตัวให้ใช้ Data Holder ร่วมกันได้
data class Result<T>(val isSuccess: Boolean, val message: String?, val data: T?) {
companion object {
fun <T> success(data: T?): Result<T> = Result(true, null, data)
fun <T> error(message: String?): Result<T> = Result(false, message, null)
}
}
ดังนั้นไม่ว่าจะเป็น Data Source แบบไหนก็ตาม เมื่อได้ข้อมูลจากที่ใดก็ตาม จะต้องเก็บข้อมูลไว้ใน Result ก่อนที่จะส่งกลับไปให้ต้นทาง
interface DataSource {
fun getProfile(id: String?): Result<Profile>
}
ถ้าเรียกข้อมูลจาก Network ก็จะทำให้ข้อมูลอยู่ในรูปแบบของคลาส Result และถ้าดึงจาก Database ก็ต้องทำให้ข้อมูลอยู่ในรูปแบบเหมือนกันด้วย เพราะต้นทางที่เรียกใช้งานไม่จำเป็นต้องรู้ว่า Data Source นั้นดึงข้อมูลมาจากที่ไหน
class RemoteDataSource(private var apiManager: ApiManager) : DataSource {
override fun getProfile(id: String?): Result<Profile> {
val response = apiManager.getProfile(id)
return if (response.isSuccess()) {
Result.success(response.data)
} else {
Result.error<Profile>(response.message)
}
}
}
class LocalDataSource(private var databaseManager: DatabaseManager) : DataSource {
override fun getProfile(id: String?): Result<Profile> {
val profile = databaseManager.getProfile(id)
return profile?.let {
Result.success(profile)
} ?: run {
Result.error("No data in database")
}
}
}
ด้วยความยุ่งยากของขั้นตอนนี้ จึงมีการออกแบบให้ Data Source ทุกตัวนั้นสามารถใช้ Data Holder เป็น LiveData เพื่อให้ข้อมูลไหลไปมาอยู่บนเส้นเดียวกันไม่ว่าจะเป็น Data Source ใดๆก็ตาม (ซึ่งตอนนี้ทั้ง Room และ Retrofit สามารถใช้ LiveData ได้ทั้งคู่)
อาจจะฟังดูเหมือนว่า LiveData เข้ามาแก้ปัญหานี้ได้อย่างง่ายดาย แต่จริงๆแล้วกลับไม่ได้ง่ายขนาดนั้น เพราะว่า LiveData ไม่ได้เป็นแค่ Data Holder แต่ยังมีความสามารถอื่นๆที่ทำให้การใช้ LiveData ตัวเดียวกับ Data Source หลายๆตัวเป็นเรื่องยาก ดังนั้นในบทความนี้เจ้าของบล็อกจะยังไม่พูดถึง LiveData เพื่อให้เข้าใจ Repository ในแบบที่ควรจะเป็นก่อน
จากโค้ดตัวอย่างก่อนหน้านี้จะเห็นว่า RemoteDataSource และ LocalDataSource นั้น Implement มาจาก Interface รวมไปถึง ApiManager กับ DatabaseManager ก็เป็น Interface เช่นกัน เพื่อป้องกันไม่ให้ Class ผูกกันมากจนเกินไป (หนึ่งในสาเหตุที่ทำให้เขียนเทสยาก)
class RemoteDataSource(private var apiManager: ApiManager) : DataSource { ... }
class LocalDataSource(private var databaseManager: DatabaseManager) : DataSource { ... }
การเรียกใช้งาน Data Source ใน Repository
หน้าที่ของ Repository ก็คือการเรียกข้อมูลจาก Data Source ต่างๆตาม Business Logic นั่นเอง
class DataRepository(var dataSource: DataSource) {
fun getProfileWelcome(id: String?): Result<String> { ... }
}
ตัวอย่างข้างบนคือการใช้งาน Data Source ในรูปแบบที่เรียบง่ายที่สุด ก็คือให้ Repository ไปดึงข้อมูลจาก Data Source แล้วส่งผลลัพธ์ออกไป
แต่บ่อยครั้งผู้ที่หลงเข้ามาอ่านก็อาจจะต้องการทำบางอย่างเพิ่มอีก เช่น แปลงข้อมูลที่ได้จาก Data Source นิดหน่อย หรือแม้กระทั่งการจัดการกับ Data Source หลายๆตัวพร้อมกัน ดังนั้นในบางครั้งมันก็อาจจะมีหน้าตาประมาณนี้
class DataRepository(var localDataSource: DataSource, var remoteDataSource: DataSource) {
fun getProfile(id: String?, networkOnly: Boolean = false): Result<Profile> {
if (!networkOnly) {
val result = localDataSource.getProfile(id)
if (result.isSuccess) {
return result
}
}
return remoteDataSource.getProfile(id)
}
}
จากตัวอย่างข้างบนนี้เจ้าของบล็อกให้ทำการเช็คใน Local Data Source ก่อนว่ามีข้อมูลอยู่หรือไม่ ถ้ามีก็จะใช้ข้อมูลนั้นทันที แต่ถ้าไม่มีก็จะใช้เป็น Remote Data Source แทน แต่เพื่อให้ Skip การดึงข้อมูลจาก Local Data Source ได้ จึงเพิ่มตัวแปรที่ชื่อว่า networkOnly
เพื่อใช้ตอนที่ต้องการดึงข้อมูลจาก Network Data Source โดยตรง
ไม่ว่าจะเขียนด้วย Logic ไหนก็ตาม ขอแค่อย่างเดียว…
อย่าลืมเขียนเทสให้ Repository
เนื่องจาก Data Source เป็น Interface จึงสามารถ Mock ขึ้นมาได้อย่างง่าย โดยไม่ต้องไปนั่ง Mock ถึงระดับ ApiManager หรือ DatabaseManager เลยซักนิด ซึ่งจะช่วยลดความยุ่งยากในการเทสได้เยอะมากกกกกกกก
ดังนั้นในการเขียนเทสเพื่อทดสอบการทำงานของ Repository จึงเน้นไปที่การใช้ Mockito เพื่อจำลองการทำงานของ Data Source เพื่อทดสอบดูว่าถ้ากำหนดให้ Data Source ทำงานในเงื่อนไขต่างๆ จะได้ผลลัพธ์ออกมาตรงกับที่คิดไว้หรือไม่
ยกตัวอย่างเช่น
@Test
fun `Get profile - network only, local result, remote result - Should success with remote result`() {
val localDataSource = mock(DataSource::class.java)
val remoteDataSource = mock(DataSource::class.java)
`when`(remoteDataSource.getProfile(any())).thenReturn(Result.success(Profile("1234567890", "Akexorcist", "Android Developer")))
val dataRepository = DataRepository(localDataSource, remoteDataSource)
val result = dataRepository.getProfile("123", true)
verify(localDataSource, times(0)).getProfile(any())
verify(remoteDataSource, times(1)).getProfile(any())
assertEquals("Akexorcist", result.data?.name)
assertTrue(result.isSuccess)
}
จะเห็นว่าเจ้าของบล็อกสามารถจำลองการทำงานของ Data Source ทั้ง 2 ตัวได้ง่ายมาก แถมสามารถเช็คได้ด้วยว่าตัวไหนถูกเรียกหรือไม่ถูกเรียก โดยที่ไม่ต้องไปทำอะไรกับ Data Source ของจริงเลย
ดังนั้นเมื่อลองเขียนทุกเงื่อนไขที่น่าจะเป็นไปได้ ก็จะได้ออกมาทั้งหมดประมาณนี้
class DataRepositoryTest {
@Test
fun `Get profile - local result, remote result - Should success with local result`() {
val localDataSource = mock(DataSource::class.java)
val remoteDataSource = mock(DataSource::class.java)
`when`(localDataSource.getProfile(any())).thenReturn(Result.success(Profile("1234567890", "Akexorcist", "Android Developer")))
val dataRepository = DataRepository(localDataSource, remoteDataSource)
val result = dataRepository.getProfile("123", false)
verify(localDataSource, times(1)).getProfile(any())
verify(remoteDataSource, times(0)).getProfile(any())
assertEquals("Akexorcist", result.data?.name)
assertTrue(result.isSuccess)
}
@Test
fun `Get profile - no local result, remote result - Should success with remote result`() {
val localDataSource = mock(DataSource::class.java)
val remoteDataSource = mock(DataSource::class.java)
`when`(localDataSource.getProfile(any())).thenReturn(Result.error("No data in database"))
`when`(remoteDataSource.getProfile(any())).thenReturn(Result.success(Profile("1234567890", "Akexorcist", "Android Developer")))
val dataRepository = DataRepository(localDataSource, remoteDataSource)
val result = dataRepository.getProfile("123", false)
verify(localDataSource, times(1)).getProfile(any())
verify(remoteDataSource, times(1)).getProfile(any())
assertEquals("Akexorcist", result.data?.name)
assertTrue(result.isSuccess)
}
@Test
fun `Get profile - no local result, no remote result - Should error`() {
val localDataSource = mock(DataSource::class.java)
val remoteDataSource = mock(DataSource::class.java)
`when`(localDataSource.getProfile(any())).thenReturn(Result.error("No data in database"))
`when`(remoteDataSource.getProfile(any())).thenReturn(Result.error("Service unavailable"))
val dataRepository = DataRepository(localDataSource, remoteDataSource)
val result = dataRepository.getProfile("123", false)
verify(localDataSource, times(1)).getProfile(any())
verify(remoteDataSource, times(1)).getProfile(any())
assertNull(result.data)
assertFalse(result.isSuccess)
}
@Test
fun `Get profile - network only, local result, remote result - Should success with remote result`() {
val localDataSource = mock(DataSource::class.java)
val remoteDataSource = mock(DataSource::class.java)
`when`(remoteDataSource.getProfile(any())).thenReturn(Result.success(Profile("1234567890", "Akexorcist", "Android Developer")))
val dataRepository = DataRepository(localDataSource, remoteDataSource)
val result = dataRepository.getProfile("123", true)
verify(localDataSource, times(0)).getProfile(any())
verify(remoteDataSource, times(1)).getProfile(any())
assertEquals("Akexorcist", result.data?.name)
assertTrue(result.isSuccess)
}
@Test
fun `Get profile - network only, no local result, remote result - Should success with remote result`() {
val localDataSource = mock(DataSource::class.java)
val remoteDataSource = mock(DataSource::class.java)
`when`(localDataSource.getProfile(any())).thenReturn(Result.error("No data in database"))
`when`(remoteDataSource.getProfile(any())).thenReturn(Result.success(Profile("1234567890", "Akexorcist", "Android Developer")))
val dataRepository = DataRepository(localDataSource, remoteDataSource)
val result = dataRepository.getProfile("123", true)
verify(localDataSource, times(0)).getProfile(any())
verify(remoteDataSource, times(1)).getProfile(any())
assertEquals("Akexorcist", result.data?.name)
assertTrue(result.isSuccess)
}
@Test
fun `Get profile - network only, no local result, no remote result - Should error`() {
val localDataSource = mock(DataSource::class.java)
val remoteDataSource = mock(DataSource::class.java)
`when`(remoteDataSource.getProfile(any())).thenReturn(Result.error("Service unavailable"))
val dataRepository = DataRepository(localDataSource, remoteDataSource)
val result = dataRepository.getProfile("123", true)
verify(localDataSource, times(0)).getProfile(any())
verify(remoteDataSource, times(1)).getProfile(any())
assertNull(result.data)
assertFalse(result.isSuccess)
}
}
เย้ แค่มีเทสก็อุ่นใจแล้ว
สรุป
Repository นั้นเป็น 1 ในหัวใจสำคัญของ MVVM + Clean Architecture ที่ไม่ได้มีรูปแบบตายตัว ควรทำให้ Repository สามารถทำงานร่วมกับ Data Source ได้มากกว่า 1 ตัว และสามารถเขียนเทสได้ โดยที่ไม่ต้องมานั่งจำลองการทำงานของ API หรือ Database ให้เสียเวลา
และรูปแบบของ Data Holder ที่ได้จาก Repository ไม่ควรผูกกับ Data Source เพียงตัวใดตัวหนึ่ง การทำ Data Holder กลางเพื่อให้ Data Source ใช้ร่วมกันก็เป็นทางออกที่ดี และถ้าในอนาคตมีการเปลี่ยนแปลง Library ที่ใช้ ก็จะไม่ส่งผลกระทบกับการทำงานใน Repository โดยตรง สามารถเปลี่ยนไปใช้เป็นตัวอื่นได้ง่ายอีกด้วย