1. 事前準備
在先前的程式碼研究室中,您已瞭解如何使用 Retrofit 發出網路要求。在本程式碼研究室中,您將瞭解如何撰寫網路程式碼的單元測試。
必要條件
- 您已在 Android Studio 中建立測試目錄。
- 您已在 Android Studio 中編寫了單元和檢測設備測試。
- 您已將 Gradle 依附元件新增至 Android 專案。
課程內容
- 如何儲存測試資源。
- 如何模擬 API 回應以進行測試。
- 如何測試 Retrofit API 服務。
軟硬體需求
- 已安裝 Android Studio 的電腦。
- Mars Photos 應用程式的解決方案程式碼。
下載本程式碼研究室的範例程式碼
在本程式碼研究室中,您必須新增檢測設備測試至先前解決方案程式碼的 Mars Photos 應用程式。
如要取得這個程式碼研究室的程式碼,並在 Android Studio 中開啟,請採取以下步驟。
取得程式碼
- 按一下上面顯示的網址。系統會在瀏覽器中開啟專案的 GitHub 頁面。
- 在專案的 GitHub 頁面中,按一下「Code」按鈕開啟對話方塊。

- 在對話方塊中,按一下「Download ZIP」按鈕,將專案儲存到電腦。等待下載作業完成。
- 在電腦中找到該檔案 (可能位於「下載」資料夾中)。
- 按兩下解壓縮 ZIP 檔案。這項操作會建立含有專案檔案的新資料夾。
在 Android Studio 中開啟專案
- 啟動 Android Studio。
- 在「Welcome to Android Studio」視窗中,按一下「Open an existing Android Studio project」。

注意:如果 Android Studio 已開啟,請依序選取「File」>「New」>「Import Project」選單選項。

- 在「Import Project」對話方塊中,前往解壓縮專案資料夾所在的位置 (可能位於「下載」資料夾中)。
- 按兩下該專案資料夾。
- 等待 Android Studio 開啟專案。
- 按一下「Run」按鈕  即可建構並執行應用程式。請確認應用程式的建構符合預期。 即可建構並執行應用程式。請確認應用程式的建構符合預期。
- 在「Project」工具視窗中瀏覽專案檔案,查看應用程式的設定方式。
2. 入門應用程式總覽
Mars Photos 應用程式內含單一畫面,列出使用網路要求所擷取的相片清單。
這個範例程式碼含有與測試相關的其他獨特之處。我們不會深入探討這些程式碼,因為這類程式碼不在本程式碼研究室的範圍內,但提及也很重要。
測試應用程式如何處理從 API 擷取的資料時,最好一律提供自己的資料,以便我們確定資料應如何呈現。這個 API 提供的資料可能會變動,這可能會導致測試中斷,並造成不必要的作業失敗。此外,仰賴真實的網路呼叫可能會導致我們因為網路連線或網路速度而失敗,導致測試不一致。因此,我們將在測試中產生部分資料,並儲存在 JSON 的「test/res」目錄中。這是資源目錄,與主要應用程式碼中的資源目錄類似,不同之處在於我們的測試資源會儲存在「test/res」中。
- 新增 test/res目錄。在專案檢視畫面中的src目錄上按一下滑鼠右鍵,然後從下拉式選單中依序選取「New」->「Directory」。

- 在出現的彈出式視窗中,向下捲動並選取 test/res。

- 在專案檢視畫面中的 test/res目錄上按一下滑鼠右鍵,然後依序選擇「New」->「File」。

- 命名檔案 mars_photos.json。

- 將下列程式碼複製到 mars_photos.json檔案中。
[
 {
   "id":"424905",
   "img_src":"http://mars.jpl.nasa.gov/msl-raw-images/msss/01000/mcam/1000MR0044631300503690E01_DXXX.jpg"
 },
 {
   "id":"424906",
   "img_src":"http://mars.jpl.nasa.gov/msl-raw-images/msss/01000/mcam/1000ML0044631300305227E03_DXXX.jpg"
 }
]
- 若要存取這個資源目錄中的檔案,您必須在建構檔案中將測試資源目錄明確指定為「來源」目錄。新增下列程式碼:
app/build.gradle
android {
    ...
    sourceSets {
       test.resources.srcDirs += 'src/test/res'
    }
}
如此一來,我們即可存取資源檔案,且無須在每次測試中存取檔案時,在程式碼中輸入檔案完整路徑。各機器與作業系統之間的檔案路徑可能各有不同,因此輸入完整路徑不是個好做法。
- 在同一個檔案中,新增下列依附元件並同步處理 Gradle。
app/build.gradle
dependencies {
    ...
    testImplementation 'junit:junit:4.12'
    testImplementation "androidx.arch.core:core-testing:2.1.0"
    testImplementation "com.squareup.okhttp3:mockwebserver:4.9.1"
}
- 現在請建立一般測試目錄。

