Swift 2.0 Unit Test

Swift Unit Testing

1. Giới thiệu

Thông thường mọi người thấy code của mình đã ổn và việc phải viết Unit Test là không cần thiết và làm chậm tiến độ dự án. Nhưng thực tế, Unit Test là một cách tuyệt vời để viết code tốt hơn, nó giúp tìm ra bug ngay từ những giai đoạn đầu, giảm số lượng bug từ đó giảm thời gian phải bỏ ra để fix bug về sau. Quan trọng hơn, viết code với phương pháp dựa trên Unit Test sẽ giúp bạn viết code theo mô đun, từ đó code sẽ dễ dàng bảo trì hơn. Có một quy tắc là: nếu code của bạn không dễ dàng để test thì nó cũng không dễ dàng để bảo trì hay tìm bug. Tác giả của quyển Clean Code có nói rằng nếu code của bạn không thể test thì có nghĩa là code của bạn là "legacy code".

Việc áp dụng Unit Test trong Swift trước đây khá phức tạp với việc phải để tất cả thành public và phải thêm target vào project test. Kể từ Swift 2.0 việc thực hiện Unit Test đã dễ dàng hơn nhiều với sự ra đời của từ khóa @testable. Import mô đun với từ khóa này sẽ giúp cho Unit Test có khả năng truy cập tới các biến có thuộc tính internal.

Trong bài hướng dẫn này bạn sẽ tìm hiểu làm thế nào để viết Unit Test cho một ứng dụng quản lý người dùng đơn giản.

2. Demo

Tạo project

Tạo một project mới theo template Single View Application, language Swift, tích chọn Include Unit Tests

New Project

Tạo Model và Service

Trước tiên chúng ta sẽ tạo user model, đây sẽ là đối tượng chính của chương trình

import UIKit

class User: NSObject {
    var id = ""
    var username = ""
    var email = ""
}

Tiếp theo là viết UserService dùng để quản lý user. Để đơn giản, trong dự án này chúng ta sẽ lưu user vào một static dictionary. Trong thực tế các bạn có thể lưu vào database ví dụ như Core Data.

UserService implement UserServiceProtocol

import Foundation

protocol UserServiceProtocol {
    func getAll() -> [User]
    func addUser(user: User)
    func updateUser(user: User)
    func deleteUser(user: User)
}
import UIKit

class UserService: NSObject, UserServiceProtocol {

    static var userDic = Dictionary<String, User>()

    func getAll() -> [User] {
        return [User](UserService.userDic.values)
    }

    func addUser(user: User)  {
        UserService.userDic[user.id] = user
    }

    func updateUser(user: User) {
        UserService.userDic[user.id] = user
    }
    func deleteUser(user: User) {
        UserService.userDic.removeValueForKey(user.id)
    }
}

Xây dựng các view controller

UserListViewController

UserListViewController có nhiệm vụ hiển thị danh sách user và thực hiện các tác vụ add, update, delete user.

UserListViewController kế thừa UITableViewController, nên ta cần phải implement UITableViewDataSource. Để tránh làm cho controller phình to với quá nhiều code cũng như để cho việc test được dễ dàng chúng ta sẽ tách phần implement UITableViewDataSource ra một class khác tên là UserListDataProvider thỏa mãn protocol UserListDataProviderProtocol

import Foundation
import UIKit

protocol UserListDataProviderProtocol: UITableViewDataSource {
    weak var tableView: UITableView? { get set }
    subscript(index: Int) -> User? { get }
    func addUser(user: User)
    func updateUser(user: User)
    func fetch()
}

Khi đó UserListViewController của chúng ta sẽ có code gọn nhẹ như sau:

import UIKit

class UserListViewController: UITableViewController {

    var userListDataProvider: UserListDataProviderProtocol!
    let userSegueIdentifier = "presentUser"

    override func viewDidLoad() {
        super.viewDidLoad()

        userListDataProvider = UserListDataProvider()
        userListDataProvider.tableView = self.tableView
        tableView.dataSource = userListDataProvider
    }

    @IBAction func onAddButtonClicked(sender: UIBarButtonItem) {
        self.performSegueWithIdentifier(userSegueIdentifier, sender: nil)
    }

    override func tableView(tableView: UITableView, didSelectRowAtIndexPath indexPath: NSIndexPath) {
        let user = userListDataProvider[indexPath.row]
        if user != nil  {
            self.performSegueWithIdentifier(userSegueIdentifier, sender: user)
        }
    }

    // MARK: - Navigation

    override func prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) {
        if segue.identifier == userSegueIdentifier {
            let controller = (segue.destinationViewController as! UINavigationController).topViewController as! UserViewController
            controller.delegate = self
            controller.user = sender as? User
        }
    }
}

