はじめに
①ViewControllerから処理の一部を切り出したい → ViewControllerの肥大化
②ViewControllerで状態(フラグ)を意識したくない → フラグ管理がカオス
③ユニットテストしやすくしたい → テスト容易性
上記の課題を解消する一つの方法をご紹介します。
その答えは、「条件分岐を減らす」です。
環境
XCode : 8.0
Swift : 3.0
今回ご説明する例
今回は、下記のようなシンプルな例で説明します。
①ログオフ状態の場合、ログインボタンを表示する(フォントカラーは青色)
②ログイン状態の場合、ログアウトボタンを表示する(フォントカラーは赤色)
1. Badケース
まず最初にBadケースをご紹介します。
ViewController内でログイン状態を意識し、
ログインとログオフで処理や表示に違いがある度に、
条件分岐が増えてしまいます。
ユニットテストの実施も難しいですね。
※今回は、UI周り(storyboard関連)は割愛させていただきます。
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var loginButton: UIButton!
private var isLogined = false
//MARK:- LifeCycle
override func viewDidLoad() {
super.viewDidLoad()
setupLoginButton()
}
//MARK:- IBAction
@IBAction func didTapLogin(_ sender: UIButton) {
isLogined = loadLoginState()
if isLogined {
setLogoutState()
} else {
setLoginState()
}
setupLoginButton()
}
//MAKR:- Private
fileprivate func setupLoginButton() {
isLogined = loadLoginState()
if isLogined {
setLoginButtonView()
} else {
setLogoutButtonView()
}
}
fileprivate func setLoginState() {
UserDefaults.standard.set(true, forKey: "isLogined")
UserDefaults.standard.synchronize()
}
fileprivate func setLogoutState() {
UserDefaults.standard.set(false, forKey: "isLogined")
UserDefaults.standard.synchronize()
}
fileprivate func loadLoginState() -> Bool{
return UserDefaults.standard.bool(forKey: "isLogined")
}
fileprivate func setLogoutButtonView() {
loginButton.setTitle("ログアウト", for: .normal)
loginButton.setTitleColor(UIColor.red, for: .normal)
}
fileprivate func setLoginButtonView() {
loginButton.setTitle("ログイン", for: .normal)
loginButton.setTitleColor(UIColor.blue, for: .normal)
}
}
2. Goodケース
今回の課題を解決するために、
GoFのStrategy パターン or Stateパターンを利用して、条件分岐を減らします。
それでは、やってみます。
2.1. ログイン状態を管理するクラス
ログイン状態は、UserDefaultsにて管理します。
必要な機能は、下記の3点とします。
①ログイン中かどうか?
②ログインする
③ログアウトする
2.1.1. プロトコル
import UIKit
protocol LoginStateProtocol {
func isLogined() -> Bool
func setLoginState()
func setLogoutState()
}
2.1.2. UserDefaultsのキー名
struct LoginStateUDConst {
static let isLogined = "isLogined"
}
2.1.3. 実装クラス
final class LoginStateRepository: LoginStateProtocol {
func isLogined() -> Bool {
return UserDefaults.standard.bool(forKey: LoginStateUDConst.isLogined)
}
func setLoginState() {
UserDefaults.standard.set(true, forKey: LoginStateUDConst.isLogined)
UserDefaults.standard.synchronize()
}
func setLogoutState() {
UserDefaults.standard.set(false, forKey: LoginStateUDConst.isLogined)
UserDefaults.standard.synchronize()
}
}
2.2. ログイン・ログアウトの実行、ログインボタンの表示切り替えを行うクラス
2.2.1. プロトコル
必要な機能は、下記の3点とします。
①ログインまたは、ログアウトする
②ログインボタンのタイトルを取得する
③ログインボタンのフォントカラーを取得する
import UIKit
protocol LoginUsecaseProtocol {
func loginOrLogout()
func loginButtonTitle() -> String
func loginButtonTitleColor() -> UIColor
}
2.2.2. ログインボタンのタイトル
struct LoginTitleString {
static let login = "ログイン"
static let logout = "ログアウト"
}
2.2.3. ログイン用の実装クラス
ログイン時の処理を記載します
import UIKit
final class Login: LoginUsecaseProtocol {
func loginOrLogout() {
LoginStateRepository().setLoginState()
}
func loginButtonTitle() -> String {
return LoginTitleString.login
}
func loginButtonTitleColor() -> UIColor {
return UIColor.blue
}
}
2.2.4. ログアウト用の実装クラス
ログアウト時の処理を記載します
import UIKit
final class Logout: LoginUsecaseProtocol {
func loginOrLogout() {
LoginStateRepository().setLogoutState()
}
func loginButtonTitle() -> String {
return LoginTitleString.logout
}
func loginButtonTitleColor() -> UIColor {
return UIColor.red
}
}
2.2.5. ViewControllerからのディスパッチするクラス
ここで初めて、分岐処理が登場します。
ログイン中は、ログアウトインスタンスを、
ログアウト中は、ログインインスタンスを返却します。
それ以外の処理は、ViewControllerの指示の元に、該当インスタンスへ処理を委譲します。(ポリモーフィズム)
final class LoginUsecase {
fileprivate var loginState: LoginUsecaseProtocol?
init() {
loginState = LoginStateRepository().isLogined() ? Logout() : Login()
}
func loginOrLogout() {
loginState?.loginOrLogout()
}
func loginButtonTitle() -> String {
return loginState?.loginButtonTitle() ?? ""
}
func loginButtonTitleColor() -> UIColor {
return loginState?.loginButtonTitleColor() ?? UIColor.black
}
}
2.3. ViewControllerクラス
初期表示とボタン押下時の処理を実装します
基本的には、ViewController内では、
ログインの状態を管理せずに、ユースケースに処理を委譲しています。
※今回は、UI周り(storyboard関連)は割愛させていただきます。
import UIKit
class ViewController: UIViewController {
@IBOutlet weak var loginButton: UIButton!
//MARK:- LifeCycle
override func viewDidLoad() {
super.viewDidLoad()
setupLoginButton()
}
//MARK:- IBAction
@IBAction func didTapLogin(_ sender: UIButton) {
login()
setupLoginButton()
}
//MAKR:- Private
fileprivate func setupLoginButton() {
let loginUsecase = LoginUsecase()
loginButton.setTitle(loginUsecase.loginButtonTitle(), for: .normal)
loginButton.setTitleColor(loginUsecase.loginButtonTitleColor(), for: .normal)
}
fileprivate func login() {
LoginUsecase().loginOrLogout()
}
}
まとめ
・ViewControllerから処理の一部を切り出したい
・ViewControllerで状態を意識したくない
・テストしやすくしたい
上記の課題は、一応クリアできたのではないでしょうか。
もっと、良い方法があれば、ご教授頂ければ幸いです。
おい、ユニットテストはどうなったという方のために、おまけ
おまけ
import XCTest
@testable import Test
class LoginUsecaseTests: XCTestCase {
override func setUp() {
super.setUp()
//デフォルトは、false(ログアウト状態)
UserDefaults.standard.set(false, forKey: LoginStateUDConst.isLogined)
UserDefaults.standard.synchronize()
}
override func tearDown() {
super.tearDown()
UserDefaults.standard.removeObject(forKey: LoginStateUDConst.isLogined)
}
func testConfig() {
//UDのキー名は正しいか?
XCTAssertEqual(LoginStateUDConst.isLogined, "isLogined")
}
func testInit() {
//初期表示は正しいか?
XCTAssertEqual(LoginUsecase().loginButtonTitle(), "ログイン")
XCTAssertEqual(LoginUsecase().loginButtonTitleColor(), UIColor.blue)
}
func testLogin() {
//ログイン後の表示は正しいか?
LoginUsecase().loginOrLogout()
XCTAssertTrue(UserDefaults.standard.bool(forKey: LoginStateUDConst.isLogined))
XCTAssertEqual(LoginUsecase().loginButtonTitle(), "ログアウト")
XCTAssertEqual(LoginUsecase().loginButtonTitleColor(), UIColor.red)
}
func testLogout() {
//ログアウト後の表示は正しいか?
LoginUsecase().loginOrLogout()
XCTAssertTrue(UserDefaults.standard.bool(forKey: LoginStateUDConst.isLogined))
LoginUsecase().loginOrLogout()
XCTAssertFalse(UserDefaults.standard.bool(forKey: LoginStateUDConst.isLogined))
XCTAssertEqual(LoginUsecase().loginButtonTitle(), "ログイン")
XCTAssertEqual(LoginUsecase().loginButtonTitleColor(), UIColor.blue)
}
}