Notice
Recent Posts
Recent Comments
Link
관리 메뉴

김종권의 iOS 앱 개발 알아가기

[iOS - Swift] 1. 유닛 테스트 방법 - Dependency Injection (@Injected) 주입 구조 본문

iOS 응용 (swift)

[iOS - Swift] 1. 유닛 테스트 방법 - Dependency Injection (@Injected) 주입 구조

jake-kim 2022. 12. 10. 22:36

1. 유닛 테스트 방법 - Dependency Injection (@Injected) 주입 구조 <

2. 유닛 테스트 방법 - Quick과 Nimble을 이용한 테스트 코드 구현 방법

3. 유닛 테스트 방법 - RxExpect를 이용한 Rx관련 비동기 테스트 코드 구현 방법

번외) 유닛 테스트 방법 - XCTest와 RxSwift만을 이용한 비동기 테스트 구현 방법

일반적인 DI 방법

  • AccessToken을 관리하는 AccessTokenService가 있을 때, 이것을 사용하는 쪽에서는 구현체에 의존하지 않고 protocol에 의존하게 만들도록 구현
  • 사용하는쪽 ViewModel에서 AccessTokenServiceType에 의존
  • 구현체에 의존하고 있지 않고 인터페이스에 의존하고 있으므로, 구현체를 바꾸어서 테스트가 가능
protocol AccessTokenServiceType {
    
}
class AccessTokenService: AccessTokenServiceType {
    
}

class SomeViewModel {
    let accessTokenService: AccessTokenServiceType
    
    init(accessTokenService: AccessTokenServiceType) {
        self.accessTokenService = accessTokenService
    }
}
  • 테스트 시 AccessTokenServiceType를 준수하는 하나의 MockAccessToken이라는 것을 만들어서 사용
class MockAccessToken: AccessTokenServiceType {
}
let viewModel = SomeViewModel(accessTokenService: MockAccessToken())
  • 위 방법의 문제점
    • initializer에 현재 accessTokenService만 있지만 앞으로 여러개의 서비스가 생겨난다면 일일이 모두 init 구문에 선언되고, 초기화 하는 코드가 다 생겨나는 단점이 존재
  • 대안 아이디어
    • init에서 초기화하는 코드를 일일이 작성 안하고, service를 아래처럼 선언만 해서 사용하는 방법 (+ test 가능한 구조)
    • 주입하는것은 앱 켜질때 한번만 주입해주고 (싱글톤), 사용하는 쪽에서는 property wrapper로 런타임시에 인스턴스를 싱글톤 or mock 인스턴스에 접근하도록 구현
class SomeViewModel {
    @Injected let accessTokenService: AccessTokenServiceType
    @Injected let authService: AuthServiceType
}

property wrapper를 통한 구현 방법

* 방법은 해당 블로그에서 참고

  • @Injected 구현 - dependencyStore의 인서턴스를 가지고 있는 property wrapper
  • dependency는 injected를 사용하여 생성한 인스턴스들을 모두 가지고 있는 컨테이너
// https://medium.com/streamotion-tech-blog/magic-dependency-injection-in-swift-70476c7743ec

@propertyWrapper
public class Injected<T> {

    private var storage: T?

    private let dependencyStore: DependencyStore

    public init() {
        dependencyStore = DependencyStore.shared
    }

    public var wrappedValue: T {
        if let storage = storage { return storage }
        let object: T = dependencyStore.resolve()
        storage = object
        return object
    }

}
  • DependencyStore
    • register로 인스턴스 등록 (store라는 딕셔너리에 기록)
    • resolve로 인스턴스 획득
    • 일반 싱글톤 정의와는 다르게, shared는 let이 아닌 var로 선언하고, init도 private이 아닌 internal로 설정
    • 핵심은 store 프로퍼티 - [String: Any] 타입이 아닌 [String: () -> Any]로 선언한 이유는 lazy하게 인스턴스들이 동작되도록 하기 위함
      • 클로저의 특성 - 실행 지연이 가능
