【SwiftUI × Combine】ログイン画面を作ってみる MVVM編

## 前提
MVVMの理解のためにログイン画面を作ります。
UnitTest, UITestも簡単に書いてみます。

## 環境
macOS 11.4
xcode13.1
swift5

## できたもの
![9pn3w-2u62f.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/263661/7a240286-a030-c715-39b4-17d8f585b203.gif)

## 参考
MVVM以外にも色々なアーキテクチャが実装例付きで説明されていてわかりやすい書籍です。

https://peaks.cc/C53A950F/iOS_architecture

MVVMの実装例の記事は色々ありますがこれが一番わかりやすかったです。

https://tech.toreta.in/entry/2019/12/24/104612

## 実装

### M: Model
Modelの説明として「iOSアプリ設計パターン入門」にこう記載されています。
>Model は UI に関係しない純粋なドメインロジックやそのデータを持ちます。

今回は入力されたメールアドレスとパスワードのValidationを行うModelを作ります。
ログイン機能はサーバーサイドにHTTPRequestを送って認証・認可を行いますが
そのようにAPIを使う場合もModelになると思います。

```swift
enum LoginValidateError: Error {
    case emailEmpty
    case passwordEmpty

    var localizedDescription: String {
        switch self {
            case .emailEmpty: return "emailEmpty"
            case .passwordEmpty: return "passwordEmpty"
        }
    }
}

protocol ILoginValidator {
    func validate(email: String, password: String) -> String
}

final class LoginValidator: ILoginValidator {
    func validate(email: String, password: String) -> String {
        if email.isEmpty {
            return LoginValidateError.isEmailEmpty.localizedDescription
        }

        if password.isEmpty {
            return LoginValidateError.isPasswordEmpty.localizedDescription
        }

        return ""
    }
}
```

### V: View
Viewの説明として「iOSアプリ設計パターン入門」にこう記載されています。
>View はユーザー操作の受け付けと、画面表示を担当するコンポーネントです。

```swift
import SwiftUI

struct LoginView: View {
    @ObservedObject private var viewModel: LoginViewModel

    init(viewModel: LoginViewModel) {
        self.viewModel = viewModel
    }

    var body: some View {
        VStack {
            invalidMessage
            emailTextField
            passwordSecureField
            loginButton
        }
        .alert("ログイン成功", isPresented: $viewModel.isLoginCompleted) {}
    }

    var emailTextField: some View {
        TextField("Eメール", text: $viewModel.email)
            .textFieldStyle(.roundedBorder)
            .autocapitalization(.none)
            .padding()
            .accessibility(identifier: "loginEmailTextField")
    }

    var passwordSecureField: some View {
        SecureField("パスワード", text: $viewModel.password)
            .textFieldStyle(.roundedBorder)
            .autocapitalization(.none)
            .padding()
            .accessibility(identifier: "loginPasswordSecureField")
    }

    var loginButton: some View {
        Button(action: { viewModel.didTapLoginButton.send() }) {
            Text("ログイン")
                .frame(maxWidth: .infinity)
                .padding()
                .foregroundColor(.white)
                .background(Color(.orange))
                .cornerRadius(24)
                .padding()
                .disabled(!viewModel.isLoginEnabled)
                .opacity(viewModel.isLoginEnabled ? 1: 0.5)
                .accessibility(identifier: "loginButton")
        }
    }

    var invalidMessage: some View {
        Text(viewModel.invalidMessage)
            .foregroundColor(.red)
            .accessibility(identifier: "loginInvalidMessage")
    }
}
```

### VM: ViewModel
ViewModelの役割は「iOSアプリ設計パターン入門」にこう記載されています。
>ViewModel は View-Model 間の画面表示のための仲介役であり、次の責務を担います。
• View に表示するためのデータを保持する
• View からイベントを受け取り、Model の処理を呼び出す
• View からイベントを受け取り、加工して値を更新する

```swift
import Foundation
import Combine

final class LoginViewModel: ObservableObject {
    // binding
    @Published var email: String = ""
    @Published var password: String = ""

    @Published var isLoginCompleted: Bool = false

    // Input
    let didTapLoginButton = PassthroughSubject<Void, Never>()

    // output
    @Published private(set) var isLoginEnabled: Bool = false
    @Published private(set) var invalidMessage: String = ""

    // cancellable
    private var cancellables = Set<AnyCancellable>()

    init(loginValidator: LoginValidator) {
        let isLoginEnabled = $invalidMessage.dropFirst().map { $0.isEmpty }
        let invalidMessage = Publishers.CombineLatest($email, $password)
            .dropFirst()
            .map { loginValidator.validate(email: $0, password: $1) }


        let isLoginCompleted = didTapLoginButton
            .flatMap {
                Just(true)
            }

        cancellables.formUnion([
            isLoginEnabled.assign(to: \.isLoginEnabled, on: self),
            invalidMessage.assign(to: \.invalidMessage, on: self),
            isLoginCompleted.assign(to: \.isLoginCompleted, on: self)
        ])
    }
}

```

