diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0e1470e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + package: + name: Package tests (macOS) + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + - name: Run tests + run: swift test + + sample-apps: + name: Sample apps (iOS) + runs-on: macos-15 + strategy: + matrix: + sample: [DeallocTestsAppSPM, DeallocTestsAppDIFreeSPM] + steps: + - uses: actions/checkout@v4 + # The samples contain an intentional leak, so their tests are expected to fail. + # CI only verifies that they compile. + - name: Build for testing + working-directory: SampleApps/${{ matrix.sample }} + run: | + xcodebuild build-for-testing \ + -scheme DeallocTestsAppSPM \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO diff --git a/.jazzy.yml b/.jazzy.yml deleted file mode 100644 index e7a6b02..0000000 --- a/.jazzy.yml +++ /dev/null @@ -1,8 +0,0 @@ -author: Daniel Cech -author_url: https://twitter.com/DanielCech -github_url: https://github.com/DanielCech/DeallocTests -root_url: https://DanielCech.github.io/DeallocTests/ -module: DeallocTests -output: docs -theme: fullwidth -xcodebuild_arguments: [-workspace, 'DeallocTests.xcworkspace', -scheme, 'DeallocTests-iOS'] diff --git a/.ruby-version b/.ruby-version deleted file mode 100644 index 49cdd66..0000000 --- a/.ruby-version +++ /dev/null @@ -1 +0,0 @@ -2.7.6 diff --git a/.swift-version b/.swift-version deleted file mode 100644 index 819e07a..0000000 --- a/.swift-version +++ /dev/null @@ -1 +0,0 @@ -5.0 diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 5c859be..0000000 --- a/.travis.yml +++ /dev/null @@ -1,95 +0,0 @@ -language: objective-c -osx_image: xcode10.2 -branches: - only: - - master - - develop - - /^v\d+\.\d+(\.\d+)?(-\S*)?$/ -cache: - directories: - - $HOME/Library/Caches/org.carthage.CarthageKit/dependencies - - Carthage - - vendor/bundle - -before_install: - - bundle install --path vendor/bundle - - gem install xcpretty - - brew update - - brew outdated carthage || brew upgrade carthage - -env: - global: - - LC_CTYPE=en_US.UTF-8 - - LANG=en_US.UTF-8 - - WORKSPACE=DeallocTests.xcworkspace - - IOS_FRAMEWORK_SCHEME="DeallocTests-iOS" - - OSX_FRAMEWORK_SCHEME="DeallocTests-macOS" - - TVOS_FRAMEWORK_SCHEME="DeallocTests-tvOS" - - WATCHOS_FRAMEWORK_SCHEME="DeallocTests-watchOS" - matrix: - - DESTINATION="OS=12.2,name=iPhone XS" SCHEME="$IOS_FRAMEWORK_SCHEME" RUN_TESTS="YES" - - DESTINATION="OS=11.4,name=iPhone X" SCHEME="$IOS_FRAMEWORK_SCHEME" RUN_TESTS="YES" - - DESTINATION="OS=10.3.1,name=iPhone 7 Plus" SCHEME="$IOS_FRAMEWORK_SCHEME" RUN_TESTS="YES" - - DESTINATION="OS=9.0,name=iPhone 6" SCHEME="$IOS_FRAMEWORK_SCHEME" RUN_TESTS="YES" - - DESTINATION="arch=x86_64" SCHEME="$OSX_FRAMEWORK_SCHEME" RUN_TESTS="YES" - - DESTINATION="OS=12.2,name=Apple TV 4K (at 1080p)" SCHEME="$TVOS_FRAMEWORK_SCHEME" RUN_TESTS="YES" - - DESTINATION="OS=11.4,name=Apple TV 4K (at 1080p)" SCHEME="$TVOS_FRAMEWORK_SCHEME" RUN_TESTS="YES" - - DESTINATION="OS=10.2,name=Apple TV 1080p" SCHEME="$TVOS_FRAMEWORK_SCHEME" RUN_TESTS="YES" - - DESTINATION="OS=9.0,name=Apple TV 1080p" SCHEME="$TVOS_FRAMEWORK_SCHEME" RUN_TESTS="YES" - - DESTINATION="OS=5.2,name=Apple Watch Series 4 - 44mm" SCHEME="$WATCHOS_FRAMEWORK_SCHEME" RUN_TESTS="NO" - - DESTINATION="OS=4.2,name=Apple Watch Series 3 - 42mm" SCHEME="$WATCHOS_FRAMEWORK_SCHEME" RUN_TESTS="NO" - - DESTINATION="OS=3.1,name=Apple Watch Series 2 - 42mm" SCHEME="$WATCHOS_FRAMEWORK_SCHEME" RUN_TESTS="NO" - - DESTINATION="OS=2.0,name=Apple Watch - 38mm" SCHEME="$WATCHOS_FRAMEWORK_SCHEME" RUN_TESTS="NO" - -before_script: - - sh scripts/bootstrap - -script: - - set -o pipefail - - xcodebuild -version - - xcodebuild -showsdks - - # Build Framework in Debug and Run Tests if specified - - if [ $RUN_TESTS == "YES" ]; then - xcodebuild -workspace "$WORKSPACE" -scheme "$SCHEME" -destination "$DESTINATION" -configuration Debug ONLY_ACTIVE_ARCH=NO ENABLE_TESTABILITY=YES test | xcpretty -c; - else - xcodebuild -workspace "$WORKSPACE" -scheme "$SCHEME" -destination "$DESTINATION" -configuration Debug ONLY_ACTIVE_ARCH=NO build | xcpretty -c; - fi - - # Build Framework in Release and Run Tests if specified - - if [ $RUN_TESTS == "YES" ]; then - xcodebuild -workspace "$WORKSPACE" -scheme "$SCHEME" -destination "$DESTINATION" -configuration Release ONLY_ACTIVE_ARCH=NO ENABLE_TESTABILITY=YES test | xcpretty -c; - else - xcodebuild -workspace "$WORKSPACE" -scheme "$SCHEME" -destination "$DESTINATION" -configuration Release ONLY_ACTIVE_ARCH=NO build | xcpretty -c; - fi - -jobs: - include: - - stage: Danger - script: bundle exec danger - - stage: Pod lib lint - script: bundle exec pod lib lint --verbose --allow-warnings - - stage: Deploy Github - script: skip - before_deploy: - - carthage build --no-skip-current --cache-builds - - carthage archive DeallocTests - deploy: - - provider: releases - api_key: - secure: # https://docs.travis-ci.com/user/deployment/releases/ - file: - - DeallocTests.framework.zip - skip_cleanup: true - overwrite: true - on: - repo: DanielCech/DeallocTests - tags: true - - stage: Deploy Cocoapods - script: skip - deploy: - - provider: script # https://fuller.li/posts/automated-cocoapods-releases-with-ci/ - script: bundle exec pod trunk push - skip_cleanup: true - on: - tags: true diff --git a/Dangerfile b/Dangerfile deleted file mode 100644 index 3907e84..0000000 --- a/Dangerfile +++ /dev/null @@ -1,9 +0,0 @@ -# Sometimes it's a README fix, or something like that - which isn't relevant for -# including in a project's CHANGELOG for example -declared_trivial = github.pr_title.include? "#trivial" - -# Make it more obvious that a PR is a work in progress and shouldn't be merged yet -warn("PR is classed as Work in Progress") if github.pr_title.include? "[WIP]" - -# Warn when there is a big PR -warn("Big PR") if git.lines_of_code > 500 diff --git a/Package.resolved b/Package.resolved index fbff33d..079bab0 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "1ecfde1ad21f8a051770df0d5ded503e30660f902976d02be63c6740811b36fd", + "originHash" : "03f6a00fd6dd53633e26cf2e21b6657b3434cd1af7cce0ab3346d4822e73b9b4", "pins" : [ { "identity" : "ios-dependency-injection", "kind" : "remoteSourceControl", - "location" : "git@github.com:strvcom/ios-dependency-injection.git", + "location" : "https://github.com/strvcom/ios-dependency-injection.git", "state" : { - "revision" : "e70e77a882f66888cb664ea53014f0bef7c04f95", - "version" : "2.0.0" + "revision" : "bff058aab9f85fd687be3daddb0d38d094b56e7b", + "version" : "2.0.1" } } ], diff --git a/Package.swift b/Package.swift index bd62083..d12c0d3 100644 --- a/Package.swift +++ b/Package.swift @@ -26,14 +26,14 @@ let package = Package( ), ], dependencies: [ - // Dependencies declare other packages that this package depends on. - .package(url: "git@github.com:strvcom/ios-dependency-injection.git", .upToNextMajor(from: "2.0.0")) + .package(url: "https://github.com/strvcom/ios-dependency-injection.git", .upToNextMajor(from: "2.0.0")) ], targets: [ .target( name: "DeallocTests", dependencies: [.product(name: "DependencyInjection", package: "ios-dependency-injection")], - path: "Sources/DeallocTests" + path: "Sources/DeallocTests", + swiftSettings: [.define("DEALLOC_TESTS_DI")] ), .target( name: "DeallocTestsDIFree", @@ -42,7 +42,12 @@ let package = Package( .testTarget( name: "DeallocTestsTests", dependencies: ["DeallocTests"], - path: "Tests" + path: "Tests/DeallocTestsTests" + ), + .testTarget( + name: "DeallocTestsDIFreeTests", + dependencies: ["DeallocTestsDIFree"], + path: "Tests/DeallocTestsDIFreeTests" ), ], swiftLanguageModes: [.v6] diff --git a/README.md b/README.md index 260431a..ace60a4 100644 --- a/README.md +++ b/README.md @@ -27,195 +27,135 @@ DeallocTests work well with apps that use MVVM-C (MVVM with ViewCoordinators) ar ## STRV Dependency Injection library -The main version of DeallocTests uses [STRV Dependency Injection library](https://github.com/strvcom/ios-dependency-injection) as the only dependency. The support of dependency injection is great benefit but DeallocTests can work also without it, if needed. If you don't use STRV Dependency Injection in your app, please use DeallocTestsDIFree library: -* For installation using SPM: Library `DeallocTestsDIFree` - -## DeallocTests. Easy-to-use framework for custom deallocation tests. - -- [Requirements](#requirements) -- [Installation](#installation) -- [License](#license) +The main version of DeallocTests uses [STRV Dependency Injection library](https://github.com/strvcom/ios-dependency-injection) as the only dependency. The support of dependency injection is a great benefit, but DeallocTests also work without it. If you don't use STRV Dependency Injection in your app, use the `DeallocTestsDIFree` product instead. ## Requirements -- iOS 13.0+ / Mac OS X 10.10+ / tvOS 9.0+ / watchOS 2.0+ -- Xcode 12.0+ +- iOS 17.0+ / macOS 13.0+ +- Swift 6.0+ / Xcode 16.0+ +- XCTest ## Installation -### Dependency Managers -
- Swift Package Manager - -To use DeallocTests as a [Swift Package Manager](https://swift.org/package-manager/) package just add the following in your Package.swift file. +DeallocTests is distributed via [Swift Package Manager](https://swift.org/package-manager/). Add it to the **test target** of your app: ``` swift -// swift-tools-version:5.5 +// swift-tools-version:6.0 import PackageDescription let package = Package( name: "HelloDeallocTests", dependencies: [ - .package(url: "https://github.com/strvcom/DeallocTests.git", .upToNextMajor(from: "1.0.0")) + .package(url: "https://github.com/strvcom/DeallocTests.git", .upToNextMajor(from: "3.1.0")) ], targets: [ - .target(name: "HelloDeallocTests", dependencies: ["DeallocTests"]) + .testTarget( + name: "HelloDeallocTestsTests", + dependencies: [ + "HelloDeallocTests", + // or "DeallocTestsDIFree" if you don't use STRV Dependency Injection + .product(name: "DeallocTests", package: "DeallocTests") + ] + ) ] ) ``` -__WARNING__: When you install DeallocTests with SPM and you use Xcode 12.5+ you must set `ENABLE_TESTING_SEARCH_PATHS` to `YES` in your app target build settings. - -
- -### Manually - -If you prefer not to use either of the aforementioned dependency managers, you can integrate DeallocTests into your project manually. - -
- Git Submodules

- -- Open up Terminal, `cd` into your top-level project directory, and run the following command "if" your project is not initialized as a git repository: - -```bash -$ git init -``` - -- Add DeallocTests as a git [submodule](http://git-scm.com/docs/git-submodule) by running the following command: - -```bash -$ git submodule add https://github.com/DanielCech/DeallocTests.git -$ git submodule update --init --recursive -``` - -- Open the new `DeallocTests` folder, and drag the `DeallocTests.xcodeproj` into the Project Navigator of your application's Xcode project. - - > It should appear nested underneath your application's blue project icon. Whether it is above or below all the other Xcode groups does not matter. - -- Select the `DeallocTests.xcodeproj` in the Project Navigator and verify the deployment target matches that of your application target. -- Next, select your application project in the Project Navigator (blue project icon) to navigate to the target configuration window and select the application target under the "Targets" heading in the sidebar. -- In the tab bar at the top of that window, open the "General" panel. -- Click on the `+` button under the "Embedded Binaries" section. -- You will see two different `DeallocTests.xcodeproj` folders each with two different versions of the `DeallocTests.framework` nested inside a `Products` folder. - - > It does not matter which `Products` folder you choose from. - -- Select the `DeallocTests.framework`. -- And that's it! +In Xcode, add the package via *File › Add Package Dependencies…* and link the `DeallocTests` (or `DeallocTestsDIFree`) product to your test target only. -> The `DeallocTests.framework` is automagically added as a target dependency, linked framework and embedded framework in a copy files build phase which is all you need to build on the simulator and a device. +## Usage -

+1. Conform the tested classes to `DeallocTestable` in your test target. No changes to the main target are needed: -
- Embedded Binaries

- -- Download the latest release from https://github.com/DanielCech/DeallocTests/releases -- Next, select your application project in the Project Navigator (blue project icon) to navigate to the target configuration window and select the application target under the "Targets" heading in the sidebar. -- In the tab bar at the top of that window, open the "General" panel. -- Click on the `+` button under the "Embedded Binaries" section. -- Add the downloaded `DeallocTests.framework`. -- And that's it! - -

- -## Sample App - -The folder SampleApps contains a demo project that demonstrates at least some features. The application itself is very simple—there are just three screens in the navigation stack. All screens are handled by `MainCoordinator`. - -The Podfile adds DeallocTests support to the app's test target. +```swift +import DeallocTests +@testable import MyApp -```ruby -target 'DeallocTestsAppCocoapodsTests' do - pod 'DeallocTests', :path=>'../../' -end +extension MainCoordinator: @retroactive DeallocTestable {} +extension FirstViewController: @retroactive DeallocTestable {} ``` -The file `DeallocTestsConformances.swift` contains the DeallocTestable protocol conformances to all tested classes. +2. Subclass `DeallocTester` and describe the scenario: ```swift import DeallocTests -@testable import DeallocTestsAppCocoapods +@testable import MyApp + +final class MainCoordinatorDeallocTester: DeallocTester { + @MainActor + func test_mainCoordinatorDealloc() async { + let mainCoordinator = MainCoordinator() + let expectation = expectation(description: "dealloc test") + + await performDeallocTest( + deallocTests: [ + DeallocTest(objectCreation: { [mainCoordinator] _ in mainCoordinator.createFirstViewController() }), + DeallocTest(objectCreation: { [mainCoordinator] _ in mainCoordinator.createSecondViewController() }), + DeallocTest(objectCreation: { _ in MainCoordinator() }) + ], + expectation: expectation + ) -extension MainCoordinator: DeallocTestable {} -extension FirstViewController: DeallocTestable {} -extension SecondViewController: DeallocTestable {} -extension ThirdViewController: DeallocTestable {} + await fulfillment(of: [expectation], timeout: 60) + } +} ``` -The file `MainCoordinatorDeallocTester.swift` is also very simple. It defines the testing scenario for MainCoordinator. +Each `DeallocTest` creates an object, releases it and checks that it was deallocated: -```swift -var mainCoordinator: MainCoordinator? { - applyAssembliesToContainer() - return MainCoordinator() -} -``` +- A `UIViewController` is presented full screen and dismissed first, so its whole lifecycle runs. The presenting controller is created automatically. You can still call `showPresentingController()` and assign `presentingController` yourself. +- Any other object is released right away. +- After release, DeallocTests waits up to `deallocationTimeout` (2 seconds by default) for every tracked instance to deallocate. Leaks are detected per instance, so a second leaked instance of the same class is caught. +- `checkClasses` restricts the check to the listed classes. Each listed class must have been tracked (it is `DeallocTestable` and `initializeDeallocTestSupport()` was called on it). +- `actionBeforeCheck` runs after the object is released and before the check. +- A failing step is reported with `XCTFail` and the scenario continues with the next step. The expectation is always fulfilled. -This will initialize a dependency injection container and instantiate the main coordinator. The method `applyAssembliesToContainer` is defined in the main target. The scenario for the main coordinator test looks like this: (Don't be scared, it is almost boilerplate code which is common for every test scenario.) +Set `DeallocTester.isLoggingEnabled = true` to print `Alloc`/`Dealloc` messages for every tracked object. -```swift -func test_mainCoordinatorDealloc() { - presentingController = showPresentingController() - - deallocTests = [ - DeallocTest( - objectCreation: { [weak self] _ in - return self?.mainCoordinator?.createFirstViewController() - } - ), - DeallocTest( - objectCreation: { [weak self] _ in - return self?.mainCoordinator?.createSecondViewController() - } - ), - DeallocTest( - objectCreation: { [weak self] _ in - return self?.mainCoordinator?.createThirdViewController() - } - ), - DeallocTest( - objectCreation: { _ in - return MainCoordinator() - } - ) - ] +### STRV Dependency Injection - let expectation = self.expectation(description: "deallocTest test_mainCoordinatorDealloc") +With the `DeallocTests` product, `objectCreation` receives an `AsyncContainer`. Before every step the container is cleaned and `registerDependencies()` is called. Shared instances are released before the check: - performDeallocTest( - deallocTests: deallocTests, - expectation: expectation - ) +```swift +final class DependencyGraphDeallocTester: DeallocTester { + override func registerDependencies() async { + await container.register(type: APIManaging.self, in: .shared) { _ in APIManager() } + } + + @MainActor + func test_dependencyGraphDealloc() async { + let expectation = expectation(description: "dealloc test") + + await performDeallocTest( + deallocTests: [ + DeallocTest(objectCreation: { await $0.resolve(type: APIManaging.self) as AnyObject }) + ], + expectation: expectation + ) - waitForExpectations(timeout: 200, handler: nil) + await fulfillment(of: [expectation], timeout: 60) + } } ``` -The variable `presentingController` is just simple empty controller on top of everything - it is handled by DeallocTests framework. The array `deallocTests` consists of four items. The first three are for view controllers and the last one is for the view coordinator itself. The `objectCreation` closure initializes the particular view controller from the view coordinator. The `expectation` is a standard `XCTestExpectation` used to wait for the result of the test. The main test processing is hidden in the `performDeallocTest` call. +With `DeallocTestsDIFree`, `objectCreation` takes no parameter: `DeallocTest(objectCreation: { MyObject() })`. -The sample app intentionally contains a memory leak in SecondViewController.swift. This class contains the closure with a strong reference to `self`. The DeallocTests console output looks like this: +## Sample Apps -``` -Checking: -Alloc FirstViewController -Dealloc FirstViewController +The folder `SampleApps` contains two demo projects, `DeallocTestsAppSPM` (with STRV Dependency Injection) and `DeallocTestsAppDIFreeSPM`. The application itself is very simple: there are just three screens in the navigation stack, all handled by `MainCoordinator`. -Checking: -Alloc SecondViewController -/Users/danielcech/Documents/[Development]/[Projects]/ios-research-dealloc-tests/Sources/Core/DeallocTester.swift:175: error: -[DeallocTestsAppCocoapodsTests.MainCoordinatorDeallocTester test_mainCoordinatorDealloc] : failed - Failed: dealloc test failed on classes: [DeallocTestsAppCocoapods.SecondViewController] +- `DeallocTestConformances.swift` adds the `DeallocTestable` conformances to all tested classes. +- `MainCoordinatorDeallocTester.swift` defines the testing scenario for `MainCoordinator`: the three view controllers one by one, then the coordinator itself. +- `DependencyGraphDeallocTester.swift` (DI sample only) checks a service resolved from the container. -Checking: -Alloc ThirdViewController -Dealloc ThirdViewController +The sample app intentionally contains a memory leak in `SecondViewController.swift`. This class contains a closure with a strong reference to `self`. The test fails with: -Checking: -Alloc MainCoordinator -Dealloc MainCoordinator +``` +DeallocTester.swift:233: error: -[DeallocTestsAppSPMTests.MainCoordinatorDeallocTester test_mainCoordinatorDealloc] : failed - Failed: dealloc test #1 failed on classes: [DeallocTestsAppSPM.SecondViewController] (1 tracked instance(s) still alive) ``` -If you comment out the first line and uncomment the second one, you will see that the retain cycle disappears and the test will succeed. +If you comment out the first line and uncomment the second one, the retain cycle disappears and the test will succeed. ```swift someClosure = { number in self.view(number) } @@ -233,4 +173,4 @@ Issues and pull requests are welcome! ## License -DeallocTests is released under the MIT license. See [LICENSE](https://github.com/DanielCech/DeallocTests/blob/master/LICENSE) for details. +DeallocTests is released under the MIT license. See [LICENSE](https://github.com/strvcom/DeallocTests/blob/master/LICENSE) for details. diff --git a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 82b9896..7fa1551 100644 --- a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPM.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "8c14dc87e9169f0c50a5681f322928bb038c4a355ddffcb82006364999de8044", + "originHash" : "a4a705ed389078d4ae491a782d2cc5bb828f2b7e7408f815fea6287b2dc5fd3e", "pins" : [ { "identity" : "ios-dependency-injection", "kind" : "remoteSourceControl", "location" : "https://github.com/strvcom/ios-dependency-injection.git", "state" : { - "revision" : "aa5e4da9a4a3dc0076d870776972a701b7eb0949", - "version" : "1.0.4" + "revision" : "bff058aab9f85fd687be3daddb0d38d094b56e7b", + "version" : "2.0.1" } } ], diff --git a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/DeallocTestConformances.swift b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/DeallocTestConformances.swift index 79e6c7c..e37d67f 100644 --- a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/DeallocTestConformances.swift +++ b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/DeallocTestConformances.swift @@ -10,7 +10,7 @@ import Foundation import DeallocTestsDIFree @testable import DeallocTestsAppSPM -extension MainCoordinator: DeallocTestable {} -extension FirstViewController: DeallocTestable {} -extension SecondViewController: DeallocTestable {} -extension ThirdViewController: DeallocTestable {} +extension MainCoordinator: @retroactive DeallocTestable {} +extension FirstViewController: @retroactive DeallocTestable {} +extension SecondViewController: @retroactive DeallocTestable {} +extension ThirdViewController: @retroactive DeallocTestable {} diff --git a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift index d0acf57..b1ce821 100644 --- a/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift +++ b/SampleApps/DeallocTestsAppDIFreeSPM/DeallocTestsAppSPMTests/MainCoordinatorDeallocTester.swift @@ -16,11 +16,8 @@ class MainCoordinatorDeallocTester: DeallocTester { return MainCoordinator() } + @MainActor func test_mainCoordinatorDealloc() async { - presentingController = showPresentingController() - - try? await Task.sleep(for: .milliseconds(200)) - deallocTests = [ DeallocTest( objectCreation: { [weak self] in diff --git a/Sources/DeallocTests.h b/Sources/DeallocTests.h deleted file mode 100644 index 440effb..0000000 --- a/Sources/DeallocTests.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// DeallocTests.h -// DeallocTests -// -// Created by Daniel Cech on 01/04/19. -// Copyright © 2019 DanielCech. All rights reserved. -// - -#import - -//! Project version number for DeallocTests. -FOUNDATION_EXPORT double DeallocTestsVersionNumber; - -//! Project version string for DeallocTests. -FOUNDATION_EXPORT const unsigned char DeallocTestsVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import diff --git a/Sources/DeallocTests/DeallocTestable+Internals.swift b/Sources/DeallocTests/DeallocTestable+Internals.swift index d432890..7776336 100644 --- a/Sources/DeallocTests/DeallocTestable+Internals.swift +++ b/Sources/DeallocTests/DeallocTestable+Internals.swift @@ -9,19 +9,18 @@ import Foundation extension DeallocTestable { - /// This stores the `DeinitializationObserver`. It's fileprivate so you + /// This stores the `DeinitializationObserver`. It's private so you /// cannot interfere with this outside. Also we're using a strong retain /// which will ensure that the `DeinitializationObserver` is deinitialized /// at the same time as your object. - private var deinitializationObserver: DeinitializationObserver { + private var deinitializationObserver: DeinitializationObserver? { get { - // swiftlint:disable:next force_cast - return objc_getAssociatedObject(self, &AssociatedKeys.DeinitializationObserver) as! DeinitializationObserver + return objc_getAssociatedObject(self, &AssociatedKeys.deinitializationObserver) as? DeinitializationObserver } set { objc_setAssociatedObject( self, - &AssociatedKeys.DeinitializationObserver, + &AssociatedKeys.deinitializationObserver, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC ) @@ -30,32 +29,25 @@ extension DeallocTestable { public var deallocTestSupportInstalled: Bool? { get { - return objc_getAssociatedObject(self, &AssociatedKeys.DeallocTestSupportInstalled) as? Bool + return objc_getAssociatedObject(self, &AssociatedKeys.deallocTestSupportInstalled) as? Bool } set { objc_setAssociatedObject( self, - &AssociatedKeys.DeallocTestSupportInstalled, + &AssociatedKeys.deallocTestSupportInstalled, newValue, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC ) } } - /// This is what you call to add a block that should execute on `deinit` + /// Starts tracking this instance. Calling it repeatedly has no effect. public func initializeDeallocTestSupport() { if deallocTestSupportInstalled != nil { return } - - deinitializationObserver = DeinitializationObserver( - execute: { className in - print("Dealloc \(className)") - deallocatedClasses.append(className) - }, - myClass: myClass - ) + deinitializationObserver = DeinitializationObserver(myClass: myClass) deallocTestSupportInstalled = true } } diff --git a/Sources/DeallocTests/DeallocTestable.swift b/Sources/DeallocTests/DeallocTestable.swift index 62de748..e4364ed 100644 --- a/Sources/DeallocTests/DeallocTestable.swift +++ b/Sources/DeallocTests/DeallocTestable.swift @@ -9,15 +9,14 @@ import Foundation /// We're using objc associated objects to have this `DeinitializationObserver` -/// stored inside the protocol extension - +/// stored inside the protocol extension. The keys are only used for their address. enum AssociatedKeys { - @MainActor static var DeinitializationObserver = "DeinitializationObserver" - @MainActor static var DeallocTestSupportInstalled = "DeallocTestSupportInstalled" + @MainActor static var deinitializationObserver: UInt8 = 0 + @MainActor static var deallocTestSupportInstalled: UInt8 = 0 } /// Protocol for any object that implements this logic @MainActor -public protocol DeallocTestable: ClassNameIdentifiable, Sendable { +public protocol DeallocTestable: ClassNameIdentifiable { func initializeDeallocTestSupport() } diff --git a/Sources/DeallocTests/DeallocTester.swift b/Sources/DeallocTests/DeallocTester.swift index 422f929..5df1b79 100644 --- a/Sources/DeallocTests/DeallocTester.swift +++ b/Sources/DeallocTests/DeallocTester.swift @@ -8,10 +8,7 @@ import Foundation -let delayTime: Double = 0.1 -let presentationAnimated = false - -#if canImport(DependencyInjection) +#if DEALLOC_TESTS_DI import DependencyInjection #endif @@ -22,12 +19,12 @@ import XCTest #endif public struct DeallocTest { -#if canImport(DependencyInjection) +#if DEALLOC_TESTS_DI public typealias ObjectCreationClosure = @MainActor (AsyncContainer) async -> AnyObject? #else public typealias ObjectCreationClosure = @MainActor () async -> AnyObject? #endif - + public typealias SimpleClosure = (() -> Void) public var objectCreation: ObjectCreationClosure @@ -46,28 +43,44 @@ open class DeallocTester: XCTestCase { public var deallocTests = [DeallocTest]() + /// How long to wait for tracked objects to deallocate before the step fails + open var deallocationTimeout: Duration = .seconds(2) + + /// Prints `Alloc`/`Dealloc` messages for every tracked object. Off by default. + public static var isLoggingEnabled: Bool { + get { DeallocRegistry.shared.isLoggingEnabled } + set { DeallocRegistry.shared.isLoggingEnabled = newValue } + } + #if canImport(UIKit) // swiftlint:disable:next implicitly_unwrapped_optional var window: UIWindow! + /// Controller used for presenting tested view controllers. + /// It's created automatically when a tested object is a `UIViewController`. // swiftlint:disable:next implicitly_unwrapped_optional public var presentingController: UIViewController! #endif +#if DEALLOC_TESTS_DI + /// Dependency Injection container + // swiftlint:disable:next implicitly_unwrapped_optional + public var container: AsyncContainer! +#endif + /// Initialize DI container with shared dependency registrations @MainActor open func registerDependencies() async { - // TODO: Override in descendants. Initialize assembler from main project - + // Override in descendants. Initialize assembler from main project } #if canImport(UIKit) /// Controller for presenting tested controllers @MainActor public func showPresentingController() async -> UIViewController { - if #available(iOS 13.0, *), - let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication, - let windowScene = application.connectedScenes.first as? UIWindowScene { + // `UIApplication.shared` is accessed via KVC so the library stays app-extension safe + if let application = UIApplication.value(forKeyPath: #keyPath(UIApplication.shared)) as? UIApplication, + let windowScene = application.connectedScenes.first as? UIWindowScene { window = UIWindow(windowScene: windowScene) } else { window = UIWindow(frame: UIScreen.main.bounds) @@ -84,133 +97,177 @@ open class DeallocTester: XCTestCase { } #endif -#if canImport(DependencyInjection) - /// Dependency Injection container - // swiftlint:disable:next implicitly_unwrapped_optional - public var container: AsyncContainer! -#endif - - public override func setUp() async throws { + override open func setUp() async throws { try await super.setUp() - #if canImport(DependencyInjection) + #if DEALLOC_TESTS_DI container = AsyncContainer() #endif - await MainActor.run { - allocatedClasses = [] - deallocatedClasses = [] - } + DeallocRegistry.shared.reset() } - public override func tearDown() { + override open func tearDown() { + #if canImport(UIKit) + let window = window + self.window = nil + presentingController = nil + + // XCTest calls the synchronous tearDown on the main thread + MainActor.assumeIsolated { + window?.isHidden = true + } + #endif + super.tearDown() } - /// Instantiate and release tested item + /// Instantiate and release tested items one by one. + /// The expectation is always fulfilled, failures are reported via `XCTFail`. @MainActor public func performDeallocTest( deallocTests: [DeallocTest], expectation: XCTestExpectation ) async { - await performDeallocTest(index: 0, deallocTests: deallocTests, expectation: expectation) - } - - /// Instantiate and release tested item - @MainActor - private func performDeallocTest( - index: Int, - deallocTests: [DeallocTest], - expectation: XCTestExpectation - ) async { - // Last item in sequence - if index == deallocTests.count { - print("") - expectation.fulfill() - return + for (index, deallocTest) in deallocTests.enumerated() { + await performDeallocTest(deallocTest, index: index) } - allocatedClasses = [] - deallocatedClasses = [] + expectation.fulfill() + } +} + +// MARK: - Private - #if canImport(DependencyInjection) +private extension DeallocTester { + var registry: DeallocRegistry { + DeallocRegistry.shared + } + + @MainActor + func performDeallocTest(_ deallocTest: DeallocTest, index: Int) async { + registry.reset() + + #if DEALLOC_TESTS_DI await container.clean() await registerDependencies() #endif - try? await Task.sleep(for: .seconds(delayTime)) + registry.log("\nChecking:") - print("\nChecking:") + // The tested instance lives only inside this call + guard await createAndExercise(deallocTest, index: index) else { + return + } - let dependencyDeallocTest = deallocTests[index] + #if DEALLOC_TESTS_DI + await container.releaseSharedInstances() + #endif + + deallocTest.actionBeforeCheck?() - #if canImport(DependencyInjection) - var instance: AnyObject? = await dependencyDeallocTest.objectCreation(self.container) + await waitForDeallocation() + checkResult(checkedClasses: deallocTest.checkClasses, index: index) + } + + /// Returns `false` when the step cannot be checked + @MainActor + func createAndExercise(_ deallocTest: DeallocTest, index: Int) async -> Bool { + #if DEALLOC_TESTS_DI + let instance = await deallocTest.objectCreation(container) #else - var instance: AnyObject? = await dependencyDeallocTest.objectCreation() + let instance = await deallocTest.objectCreation() #endif - guard instance is DeallocTestable else { - // swiftlint:disable:next force_unwrapping - let className = NSStringFromClass(type(of: instance!)) - XCTFail("Failed: class \(className) is not DeallocTestable") - return + guard let instance else { + XCTFail("Failed: objectCreation of dealloc test #\(index) returned nil") + return false + } + + guard let testable = instance as? DeallocTestable else { + XCTFail("Failed: class \(NSStringFromClass(type(of: instance))) is not DeallocTestable") + return false } - (instance as? DeallocTestable)?.initializeDeallocTestSupport() + testable.initializeDeallocTestSupport() #if canImport(UIKit) - try? await Task.sleep(for: .seconds(delayTime)) - - if let controller = instance as? UIViewController { - controller.modalPresentationStyle = .fullScreen - presentingController.present(controller, animated: presentationAnimated) { [weak self] in - Task { - try? await Task.sleep(for: .seconds(delayTime)) - self?.presentingController.dismiss(animated: presentationAnimated, completion: { [weak self] in - Task { - try? await Task.sleep(for: .seconds(delayTime)) - - instance = nil - - await self?.continueWithNextStep(deallocTests: deallocTests, index: index, expectation: expectation) - } - }) + if let controller = instance as? UIViewController { + return await presentAndDismiss(controller) + } + #endif + + return true + } + + /// Polls until every tracked instance is gone or the timeout elapses + @MainActor + func waitForDeallocation() async { + let deadline = ContinuousClock.now + deallocationTimeout + + while registry.hasLiveInstances, ContinuousClock.now < deadline { + try? await Task.sleep(for: .milliseconds(10)) + } + } + + @MainActor + func checkResult(checkedClasses: [AnyClass]?, index: Int) { + let entries = registry.entries + var failedClasses = [AnyClass]() + + if let checkedClasses { + for checkedClass in checkedClasses { + let matching = entries.filter { $0.objectClass == checkedClass } + if matching.isEmpty || matching.contains(where: { !$0.isDeallocated }) { + failedClasses.append(checkedClass) } } } else { - instance = nil - await continueWithNextStep(deallocTests: deallocTests, index: index, expectation: expectation) + for entry in entries where !entry.isDeallocated && !failedClasses.contains(where: { $0 == entry.objectClass }) { + failedClasses.append(entry.objectClass) + } + } + + if !failedClasses.isEmpty { + let liveCount = entries.filter { !$0.isDeallocated }.count + XCTFail("Failed: dealloc test #\(index) failed on classes: \(failedClasses) (\(liveCount) tracked instance(s) still alive)") } - #endif } +} - /// Start testing of next item +#if canImport(UIKit) +private extension DeallocTester { + /// Presents and dismisses the controller to run its lifecycle @MainActor - private func continueWithNextStep(deallocTests: [DeallocTest], index: Int, expectation: XCTestExpectation) async { - #if canImport(DependencyInjection) - await container.releaseSharedInstances() - #endif - - try? await Task.sleep(for: .seconds(delayTime)) + func presentAndDismiss(_ controller: UIViewController) async -> Bool { + if presentingController == nil { + presentingController = await showPresentingController() + } - let dependencyDeallocTest = deallocTests[index] - dependencyDeallocTest.actionBeforeCheck?() + guard let presentingController, presentingController.view.window != nil else { + XCTFail("Failed: presentingController is not in a window hierarchy") + return false + } - try? await Task.sleep(for: .seconds(delayTime)) + if presentingController.presentedViewController != nil { + await withCheckedContinuation { continuation in + presentingController.dismiss(animated: false) { continuation.resume() } + } + } - await checkTestResult(checkedClasses: dependencyDeallocTest.checkClasses ?? allocatedClasses) + controller.modalPresentationStyle = .fullScreen - await performDeallocTest(index: index + 1, deallocTests: deallocTests, expectation: expectation) - } + await withCheckedContinuation { continuation in + presentingController.present(controller, animated: false) { continuation.resume() } + } - /// Check proper deallocation - @MainActor - private func checkTestResult(checkedClasses: [AnyClass]) async { - let notFoundClassNames = checkedClasses.filter { testedClass in deallocatedClasses.first(where: { $0 == testedClass }) == nil } + await Task.yield() - if !notFoundClassNames.isEmpty { - XCTFail("Failed: dealloc test failed on classes: \(notFoundClassNames)") + await withCheckedContinuation { continuation in + presentingController.dismiss(animated: false) { continuation.resume() } } + + return true } } +#endif diff --git a/Sources/DeallocTests/DeallocTests.h b/Sources/DeallocTests/DeallocTests.h deleted file mode 100644 index 440effb..0000000 --- a/Sources/DeallocTests/DeallocTests.h +++ /dev/null @@ -1,17 +0,0 @@ -// -// DeallocTests.h -// DeallocTests -// -// Created by Daniel Cech on 01/04/19. -// Copyright © 2019 DanielCech. All rights reserved. -// - -#import - -//! Project version number for DeallocTests. -FOUNDATION_EXPORT double DeallocTestsVersionNumber; - -//! Project version string for DeallocTests. -FOUNDATION_EXPORT const unsigned char DeallocTestsVersionString[]; - -// In this header, you should import all the public headers of your framework using statements like #import diff --git a/Sources/DeallocTests/DeallocTests.swift b/Sources/DeallocTests/DeallocTests.swift deleted file mode 100644 index e69de29..0000000 diff --git a/Sources/DeallocTests/DefaultValue.swift b/Sources/DeallocTests/DefaultValue.swift index 4772c69..6c24314 100644 --- a/Sources/DeallocTests/DefaultValue.swift +++ b/Sources/DeallocTests/DefaultValue.swift @@ -8,34 +8,40 @@ import Foundation +@available(*, deprecated, message: "DefaultInitializable is unrelated to dealloc testing and will be removed in DeallocTests 4.0") public protocol DefaultInitializable { static var defaultValue: Self { get } } +@available(*, deprecated) extension Int: DefaultInitializable { public static var defaultValue: Int { return Int.random(in: 0 ... 100) } } +@available(*, deprecated) extension Float: DefaultInitializable { public static var defaultValue: Float { return Float.random(in: 0 ... 100) } } +@available(*, deprecated) extension Double: DefaultInitializable { public static var defaultValue: Double { return Double.random(in: 0 ... 100) } } +@available(*, deprecated) extension Bool: DefaultInitializable { public static var defaultValue: Bool { return Bool.random() } } +@available(*, deprecated) extension String: DefaultInitializable { public static var defaultValue: String { let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" @@ -43,30 +49,35 @@ extension String: DefaultInitializable { } } +@available(*, deprecated) extension URL: DefaultInitializable { public static var defaultValue: URL { return URL(string: "http://google.com")! } } +@available(*, deprecated) extension Array: DefaultInitializable { public static var defaultValue: Array { return [] } } +@available(*, deprecated) extension Dictionary: DefaultInitializable { public static var defaultValue: Dictionary { return [:] } } +@available(*, deprecated) extension Set: DefaultInitializable { public static var defaultValue: Set { return Set() } } +@available(*, deprecated) extension Optional: DefaultInitializable { public static var defaultValue: Optional { return nil diff --git a/Sources/DeallocTests/DeinitializationObserver.swift b/Sources/DeallocTests/DeinitializationObserver.swift index 266345e..cacbbcd 100644 --- a/Sources/DeallocTests/DeinitializationObserver.swift +++ b/Sources/DeallocTests/DeinitializationObserver.swift @@ -7,11 +7,7 @@ // import Foundation - -typealias ClassClosureType = (AnyClass) -> Void - -@MainActor var allocatedClasses = [AnyClass]() -@MainActor var deallocatedClasses = [AnyClass]() +import os public protocol ClassNameIdentifiable: AnyObject { var myClass: AnyClass { get } @@ -23,23 +19,86 @@ public extension ClassNameIdentifiable { } } -/// This is a simple object whose job is to execute -/// some closure when it deinitializes -class DeinitializationObserver { - let execute: (AnyClass) -> Void - var myClass: AnyClass +/// Thread-safe record of the instances tracked during a single dealloc test step. +/// +/// Every tracked instance gets its own token so leaks are detected per instance, +/// not per class. Deinitialization can happen on any thread, hence the lock. +final class DeallocRegistry: Sendable { + struct Entry { + let objectClass: AnyClass + var isDeallocated: Bool + } - init(execute: @escaping (AnyClass) -> Void, myClass: AnyClass) { - self.execute = execute - self.myClass = myClass + private struct State { + var nextToken = 0 + var entries: [Int: Entry] = [:] + var isLoggingEnabled = false + } + + static let shared = DeallocRegistry() + + private let state = OSAllocatedUnfairLock(uncheckedState: State()) - print("Alloc \(myClass)") - DispatchQueue.main.async { - allocatedClasses.append(myClass) + var isLoggingEnabled: Bool { + get { state.withLockUnchecked { $0.isLoggingEnabled } } + set { state.withLockUnchecked { $0.isLoggingEnabled = newValue } } + } + + /// Tracked instances in the order they were registered + var entries: [Entry] { + state.withLockUnchecked { state in + state.entries.sorted { $0.key < $1.key }.map(\.value) } } + var hasLiveInstances: Bool { + state.withLockUnchecked { state in + state.entries.values.contains { !$0.isDeallocated } + } + } + + func reset() { + state.withLockUnchecked { $0.entries = [:] } + } + + func registerAllocation(of objectClass: AnyClass) -> Int { + let token = state.withLockUnchecked { state in + let token = state.nextToken + state.nextToken += 1 + state.entries[token] = Entry(objectClass: objectClass, isDeallocated: false) + return token + } + log("Alloc \(objectClass)") + return token + } + + func registerDeallocation(token: Int, of objectClass: AnyClass) { + state.withLockUnchecked { $0.entries[token]?.isDeallocated = true } + log("Dealloc \(objectClass)") + } + + func log(_ message: @autoclosure () -> String) { + guard isLoggingEnabled else { + return + } + print(message()) + } +} + +/// This is a simple object whose job is to report to `DeallocRegistry` +/// when it deinitializes together with its owner. +final class DeinitializationObserver { + private let token: Int + private let myClass: AnyClass + private let registry: DeallocRegistry + + init(myClass: AnyClass, registry: DeallocRegistry = .shared) { + self.myClass = myClass + self.registry = registry + token = registry.registerAllocation(of: myClass) + } + deinit { - execute(myClass) + registry.registerDeallocation(token: token, of: myClass) } } diff --git a/Tests/DeallocTestsDIFreeTests/DeallocTesterDIFreeTests.swift b/Tests/DeallocTestsDIFreeTests/DeallocTesterDIFreeTests.swift new file mode 100644 index 0000000..fc99172 --- /dev/null +++ b/Tests/DeallocTestsDIFreeTests/DeallocTesterDIFreeTests.swift @@ -0,0 +1,48 @@ +// +// DeallocTesterDIFreeTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import DeallocTestsDIFree +import XCTest + +final class CleanObject: DeallocTestable {} + +final class LeakingObject: DeallocTestable { + var closure: (() -> Void)? + + init() { + closure = { _ = self } + } +} + +final class DeallocTesterDIFreeTests: DeallocTester { + @MainActor + func test_cleanObject_passes() async { + await run([ + DeallocTest(objectCreation: { CleanObject() }) + ]) + } + + @MainActor + func test_retainCycle_fails() async { + XCTExpectFailure("LeakingObject has a retain cycle") + + await run([ + DeallocTest(objectCreation: { LeakingObject() }) + ]) + } +} + +private extension DeallocTesterDIFreeTests { + @MainActor + func run(_ deallocTests: [DeallocTest]) async { + let expectation = expectation(description: "dealloc test") + + await performDeallocTest(deallocTests: deallocTests, expectation: expectation) + + await fulfillment(of: [expectation], timeout: 10) + } +} diff --git a/Tests/DeallocTestsSpec.swift b/Tests/DeallocTestsSpec.swift deleted file mode 100644 index 11f6b5c..0000000 --- a/Tests/DeallocTestsSpec.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// DeallocTestsSpec.swift -// DeallocTests -// -// Created by Daniel Cech on 01/04/19. -// Copyright © 2019 DanielCech. All rights reserved. -// - -import Quick -import Nimble -@testable import DeallocTests - -class DeallocTestsSpec: QuickSpec { - override func spec() { - - } -} diff --git a/Tests/DeallocTestsTests/DeallocTesterTests.swift b/Tests/DeallocTestsTests/DeallocTesterTests.swift new file mode 100644 index 0000000..cab0e35 --- /dev/null +++ b/Tests/DeallocTestsTests/DeallocTesterTests.swift @@ -0,0 +1,142 @@ +// +// DeallocTesterTests.swift +// DeallocTests +// +// Copyright © 2026 STRV. All rights reserved. +// + +import DependencyInjection +import XCTest +@testable import DeallocTests + +// MARK: - Fixtures + +final class CleanObject: DeallocTestable {} + +final class LeakingObject: DeallocTestable { + var closure: (() -> Void)? + + init() { + // Strong reference to self creates a retain cycle + closure = { _ = self } + } +} + +final class NotTestableObject {} + +protocol Service: AnyObject, Sendable {} + +final class SharedService: Service, DeallocTestable {} + +@MainActor +enum Leaks { + static var retained = [AnyObject]() +} + +// MARK: - Tests + +final class DeallocTesterTests: DeallocTester { + @MainActor + func test_cleanObject_passes() async { + await run([ + DeallocTest(objectCreation: { _ in CleanObject() }) + ]) + } + + @MainActor + func test_retainCycle_fails() async { + XCTExpectFailure("LeakingObject has a retain cycle") + + await run([ + DeallocTest(objectCreation: { _ in LeakingObject() }) + ]) + } + + @MainActor + func test_leakIsDetectedPerInstance() async { + XCTExpectFailure("Second CleanObject instance is retained") + + await run([ + DeallocTest(objectCreation: { _ in + let leaked = CleanObject() + leaked.initializeDeallocTestSupport() + Leaks.retained.append(leaked) + return CleanObject() + }) + ]) + } + + @MainActor + func test_nilObject_failsWithoutHanging() async { + XCTExpectFailure("objectCreation returned nil") + + let start = ContinuousClock.now + await run([ + DeallocTest(objectCreation: { _ in nil }), + DeallocTest(objectCreation: { _ in CleanObject() }) + ]) + + XCTAssertLessThan(ContinuousClock.now - start, .seconds(5)) + } + + @MainActor + func test_notDeallocTestable_failsWithoutHanging() async { + XCTExpectFailure("NotTestableObject is not DeallocTestable") + + let start = ContinuousClock.now + await run([ + DeallocTest(objectCreation: { _ in NotTestableObject() }) + ]) + + XCTAssertLessThan(ContinuousClock.now - start, .seconds(5)) + } + + @MainActor + func test_checkClasses_failsForUntrackedClass() async { + XCTExpectFailure("NotTestableObject is never tracked") + + await run([ + DeallocTest(objectCreation: { _ in CleanObject() }, checkClasses: [CleanObject.self, NotTestableObject.self]) + ]) + } + + @MainActor + func test_checkClasses_passesForDeallocatedClass() async { + await run([ + DeallocTest(objectCreation: { _ in CleanObject() }, checkClasses: [CleanObject.self]) + ]) + } + + @MainActor + func test_sharedInstanceFromContainer_isReleased() async { + await run([ + DeallocTest(objectCreation: { await $0.resolve(type: Service.self) as AnyObject }) + ]) + } + + @MainActor + func test_actionBeforeCheck_isCalled() async { + var called = false + + await run([ + DeallocTest(objectCreation: { _ in CleanObject() }, actionBeforeCheck: { called = true }) + ]) + + XCTAssertTrue(called) + } + + override func registerDependencies() async { + await container.register(type: Service.self, in: .shared) { _ in SharedService() } + } +} + +private extension DeallocTesterTests { + @MainActor + func run(_ deallocTests: [DeallocTest]) async { + let expectation = expectation(description: "dealloc test") + + await performDeallocTest(deallocTests: deallocTests, expectation: expectation) + + await fulfillment(of: [expectation], timeout: 10) + } +} diff --git a/scripts/bootstrap b/scripts/bootstrap deleted file mode 100644 index e5b0bda..0000000 --- a/scripts/bootstrap +++ /dev/null @@ -1 +0,0 @@ -carthage bootstrap --no-use-binaries --cache-builds