LoginSignup
7
5

More than 3 years have passed since last update.

【Storyboard】遅延初期化しているViewModelをDIしてViewControllerをテスト可能にする

Last updated at Posted at 2020-02-02

はじめに

ViewModelのinitializerに引数がある場合に、ViewController側で以下のような遅延初期化をすることが多々あります。
このViewControllerにViewModelを注入してテストをしたい場合に、どうすれば注入することができるようになるでしょうか。

本投稿では以下の2点を重視して、既存のプロジェクトでも導入できる方法を解説していきます。

  • Storyboardを用いてViewControllerが初期化される
  • ViewModelの既存のインターフェースを大きく変更しない

※ Combine.frameworkを利用していますが、RxSwiftでも同様の実装が応用できます。
※ GitHubでソースコードも公開しています。

ベースとなるViewControllerの実装
final class CounterViewController: UIViewController {
    @IBOutlet weak var incrementButton: UIButton!
    @IBOutlet weak var decrementButton: UIButton!
    @IBOutlet weak var countLabel: UILabel!

    private lazy var viewModel = CounterViewModel( // 👈 このViewModelをどう注入するか
        count: 0,
        increment: incrementButton.extension.tap(),
        decrement: decrementButton.extension.tap()
    )
    private var cancellables: [AnyCancellable] = []

    override func viewDidLoad() {
        super.viewDidLoad()

        viewModel.countText
            .assign(to: \.text, on: countLabel)
            .store(in: &cancellables)

        viewModel.isDecrementEnabled
            .assign(to: \.isEnabled, on: decrementButton)
            .store(in: &cancellables)
    }
}
ベースとなるViewModelの実装
final class CounterViewModel {
    let countText: AnyPublisher<String?, Never>
    let isDecrementEnabled: AnyPublisher<Bool, Never>

    private var cancellables = [AnyCancellable]()

    init(count: Int,
         increment: AnyPublisher<Void, Never>,
         decrement: AnyPublisher<Void, Never>) {
        let store = Store()
        store.count = count

        self.countText = store.$count.map(String.init)
            .eraseToAnyPublisher()

        self.isDecrementEnabled = store.$count.map { $0 > 0 }
            .eraseToAnyPublisher()

        increment.map { _ in 1 }
            .merge(with: decrement.map { _ in -1 })
            .flatMap { Just(store.count + $0) }
            .assign(to: \.count, on: store)
            .store(in: &cancellables)
    }

    private final class Store {
        @Published var count = 0
    }
}

ViewModelのInitializeをClosureとして注入する方法

まずは少しの変更で対応できる方法を解説します。

1. CounterViewModelのprotocolを定義する

まずはCounterViewModelのprotocolを定義して、CounterViewModelに採用します。

protocol CounterViewModelType {
    var countText: AnyPublisher<String?, Never> { get }
    var isDecrementEnabled: AnyPublisher<Bool, Never> { get }
}

final class CounterViewModel: CounterViewModelType {
    let countText: AnyPublisher<String?, Never>
    let isDecrementEnabled: AnyPublisher<Bool, Never>
}

2. InitializerをClosureとして定義する

InitializerをClosureとして定義すると

let initViewModel = CounterViewModel.init
let viewModel = initViewModel(0,
                              incrementButton.extension.tap(),
                              decrementButton.extension.tap())

という実装ができます。
この実装に対して型を明記すると

let initViewModel: (Int, AnyPublisher<Void, Never>, AnyPublisher<Void, Never>) -> CounterViewModel = CounterViewModel.init
let viewModel: CounterViewModel = initViewModel(0,
                                                incrementButton.extension.tap(),
                                                decrementButton.extension.tap())

となります。
このClosureをtypealiasで定義すると以下のようになります。

typealias InitViewModel = (Int, AnyPublisher<Void, Never>, AnyPublisher<Void, Never>) -> CounterViewModel

let initViewModel: InitViewModel = CounterViewModel.init