## Test

### UnitTest

#### Model
Modelは具体的な処理の結果が期待されたものになるかをテストしています。
今回は`validate()`の結果が期待されたものになるかをテストします。

```swift
import XCTest
@testable import SwiftUI_MVVM_Login

class LoginValidatorTests: XCTestCase {

    private let model = LoginValidator()

    func testValidateForNoError() throws {
        let email = "email"
        let password = "password"
        let expected = ""

        let actual = model.validate(email: email, password: password)

        XCTAssertEqual(expected, actual)
    }

    func testValidateForEmailEmpty() throws {
        let email = ""
        let password = ""
        let expected = LoginValidateError.emailEmpty.localizedDescription

        let actual = model.validate(email: email, password: password)

        XCTAssertEqual(expected, actual)
    }

    func testValidateForPasswordEmpty() throws {
        let email = "email"
        let password = ""
        let expected = LoginValidateError.passwordEmpty.localizedDescription

        let actual = model.validate(email: email, password: password)

        XCTAssertEqual(expected, actual)
    }

}
```

#### ViewModel
ViewModelはViewとModelの橋渡し役なので
Modelの処理結果が期待されたプロパティに反映されるかをテストしています。
今回は`invalidMessage`が期待されたメッセージになっているかどうかをテストします。

また、LoginValidatorをILoginValidatorに準拠させることで
ViewModelのUnitTestの際にDIでViewModelを生成でき
LoginValidatorのMockを使えるようになります。
今回のUnitTestではそのままLoginValidatorを使っていますが
このようにMockを使うこともできます。

```swift
// これが今回
private let viewModel = LoginViewModel(loginValidator: LoginValidator())

// Mockを使う場合
private let viewModel = LoginViewModel(loginValidator: MockLoginValidator())
```

```swift
import XCTest
@testable import SwiftUI_MVVM_Login

class LoginViewModelTests: XCTestCase {

    private let viewModel = LoginViewModel(loginValidator: LoginValidator())

    func testNoInvalidMessage() throws {
        let expected = ""

        viewModel.email = "email"
        viewModel.password = "password"

        XCTAssertEqual(expected, viewModel.invalidMessage)
    }

    func testEmailInvalidMessage() throws {
        let expected = LoginValidateError.emailEmpty.localizedDescription

        viewModel.email = ""
        viewModel.password = "password"

        XCTAssertEqual(expected, viewModel.invalidMessage)
    }

    func testPasswordInvalidMessage() throws {
        let expected = LoginValidateError.passwordEmpty.localizedDescription

        viewModel.email = "email"
        viewModel.password = ""

        XCTAssertEqual(expected, viewModel.invalidMessage)
    }
}
```

### UITest
UITestでは,　何かしらの操作をした場合に期待された動きになるかをテストしています。
今回は、validationにかかったときはログインボタンが非活性化し、validateが全て通った場合にログインボタンが活性化し、タップできるようになるかどうかをテストします。


```swift
import XCTest
import SwiftUI_MVVM_Login
@testable import SwiftUI_MVVM_Login

class LoginViewTests: XCTestCase {

    override func setUpWithError() throws {
        // Put setup code here. This method is called before the invocation of each test method in the class.

        // In UI tests it is usually best to stop immediately when a failure occurs.
        continueAfterFailure = false
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }

    func testCanLoginButtonTap() throws {
        let app = XCUIApplication()
        app.launch()

        let loginButton = app.buttons["loginButton"]
        XCTAssertEqual(false, loginButton.isEnabled)

        let emailTextField = app.textFields["loginEmailTextField"]
        let passwordSecureField = app.secureTextFields["loginPasswordSecureField"]

        emailTextField.tap()
        emailTextField.typeText("test")

        passwordSecureField.tap()
        passwordSecureField.typeText("111")

        XCTAssertEqual(true, loginButton.isEnabled)
    }
}
```

## 成果物
https://github.com/yuskey38/SwiftUI-MVVM-Login