Swift 2.0 Unit Test
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 ...
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.
Tạo project
Tạo một project mới theo template Single View Application, language Swift, tích chọn Include Unit Tests
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
UserViewController
UserViewController dùng để điền thông tin user, giao diện như sau
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:
Xóa 2 hàm mặc định là testExample() và 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 UserListViewController là UserListViewController
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...
Đế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