しかし、このままではCounterViewModelしか初期化することができないため、返り値がCounterViewModelTypeとなるClosureをtypealiasとして定義します。

typealias InitViewModel = (Int, AnyPublisher<Void, Never>, AnyPublisher<Void, Never>) -> CounterViewModelType

let initViewModel: InitViewModel = CounterViewModel.init
let viewModel: CounterViewModelType = initViewModel(0,
                                                    incrementButton.extension.tap(),
                                                    decrementButton.extension.tap())

つまりCounterViewModelTypeが返り値となるため、以下のようにモック化したオブジェクトも扱えるようになります。

final class MockCounterViewModel: CounterViewModelType {
    var countText: AnyPublisher<String?, Never> { _countText.eraseToAnyPublisher() }
    let _countText = PassthroughSubject<String?, Never>()
    var isDecrementEnabled: AnyPublisher<Bool, Never> { _isDecrementEnabled.eraseToAnyPublisher() }
    let _isDecrementEnabled = PassthroughSubject<Bool, Never>()
}

let initViewModel: InitViewModel = { _, _, _ in MockCounterViewModel() }
let viewModel: CounterViewModelType = initViewModel(0,
                                                    incrementButton.extension.tap(),
                                                    decrementButton.extension.tap())

3. ViewControllerでInitializerのtypealiasを利用する

InitializerのClosureをinitViewModelという命名でViewControllerのpropertyとして定義します。
Storyboardからの初期化を想定しているため、Implicitly Unwrapped Optionalで定義しています。
遅延初期化をしているViewModelを、CounterViewModel.initの代わりにinitViewModelを利用して遅延初期化します。

final class CounterViewController: UIViewController {
    @IBOutlet weak var incrementButton: UIButton!
    @IBOutlet weak var decrementButton: UIButton!

    typealias InitViewModel = (Int, AnyPublisher<Void, Never>, AnyPublisher<Void, Never>) -> CounterViewModelType
    private var initViewModel: InitViewModel!
    private var count: Int!
    private lazy var viewModel: CounterViewModelType = initViewModel(
        count: count,
        increment: incrementButton.extension.tap(),
        decrement: decrementButton.extension.tap()
    )

    ...
}

StoryboardからViewControllerを初期化してinitViewModelに任意のInitializerを渡す実装以下のようになります。
このstatic methodからViewControllerの生成を行うことで、実装の順番的にviewDidLoadよりも先にpropertyの再代入が行われることが保証できているので、クラッシュすることはありません。

extension CounterViewController {
    static func makeFromStoryboard(count: Int, initViewModel: @escaping InitViewModel) -> CounterViewController {
        let storyboard = UIStoryboard(name: "CounterViewController", bundle: nil)
        let viewController = storyboard.instantiateInitialViewController() as! CounterViewController
        viewController.count = count
        viewController.initViewModel = initViewModel
        return viewController
    }
}

そしてViewControllerを以下のように初期化することで、CounterViewModelの振る舞いをするViewControllerが利用できます。

let viewController = CounterViewController.makeFromStoryboard(count: 0, initViewModel: CounterViewModel.init)

テストの実装

インスタンス化したモックオブジェクトをInitializerのClosureで返すようにすることで、ViewControllerの振る舞いをテストすることができます。

class CounterViewControllerTests: XCTestCase {
    private var viewController: CounterViewController!
    private var viewModel: MockCounterViewModel!

    override func setUp() {
        let viewModel = MockCounterViewModel()
        self.viewModel = viewModel
        self.viewController = CounterViewController.makeFromStoryboard(count: 0) { _, _, _ in
            return viewModel
        }
        self.viewController.loadViewIfNeeded()
    }

    func test_countLabel_text() {
        let expected = UUID().uuidString
        viewModel._countText.send(expected)
        XCTAssertEqual(viewController.countLabel.text, expected)
    }
}