- 在測試目錄中建立新的套件 com.example.android.marsphotos。
- 在套件中,建立名為 BaseTest.kt的新 Kotlin 檔案。

- 複製並貼上以下程式碼到 BaseTest.kt檔案中。如果不知道程式碼是否成功,沒關係!以下程式碼會重複出現「Mock」這個字詞,我們會在本程式碼研究室中討論。這個類別中的enqueue()函式會剖析您建立的mars_photos.json檔案中的資料,方便您在之後編寫的測試中使用。
BaseTest.kt
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.buffer
import okio.source
open class BaseTest {
   val mockWebServer = MockWebServer()
   fun enqueue(fileName: String) {
       val inputStream = javaClass.classLoader!!.getResourceAsStream(fileName)
       val buffer = inputStream.source().buffer()
       mockWebServer.enqueue(
           MockResponse()
               .setResponseCode(200)
               .setBody(buffer.readString(Charsets.UTF_8))
       )
   }
}
3. 模擬資料
在本程式碼研究室中,我們非常仰賴模擬資料。對於測試,模擬是指模擬了一段程式碼傳回的值。在先前的程式碼研究室中,我們模擬一個類別。我們也可以模擬函式,並使其傳回我們指定的值,或模擬 API 來傳回指定的一段資料。這項功能非常實用,因為可以協助我們區隔一段測試用的程式碼。在本程式碼研究室中,我們會專注於模擬 API 回應。我們會在另一個程式碼研究室中討論模擬功能。
4. 建立單元測試類別
在這項測試中,我們會測試 API 服務。首先,建立一個名為 MarsApiServiceTests.kt 的新類別。
5. Dependencies
本程式碼研究室的起始程式碼是您需要的依附元件。然而,我們尚未探討值得注意的依附元件。
testImplementation "com.squareup.okhttp3:mockwebserver:4.9.1"
這個依附元件讓我們建立模擬伺服器。基本上,模擬伺服器會攔截網路要求並重新轉送,以傳回我們定義的模擬資料。我們稍後會在這個程式碼研究室中探討模擬代表的意義。
6. 最佳做法
請注意,我們的測試是以 Kotlin 編寫,而 Kotlin 是物件導向式程式設計語言,這表示我們可以為測試編寫物件導向程式碼。我們將為這個測試撰寫的程式碼量,實際上不需要使用物件導向的做法。不過,我們將會以示範實作程式碼的方式呈現概念。
您或許已注意到,範例程式碼已有單元測試目錄,其中有一個名為 BaseTest 的類別。如果有一段程式碼可能會用於多項測試,我們就可以建立 open class,並將測試類別沿用它。請注意,此 BaseTest 類別只有一個名為 enqueue() 的方法,而本程式碼研究室只會編寫一個測試類別。請記住,自行撰寫測試時,可以使用物件導向式程式設計的優勢。不過,非必要時務必不要過度使用。
7. 撰寫網路要求測試
首先,請確認您的測試類別沿用自 BaseTest。
我們會直接測試 MarsApiService,因此需要為其建立一個執行個體。設定方式與在應用程式的程式碼中相同,但這項新服務的網址將會不同。
- 在測試類別中,為 MarsApiService建立lateinit變數。
private lateinit var service: MarsApiService
現在,您需要在每次測試前定義 service 變數的函式。
- 建立名為 setup()的函式,並以@Before加上註解。
@Before
fun setup() {}
對於這項測試,我們不會使用網址進行網路要求。這時 MockWebServer 能夠派上用場。
模擬資料
BaseTest 類別中有一個名為 mockWebServer, 的屬性,這僅是 MockWebServer 物件的執行個體。這個物件會攔截我們的網路要求,但我們首先必須將網路要求導向至將遭攔截的網址。
MockWebServer 設有名為 url() 的函式,用於指定要攔截的網址。提醒您,我們不希望提出真實的網路要求,我們只是為了假定要提出一個網路要求,使得可以利用測試本身控制的資料來測試網路程式碼。url() 函式採用代表假網址的字串,並傳回 HttpUrl 物件。在您的 setup() 函式中,編寫下列內容:
val url = mockWebServer.url("/")
我們設定了要攔截的網址端點,並擷取了傳回的 HttpUrl 物件。
在這個函式中,建立 MarsApiService 執行個體的方式與 MarsApiService 和 MarsApiClass 相同 (不包括 lazy 變數)。不過,暫時不要包含基準網址。如下所示:
service = Retrofit.Builder()
   .addConverterFactory(MoshiConverterFactory.create(
       Moshi.Builder()
           .add(KotlinJsonAdapterFactory())
           .build()
   ))
   .build()
   .create(MarsApiService::class.java)
