8
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-11-28

前提

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

環境

macOS 11.4
xcode13.1
swift5

できたもの

9pn3w-2u62f.gif

参考

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

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

実装

M: Model

Modelの説明として「iOSアプリ設計パターン入門」にこう記載されています。

Model は UI に関係しない純粋なドメインロジックやそのデータを持ちます。

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

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 はユーザー操作の受け付けと、画面表示を担当するコンポーネントです。

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 からイベントを受け取り、加工して値を更新する

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()の結果が期待されたものになるかをテストします。

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を使うこともできます。

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

// Mockを使う場合
private let viewModel = LoginViewModel(loginValidator: MockLoginValidator())
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が全て通った場合にログインボタンが活性化し、タップできるようになるかどうかをテストします。

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)
    }
}

成果物

8
10
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
8
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?