Skip to main content

Retain cycles unit test


Trong quá trình phát triển ứng dụng, chúng ta rất khó tránh khỏi việc tạo ra retain cycles kể cả với những lập trình viên giàu kinh nghiệm. Retain cycles không phải lúc nào cũng dễ dàng để phát hiện và có thể dẫn đến hàng giờ để debug. Tất nhiên có những công cụ tuyệt vời giúp chúng ta thực hiện debug dễ dàng hơn (như: memory graph debugger,...) nhưng quá trình debug vẫn khá là vất vả và mất thời gian.
Điều quan trọng để fix retain cycles là cần phát hiện ra chúng. Bài viết này sẽ trình bày một số đoạn code mà bạn có thể đưa vào unit test giúp việc phát hiện ra retain cycles dễ dàng hơn.

Ý tưởng đơn giản

Một quy tắc khá đơn giản trong ARC: một đối tượng sẽ được giữ lại nếu vẫn còn ít nhất 1 tham chiếu mạnh đến nó.
class NoisyDeinit {
    deinit {
        print("I'm in deinit")
    }
}

var example: NoisyDeinit? = .init()

// Setting `example` (the only reference to the `NoisyDeinit` instance) to `nil`
// causes the instance to be deallocated and it's `deinit` will be invoked.
example = nil
Tương tự, chúng ta đều biết rằng một tham chiếu yếu (weak) đến một đối tượng sẽ không làm tăng retain count và đối tượng sẽ giải phóng khi tham chiếu mạnh cuối cùng kết thúc.
var example: NoisyDeinit? = .init()
weak var weakReference = example

assert(weakReference != nil)

// Setting `example` (the only reference to the `NoisyDeinit` instance) to `nil`
// also causes `weakReference` to be set to `nil`.
example = nil
assert(weakReference == nil)
Nắm được những điều cơ bản trên, chúng ta có thể viết unit tests theo cách mà chúng ta có cả tham chiếu mạnh và tham chiếu yếu đến đối tượng cần test. Sau khi hoàn thành sử dụng đối tượng, chúng ta sẽ set tham chiếu mạnh thành nil và xác minh rằng tham chiếu yếu cũng là nil. Nếu tham chiếu yếu không phải nil tại thời điểm này thì chúng ta cần tìm ra nguyên nhân khiến đối tượng tồn tại (đây có thể là 1 retain cycle).
Chúng ta hãy xem nó trông như thế nào. Đây là một unit test mà không cần kiểm tra cycle:
final class SampleTests: XCTestCase {    
    func testGreeting() {
        let sut = Greeter()
        XCTAssertEqual(sut.greet("Paul"), "Hello Paul")
    }
}
Để thêm bài test này cho một đối tượng, chúng ta làm như sau:
 1 final class SampleTests: XCTestCase {
 2     func testGreeting() {
 3         var sut: Greeter? = .init()
 4         weak var weakSut = sut
 5 
 6         XCTAssertEqual(sut?.greet("Paul"), "Hello Paul")
 7 
 8         sut = nil
 9         XCTAssertNil(weakSut)
10     }
11 }
  1. Một biến weak var để giữ tham chiếu yếu đến đối tượng mà chúng ta đang cần kiểm tra vòng đời.
  2. Set nil để loại bỏ tham chiếu mạnh (line 8)
  3. Kiểm tra weakSut sẽ trở thành nil (line 9)

Chúng ta có thể đơn giản hóa?

Thêm 3 dòng như trên cho mỗi đối tượng là một công việc khá tẻ nhạt và dễ bị lỗi. Ví dụ, bạn có thể vô tình quên bất kỳ một trong các bước này và xác thực sẽ không còn hoạt động. Chúng ta có thể viết một vài helper functions mà chúng ta có thể thêm dưới dạng extension trên XCTestCase cho phép chúng ta chỉ cần thêm một dòng cho mỗi đối tượng mà chúng ta muốn test vòng đời. Trước tiên, hãy thêm một function cho phép chúng ta xác thực rằng một đối tượng được giải phóng sau khi chúng ta thực thi một block mà caller cung cấp. Điều này sẽ hữu ích cho các tình huống trong đó bạn có một thuộc tính instance đang giữ đối tượng của bạn.
 1 extension XCTestCase {
 2     func assertNil(_ subject: AnyObject?, after: @escaping () -> Void, file: StaticString = #file, line: UInt = #line) {
 3         guard let value = subject else {
 4             return XCTFail("Argument must not be nil", file: file, line: line)
 5         }
 6 
 7         addTeardownBlock { [weak value] in
 8             after()
 9             XCTAssert(value == nil, "Expected subject to be nil after test! Retain cycle?", file: file, line: line)
10         }
11     }
12 }
Dòng 3-5 thực hiện một chút kiểm tra. Nếu đối tượng subject truyền vào nil thì sẽ trả ra thất bại. Các dòng 7-9 là một closure được gọi sau khi test đã được chạy Dòng 7 là nơi tham chiếu yếu đến đối tượng value Dòng 8 là nơi chúng ta thực hiện closure after() Dòng 9 là nơi chúng ta thực hiện xác nhận rằng tham chiếu yếu của chúng tôi không có
Khi sử dụng helper function, unit test của chúng ta trở thành:
final class SampleTests: XCTestCase {
    var sut: Greeter!

