Unit Test For API Call in iOS

Thanphicha Y
Gofive
4 min readMay 6, 2024

--

มาลองสร้าง Unit Test สำหรับการ Call API เบื้องต้นกัน!

หลายคนอาจเคยเรียนรู้หรือลองเขียน Unit Test เพื่อทดสอบการทำงานของฟังก์ชันในโปรแกรมกันมาบ้างแล้ว หากยังไม่รู้มาก่อนหรือยังไม่เคยลองเขียน สามารถศึกษาได้จากบทความด้านล่างได้เลยค่ะ

ตัวอย่างการเขียน Unit Test เบื้องต้น https://medium.com/gofive/easy-unit-tests-in-swift-811a2a39f460

ซึ่งในบทความนี้จะเป็นส่วนต่อขยายโดยเน้นประเด็นไปที่การทดสอบฟังก์ชัน Call API ที่พวกเราต้องได้เขียนและทำในโปรเจคจริงอยู่เสมอ มาดูกันว่าเราจะทดสอบฟังก์ชันเหล่านั้นกันยังไง

ในบทความนี้จะเป็นตัวอย่างโปรแกรมแสดงอัตราแลกเปลี่ยนเงินของแต่ละประเทศ โดยจะเรียก API จาก Server ของเว็ปไซต์ https://www.exchangerate-api.com/และนำมาแสดงบนหน้า UI ของโปรแกรม โดยจะมี Design Pattern แบบ MVVM และจะใช้ Alamofire และ SwiftyJSON เป็น Package เสริมเพื่อช่วยในการเรียกและอ่านค่าจาก API

ตัวอย่างโค้ดสำหรับโปรแกรมเป็นดังนี้

ในหน้า Model ใส่โค้ดดังนี้ สามารถออกแบบโมเดลตามที่อยากให้เป็นได้เลย โดยในโปรแกรมนี้ให้โมเดลเป็น Array ที่แสดงชื่อของสกุลเงิน (String) และอัตราแลกเปลี่ยน (Double)

import Foundation

struct RateModel {
var rates: [String: Double]

var currencies: [String] {
rates.keys.sorted()
}

func rate(for currency: String) -> Double {
rates[currency] ?? 0
}
}

ในหน้า ViewModel กำหนดตัวแปรที่เรียกใช้โมเดลและเขียน Function เรียก API จาก Server ผูกเข้ากับโมเดลที่เราออกแบบ

import Foundation
import Alamofire
import SwiftyJSON

class RateListViewModel: ObservableObject {
@Published var rate: RateModel?

func getCurrency(){
var URL = String(format: "https://v6.exchangerate-api.com/v6/{API_KEY}/latest/USD")

Alamofire.request(URL).responseJSON { (response: DataResponse<Any>) in
let result = response.result
if result.isSuccess {
let json = JSON(result.value)
let rates = json["conversion_rates"].dictionaryObject as? [String: Double] ?? [:]
self.rate = RateModel(rates: rates)
} else {
self.rate = nil
print("No data found")
}
}
}
}

ในหน้า View เขียนโค้ดเพื่อแสดง UI ในโปรแกรม

import SwiftUI

struct ExchangeRateListView: View {
@ObservedObject var viewModel = RateListViewModel()

var body: some View {
VStack{
Text("Currency Rates: USD Based")
.font(.headline)
List {
ForEach(viewModel.rate?.currencies ?? [], id: \.self) { currency in
Text("\(currency): \(viewModel.rate?.rate(for: currency) ?? 0)")
}
}
.onAppear {
viewModel.getCurrency()
}
}
}
}

เมื่อ Run โปรแกรมจะได้หน้า UI ออกมาดังนี้

จะเห็นได้ว่าการแสดงผลของอัตราแลกเปลี่ยนเงินมาจากการ fetch ข้อมูลมาจาก API ผ่านฟังก์ชัน getCurrency() ใน ViewModel ซึ่งเราจะนำฟังก์ชันนี้มาเขียน Unit Test เพื่อทดสอบว่าโค้ดที่เราเขียนมีประสิทธิภาพมากน้อยแค่ไหนกับการเรียกใช้ API

