Swift 2.0 Unit Test
Bài đăng này đã không được cập nhật trong 3 năm
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
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
...
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