    override func setUp() {
        super.setUp()
        sut = Greeter()
        assertNil(sut, after: { self.sut = nil })
    }

    func testGreeting() {
        XCTAssertEqual(sut.greet("Paul"), "Hello Paul")
    }
}
Trong các trường hợp chúng ta không có thuộc tính instance giữ đối tượng của mình, chúng ta có thể viết một hàm đơn giản hơn:
extension XCTestCase {
    func assertNilAfterTest(_ subject: AnyObject?, file: StaticString = #file, line: UInt = #line) {
        assertNil(subject, after: {}, file: file, line: line)
    }
}
Đoạn code trên hoạt động vì nếu không có gì giữ đối tượng của chúng ta bên ngoài scope của test function, nó sẽ tự động được giải phóng bởi thực tế là tham chiếu mạnh duy nhất đã được giải phóng khi kết thúc scope. Điều này cho phép việc test thậm chí còn đơn giản hơn:
final class SampleTests: XCTestCase {
    func testGreeting() {
        let sut = Greeter()
        assertNilAfterTest(sut)
        XCTAssertEqual(sut.greet("Paul"), "Hello Paul")
    }
}

Kết luận

Hai helper functions trên tạo nên một API đơn giản, hy vọng sẽ hữu ích trong việc giúp phát hiện retain cycle trước khi chúng trở thành một vấn đề thực sự. Đoạn code trên hy vọng đủ đơn giản để hiểu và không cần chỉnh sửa quá nhiều các bài test hiện có (no subclassing etc).
Hy vọng bài viết trên sẽ hữu ích với bạn, bài viết được dịch từ: https://paul-samuels.com/blog/2018/11/20/unit-testing-retain-cycles/

Comments

Popular posts from this blog

Alamofire vs URLSession

Alamofire vs URLSession: a comparison for networking in Swift Alamofire and URLSession both help you to make network requests in Swift. The URLSession API is part of the foundation framework, whereas Alamofire needs to be added as an external dependency. Many  developers  doubt  whether it’s needed to include an extra dependency on something basic like networking in Swift. In the end, it’s perfectly doable to implement a networking layer with the great URLSession API’s which are available nowadays. This blog post is here to compare both frameworks and to find out when to add Alamofire as an external dependency. Build better iOS apps faster Looking for a great mobile CI/CD solution that has tons of iOS-specific tools, smooth code signing, and even real device testing? Learn more about Bitrise’s iOS specific solutions! This shows the real power of Alamofire as the framework makes a lot of things easier. What is Alamofire? Where URLSession...

Swift Tool Belt, Part 1: Adding a Border, Corner Radius, and Shadow to a UIView with Interface Builder

During my iOS work, I’ve assembled a set of code that I bring with me on every iOS project. I’m not talking about large frameworks or CocoaPods here. These are smaller Swift extensions or control overrides that are applicable to many projects. I think of them as my tool belt. In this post, I’ll show you an extension that will add a border, a corner radius, and a shadow to any UIView, UIButton, or UILabel and allow you to preview what it will look like in Interface Builder. Back in 2014, I wrote a blog post on Expanding User-Defined Runtime Attributes in Xcode where I added a border, corner radius, and shadow to a UIView using Interface Builder’s user-defined runtime attributes. This solution had no type checking—you had to type the property you wanted to modify by hand and often had to look up what it was called. You also had to run your project in order to see the effect of the runtime attribute. Starting with Xcode 6 , there is a new mech...

Frame vs Bounds in iOS

This article is a repost of an answer I wrote on Stack Overflow . Short description frame = a view’s location and size using the parent view’s coordinate system ( important for placing the view in the parent) bounds = a view’s location and size using its own coordinate system (important for placing the view’s content or subviews within itself) Details To help me remember frame , I think of a picture frame on a wall . The picture frame is like the border of a view. I can hang the picture anywhere I want on the wall. In the same way, I can put a view anywhere I want inside a parent view (also called a superview). The parent view is like the wall. The origin of the coordinate system in iOS is the top left. We can put our view at the origin of the superview by setting the view frame’s x-y coordinates to (0, 0), which is like hanging our picture in the very top left corner of the wall. To move it right, increase x, to move it down increase y. To help me remember bound...