extension CounterViewControllerTests {
    private final class MockCounterViewModel: CounterViewModelType {
        var countText: AnyPublisher<String?, Never> { _countText.eraseToAnyPublisher() }
        let _countText = PassthroughSubject<String?, Never>()
        ...
    }
}

ViewModelのFactoryから注入する方法

ViewModelのInitializeをClosureとして注入する方法を、より厳密に定義します。

1. ViewModelFactoryを定義する

initViewModel: InitViewModelの代わりにfactory: ViewModelFactoryからViewModelを初期化できるようにします。

struct CounterViewModelFactory {
    private let count: Int

    init(count: Int) {
        self.count = count
    }

    func initialize(increment: AnyPublisher<Void, Never>,
                    decrement: AnyPublisher<Void, Never>) -> CounterViewModelType {
        CounterViewModel(count: self.count,
                         increment:increment,
                         decrement:decrement)
    }
}

ViewController内で遅延初期化が必要になっている引数は、incrementdecrementなので、countは外部から受け取りできる状態にします。
このように実装すると以下のようにViewModelを初期化することができます。

let viewModel: CounterViewModelType = CounterViewModelFactory(count: 0)
    .initialize(increment: incrementButton.extension.tap(),
                decrement: decrementButton.extension.tap())

しかし、この実装でCounterViewModelTypeが返り値となっていますが、内部実装はCounterViewModelに固定されてしまっているため、実際は振る舞いを変えることができません。

2. ViewModelFactoryのprotocolを定義する

ViewModelFactoryの振る舞いを変更可能にするために、ViewModelFactoryのprotocolを定義します。

protocol ViewModelFactoryType {
    associatedtype Dependency
    associatedtype ViewModel
    func initialize(_ dependency: Dependency) -> ViewModel
}

初期化時の引数を汎用的にするためにDependencyを定義しているので、CounterViewModelFactoryに適用します。

struct CounterViewModelFactory: ViewModelFactoryType {
    ...
    func initialize(_ dependency: (
        increment: AnyPublisher<Void, Never>,
        decrement: AnyPublisher<Void, Never>)
    ) -> CounterViewModelType {
        CounterViewModel(count: self.count,
                         increment: dependency.increment,
                         decrement: dependency.decrement)
    }
}

しかし、ViewModelFactoryTypeはassociatedTypeを定義しているため、let viewModelFactory: ViewModelFactoryTypeのような定義ができません。
そこで、AnyViewModelFactoryを定義します。

struct AnyViewModelFactory<Dependency, ViewModel>: ViewModelFactoryType {

    private let _initialize: (Dependency) -> ViewModel

    init<Factory: ViewModelFactoryType>(_ factory: Factory) where Factory.Dependency == Dependency, Factory.ViewModel == ViewModel {
        self._initialize = { factory.initialize($0) }
    }

    func initialize(_ dependency: Dependency) -> ViewModel {
        _initialize(dependency)
    }
}

AnyViewModelFactoryを用いてViewModelの初期化をすると、以下のようになります。

let factory = AnyViewModelFactory<CounterViewModelFactory.Dependency, CounterViewModelType>(CounterViewModelFactory(count: 0))
let viewModel: CounterViewModelType = factory
    .initialize(increment: incrementButton.extension.tap(),
                decrement: decrementButton.extension.tap())

また、MockCounterViewModelFactoryを定義してAnyViewModelFactoryを用いた場合は以下のようになります。

struct MockCounterViewModelFactory: ViewModelFactoryType {

    let viewModel: MockCounterViewModel

    func initialize(_ dependency: (
        increment: AnyPublisher<Void, Never>,
        decrement: AnyPublisher<Void, Never>)
    ) -> CounterViewModelType {
        viewModel
    }
}

let factory = AnyViewModelFactory<CounterViewModelFactory.Dependency, CounterViewModelType>(MockCounterViewModelFactory(viewModel: MockCounterViewModel()))
let viewModel: CounterViewModelType = factory
    .initialize(increment: incrementButton.extension.tap(),
                decrement: decrementButton.extension.tap())

