前提
MVVMの理解のためにログイン画面を作ります。
UnitTest, UITestも簡単に書いてみます。
環境
macOS 11.4
xcode13.1
swift5
できたもの
参考
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)
}
}
成果物