現在可以設定基準網址了。將以下內容新增至 Retrofit.Builder() 的函式鏈:
.baseUrl(url)
這指定我們的 API 服務要將要求轉送至 MockWebServer。
service = Retrofit.Builder()
   .baseUrl(url)
   .addConverterFactory(MoshiConverterFactory.create(
       Moshi.Builder()
           .add(KotlinJsonAdapterFactory())
           .build()
   ))
   .build()
   .create(MarsApiService::class.java)
再次提醒您,MockWebServer 的重點是避免發出實實的網路要求至真實的 API。基本概念是,如果發出真實的網路要求,測試會在 API 失敗時失敗。使用 API 測試 API 本身,我們只會考慮測試 Android 專案中的程式碼。
您可以將 MockWebServer 想成是假的 API,用來傳回我們建立的資料。因此,我們必須在提出要求之前明確指出 MockWebServer 要傳回什麼內容。這就是 BaseTest 中的 enqueue() 函式的來源。如果覺得這個函式中的程式碼相關資訊有點太多,還沒有完全掌握,不用擔心,這不在本程式碼研究室涵蓋的範圍內,您只需知道此函式會從我們的測試資源擷取檔案,並轉換成假的 API 回應。
- 建立名為 api_service()的測試函式。在測試函式中,呼叫enqueue方法的方式如下:
enqueue("mars_photos.json")
- 接下來,我們會直接從 MarsApiService呼叫getPhotos()函式。請注意,getPhotos()是暫停函式,必須從協同程式範圍中呼叫,做法是將呼叫包裝在runBlocking中的方法,如下所示:
runBlocking {
    val apiResponse = service.getPhotos()
}
- 現在,我們確保 getPhotos()回應不是null。請記得,我們在runBlocking中定義了apiResponse,因此也必須在runBlocking內部存取。
runBlocking {
    val apiResponse = service.getPhotos()
    assertNotNull(apiResponse)
}
getPhotos() 會傳回包含 MarsPhoto 物件的清單,所以請確認清單的大小如所預期。
- 查看 test/res 中的 mars_photos.json 檔案。請建立另一個斷言,確認清單並未空白。我們還要建立斷言,來檢查部分資料是否正確。前往 test/res/mars_photos.json 並複製其中一個 ID。聲明 ID 的值等於對應清單項目中 ID 的值。
runBlocking {
   val apiResponse = service.getPhotos()
   assertNotNull(apiResponse)
   assertTrue("The list was empty", apiResponse.isNotEmpty())
   assertEquals("The IDs did not match", "424905", apiResponse[0].id)
}
您的測試程式碼現在應該像這樣:
8. 解決方案程式碼
9. 恭喜
測試網路要求可能有些複雜,本程式碼研究室僅觸及主題的面貌。您撰寫的每個網路測試對於應用程式取得資料的 API 而言都不會重複。隨著您累積更多使用 API 的經驗,您可以擴充測試來模擬網路失敗和不同的 API 回應。對於測試而言,這個領域可能非常艱難,必須經歷反覆嘗試與錯誤才能成為專家,因此請務必持續練習。
在本程式碼研究室中,我們學到:
- 如何將物件導向式程式設計套用至測試。
- 如何將資源儲存在測試目錄中。
- 如何在測試中呼叫暫停函式。
- 如何在測試中模擬 API 回應。