ในการทดสอบ Unit Test ของ API ขั้นตอนแรกเราจะต้องสร้าง Network จำลองขึ้นมาและทดลองยิง Request เพื่อตรวจสอบผลลัพธ์

ข้อดีของ Network จำลองคือ ช่วยให้เราสามารถทดสอบโมเดลได้โดยอิสระจาก API จริง ทำให้การทดสอบเร็วขึ้นและมีความเป็นอิสระทางเทคนิคมากขึ้น

ในโปรเจค Xcode สร้างไฟล์ MockNetwork เพิ่มเข้ามาและใส่โค้ดดังนี้

import Foundation
import Alamofire

//สร้าง protocol ของ network เพื่อนำไปใช้ใน class อื่น
protocol NetworkProtocol {
func request(_ url: URLConvertible, completion: @escaping (DataResponse<Any>) -> Void)
}

class MockNetwork: NetworkProtocol {
let sessionManager: SessionManager

//ตัวแปรจำลองเพื่อกำหนดว่ายิง request สำเร็จหรือไม่สำเร็จ
var success: Bool

init(sessionManager: SessionManager = SessionManager.default, success: Bool = true) {
self.sessionManager = sessionManager
self.success = success
}

func request(_ url: URLConvertible, completion: @escaping (DataResponse<Any>) -> Void) {
// จำลองสถานการณ์กรณียิง request ผ่าน
if success {
// สร้าง response ในกรณีที่มันผ่าน สามารถ mock json data ได้ตามใจ
let mockResponse = DataResponse<Any>(
request: nil,
response: nil,
data: nil,
result: .success(["mock": "response"])
)
completion(mockResponse)
} else { // จำลองสถานการณ์กรณียิง request fail
// สร้าง response ในกรณีที่มัน error
let error = NSError(domain: "MockNetwork", code: 404, userInfo: nil)
let mockResponse = DataResponse<Any>(
request: nil,
response: nil,
data: nil,
result: .failure(error)
)
completion(mockResponse)
}
}
}

ก่อนหน้านี้ Alamofire เชื่อมต่อกับ API จริงอยู่ โดยเราจะเอา Protocol ของ Network ที่เราสร้างไปใส่เพื่อให้ Alamofire เชื่อมต่อกับ Network จำลองแทน ทำการสร้าง class AlamofireNetwork ขึ้นมาและใส่โค้ดดังนี้

import Foundation
import Alamofire

class AlamofireNetwork: NetworkProtocol {
func request(_ url: URLConvertible, completion: @escaping (DataResponse<Any>) -> Void) {
Alamofire.SessionManager.default.request(url).responseJSON { response in
completion(response)
}
}
}

จากนั้นนำ Protocol นี้ไปใส่ใน ViewModel ด้วย และเปลี่ยนไปใช้ Alamofire ที่เชื่อมต่อกับ Network จำลองแทนในฟังก์ชัน getCurrency() เพิ่มและเปลี่ยนโค้ดได้ดังนี้

class RateListViewModel: ObservableObject {

@Published var rate: RateModel?

var network: NetworkProtocol
init(network: NetworkProtocol = AlamofireNetwork()) {
self.network = network
}

func getCurrency(){
//URL สามารถ mock ในขั้นตอนนี้ได้เช่นกัน จะ mock อะไรก็ได้
var URL = String(format: "https://v6.exchangerate-api.com/v6/{API_KEY}/latest/USD")

//เปลี่ยนมาใช้ AlamofireNetwork
network.request(URL) { (response: DataResponse<Any>) in
if response.result.isSuccess {
let json = JSON(response.result.value)
let rates = json["conversion_rates"].dictionaryObject as? [String: Double] ?? [:]
self.rate = RateModel(rates: rates)
} else {
self.rate = nil
print("No data found")
}
}
}
}

เมื่อสร้าง Network จำลองเสร็จแล้วก็พร้อมเขียน Unit Test แล้ว ในไฟล์ XCTest ใส่โค้ดดังนี้

