[iOS] UnitTest With XCode

此篇, 將利用1個簡單的project來介紹Xcode內建的Unit Test, 該project只會做簡單的幾件事情:

(1.) 當user按下Calculate Button時計算2個輸入數字X與Y的總和, 並將結果顯示在resultLabel上.

(2.) 當user按下Request Button時發送get request, 並將response data顯示在responseTextView上.

(3.) 在viewWillAppear生命週期時清空resultLabel與responseTextView的文字

你可以在這裡下載完整的project.

此Project裡有3個主要的classes,

(1.) ViewController

(在viewWillAppear時呼叫clearData function將label與textView清空)

class ViewController: UIViewController {    @IBOutlet weak var xTextField: UITextField!    @IBOutlet weak var yTextField: UITextField!    @IBOutlet weak var resultLabel: UILabel!    @IBOutlet weak var urlTextField: UITextField!    @IBOutlet weak var responseTextView: UITextView!        override func viewDidLoad() {        super.viewDidLoad()        // Do any additional setup after loading the view, typically from a nib.    }​    override func viewWillAppear(_ animated: Bool) {        super.viewWillAppear(animated)        self.clearData()    }        override func viewDidAppear(_ animated: Bool) {        super.viewDidAppear(animated)            }        override func didReceiveMemoryWarning() {        super.didReceiveMemoryWarning()        // Dispose of any resources that can be recreated.    }​    func clearData(){        self.resultLabel.text = ""        self.responseTextView.text = ""    }        @IBAction func calculateButton_Touch(_ sender: Any) {        let x = Math.stringToInt(string: self.xTextField.text)        let y = Math.stringToInt(string: self.yTextField.text)        var outputString = "invalid input"                if let x = x, let y = y {            let result = Math.add(x: x, y: y)            outputString = "\(result)"        }                self.resultLabel.text = outputString    }        @IBAction func requestButton_Touch(_ sender: Any) {        if let url = urlTextField.text {            Network.dataTask(urlString: url) { (data, response, error) in                guard error == nil else {                    self.responseTextView.text = "\(error!.localizedDescription)"                    return                }                                if let data = data {                    if let jsonString = String(data: data, encoding: .utf8){                        DispatchQueue.main.async {                            self.responseTextView.text = jsonString    class ViewController: UIViewController {
    @IBOutlet weak var xTextField: UITextField!
    @IBOutlet weak var yTextField: UITextField!
    @IBOutlet weak var resultLabel: UILabel!
    @IBOutlet weak var urlTextField: UITextField!
    @IBOutlet weak var responseTextView: UITextView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        self.clearData()
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        
    }
    
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }

    func clearData(){
        self.resultLabel.text = ""
        self.responseTextView.text = ""
    }
    
    @IBAction func calculateButton_Touch(_ sender: Any) {
        let x = Math.stringToInt(string: self.xTextField.text)
        let y = Math.stringToInt(string: self.yTextField.text)
        var outputString = "invalid input"
        
        if let x = x, let y = y {
            let result = Math.add(x: x, y: y)
            outputString = "\(result)"
        }
        
        self.resultLabel.text = outputString
    }
    
    @IBAction func requestButton_Touch(_ sender: Any) {
        if let url = urlTextField.text {
            Network.dataTask(urlString: url) { (data, response, error) in
                guard error == nil else {
                    self.responseTextView.text = "\(error!.localizedDescription)"
                    return
                }
                
                if let data = data {
                    if let jsonString = String(data: data, encoding: .utf8){
                        DispatchQueue.main.async {
                            self.responseTextView.text = jsonString
                        }
                    }
                }
            }
        }
    }
}

(2.) Math

add: 將2個數字相加並回傳

stringToInt: string轉int並回傳結果, 若失敗回傳nil

class Math: NSObject {
   
    class func add(x: Int, y: Int) -> Int {
        let result = x + y
        return result
    }
    
    class func stringToInt(string: String?) -> Int? {
        var result:Int? = nil
        
        if let string = string {
            result = Int(string)
        }
        return result
    }
}

(3.) Network dataTask:

發送一個request並回傳response

class Network: NSObject {

    class func dataTask(urlString: String, responseHandler: @escaping (Data?, URLResponse?, Error?) -> Swift.Void) {
        let url = URL(string: urlString)
        
        if let url = url {
            let request = URLRequest(url: url)
            let task = URLSession.shared.dataTask(with: request) { (data, response, error) in
                print("Retrieved data")
                responseHandler(data, response, error)
            }
            task.resume()
        } else {
            let myError = NSError(domain:"", code: -9999, userInfo:nil)
            responseHandler(nil, nil, myError)
        }
    }
}

建置Unit Test

首先, 如果您的project尚未加入Unit Test, 可以從File -> New -> Target 新建一個Unit Test Bundle.

