From fe5edfa5432a047466c5ae6c9e94ac08f318f8d0 Mon Sep 17 00:00:00 2001 From: Alexander Kauer Date: Tue, 2 Dec 2025 15:07:08 +0100 Subject: [PATCH 1/2] Added the possibility to remove dependencies by type and instance --- .github/workflows/swift-tests.yaml | 5 +- README.md | 38 +++++ Sources/Injection/DependencyInjection.swift | 66 +++++++- Tests/Injection/RemoveTests.swift | 167 ++++++++++++++++++++ 4 files changed, 272 insertions(+), 4 deletions(-) create mode 100644 Tests/Injection/RemoveTests.swift diff --git a/.github/workflows/swift-tests.yaml b/.github/workflows/swift-tests.yaml index 6cbe19d..e48c3be 100644 --- a/.github/workflows/swift-tests.yaml +++ b/.github/workflows/swift-tests.yaml @@ -7,8 +7,9 @@ jobs: runs-on: macos-15 steps: - uses: actions/checkout@v4 - - name: Select Xcode 16.4 - run: sudo xcode-select -s /Applications/Xcode_16.4.app/Contents/Developer + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '26.1.1' - name: Build run: swift build -v - name: Run tests diff --git a/README.md b/README.md index cf88044..7a8655b 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,30 @@ class MyViewModel { } ``` +### Managing Dependencies + +Remove specific dependencies without clearing the entire container: + +```swift +// Register dependencies +DependencyInjector.register(MyService()) +DependencyInjector.register(UserRepository()) + +// Option 1: Remove by type +DependencyInjector.remove(MyService.self) + +// Option 2: Remove by instance (when you have a reference) +let service = MyService() +DependencyInjector.register(service) +DependencyInjector.remove(service) + +// UserRepository is still available +let repo: UserRepository = DependencyInjector.resolve() // Works +let removed: MyService = DependencyInjector.resolve() // Fatal error - removed +``` + +This is useful when you need to swap out specific dependencies during testing or when cleaning up individual dependencies that are no longer needed. + ### Testing Support Clear all dependencies between tests: @@ -73,6 +97,18 @@ func tearDown() { } ``` +Or remove specific dependencies for testing: + +```swift +func testWithMockService() { + // Remove the real service + DependencyInjector.remove(MyService.self) + + // Register a mock + DependencyInjector.register(MockService()) +} +``` + ## API Reference ### DependencyInjector @@ -81,6 +117,8 @@ func tearDown() { - `register(_ dependency: T, as type: T.Type)` - Register a dependency instance with explicit type - `resolve() -> T` - Resolve a dependency (crashes if not found) - `safeResolve() -> T?` - Safely resolve a dependency (returns nil if not found) +- `remove(_ type: T.Type)` - Remove a specific registered dependency by type +- `remove(_ dependency: T)` - Remove a specific registered dependency by instance - `reset()` - Clear all registered dependencies ### @Inject Property Wrapper diff --git a/Sources/Injection/DependencyInjection.swift b/Sources/Injection/DependencyInjection.swift index 4ee27d2..5045ae0 100644 --- a/Sources/Injection/DependencyInjection.swift +++ b/Sources/Injection/DependencyInjection.swift @@ -130,7 +130,61 @@ public struct DependencyInjector { public static func reset() { shared = DependencyInjector() } - + + /// Removes a registered dependency of the specified type from the container. + /// + /// This method removes a previously registered dependency instance of the + /// specified type from the container. If no dependency of the requested type + /// has been registered, this method silently succeeds without any effect. + /// + /// - Parameter type: The type of dependency to remove. + /// + /// ## Example + /// ```swift + /// // Register a dependency + /// DependencyInjector.register(MyService()) + /// + /// // Remove it + /// DependencyInjector.remove(MyService.self) + /// + /// // Subsequent resolve will fail + /// let service: MyService = DependencyInjector.resolve() // Fatal error + /// ``` + /// + /// - Note: This is useful for testing scenarios where you need to swap out + /// specific dependencies without clearing the entire container with `reset()`. + public static func remove(_ type: T.Type) { + DependencyInjector.shared.remove(type) + } + + /// Removes a registered dependency by its instance from the container. + /// + /// This method removes a previously registered dependency instance from the + /// container by extracting the type from the provided instance. If no dependency + /// of the inferred type has been registered, this method silently succeeds + /// without any effect. + /// + /// - Parameter dependency: The dependency instance to remove (type is inferred). + /// + /// ## Example + /// ```swift + /// // Register a dependency + /// let service = MyService() + /// DependencyInjector.register(service) + /// + /// // Remove it by passing the instance + /// DependencyInjector.remove(service) + /// + /// // Subsequent resolve will fail + /// let resolved: MyService = DependencyInjector.resolve() // Fatal error + /// ``` + /// + /// - Note: This overload provides convenience when you have a reference to the + /// dependency instance and want to remove it without explicitly specifying the type. + public static func remove(_ dependency: T) { + DependencyInjector.shared.remove(dependency) + } + private func resolve() -> T { guard let t = dependencyList[ObjectIdentifier(T.self)] as? T else { fatalError("No provider registered for type \(T.self)") @@ -148,7 +202,15 @@ public struct DependencyInjector { private mutating func register(_ dependency : T) { dependencyList[ObjectIdentifier(T.self)] = dependency } - + + private mutating func remove(_ type: T.Type) { + dependencyList.removeValue(forKey: ObjectIdentifier(type)) + } + + private mutating func remove(_ dependency: T) { + dependencyList.removeValue(forKey: ObjectIdentifier(T.self)) + } + /// Singleton instance of the DependencyInjector. internal static var shared = DependencyInjector() private init() { } diff --git a/Tests/Injection/RemoveTests.swift b/Tests/Injection/RemoveTests.swift new file mode 100644 index 0000000..35776d2 --- /dev/null +++ b/Tests/Injection/RemoveTests.swift @@ -0,0 +1,167 @@ +import Testing +@testable import Injection + +@MainActor +class RemoveTests { + + init() { + + } + + deinit { + Task { @MainActor in + DependencyInjector.reset() + } + } + + @Test func testRemoveExistingDependency() async throws { + // Register a dependency + let service = ConcreteService() + DependencyInjector.register(service) + + // Verify it exists + let resolved: ConcreteService = DependencyInjector.resolve() + #expect(resolved === service) + + // Remove it + DependencyInjector.remove(ConcreteService.self) + + // Verify it's gone (safeResolve should return nil) + let afterRemove: ConcreteService? = DependencyInjector.safeResolve() + #expect(afterRemove == nil) + } + + @Test func testRemoveNonExistentDependency() async throws { + // Removing a non-existent dependency should not crash + DependencyInjector.remove(ConcreteService.self) + + // Should still return nil when trying to resolve + let resolved: ConcreteService? = DependencyInjector.safeResolve() + #expect(resolved == nil) + } + + @Test func testRemoveOneOfMultiple() async throws { + // Register multiple dependencies + let service = ConcreteService() + let repository = ConcreteRepository() + DependencyInjector.register(service) + DependencyInjector.register(repository) + + // Remove only the service + DependencyInjector.remove(ConcreteService.self) + + // Verify service is gone + let resolvedService: ConcreteService? = DependencyInjector.safeResolve() + #expect(resolvedService == nil) + + // Verify repository is still available + let resolvedRepository: ConcreteRepository = DependencyInjector.resolve() + #expect(resolvedRepository === repository) + } + + @Test func testRemoveAndReregister() async throws { + // Register a dependency + let originalService = ConcreteService() + DependencyInjector.register(originalService) + + // Remove it + DependencyInjector.remove(ConcreteService.self) + + // Register a new instance + let newService = ConcreteService() + DependencyInjector.register(newService) + + // Verify the new instance is resolved + let resolved: ConcreteService = DependencyInjector.resolve() + #expect(resolved === newService) + #expect(resolved !== originalService) + } + + @Test func testRemoveProtocolRegistration() async throws { + // Register a protocol implementation + let implementation = ServiceImplementation() + DependencyInjector.register(implementation, as: ServiceProtocol.self) + + // Verify it exists + let resolved: ServiceProtocol = DependencyInjector.resolve() + #expect(resolved === implementation) + + // Remove it by protocol type + DependencyInjector.remove(ServiceProtocol.self) + + // Verify it's gone + let afterRemove: ServiceProtocol? = DependencyInjector.safeResolve() + #expect(afterRemove == nil) + } + + @Test func testRemoveByInstance() async throws { + // Register a dependency + let service = ConcreteService() + DependencyInjector.register(service) + + // Verify it exists + let resolved: ConcreteService = DependencyInjector.resolve() + #expect(resolved === service) + + // Remove it by passing the instance + DependencyInjector.remove(service) + + // Verify it's gone + let afterRemove: ConcreteService? = DependencyInjector.safeResolve() + #expect(afterRemove == nil) + } + + @Test func testRemoveByInstanceVsType() async throws { + // Register two separate instances for testing + let service1 = ConcreteService() + let service2 = ConcreteService() + + // Test 1: Remove by instance + DependencyInjector.register(service1) + DependencyInjector.remove(service1) + let afterRemoveByInstance: ConcreteService? = DependencyInjector.safeResolve() + #expect(afterRemoveByInstance == nil) + + // Test 2: Remove by type - should have same effect + DependencyInjector.register(service2) + DependencyInjector.remove(ConcreteService.self) + let afterRemoveByType: ConcreteService? = DependencyInjector.safeResolve() + #expect(afterRemoveByType == nil) + } + + @Test func testRemoveByInstanceWithMultiple() async throws { + // Register multiple dependencies + let service = ConcreteService() + let repository = ConcreteRepository() + DependencyInjector.register(service) + DependencyInjector.register(repository) + + // Remove only the service by instance + DependencyInjector.remove(service) + + // Verify service is gone + let resolvedService: ConcreteService? = DependencyInjector.safeResolve() + #expect(resolvedService == nil) + + // Verify repository is still available + let resolvedRepository: ConcreteRepository = DependencyInjector.resolve() + #expect(resolvedRepository === repository) + } +} + +// Test helper classes +fileprivate final class ConcreteService { + +} + +fileprivate final class ConcreteRepository { + +} + +fileprivate protocol ServiceProtocol: AnyObject { + +} + +fileprivate final class ServiceImplementation: ServiceProtocol { + +} From d8abc9f13a846fbf42ee37e0b4c31b9360f1704a Mon Sep 17 00:00:00 2001 From: Alexander Kauer Date: Tue, 2 Dec 2025 15:27:20 +0100 Subject: [PATCH 2/2] Fixed parallel testing with non-shared dependencies --- .swiftpm/Injection.xctestplan | 2 +- .../Injection/MainActorResolutionTests.swift | 27 +++---- .../NonMainActorResolutionTests.swift | 32 +++------ .../Injection/ProtocolResolvementTests.swift | 25 ++----- Tests/Injection/RemoveTests.swift | 70 +++++++++++-------- 5 files changed, 66 insertions(+), 90 deletions(-) diff --git a/.swiftpm/Injection.xctestplan b/.swiftpm/Injection.xctestplan index c3f19e0..c5d8241 100644 --- a/.swiftpm/Injection.xctestplan +++ b/.swiftpm/Injection.xctestplan @@ -13,7 +13,7 @@ }, "testTargets" : [ { - "parallelizable" : false, + "parallelizable" : true, "target" : { "containerPath" : "container:", "identifier" : "InjectionTests", diff --git a/Tests/Injection/MainActorResolutionTests.swift b/Tests/Injection/MainActorResolutionTests.swift index 6260370..c4fa601 100644 --- a/Tests/Injection/MainActorResolutionTests.swift +++ b/Tests/Injection/MainActorResolutionTests.swift @@ -2,19 +2,16 @@ import Testing @testable import Injection @MainActor -class MainActorResolutionTests { +struct MainActorResolutionTests { init() { } - - deinit { - Task { @MainActor in - DependencyInjector.reset() - } - } @Test func testDependencyProviderInline() async throws { + final class MyTestDependency: Sendable {} + final class MySecondDependency: Sendable {} + // Register dependencies let providedDependency = MyTestDependency() DependencyInjector.register(providedDependency) @@ -27,6 +24,9 @@ class MainActorResolutionTests { } @Test func testDependencyProviderPropertyWrapper() async throws { + final class MyTestDependency: Sendable {} + final class MySecondDependency: Sendable {} + // Register dependencies let providedDependency = MyTestDependency() DependencyInjector.register(providedDependency) @@ -40,6 +40,9 @@ class MainActorResolutionTests { } @Test func expectNilForResolveWithoutRegistration() async throws { + final class MyTestDependency: Sendable {} + final class MySecondDependency: Sendable {} + let dependency: MyTestDependency? = DependencyInjector.safeResolve() #expect(dependency == nil) @@ -51,13 +54,3 @@ class MainActorResolutionTests { #expect(resolvedDependency === providedDependency) } } - -/// Dependency just for testing purposes -fileprivate final class MyTestDependency { - -} - -/// Dependency just for testing purposes -fileprivate final class MySecondDependency { - -} diff --git a/Tests/Injection/NonMainActorResolutionTests.swift b/Tests/Injection/NonMainActorResolutionTests.swift index dcf650c..2238265 100644 --- a/Tests/Injection/NonMainActorResolutionTests.swift +++ b/Tests/Injection/NonMainActorResolutionTests.swift @@ -1,19 +1,11 @@ import Testing @testable import Injection -class NonMainActorResolutionTests { - - init() { - - } - - deinit { - Task { @MainActor in - DependencyInjector.reset() - } - } - +struct NonMainActorResolutionTests { @Test func testDependencyProviderInline() async throws { + final class MyTestDependency: Sendable {} + final class MySecondDependency: Sendable {} + // Register dependencies let providedDependency = MyTestDependency() await DependencyInjector.register(providedDependency) @@ -26,6 +18,9 @@ class NonMainActorResolutionTests { } @Test func testDependencyProviderPropertyWrapper() async throws { + final class MyTestDependency: Sendable {} + final class MySecondDependency: Sendable {} + // Register dependencies let providedDependency = MyTestDependency() await DependencyInjector.register(providedDependency) @@ -39,6 +34,9 @@ class NonMainActorResolutionTests { } @Test func expectNilForResolveWithoutRegistration() async throws { + final class MyTestDependency: Sendable {} + final class MySecondDependency: Sendable {} + let dependency: MyTestDependency? = await DependencyInjector.safeResolve() #expect(dependency == nil) @@ -50,13 +48,3 @@ class NonMainActorResolutionTests { #expect(resolvedDependency === providedDependency) } } - -/// Dependency just for testing purposes -fileprivate final class MyTestDependency: Sendable{ - -} - -/// Dependency just for testing purposes -fileprivate final class MySecondDependency: Sendable { - -} diff --git a/Tests/Injection/ProtocolResolvementTests.swift b/Tests/Injection/ProtocolResolvementTests.swift index e0fe06b..faa5556 100644 --- a/Tests/Injection/ProtocolResolvementTests.swift +++ b/Tests/Injection/ProtocolResolvementTests.swift @@ -2,19 +2,11 @@ import Testing @testable import Injection @MainActor -class ProtocolResolvementTests { - - init() { - - } - - deinit { - Task { @MainActor in - DependencyInjector.reset() - } - } - +struct ProtocolResolvementTests { @Test func testProtocolResolve() async throws { + protocol MyProtocol: AnyObject {} + final class MyProtocolImplementation: MyProtocol {} + let providedDependency = MyProtocolImplementation() DependencyInjector.register(providedDependency, as: MyProtocol.self) @@ -23,12 +15,3 @@ class ProtocolResolvementTests { #expect(resolvedDependency === providedDependency) } } - - -fileprivate protocol MyProtocol: AnyObject { - -} - -fileprivate final class MyProtocolImplementation: MyProtocol { - -} diff --git a/Tests/Injection/RemoveTests.swift b/Tests/Injection/RemoveTests.swift index 35776d2..9d4dbd0 100644 --- a/Tests/Injection/RemoveTests.swift +++ b/Tests/Injection/RemoveTests.swift @@ -2,19 +2,13 @@ import Testing @testable import Injection @MainActor -class RemoveTests { - - init() { - - } - - deinit { - Task { @MainActor in - DependencyInjector.reset() - } - } - +struct RemoveTests { @Test func testRemoveExistingDependency() async throws { + final class ConcreteService {} + final class ConcreteRepository {} + protocol ServiceProtocol: AnyObject {} + final class ServiceImplementation: ServiceProtocol {} + // Register a dependency let service = ConcreteService() DependencyInjector.register(service) @@ -32,6 +26,11 @@ class RemoveTests { } @Test func testRemoveNonExistentDependency() async throws { + final class ConcreteService {} + final class ConcreteRepository {} + protocol ServiceProtocol: AnyObject {} + final class ServiceImplementation: ServiceProtocol {} + // Removing a non-existent dependency should not crash DependencyInjector.remove(ConcreteService.self) @@ -41,6 +40,11 @@ class RemoveTests { } @Test func testRemoveOneOfMultiple() async throws { + final class ConcreteService {} + final class ConcreteRepository {} + protocol ServiceProtocol: AnyObject {} + final class ServiceImplementation: ServiceProtocol {} + // Register multiple dependencies let service = ConcreteService() let repository = ConcreteRepository() @@ -60,6 +64,11 @@ class RemoveTests { } @Test func testRemoveAndReregister() async throws { + final class ConcreteService {} + final class ConcreteRepository {} + protocol ServiceProtocol: AnyObject {} + final class ServiceImplementation: ServiceProtocol {} + // Register a dependency let originalService = ConcreteService() DependencyInjector.register(originalService) @@ -78,6 +87,11 @@ class RemoveTests { } @Test func testRemoveProtocolRegistration() async throws { + final class ConcreteService {} + final class ConcreteRepository {} + protocol ServiceProtocol: AnyObject {} + final class ServiceImplementation: ServiceProtocol {} + // Register a protocol implementation let implementation = ServiceImplementation() DependencyInjector.register(implementation, as: ServiceProtocol.self) @@ -95,6 +109,11 @@ class RemoveTests { } @Test func testRemoveByInstance() async throws { + final class ConcreteService {} + final class ConcreteRepository {} + protocol ServiceProtocol: AnyObject {} + final class ServiceImplementation: ServiceProtocol {} + // Register a dependency let service = ConcreteService() DependencyInjector.register(service) @@ -112,6 +131,11 @@ class RemoveTests { } @Test func testRemoveByInstanceVsType() async throws { + final class ConcreteService {} + final class ConcreteRepository {} + protocol ServiceProtocol: AnyObject {} + final class ServiceImplementation: ServiceProtocol {} + // Register two separate instances for testing let service1 = ConcreteService() let service2 = ConcreteService() @@ -130,6 +154,11 @@ class RemoveTests { } @Test func testRemoveByInstanceWithMultiple() async throws { + final class ConcreteService {} + final class ConcreteRepository {} + protocol ServiceProtocol: AnyObject {} + final class ServiceImplementation: ServiceProtocol {} + // Register multiple dependencies let service = ConcreteService() let repository = ConcreteRepository() @@ -148,20 +177,3 @@ class RemoveTests { #expect(resolvedRepository === repository) } } - -// Test helper classes -fileprivate final class ConcreteService { - -} - -fileprivate final class ConcreteRepository { - -} - -fileprivate protocol ServiceProtocol: AnyObject { - -} - -fileprivate final class ServiceImplementation: ServiceProtocol { - -}