การใช้ไลบรารีการแบ่งหน้าในแอปควรควบคู่ไปกับ
กลยุทธ์การทดสอบที่มีประสิทธิภาพ คุณควรทดสอบคอมโพเนนต์การโหลดข้อมูล เช่น
PagingSource และ
RemoteMediator
เพื่อให้แน่ใจว่าคอมโพเนนต์ทำงานได้ตามที่คาดไว้ นอกจากนี้ คุณควรเขียนการทดสอบตั้งแต่ต้นจนจบเพื่อ
ยืนยันว่าคอมโพเนนต์ทั้งหมดในการติดตั้งใช้งานการแบ่งหน้าทำงานร่วมกันได้อย่างถูกต้อง
โดยไม่มีผลข้างเคียงที่ไม่คาดคิด
คู่มือนี้อธิบายวิธีทดสอบไลบรารีการแบ่งหน้าในเลเยอร์สถาปัตยกรรมต่างๆ ของแอป รวมถึงวิธีเขียนการทดสอบตั้งแต่ต้นจนจบสำหรับการติดตั้งใช้งานการแบ่งหน้าทั้งหมด
การทดสอบเลเยอร์ UI
เนื่องจาก Compose ใช้ข้อมูลการแบ่งหน้าแบบประกาศผ่าน
collectAsLazyPagingItems การทดสอบเลเยอร์ UI จึงมุ่งเน้นไปที่
Flow<PagingData<Value>> ที่ ViewModel ปล่อยออกมาได้ทั้งหมด หากต้องการเขียนการทดสอบเพื่อยืนยัน
ว่าข้อมูลใน UI เป็นไปตามที่คุณคาดไว้ ให้รวมทรัพยากร Dependency ของ paging-testing โดยมีส่วนขยาย asSnapshot ใน Flow<PagingData<Value>> โดยมี
API ในตัวรับ Lambda ที่อนุญาตให้จำลองการโต้ตอบการเลื่อน โดยจะแสดงผล List<Value> มาตรฐานที่เกิดจากการโต้ตอบการเลื่อนที่จำลองขึ้น
ซึ่งช่วยให้คุณยืนยันได้ว่าข้อมูลที่กำลังเลื่อนดูมีองค์ประกอบที่คาดไว้ซึ่งเกิดจากการโต้ตอบเหล่านั้น ตัวอย่างต่อไปนี้แสดงให้เห็นถึงการดำเนินการนี้
fun test_items_contain_one_to_ten() = runTest {
// Get the Flow of PagingData from the ViewModel under test
val items: Flow<PagingData<String>> = viewModel.items
val itemsSnapshot: List<String> = items.asSnapshot {
// Scroll to the 50th item in the list. This will also suspend till
// the prefetch requirement is met if there's one.
// It also suspends until all loading is complete.
scrollTo(index = 50)
}
// With the asSnapshot complete, you can now verify that the snapshot
// has the expected values
assertEquals(
expected = (0..50).map(Int::toString),
actual = itemsSnapshot
)
}
หรือจะเลื่อนจนกว่าจะตรงตามเงื่อนไขที่กำหนดก็ได้ ดังที่แสดงใน ข้อมูลโค้ดด้านล่าง
fun test_footer_is_visible() = runTest {
// Get the Flow of PagingData from the ViewModel under test
val items: Flow<PagingData<String>> = viewModel.items
val itemsSnapshot: List<String> = items.asSnapshot {
// Scroll till the footer is visible
appendScrollWhile { item: String -> item != "Footer" }
}
การทดสอบการเปลี่ยนรูปแบบ
นอกจากนี้ คุณควรเขียนการทดสอบหน่วยที่ครอบคลุมการเปลี่ยนรูปแบบใดๆ ที่คุณใช้กับสตรีม PagingData ใช้asPagingSourceFactory
ส่วนขยาย ส่วนขยายนี้ใช้ได้กับข้อมูลประเภทต่อไปนี้
List<Value>Flow<List<Value>>
การเลือกส่วนขยายที่จะใช้ขึ้นอยู่กับสิ่งที่คุณต้องการทดสอบ ใช้
List<Value>.asPagingSourceFactory(): หากต้องการทดสอบการเปลี่ยนรูปแบบแบบคงที่ เช่นmap()และinsertSeparators()ในข้อมูลFlow<List<Value>>.asPagingSourceFactory(): หากต้องการทดสอบว่าการอัปเดต ข้อมูล เช่น การเขียนไปยังแหล่งข้อมูลสำรอง จะส่งผลต่อไปป์ไลน์การแบ่งหน้า อย่างไร
หากต้องการใช้ส่วนขยายใดส่วนขยายหนึ่ง ให้ทำตามรูปแบบต่อไปนี้
- สร้าง
PagingSourceFactoryโดยใช้ส่วนขยายที่เหมาะสมกับความต้องการของคุณ - ใช้
PagingSourceFactoryที่ส่งคืนใน ปลอม สำหรับRepository - ส่ง
Repositoryดังกล่าวให้ViewModel
จากนั้นจะทดสอบ ViewModel ได้ตามที่กล่าวถึงในส่วนก่อนหน้า
ลองพิจารณาสิ่งต่อไปนี้ ViewModel
class MyViewModel(
myRepository: myRepository
) {
val items = Pager(
config: PagingConfig,
initialKey = null,
pagingSourceFactory = { myRepository.pagingSource() }
)
.flow
.map { pagingData ->
pagingData.insertSeparators<String, String> { before, _ ->
when {
// Add a dashed String separator if the prior item is a multiple of 10
before.last() == '0' -> "---------"
// Return null to avoid adding a separator between two items.
else -> null
}
}
}
หากต้องการทดสอบการเปลี่ยนรูปแบบใน MyViewModel ให้ระบุอินสแตนซ์จำลองของ MyRepository ที่มอบสิทธิ์ให้กับ List แบบคงที่ซึ่งแสดงข้อมูลที่จะเปลี่ยนรูปแบบ ดังที่แสดงในข้อมูลโค้ดต่อไปนี้
class FakeMyRepository() : MyRepository {
private val items = (0..100).map(Any::toString)
private val pagingSourceFactory = items.asPagingSourceFactory()
// Expose as a function so a new PagingSource instance is
// created each time it is called by the Pager
fun pagingSource() = pagingSourceFactory()
}
จากนั้นคุณจะเขียนการทดสอบสำหรับตรรกะตัวคั่นได้ดังข้อมูลโค้ดต่อไปนี้
fun test_separators_are_added_every_10_items() = runTest {
// Create your ViewModel
val viewModel = MyViewModel(
myRepository = FakeMyRepository()
)
// Get the Flow of PagingData from the ViewModel with the separator transformations applied
val items: Flow<PagingData<String>> = viewModel.items
val snapshot: List<String> = items.asSnapshot()
// With the asSnapshot complete, you can now verify that the snapshot
// has the expected separators.
}
การทดสอบชั้นข้อมูล
เขียนการทำ Unit Test สำหรับคอมโพเนนต์ในชั้นข้อมูลเพื่อให้แน่ใจว่าคอมโพเนนต์จะโหลดข้อมูลจากแหล่งข้อมูลอย่างเหมาะสม ระบุเวอร์ชันปลอมของ
การอ้างอิงเพื่อยืนยันว่าคอมโพเนนต์ที่กำลังทดสอบทำงานได้อย่างถูกต้องใน
การแยก คอมโพเนนต์หลักที่คุณต้องทดสอบในเลเยอร์ที่เก็บข้อมูลคือ PagingSource และ RemoteMediator
การทดสอบ PagingSource รายการ
การทดสอบหน่วยสำหรับการติดตั้งใช้งาน PagingSource เกี่ยวข้องกับการตั้งค่าอินสแตนซ์ PagingSource และการโหลดข้อมูลจากอินสแตนซ์ดังกล่าวด้วย TestPager
หากต้องการตั้งค่าอินสแตนซ์ PagingSourceสำหรับการทดสอบ ให้ระบุข้อมูลปลอมให้กับตัวสร้าง ซึ่งจะช่วยให้คุณควบคุมข้อมูลในการทดสอบได้
ในตัวอย่างต่อไปนี้ พารามิเตอร์ RedditApi
คืออินเทอร์เฟซ Retrofit
ที่กำหนดคำขอของเซิร์ฟเวอร์และคลาสการตอบกลับ
เวอร์ชันจำลองสามารถใช้การติดตั้งใช้งานอินเทอร์เฟซ ลบล้างฟังก์ชันที่จำเป็น
และจัดเตรียมเมธอดที่สะดวกเพื่อกำหนดค่าวิธีที่ออบเจ็กต์จำลองควรตอบสนอง
ในการทดสอบ
หลังจากสร้างออบเจ็กต์จำลองแล้ว ให้ตั้งค่าการอ้างอิงและเริ่มต้นออบเจ็กต์ PagingSource ในการทดสอบ ตัวอย่างต่อไปนี้แสดงวิธี
เริ่มต้นออบเจ็กต์ FakeRedditApi ด้วยรายการโพสต์ทดสอบ และทดสอบ
อินสแตนซ์ RedditPagingSource
class SubredditPagingSourceTest {
private val mockPosts = listOf(
postFactory.createRedditPost(DEFAULT_SUBREDDIT),
postFactory.createRedditPost(DEFAULT_SUBREDDIT),
postFactory.createRedditPost(DEFAULT_SUBREDDIT)
)
private val fakeApi = FakeRedditApi().apply {
mockPosts.forEach { post -> addPost(post) }
}
@Test
fun loadReturnsPageWhenOnSuccessfulLoadOfItemKeyedData() = runTest {
val pagingSource = RedditPagingSource(
fakeApi,
DEFAULT_SUBREDDIT
)
val pager = TestPager(CONFIG, pagingSource)
val result = pager.refresh() as LoadResult.Page
// Write assertions against the loaded data
assertThat(result.data)
.containsExactlyElementsIn(mockPosts)
.inOrder()
}
}
TestPager ยังช่วยให้คุณทำสิ่งต่อไปนี้ได้ด้วย
- ทดสอบการโหลดต่อเนื่องจาก
PagingSourceโดยทำดังนี้
@Test
fun test_consecutive_loads() = runTest {
val page = with(pager) {
refresh()
append()
append()
} as LoadResult.Page
assertThat(page.data)
.containsExactlyElementsIn(testPosts)
.inOrder()
}
- ทดสอบสถานการณ์ข้อผิดพลาดใน
PagingSourceดังนี้
@Test
fun refresh_returnError() {
val pagingSource = RedditPagingSource(
fakeApi,
DEFAULT_SUBREDDIT
)
// Configure your fake to return errors
fakeApi.setReturnsError()
val pager = TestPager(CONFIG, source)
runTest {
source.errorNextLoad = true
val result = pager.refresh()
assertTrue(result is LoadResult.Error)
val page = pager.getLastLoadedPage()
assertThat(page).isNull()
}
}
การทดสอบ RemoteMediator รายการ
เป้าหมายของการทดสอบหน่วย RemoteMediator คือการยืนยันว่าฟังก์ชัน load()
ส่งคืน MediatorResult ที่ถูกต้อง
การทดสอบผลข้างเคียง เช่น การแทรกข้อมูลลงในฐานข้อมูล เหมาะสำหรับการทดสอบการผสานรวมมากกว่า
ขั้นตอนแรกคือการพิจารณาว่าการติดตั้งใช้งานRemoteMediator
ของคุณต้องมีทรัพยากร Dependency ใดบ้าง ตัวอย่างต่อไปนี้แสดงการติดตั้งใช้งาน RemoteMediator
ที่ต้องใช้ฐานข้อมูล Room, อินเทอร์เฟซ Retrofit และสตริงการค้นหา
@OptIn(ExperimentalPagingApi::class)
class PageKeyedRemoteMediator(
private val db: RedditDb,
private val redditApi: RedditApi,
private val subredditName: String
) : RemoteMediator<Int, RedditPost>() {
...
}
คุณสามารถระบุอินเทอร์เฟซ Retrofit และสตริงการค้นหาได้ตามที่แสดงในส่วนการทดสอบ PagingSource การระบุเวอร์ชันจำลอง
ของฐานข้อมูล Room นั้นมีความซับซ้อนมาก ดังนั้นการระบุการใช้งานในหน่วยความจำของ
ฐานข้อมูลแทนเวอร์ชันจำลองแบบเต็มจึงอาจง่ายกว่า เนื่องจากการสร้างฐานข้อมูล Room ต้องใช้ออบเจ็กต์ Context คุณจึงต้อง
วางการทดสอบ RemoteMediator นี้ในไดเรกทอรี androidTest และเรียกใช้
ด้วยโปรแกรมเรียกใช้การทดสอบ AndroidJUnit4 เพื่อให้มีการเข้าถึงบริบทของแอปพลิเคชันทดสอบ ดูข้อมูลเพิ่มเติมเกี่ยวกับการทดสอบที่วัดคุมได้ที่สร้างการทดสอบหน่วยที่วัดคุมได้
กำหนดฟังก์ชันการล้างข้อมูลเพื่อให้แน่ใจว่าสถานะจะไม่รั่วไหลระหว่างฟังก์ชันการทดสอบ ซึ่งจะช่วยให้ผลลัพธ์ระหว่างการทดสอบมีความสอดคล้องกัน
@ExperimentalPagingApi
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
class PageKeyedRemoteMediatorTest {
private val postFactory = PostFactory()
private val mockPosts = listOf(
postFactory.createRedditPost(SubRedditViewModel.DEFAULT_SUBREDDIT),
postFactory.createRedditPost(SubRedditViewModel.DEFAULT_SUBREDDIT),
postFactory.createRedditPost(SubRedditViewModel.DEFAULT_SUBREDDIT)
)
private val mockApi = mockRedditApi()
private val mockDb = RedditDb.create(
ApplicationProvider.getApplicationContext(),
useInMemory = true
)
@After
fun tearDown() {
mockDb.clearAllTables()
// Clear out failure message to default to the successful response.
mockApi.failureMsg = null
// Clear out posts after each test run.
mockApi.clearPosts()
}
}
ขั้นตอนถัดไปคือการทดสอบload()ฟังก์ชัน ในตัวอย่างนี้ มีกรณีที่ต้องทดสอบ 3 กรณี
ดังนี้
- กรณีแรกคือเมื่อ
mockApiแสดงข้อมูลที่ถูกต้องload()ฟังก์ชัน ควรแสดงผลMediatorResult.Successและพร็อพเพอร์ตี้endOfPaginationReachedควรเป็นfalse - กรณีที่ 2 คือเมื่อ
mockApiแสดงการตอบกลับที่สำเร็จ แต่ข้อมูลที่ส่งคืนว่างเปล่า ฟังก์ชันload()ควรแสดงผลMediatorResult.Successและพร็อพเพอร์ตี้endOfPaginationReachedควรเป็นtrue - กรณีที่ 3 คือเมื่อ
mockApiโยนข้อยกเว้นเมื่อดึงข้อมูล ฟังก์ชันload()ควรแสดงผลMediatorResult.Error
ทำตามขั้นตอนต่อไปนี้เพื่อทดสอบกรณีแรก
- ตั้งค่า
mockApiด้วยข้อมูลโพสต์ที่จะส่งคืน - เริ่มต้นออบเจ็กต์
RemoteMediator - ทดสอบฟังก์ชัน
load()
@Test
fun refreshLoadReturnsSuccessResultWhenMoreDataIsPresent() = runTest {
// Add mock results for the API to return.
mockPosts.forEach { post -> mockApi.addPost(post) }
val remoteMediator = PageKeyedRemoteMediator(
mockDb,
mockApi,
SubRedditViewModel.DEFAULT_SUBREDDIT
)
val pagingState = PagingState<Int, RedditPost>(
listOf(),
null,
PagingConfig(10),
10
)
val result = remoteMediator.load(LoadType.REFRESH, pagingState)
assertTrue { result is MediatorResult.Success }
assertFalse { (result as MediatorResult.Success).endOfPaginationReached }
}
การทดสอบครั้งที่ 2 กำหนดให้ mockApi แสดงผลลัพธ์ที่ว่างเปล่า เนื่องจากคุณล้างข้อมูลจาก mockApi หลังจากเรียกใช้การทดสอบแต่ละครั้ง ระบบจึงจะแสดงผลลัพธ์ที่ว่างเปล่าโดยค่าเริ่มต้น
@Test
fun refreshLoadSuccessAndEndOfPaginationWhenNoMoreData() = runTest {
// To test endOfPaginationReached, don't set up the mockApi to return post
// data here.
val remoteMediator = PageKeyedRemoteMediator(
mockDb,
mockApi,
SubRedditViewModel.DEFAULT_SUBREDDIT
)
val pagingState = PagingState<Int, RedditPost>(
listOf(),
null,
PagingConfig(10),
10
)
val result = remoteMediator.load(LoadType.REFRESH, pagingState)
assertTrue { result is MediatorResult.Success }
assertTrue { (result as MediatorResult.Success).endOfPaginationReached }
}
การทดสอบขั้นสุดท้ายกำหนดให้ mockApi ส่งข้อยกเว้นเพื่อให้การทดสอบ
ยืนยันได้ว่าฟังก์ชัน load() ส่งคืน MediatorResult.Error อย่างถูกต้อง
@Test
fun refreshLoadReturnsErrorResultWhenErrorOccurs() = runTest {
// Set up failure message to throw exception from the mock API.
mockApi.failureMsg = "Throw test failure"
val remoteMediator = PageKeyedRemoteMediator(
mockDb,
mockApi,
SubRedditViewModel.DEFAULT_SUBREDDIT
)
val pagingState = PagingState<Int, RedditPost>(
listOf(),
null,
PagingConfig(10),
10
)
val result = remoteMediator.load(LoadType.REFRESH, pagingState)
assertTrue {result is MediatorResult.Error }
}
การทดสอบตั้งแต่ต้นจนจบ
การทดสอบหน่วยช่วยให้มั่นใจได้ว่าคอมโพเนนต์การแบ่งหน้าแต่ละรายการทำงานแยกกัน แต่การทดสอบแบบครบวงจรจะช่วยให้มั่นใจได้มากขึ้นว่าแอปพลิเคชันทำงานได้โดยรวม การทดสอบเหล่านี้ช่วยในการยืนยันว่าชั้นข้อมูล
(PagingSource หรือ RemoteMediator), ViewModel และ Compose UI ผสานรวม
ได้อย่างราบรื่นโดยไม่มีผลข้างเคียงที่ไม่คาดคิด การทดสอบยังคงต้องใช้การจำลอง
การอ้างอิง แต่โดยทั่วไปแล้วการทดสอบจะครอบคลุมโค้ดแอปส่วนใหญ่
ตัวอย่างในส่วนนี้ใช้ทรัพยากร Dependency ของ API แบบจำลองเพื่อหลีกเลี่ยงการใช้ระบบเครือข่ายในการทดสอบ ระบบจะกำหนดค่า API จำลองให้แสดงชุดข้อมูลทดสอบที่สอดคล้องกัน
ซึ่งจะทำให้การทดสอบทำซ้ำได้ สำหรับการทดสอบแบบครบวงจร โดยปกติแล้วคุณจะสลับ API เครือข่ายจริงกับ API เครือข่ายจำลอง แต่คุณยังคงให้ไลบรารี Paging จัดการการดึงข้อมูลจริงและการแคชฐานข้อมูลในเครื่อง (หากใช้ RemoteMediator) เพื่อรักษาความถูกต้องของการทดสอบ
เขียนโค้ดในลักษณะที่ช่วยให้คุณสลับเวอร์ชันจำลองของ การอ้างอิงได้อย่างง่ายดาย ตัวอย่างต่อไปนี้ใช้การติดตั้งใช้งานตัวระบุตำแหน่งบริการพื้นฐานและตั้งค่าการทดสอบด้วย API จำลองเพื่อยืนยันว่าหน้าจอ Compose ใช้และแสดงข้อมูลที่แบ่งหน้าอย่างถูกต้อง ในแอปขนาดใหญ่ การใช้ไลบรารีการแทรกทรัพยากร Dependency เช่น Hilt จะช่วยจัดการกราฟทรัพยากร Dependency ที่ซับซ้อนมากขึ้นได้
หลังจากตั้งค่าโครงสร้างการทดสอบแล้ว ขั้นตอนถัดไปคือการยืนยันว่าข้อมูลที่Pagerการติดตั้งใช้งานส่งคืนมานั้นถูกต้อง การทดสอบหนึ่งควรยืนยันว่า
UI ของ Compose จะแสดงรายการที่ถูกต้องเมื่อหน้าจอโหลดเป็นครั้งแรก และ
การทดสอบอีกรายการควรยืนยันว่า UI จะโหลดข้อมูลเพิ่มเติมอย่างถูกต้องตาม
การโต้ตอบของผู้ใช้
ในตัวอย่างต่อไปนี้ การทดสอบจะยืนยันว่า UI แสดงข้อมูลที่แบ่งหน้าตามที่คาดไว้
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertDoesNotExist
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextClearance
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RedditScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
private val postFactory = PostFactory()
private val mockApi = MockRedditApi()
@Before
fun setup() {
// Pre-populate the mock API with test data for the default subreddit
mockApi.addPost(postFactory.createRedditPost(subreddit = "androiddev", title = "Jetpack Compose Paging"))
// Swap your real dependency injection module/Service Locator with the mock API
ServiceLocator.swap(
object : DefaultServiceLocator(useInMemoryDb = true) {
override fun getRedditApi(): RedditApi = mockApi
}
)
}
@Test
fun loadsTheDefaultResults() = runTest {
// 1. Set the Compose UI content
composeTestRule.setContent {
MyTheme {
// Assume that this composable uses `collectAsLazyPagingItems()` internally
RedditScreen(initialSubreddit = "androiddev")
}
}
// 2. Wait for the asynchronous Paging loads to complete
composeTestRule.waitUntilExactlyOneExists(
matcher = hasText("Jetpack Compose Paging"),
timeoutMillis = 5000
)
// 3. Assert that the loaded paged items are displayed correctly on screen
composeTestRule.onNodeWithText("Jetpack Compose Paging").assertIsDisplayed()
}
@Test
fun loadsNewDataBasedOnUserInput() = runTest {
// Add data for a different subreddit to the mock API
mockApi.addPost(postFactory.createRedditPost(subreddit = "compose", title = "Compose Testing"))
composeTestRule.setContent {
MyTheme {
RedditScreen(initialSubreddit = "androiddev")
}
}
// Wait for the initial load to finish
composeTestRule.waitUntilExactlyOneExists(hasText("Jetpack Compose Paging"))
// Simulate user entering a new subreddit in a text field and clicking search
composeTestRule.onNodeWithTag("SubredditInput").performTextClearance()
composeTestRule.onNodeWithTag("SubredditInput").performTextInput("compose")
composeTestRule.onNodeWithTag("SearchButton").performClick()
// Wait for the new paged data to load
composeTestRule.waitUntilExactlyOneExists(
matcher = hasText("Compose Testing"),
timeoutMillis = 5000
)
// Assert the old data is gone and the new data is displayed
composeTestRule.onNodeWithText("Jetpack Compose Paging").assertDoesNotExist()
composeTestRule.onNodeWithText("Compose Testing").assertIsDisplayed()
}
}
เนื่องจาก Flow<PagingData> โหลดข้อมูลแบบไม่พร้อมกัน คุณจึงต้องให้เวลาไลบรารีการแบ่งหน้าในการดึงข้อมูลการโหลดครั้งแรกและปล่อยข้อมูลไปยัง collectAsLazyPagingItems ก่อนที่จะทำการยืนยัน โดยใช้ composeTestRule.waitUntil หรือ waitUntilExactlyOneExists ดังที่แสดงใน
ตัวอย่างก่อนหน้า
หลังจากโหลดข้อมูลแล้ว คุณสามารถยืนยันโดยตรงกับทรีเชิงความหมายของ Compose
โดยใช้ onNodeWithText เพื่อตรวจสอบว่ารายการต่างๆ แสดงผลใน LazyColumn จริง
แหล่งข้อมูลเพิ่มเติม
ดูเนื้อหา
แนะนำสำหรับคุณ
- หมายเหตุ: ข้อความลิงก์จะแสดงเมื่อ JavaScript ปิดอยู่
- หน้าจากเครือข่ายและฐานข้อมูล
- ย้ายข้อมูลไปยัง Paging 3
- โหลดและแสดงข้อมูลแบบแบ่งหน้า