• Home
  • |
  • Hướng dẫn mô hình MVVM trong ios kỳ 4: Dependency Injection và Unit Test ViewController

Tháng Mười 5, 2019

Hướng dẫn mô hình MVVM trong ios kỳ 4: Dependency Injection và Unit Test ViewController

Download source sample tại đây.

Xem tổng quan và nội dung của series tại đây.

Xin chào bạn đã quay trở lại blog của mình nhé! Như ở bài viết trước mình đã hướng dẫn các bạn cách unit test SampleViewModel. Hôm nay chúng ta sẽ đi vào phần test SampleViewController. Ngoài ra trong bài viết này mình cũng sẽ giới thiệu qua khái niệm Dependency Injection và làm thế nào để hiện thực trong lập trình ios.

Ý tưởng

Để test SampleViewController chúng ta sẽ cần test 2 thứ:

  1. Test xem đã Data Binding đúng chưa? Khi thuộc tính isOn == true thì UISwitch có được bật lên không và ngược lại?
  2. Test xem khi tap vào button đã gọi hàm callAPI của SampleViewModel chưa?

OK chúng ta đã rõ những việc cần phải làm. Tuy nhiên câu hỏi tiếp theo là chúng ta sẽ làm như thế nào?

Nói về việc unit test SampleViewcontroller, việc đầu tiên chúng ta phải làm là bằng một cách nào đó tách rời SampleViewModel ra và truy cập lớp này từ bên ngoài.

Hiện tại thuộc tính viewModel của SampleViewModel đang là private. Chúng ta có thể set thành public để có thể truy cập thao tác.

Tuy nhiên chúng ta còn một vấn đề khác: làm thế nào để thay thế hàm callAPI? Chúng ta cần đơn giản hoá nó đi để tập trung vào chỉ logic của SampleViewController thôi.

Đây là lúc chúng ta cần áp dụng Dependency Injection để giải quyết vấn đề phụ thuộc giữa các thành phần.

Vậy Dependency Injection là gì?

Dependency Injection

Dependency Injection là một phương pháp dùng để tách rời (decouple) giữa module và các thành phần phụ thuộc của nó (dependencies).

Mục đích của việc này là để giúp module và các dependencies độc lập, dễ mở rộng, bảo trì và unit test.

Dependency Injection được thực hiện đúng như tên gọi của nó: truyền (inject) các depedencies vào module từ bên ngoài vào. Trong khi ở cách làm truyền thống module đảm nhận việc tạo và config cho các dependency.

Các dependencies được thiết kế theo hướng trừu tượng hoá (abstract), các module tương tác với dependencies dưới dạng interface (ở swift thì là protocol) được cung cấp sẵn mà không cần biết chi tiết về các dependencies hoạt động như thế nào.

Trên đây là mình giải thích một cách ngắn gọn và cô đọng nhất những gì mình hiểu và ứng dụng thường xuyên nhất. Nếu các bạn muốn tìm hiểu sâu thêm có thể tham khảo tại đây.

Như vậy chúng ta đã nắm được kiến thực cơ bản nhất về Dependency Injection. Để biết cách hiện thực DI trong code như thế nào. Chúng ta hãy bắt tay vào làm.

Trừu tượng hoá (abstract) SampleViewModel

Đầu tiên chúng ta sẽ tiến hành trừu tượng hoá SampleViewModel. Hãy vào file SampleViewModel và chèn thêm đoạn code sau:

protocol SampleViewModelType {
    var isOn: Observable<Bool> { get set }
    func callAPI()
}

Khá đơn giản và dễ hiểu đúng không nào các bạn? Chúng ta đã tạo protocol SampleViewModelType có chứa 1 thuộc tính là isOn và 1 hàm là callAPI, giống như những gì chúng ta khai báo trước đó trong SampleViewModel.

Thiết lập Dependency Injection vào SampleViewController

Trong class SampleViewController, từ phần private let viewModel: SampleViewModel = SampleViewModel(), cho đến trước đoạn viewDidLoad, hãy thay bằng đoạn code sau:

