🐈

XCTestを利用してメモリーリーク検出を自動化する。

2023/08/31に公開

プロジェクトにUnit Testを導入しました。

プロジェクトでは長らくUnit Testを行なっていなく全てのコードに対して行うには体制が整っていませんでした。重要度の高いコードや繰り返しテストを行う機能・ケースが頻繁に増える機能など、優先して書くことで品質の上昇・工数の削減を狙えるコードもナレッジが少ないためになかなか進みませんでした。

そこでテストを自動化することでメリットがあるというのがわかりやすい題材はないだろうかと探している最中、Appleのドキュメントを眺めていた時にふと以前メモリーリークの調査の際にinstrumentsを利用してかなり時間がかかっていたことを思い出しました。
https://developer.apple.com/documentation/xcode/testing-your-apps-in-xcode

そこで「XCTestを利用してメモリーリーク検出を自動化」してみようと調べてみました。

環境

OS: macOS 13.4.1
Xcode.app: Version 14.3.1 (14E300c)
動作iOSバージョン:14.0以上

XCTestを利用してメモリーリーク検出する。

メモリーリークを引き起こすViewControllerを用意する。

retain cycleが起きるようなコードを加えたViewControllerを作成します。

class LeakingViewController: UIViewController {

    lazy var button = UIButton(primaryAction: UIAction(handler: { _ in
        self.onButtonAction()
    }))

    override func viewDidLoad() {
        view.addSubview(button)
    }

    func onButtonAction() {
        print("")
    }
}

メモリーリーク検出を行うコードを追加する。

addTeardownBlockはドキュメントからテストが終わった後に実行するブロックを登録するとあるのでviewControllerにメモリーリークがなければ、参照は残らずにnilになるはずなのでXCTAssertNilをかけてnilであれば"Object should be deallocated. Detected memory leak."というメッセージが出るようにします。

addTeardownBlock

func testExample() throws {
    let viewController = LeakingViewController()
    _ = viewController.view
    addTeardownBlock { [weak viewController] in
        XCTAssertNil(viewController, "Object should be deallocated. Detected memory leak.")
    }
}

テストを実行します。下記のように検出できました。

Result001

LeakingViewControllerのコードを修正します。

lazy var button = UIButton(primaryAction: UIAction(handler: { [weak self] _ in
    self?.onButtonAction()
}))

テストを実行します。
テストが成功しメッセージは消えます。

テスト対象を増やした場合

検出はうまくいきましたがテスト対象が増えた時はどうなるのでしょうか?。
テスト項目を増やしてみます。

Presenterを持つViewControllerにretain cycleが起きるようなコードを加えました。

protocol LeakingViewControllerDelegate: AnyObject {
    func buttonClicked()
}

protocol LoadingView {
    func startLoading()
}

class LeakingViewControllerPresenter: LeakingViewControllerDelegate {
    var view: LoadingView

    init(view: LoadingView) {
        self.view = view
    }

    func buttonClicked() {
        view.startLoading()
        //do some operation ...
    }
}

class LeakingViewControllerTwo: UIViewController, LoadingView {

    var delegate: LeakingViewControllerDelegate?

    lazy var button = UIButton(primaryAction: UIAction(handler: { [weak self] _ in
        self?.delegate?.buttonClicked()
    }))

    override func viewDidLoad() {
        view.addSubview(button)
    }

    func startLoading() {
        print("show view")
    }
}

addTeardownBlockを利用してテストコードを記述します。

func testExampleTwo() throws {
    let viewController = LeakingViewControllerTwo()
    let presenter = LeakingViewControllerPresenter(view: viewController)
    viewController.delegate = presenter
    _ = viewController.view
    addTeardownBlock { [weak viewController, weak presenter] in
        XCTAssertNil(viewController, "Object should be deallocated. Detected memory leak.")
        XCTAssertNil(presenter, "Object should be deallocated. Detected memory leak.")
    }
}

XCTestCaseにメモリーリーク検出用拡張機能を追加する。

テストを実行するとテスト対象が一つの時と同じように表示できますが、テスト対象が増えるたびにブロック内のコード量が増えてしまいます。

そこで下記のようにXCTestCaseにextensionを用意してみました。
メッセージの共通化に加えて、ファイルと行を追加して、テストが失敗した正確な行とファイルに失敗メッセージが表示されるようにします。

extension XCTestCase {
    func trackForMemoryLeak(instance: AnyObject,
                            file: StaticString = #filePath,
                            line: UInt = #line) {
        addTeardownBlock { [weak instance] in
            XCTAssertNil(
                instance,
                "potential memory leak on \(String(describing: instance))",
                file: file,
                line: line
            )
        }
    }
}

実際に利用すると下記のような結果を表示できます。

Result002

今回のデモはこちらで公開していますのでご参考になればと思います。

https://github.com/kokiTakashiki/AutomateMemoryLeakDetectionInMySwiftCodeWithXCTest.git

参考

https://betterprogramming.pub/preventing-memory-leaks-using-xctests-5fee5e1fa7c5
https://qualitycoding.org/swift-memory-leak-detection-xctest/

Discussion