extension UserListViewController: UserViewControllerDelegate {
    func userViewControllerDone(sender: UserViewController) {
        let user = sender.user
        if user.id.isEmpty { // add user
            user.id = NSUUID().UUIDString
            userListDataProvider.addUser(user)
        }
        else {
            userListDataProvider.updateUser(user)
        }
        userListDataProvider.fetch()
    }
}

Giao diện

UserListViewController

UserViewController

UserViewController dùng để điền thông tin user, giao diện như sau

UserViewController

Unit Test

Ta thêm vào dự án Unit Test Case Class UserListViewControllerTests

Trong class UserListViewControllerTests ta thêm dòng @testable import MGUnitTestDemo ở phần import

import XCTest
@testable import MGUnitTestDemo

class UserListViewControllerTests: XCTestCase {
...
}

Nếu bị lỗi ở dòng @testable thì bạn cần Enable Testabiliy ở phần Build Settings của dự án như sau:

Build Settings

Xóa 2 hàm mặc định là testExample()testPerformanceExample() của UserListViewControllerTests

Thêm biến viewController và thay đổi hàm setup() như sau:

class UserListViewControllerTests: XCTestCase {
    var viewController: UserListViewController!

    override func setUp() {
        super.setUp()

        viewController = UIStoryboard(name: "Main", bundle: nil).instantiateViewControllerWithIdentifier("UserListViewController") as! UserListViewController
    }
}

Trong Storyboard, ta cần đặt Storyboard ID của UserListViewControllerUserListViewController

Storyboard ID

Cấu trúc Unit Test

Cấu trúc của một Unit Test bao gồm 3 phần:

  • Arrange: khởi tạo
  • Act: chạy đối tượng, hàm cần test
  • Assert: kiểm tra kết quả

Mock class

Để test UserListViewController, ta cần phải tạo class MockUserListDataProvider implement UserListDataProviderProtocol.

Việc tạo class mock giả lập hoạt động của class thật sẽ giúp chúng ta tạo được các kết quả theo mong muốn mà không cần thiết phải dựa vào các điều kiện thật, đặc biệt trong các trường hợp khó tạo ra kết quả hoặc gây ảnh hưởng làm thay đổi dữ liệu như truy vấn vào database hay sử dụng các hàm API.

class MockUserListDataProvider: NSObject, UserListDataProviderProtocol {
    weak var tableView: UITableView?
    var isUserAdded = false
    var isUserUpdated = false
    var isFetched = false

    subscript(index: Int) -> User? {
        return User()
    }

    func addUser(user: User) {
        isUserAdded = true
    }
    func updateUser(user: User) {
        isUserUpdated = true
    }
    func fetch() {
        isFetched = true
    }

    func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return 1
    }

    func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        return UITableViewCell()
    }
}

Unit Test 1: testDataProviderHasTableViewPropertySetAfterLoading

func testDataProviderHasTableViewPropertySetAfterLoading() {
    // Arrange

    // Act
    let _ = viewController.view

    // Assert
    XCTAssertTrue(viewController.userListDataProvider.tableView != nil, "The table view property of data provider should be set")
    XCTAssertTrue(viewController.tableView === viewController.userListDataProvider.tableView, "The table view should be set to the table view of data provider")

}

Hàm test này có nhiệm vụ kiểm tra xem userListDataProvider của controller có được tạo và thuộc tính tableView của user data provider có được thiết lập sau khi hàm viewDidLoad() của controller được gọi hay không.

Để chạy Unit Test, chúng ta chọn menu Product > Test, Xcode sẽ tiến hành build và chạy simulator, khi việc test hoàn tất Xcode sẽ báo test case OK hay Failed, và bôi đỏ các dòng Failed nếu có.

Unit Test 2: testAddButtonTransitsToUserViewController

func testAddButtonTransitsToUserViewController() {
    // Arrange
    let controller = MockUserListViewController()

    // Act
    controller.onAddButtonClicked(UIBarButtonItem())

    // Assert
    if let identifier = controller.segueIdentifier {
        XCTAssertEqual(controller.userSegueIdentifier, identifier, "The segue identifier should be the user segue identifier")
    }
    else {
        XCTFail("Segue should be performed")
    }
}

Hàm test này sẽ test việc người dùng nhấn nút Add sẽ phải chuyển sang màn hình UserViewController. Ta sẽ kiểm tra điều này qua việc xem hàm prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) của controller có được gọi hay không. Để thực hiện việc này ta cần tạo 1 mock class kế thừa UserListViewController

class MockUserListViewController: UserListViewController {
    var segueIdentifier: String?
    override func performSegueWithIdentifier(identifier: String, sender: AnyObject?) {
        segueIdentifier = identifier
    }
}

Unit Test 3: testTransitsToUserViewControllerAfterSelectingTableViewRow