完成後, 在XCode左側目錄欄會發現多了一個Tests的目錄, 且裡面已經幫你建立好了1個default的測試class.

該class的預設內容為:

import XCTest

class TestProjectTests: XCTestCase {
    
    override func setUp() {
        super.setUp()
        // Put setup code here. This method is called before the invocation of each test method in the class.
    }
    
    override func tearDown() {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
        super.tearDown()
    }
    
    func testExample() {
        // This is an example of a functional test case.
        // Use XCTAssert and related functions to verify your tests produce the correct results.
    }
    
    func testPerformanceExample() {
        // This is an example of a performance test case.
        self.measure {
            // Put the code you want to measure the time of here.
        }
    }
    
}

我們可以注意到, Unit Test用的class必須import XCTest並extend XCTestCase,

這邊有4個function,

setUp: 測試class生命週期, 在測試開始之前被呼叫

tearDown: 測試class生命週期, 在測試完成之後被呼叫

testExample: 預設的test function, 注意到所有的test function命名都必須以test開頭

testPerformanceExample: 預設的test function, 在meaure block裡面的動作皆會執行10次, 用以測試performance

接著, 我們也可以新增新的Test Class分別去測試我們的不同class

MathTest: 用來測試Math class的functions

import XCTest

class MathTest: XCTestCase {
    override func setUp() {
        super.setUp()
        // Put setup code here. This method is called before the invocation of each test method in the class.
    }
    
    override func tearDown() {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
        super.tearDown()
    }
    
    func testAdd() {
        let a = Math.add(x: 3, y: 5)
        let b = Math.add(x: 64, y: -70)
        let c = Math.add(x: 999, y: 0)
        
        XCTAssert(a == 8, "a should be 8")
        XCTAssert(b == -6, "b should be -6")
        XCTAssert(c == 999, "c should be 999")
    }
    
    func testStringToInt() {
        let a = Math.stringToInt(string: "9")
        let b = Math.stringToInt(string: "-55")
        let c = Math.stringToInt(string: "sdfsks")
        
        XCTAssert(a == 9, "a should be 8")
        XCTAssert(b == -55, "b should be -55")
        XCTAssert(c == nil, "c should be nil")
    }
}

TestNetwork: 用來測試Network class的dataTask function, 關於網路測試是否成功有2個重點

(1.) request要有發送出去

(2.) 發送出去的url, parameters, headers是否正確

在這個例子裡面我們在response的時候檢查 response?.url?.absoluteString是否跟原本的相同.

然而, 更嚴謹的網路測試通常不會真的發送request出去, 因為這樣會影響測試時間並可能污染server端的資料, 此時就可以用Dependency Injection(DI)等方法來達到測試目的, 詳情可以參考以下連結:

The complete guide to Network Unit Testing in Swift

import XCTest

class TestNetwork: XCTestCase {
    override func setUp() {
        super.setUp()
        // Put setup code here. This method is called before the invocation of each test method in the class.
    }
    
    override func tearDown() {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
        super.tearDown()
    }
    
    func testDataTask() {
        let urlString = "http://opendata2.epa.gov.tw/UV/UV.json"

        Network.dataTask(urlString: urlString) { (data, response, error) in
            let requestUrl = response?.url?.absoluteString
            
            XCTAssert(requestUrl == urlString, "wrong URL!")
        }
    }
    
}

TestViewController: 用來測試Viewcontroller class, 因Viewcontroller在viewWillAppear裡面呼叫了clearData function, 所以我們在testClearData() 裡面測試相關元件的text是否為空.

這邊需注意:

(1.) test class的Viewcontroller需要用storyboard.instantiateViewController的方式去宣告, 否則IBOutlet元件會為nil

(2. ) 必須呼叫 vc.loadView() 才能啟動Viewcontroller的生命週期

import XCTest

class TestViewController: XCTestCase {
  
    override func setUp() {
        super.setUp()
    }
    
    override func tearDown() {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
        super.tearDown()
    }
    
    func testClearData() {
        let storyboard = UIStoryboard(name: "Main", bundle: Bundle(for: ViewController.self))
        let vc = storyboard.instantiateViewController(withIdentifier: "ViewController") as! ViewController
        vc.loadView() // call life cycle
        
        vc.clearData()
        
        XCTAssert(vc.resultLabel!.text == "", "vc.resultLabel!.text should be empty ")
        XCTAssert(vc.responseTextView!.text == "", "vc.responseTextView!.text should be empty ")
    }
}

進行測試

點擊Xcode的測試按鈕就能開始測試, 結束後可以在這個頁面看到完整的測試結果, 點擊每個function的綠色勾勾也可以個別測試單個function

測試覆蓋率

(1.) 進入Edit Scheme -> Test -> Options頁面將Gather coverage for all targets選項打勾

(2.) 完成測試後就可以在這裡看到目前測試的coverage rate

Last updated