// 1
let viewModel: SampleViewModelType

// 2
init(viewModel: SampleViewModelType) {
    self.viewModel = viewModel
    super.init(nibName: String(describing: SampleViewController.self), bundle: nil)
}

// 3
required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
}

Trong đó:

  1. Thay đổi viewModel từ dạng private sang internal (hoặc public nếu bạn muốn). Thông thường mình hay code thì khi thiết lập Dependency Injection thì các thành phần phụ thuộc có thể cho phép truy xuất dưới dạng setter để dễ dàng tham khảo và tương tác.
  2. Khai báo hàm khởi tạo ViewController đi kèm cùng viewModel. Mục đích của việc này không gì khác ngoài việc thiết lập Dependency Injection cho SampleViewController. Với hàm init này SampleViewController không quan tâm ViewModel của nó được khởi tạo và config như thế nào. Nó cũng không quan tâm chi tiết ViewModel hoạt động như thế nào, chỉ cần ViewModel cung cấp interface cần thiết là được.
  3. Đoạn này chỉ để Xcode compile không bắt lỗi nên không cần quan tâm.

Tiếp theo chúng ta sẽ tiến hành chỉnh sửa ở AppDelegate nhằm inject SampleViewModel vào SampleViewController

Thay câu lệnh let viewController = SampleViewController() bằng đoạn code sau:

let viewModel: SampleViewModelType = SampleViewModel()
let viewController = SampleViewController(viewModel: viewModel)

Với đoạn code ở trên chúng ta đã inject SampleViewModel vào SampleViewController.

OK như vậy chúng ta đã thiết lập hoàn tất Dependency Injection cho SampleViewController. Và bây giờ chúng ta sẽ tiến hành unit test SampleViewController. Và mình sẽ cho bạn thấy Dependency Injection toả sáng như thế nào khi được vận dụng cho unit test 😄.

Ứng dụng Dependency Injection vào unit test

Tạo class giả lập (mock up)

Vào trong class SampleViewControllerTests, ngày trước phần khai báo class SampleViewControllerTests thêm vào đoạn code như sau:

class MockUpSampleViewModel: SampleViewModelType {
    var isOn: Observable<Bool> = Observable(true)
    
    var didCallAPI: Bool = false
    func callAPI() {
        didCallAPI = true
    }
}

Chúng ta vừa định nghĩa một class dùng để giả lập SampleViewModel.

Trong đó khi gọi hàm callAPI, thay vì phải gọi http request thì chúng ta chỉ đơn giản thay đổi didCallAPI thành true, để đánh dấu rằng hàm này đã được gọi.

Test Data Binding

Như ở đầu bài viết đề cập, chúng ta có 2 chức năng chính cần phải test. Đó là: test data binding và test event khi tap toggle button.

Để test data binding, các bạn hãy vào hàm testSwitchOn và thêm đoạn code sau:

// 1
let viewModel = MockUpSampleViewModel()
viewModel.isOn.silentUpdate(value: false)
let sampleViewController = SampleViewController(viewModel: viewModel)

// When
// 2
_ = sampleViewController.view
viewModel.isOn.value = true
// 3
let expectation = self.expectation(description: "testSwitchOn")
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
    expectation.fulfill()
}

// Then
//4
waitForExpectations(timeout: 1)

// 5
XCTAssert(sampleViewController.switch.isOn == true)

Trong đó:

  1. Khai báo và thiết lập giả lập cho ViewModel.
  2. Thay đổi thuộc tính isOn của ViewModel thành true.
  3. Vì trong ViewController chúng ta đang thiết lập là khi thuộc tính isOn của ViewModel thay đổi thì sẽ update trạng thái của UISwitch đi kèm với animation. Do đó UISwitch sẽ không cập nhật ngay mà chúng ta phải đợi, ở đây mình chỉnh tạm là 0.5 giây.
  4. Tạm dừng test case để chờ UISwitch update trạng thái.
  5. Kiểm tra điều kiện.

Để chắc ăn, chúng ta sẽ test thêm trường hợp thuộc tính isOn là false.