func testTransitsToUserViewControllerAfterSelectingTableViewRow() {
    // Arrange
    let controller = MockUserListViewController()
    let dataProvider = MockUserListDataProvider()
    controller.userListDataProvider = dataProvider

    // Act
    controller.tableView(UITableView(), didSelectRowAtIndexPath: NSIndexPath(forRow: 0, inSection: 0))

    // Assert
    if let identifier = controller.segueIdentifier {
        XCTAssertEqual(controller.userSegueIdentifier, identifier, "The segue identifier should be the user segue identifier")
    }
    else {
        XCTFail("Segue should be performed")
    }
}

Hàm test này sẽ test việc người dùng khi nhấn vào một dòng của table view phải chuyển sang màn UserViewController. Trong trường hợp này chúng ta cần dùng tới MockUserListDataProvider để giả lập việc luôn trả về kết quả của hàm subscript(index: Int) của data provider.

Và tương tự như test case 2, chúng ta cần dùng tới MockUserListViewController để giả lập hàm prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?)

Unit Test 4: testArgumentsArePassedOnUserSegue

func testArgumentsArePassedOnUserSegue() {
    // Arrange
    let userController = UserViewController()
    let navigationController = UINavigationController(rootViewController: userController)
    let segue = UIStoryboardSegue(identifier: viewController.userSegueIdentifier,
        source: viewController,
        destination: navigationController)
    let user = User()

    // Act
    viewController.prepareForSegue(segue, sender: user)

    // Assert
    XCTAssertTrue(userController.delegate === viewController, "The view controller should be set as user view controller's delegate")
    XCTAssertTrue(userController.user === user, "The user property of user view controller should be set")
}

Hàm test này test việc truyền tham số sang UserViewController thông qua segue. Ta sẽ khởi tạo một đối tượng UIStoryboardSegue giả lập segue trên Storyboard với các tham số, sau đó kiểm tra xem tham số có được truyền sang UserViewController qua hàm prepareForSegue(segue: UIStoryboardSegue, sender: AnyObject?) hay không.

Tiếp sau đây Unit Test 5 và 6 sẽ test UserViewControllerDelegate của UserViewController, hàm userViewControllerDone(sender: UserViewController) sẽ được gọi sau khi người dùng nhấn nút Done trên UserViewController để hoàn tất việc nhập dữ liệu.

Unit Test 5: testAddUser

func testAddUser() {
    // Arrange
    let dataProvider = MockUserListDataProvider()
    viewController.userListDataProvider = dataProvider

    let user = User()
    user.id = ""

    let userViewController = UserViewController()
    userViewController.user = user

    // Act
    viewController.userViewControllerDone(userViewController)

    // Assert
    XCTAssertTrue(dataProvider.isUserAdded, "A new user should be added")
    XCTAssertTrue(user.id != "", "The new user should have an id")
}

Thông qua việc dùng MockUserListDataProvider, chúng ta có thể kiểm tra xem hàm addUser(user: User) có được gọi hay không thông qua thuộc tính isUserAdded của mock class.

Unit Test 6: testUpdateUser

func testUpdateUser() {
    // Arrange
    let dataProvider = MockUserListDataProvider()
    viewController.userListDataProvider = dataProvider

    let user = User()
    user.id = NSUUID().UUIDString

    let userViewController = UserViewController()
    userViewController.user = user

    // Act
    viewController.userViewControllerDone(userViewController)

    // Assert
    XCTAssertTrue(dataProvider.isUserUpdated, "The user should be updated")
}

Tương tự Unit Test 5, nhưng chúng ta kiểm tra thuộc tính isUserUpdated

Vậy chúng ta đã hoàn thành việc test UserListViewController, các bạn có thể thực hiện việc test tương tự với UserViewController, UserService, UserListDataProvider...

3. Kết luận

Đến đây, chắc các bạn cũng nhận thấy rằng việc viết Unit Test rất mất nhiều công sức (số lượng code của phần test ít nhất là gấp 2 lần số lượng phần code cần test). Tuy nhiên thành quả có được là rất lớn: các bạn sẽ hiểu sâu về nền tảng hơn, viết code "lỏng" hơn, nâng cao tính abstract của code, tự tin hơn với các đoạn code mình viết hơn, dễ dàng bảo trì, nâng cấp và có thể refactor thoải mái mà không sợ gây bug ở các phần khác mà không kiểm soát được. Quan trọng hơn nữa là với việc rèn luyện viết Unit Test, chúng ta có thể viết các đoạn code bao phủ được phần lớn và tiến tới là toàn bộ các test case. Các bug được chặn ngay từ đầu sẽ giúp chúng ta đỡ mất rất nhiều thời gian để fix bug.

Cảm ơn các bạn đã theo dõi bài viết này.

Source code của project các bạn có thể download tại đây

All Rights Reserved