import XCTest
@testable import APIDemo //ชื่อโปรเจค
final class APIDemoTests: XCTestCase {

//สร้าง object เพื่อใช้ในการทดสอบ
var sut: RateListViewModel!
var mockNetwork: MockNetwork!

override func setUp() {
super.setUp()
//setup ให้ sut ใช้ mockNetwork เพื่อควบคุมพฤติกรรมของ network ใน ViewModel ได้
mockNetwork = MockNetwork()
sut = RateListViewModel(network: mockNetwork)
}

override func tearDown() {
//ทำการล้างค่าให้สภาพแวดล้อมการทดสอบเป็นสถานะเริ่มต้นทุกครั้งที่ทดสอบ
sut = nil
mockNetwork = nil
super.tearDown()
}

func testGetCurrencySuccess() {
//Arrange กำหนดให้ Network ยิง request ที่ success มา
mockNetwork.success = true
let expectation = XCTestExpectation(description: "Get currency success")

//Act เรียกใช้ฟังก์ชันใน ViewModel
sut.getCurrency()

DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
// Assert กำหนดผลลัพธ์ว่าถ้ายิงสำเร็จต้องมี data เข้ามา ไม่ nil
XCTAssertNotNil(self.sut.rate)
expectation.fulfill()
}

wait(for: [expectation], timeout: 5)
}

func testGetCurrencyFailure() {
//Arrange กำหนดให้ Network ยิง request ที่ fail มา
mockNetwork.success = false
let expectation = XCTestExpectation(description: "Get currency failure")

//Act เรียกใช้ฟังก์ชันใน ViewModel
sut.getCurrency()

DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
// Assert กำหนดผลลัพธ์ว่าถ้ายิงไม่สำเร็จ data จะ nil
XCTAssertNil(self.sut.rate)
expectation.fulfill()
}

wait(for: [expectation], timeout: 5)
}
}

เมื่อใส่โค้ดครบหมดแล้ว ทำการรัน Test ได้เลย ได้ผลออกมาดังนี้

จากผลลัพธ์จะเห็นได้ว่าการทดสอบฟังก์ชัน getCurrency() ทั้ง 2 กรณีนั้นผ่าน เมื่อให้ Network ยิง Request ที่สำเร็จมา จะมีข้อมูลเข้ามาตามที่ได้ออกแบบไว้ ในขณะที่การยิง Request ไม่สำเร็จก็จะได้ค่าว่างมา

สรุปแล้ว โปรแกรม APIDemo นี้เป็นการจัดทำขึ้นเพื่อเรียกใช้ API จาก Server ภายนอก และทดสอบดูว่าฟังก์ชันที่เขียนขึ้นแสดงผลตรงตามที่ออกแบบไหมเมื่อ Request ที่ได้เป็น success และ fail ซึ่งข้อดีคือ

  1. สามารถทดสอบฟังก์ชันเรียก API ได้ภายในตัวโปรแกรมเอง ไม่ต้องพึ่งพา tool ภายนอกเพื่อช่วยยิง Request เข้ามา
  2. สามารถ Mock data ได้ใน network จำลองโดยไม่ต้องใช้ API จริงก็ได้ทำให้มีความยืดหยุ่นในการทดสอบมากขึ้น

อย่างไรก็ดี ฟังก์ชันนี้ไม่ได้รองรับการยิง Request ที่ไม่สำเร็จและแสดงผลในรูปแบบอื่น เช่น แสดงข้อความมาแจ้งเตือน หรือมีไดอะล็อกแสดงแทน ตรงจุดนี้สามารถนำไปปรับและออกแบบการทำงานของฟังก์ชันและทดสอบเพื่อรองรับเคสอื่น ๆ ได้ โดยเฉพาะเคส 400, 401 หรือ 5xx ที่มักจะพบเจอกันและเป็นส่วนสำคัญอย่างมากต่อการเขียนแอปพลิเคชั่นเพื่อให้ครอบคลุมกรณีเหล่านี้ได้อย่างมีประสิทธิภาพ

--

--