Trong hàm testSwitchOff, các bạn hãy thêm đoạn code như sau:

// Given
let viewModel = MockUpSampleViewModel()
let sampleViewController = SampleViewController(viewModel: viewModel)

// When
_ = sampleViewController.view
viewModel.isOn.value = false
let expectation = self.expectation(description: "testSwitchOff")
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(500)) {
    expectation.fulfill()
}

// Then
waitForExpectations(timeout: 1)
XCTAssert(sampleViewController.switch.isOn == false)

Đoạn code này cũng tương tự như phần ở trên, phần ở trên test trường hợp UISwitch được bật, phần này test trường hợp ngược lại là được tắt.

Test tap toggle button

Ok công việc đầu tiên hoàn tất. Giờ chúng ta sẽ đến với việc thứ hai. Đó là test xem khi tap toggle button trên SampleViewController thì hàm callAPI() của viewModel có được không?

Các bạn hãy vào trong hàm testTapToggleButton() và thêm đoạn code như sau:

// Given
// 1
let viewModel = MockUpSampleViewModel()
let sampleViewController = SampleViewController(viewModel: viewModel)

// When
// 2
_ = sampleViewController.view
// 3
sampleViewController.toggleButton.sendActions(for: .touchUpInside)

// Then
// 4
XCTAssert(viewModel.didCallAPI == true)

Trong đó:

  1. Khai báo SampleViewController, tương tự như lần trước ở AppDelegate. Tuy nhiên lần này chúng ta thay phần viewModel bằng MockUpSampleViewModel.
  2. Đoạn này dùng để kích hoạt hàm viewDidLoad của SampleViewController để các IBOutlet được load vào.
  3. Kích hoạt event touch lên toggle button.
  4. Kiểm tra xem hàm callAPI của ViewModel được gọi chưa bằng cách kiểm tra thuộc tính didCallAPI.

Phần đặc sắc của đoạn code trên nằm ở đoạn code số 1. Nhờ vào việc trừu tượng hoá ViewModel bằng cách tạo protocol SampleViewModelType mà giờ đây chúng ta có thể thay bất kỳ một ViewModel, hay nói cách khác là một logic bất kỳ nào miễn sao thoả mãn protocol SampleViewModelType để có thể tuỳ ý truyền vào (inject) SampleViewController một cách đơn giản và dễ dàng! 😃

Bây giờ bạn có thể run test trên xcode. Bạn hãy chạy thử xem, nếu làm đúng thì bạn sẽ thấy Code Coverage cho SampleViewController đã là 100% 😃.

Tng kết

Đến đây các bạn đã xem xong series bài Tổng quan về MVVM. Các bạn đã nắm được phần tổng quan của mô hình MVVM, làm sao để có thể thiết kế và hiện thực mô hình MVVM một cách hiệu quả. Ngoài ra bạn đã biết cách ứng dụng Dependency Injection vào mô hình MVVM, khiến cho code trở sáng sủa và chất lượng hơn trước rất nhiều.

Với series này mình hy vọng nó có ích cho các bạn. Mặc dù đã rất cố gắng tham khảo và chỉnh sửa nhiều lần, nhưng bài viết này nói riêng và những bài viết khác trong series nói chung có thể còn nhiều thiếu sót. Mình rất mong được nhận sự nhận xét và góp ý của các bạn.

Cám ơn các bạn đã chú ý theo dõi. Hẹn gặp lại các bạn. Bái bai 😄!

Related Posts

Custom text input trong Eureka

Custom picker row trong Eureka

Hướng dẫn cài đặt React Native đơn giản bao chạy được tháng 01/2020

Hướng dẫn mô hình MVVM trong ios kỳ 3: Unit Test ViewModel

Hiển Phạm


Mình là một lập trình viên ios. Khi rảnh rỗi mình thích chơi game, đọc sách và tìm hiểu nhiều hơn về kỹ thuật lập trình.

Your Signature

Leave a Reply


Your email address will not be published. Required fields are marked

{"email":"Email address invalid","url":"Website address invalid","required":"Required field missing"}