// https://medium.com/streamotion-tech-blog/magic-dependency-injection-in-swift-70476c7743ec

public class DependencyStore {

    #if DEBUG
    public static var shared = DependencyStore()
    #else
    public static let shared = DependencyStore()
    #endif

    public init() {}

    /// A map of `identifier(for:)` to initializers
    private var store: [Identifier: () -> Any] = [:]

    func resolve<T>() -> T {
        let id = identifier(for: T.self)

        guard let initializer = store[id] else {
            fatalError("Could not resolve for \(T.self) - did you forget to `DependencyStore.register(_:)` a concrete type?")
        }

        guard let value = initializer() as? T else {
            // Never happens due to the register function being generic - this is needed only because `store.value` is `Any`
            fatalError("Could not cast \(initializer()) to \(T.self)")
        }

        return value
    }

    /// Registers a concrete dependency against protocol `T`.
    /// ```
    /// DependencyStore.register(Something(), for: SomethingType.self)
    /// ```
    public func register<T>(_ dependency: @escaping @autoclosure () -> T, for type: T.Type) {
        let id = identifier(for: T.self)
        store[id] = dependency
    }

}
  • 앱 실행시 최초에 dependency를 초기화하는 registerAll() 구현
  • mock 데이터 생성을 위한 execute 메소드 구현
    • SingleTon의 취약점인, thread safe하지 않으므로 mock 데이터를 주입할때 serial queue를 이용하여 sync하게 접근하도록 구현
// https://medium.com/streamotion-tech-blog/magic-dependency-injection-in-swift-70476c7743ec

#if DEBUG
public extension DependencyStore {

    static let syncingQueue = DispatchQueue(label: "Dependencies.syncer")

    /// In a unit test, we need `@Injected` to read from self rather than the default `DependencyStore.shared`
    /// This does `DependencyStore.shared = self` and blocks all queues until `initializations` has been performed.
    /// Therefore `@Injected` items inside of `initializations` are guaranteed to initialise and maintain a reference to `self` which it will later use if needed.
    @discardableResult
    func execute<ReturnedValues>(_ initializations: @escaping () -> ReturnedValues) -> ReturnedValues {
        var anyReturnedValues: ReturnedValues!
        DependencyStore.syncingQueue.sync {

            let defaultInstance = DependencyStore.shared
            DependencyStore.shared = self
            anyReturnedValues = initializations()
            DependencyStore.shared = defaultInstance
        }
        return anyReturnedValues
    }

}
#endif

사용하는 쪽

  • service를 정의할 때도 싱글톤으로 구현
public protocol AccessTokenServiceType {
    func getAccessToken() -> String?
}

public class AccessTokenService: AccessTokenServiceType {
    static let shared: AccessTokenService = AccessTokenService()
    
    public func getAccessToken() -> String? {
        "access-token"
    }
}
  • registerAll이라는 메소드 구현 (새로운 서비스를 만들때마다 여기에 추가)
extension DependencyStore {
    func registerAll() {
        register(AccessTokenService.shared, for: AccessTokenServiceType.self)
    }
}
  • AppDelegate에서 registerAll 실행
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        DependencyStore.shared.registerAll()
        
        return true
    }
}
  • 사용하는 쪽에서는 @Injected와 함께 service 프로퍼티를 선언하여 따로 init 코드 없이 바로 사용할 수 있는 구조
class ViewController: UIViewController {
    @Injected private var accessTokenService: AccessTokenServiceType
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        print(accessTokenService.getAccessToken())
    }
}

* 테스트 방법은 다음 포스팅 글, Quick과 Nimble을 이용한 테스트 코드 구현 참고

 

* 전체 코드: https://github.com/JK0369/ExDI

* 참고

https://medium.com/streamotion-tech-blog/magic-dependency-injection-in-swift-70476c7743ec

Comments