Factoryの型自体はAnyViewModelFactory<CounterViewModelFactory.Dependency, CounterViewModelType>になっているため、propertyとして定義して異なる振る舞いを注入することができます。

3. ViewControllerでViewModelFactoryを利用する

factory: AnyViewModelFactory<CounterViewModelFactory.Dependency, CounterViewModelType>をViewControllerに定義します。
Storyboardからの初期化を想定しているため、Implicitly Unwrapped Optionalで定義しています。
遅延初期化をしているViewModelを、CounterViewModel.initの代わりにfactory.initializeを利用して遅延初期化します。

final class CounterViewController: UIViewController {
    @IBOutlet weak var incrementButton: UIButton!
    @IBOutlet weak var decrementButton: UIButton!

    private var factory: AnyViewModelFactory<CounterViewModelFactory.Dependency, CounterViewModelType>!
    private lazy var viewModel = factory.initialize((
        increment: incrementButton.extension.tap(),
        decrement: decrementButton.extension.tap()
    ))

StoryboardからViewControllerを初期化してfactoryに任意のViewModelFactoryを渡す実装以下のようになります。
Generic ArgumentにViewModelFactoryを指定し、where句でCounterViewModelFactory.DependencyCounterViewModelTypeを持つViewModelFactoryに限定します。
そのViewModelFactoryをAnyViewModelFactoryで型消去をして、factoryに代入します。
このstatic methodからViewControllerの生成を行うことで、実装の順番的にviewDidLoadよりも先にpropertyの再代入が行われることが保証できているので、クラッシュすることはありません。

extension CounterViewController {
    static func makeFromStoryboard<Factory: ViewModelFactory>(factory: Factory) -> CounterViewController where Factory.Dependency == CounterViewModelFactory.Dependency, Factory.ViewModel == CounterViewModelType {
        let storyboard = UIStoryboard(name: "CounterViewController", bundle: nil)
        let viewController = storyboard.instantiateInitialViewController() as! CounterViewController
        viewController.factory = AnyViewModelFactory(factory)
        return viewController
    }
}

そしてViewControllerを以下のように初期化することで、CounterViewModelの振る舞いをするViewControllerが利用できます。

let viewController = CounterViewController.makeFromStoryboard(factory: CounterViewModelFactory(count: 0))

テストの実装

モック化したFactoryでモック化したViewModelのインスタンスを返すようにすることで、ViewControllerの振る舞いをテストすることができます。

class CounterViewControllerTests: XCTestCase {
    private var viewController: CounterViewController!
    private var viewModel: MockCounterViewModel!
    private var factory: MockCounterViewModelFactory!

    override func setUp() {
        self.factory = MockCounterViewModelFactory()
        self.viewModel = factory.viewModel
        self.viewController = CounterViewController.makeFromStoryboard(factory: self.factory)
        viewController.loadViewIfNeeded()
    }

    func test_countLabel_text() {
        let expected = UUID().uuidString
        viewModel._countText.send(expected)
        XCTAssertEqual(viewController.countLabel.text, expected)
    }
}

extension CounterViewControllerTests {
    private final class MockCounterViewModel: CounterViewModelType {
        var countText: AnyPublisher<String?, Never> { _countText.eraseToAnyPublisher() }
        let _countText = PassthroughSubject<String?, Never>()
        ...
    }

    private struct MockCounterViewModelFactory: ViewModelFactoryType {
        let viewModel = MockCounterViewModel()

        func initialize(_ dependency: CounterViewModelFactory.Dependency) -> CounterViewModelType {
            return viewModel
        }
    }
}

最後に

Storyboardを想定しているためImplicitly Unwrapped Optionalとしていますが、その部分以外はxibなどでも同じ実装が応用できます。
既存のプロジェクトにも比較的導入しやすい方法であると思うので、是非試してみてください。

7
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7
5