Refactor code to testable
Hokila

Cocoa heads Taipei 2017.11
2
什什麼是 Unit Test
ModuleInput Output
3
現實上的難關
常⾒見見說法
feature 都來來不及做了了,哪有時間寫
我們家有 QA
有 UI Test 啊
4
現實上的難關
實際上
不會寫
Input 無法控制 (API , 不是⾃自⼰己寫的 SDK)
ViewController 超級無敵⼤大
5
Benefit
• 同樣的 code ⽤用兩兩種思考⽅方式看過
• ⾃自然⽽而然學會切 module,或者合併商業邏輯
• ⼀一次跑完幾百個幾千的 check,令⼈人安⼼心
• ⽽而且很快
實際案例例
login View Controller
7
LoginViewController
login API
AccountKit login API
Facebook login API
save Token after login success
tracking event
set User Info
8
問題 - dependency 太多
login API,成功 or 失敗
facebook / AccountKit SDK ⾏行行為
是否有存下 AccessToken
User 資料是不是該有的都有 (UserID , MemberShip)
該有的 tracking 是不是有送
login 時
login 後
拆出 login manager
拆出 login manager
不知道在幹嘛的 module 叫 manager 就對了了
10
protocol LoginManagerDelegate:class{
func loginStart()
func loginSuccess(status:LoginStatus)
func loginFail(json:[String:Any]?,error:NSError?)
}
class LoginManager{
static let shareInstance = LoginManager()
weak var delegate:LoginManagerDelegate?
func loginFB()
func loginAccountKit()
}
11
class LoginController: BaseViewController{
func touchFBlogin(_ sender: AnyObject) {
self.loginManager.loginFB()
}
func touchAClogin(_ sender: AnyObject){
self.loginManager.loginAccountKit()
}
}
extension LoginController:LoginManagerDelegate{
func loginStart(){ //show indicator }
func loginFail(json:[String:Any]?,error:NSError?){ //error handling}
func loginSuccess(status:LoginStatus){ //success flow }
}
12
LoginViewController LoginManager
login API
AccountKit login API
Facebook login API
save Token after login success
tracking event
set User Info
login start
login fail handler
login success handler
12
LoginViewController LoginManager
login API
AccountKit login API
Facebook login API
save Token after login success
tracking event
set User Info
login start
login fail handler
login success handler
12
LoginViewController LoginManager
login API
AccountKit login API
Facebook login API
save Token after login success
tracking event
set User Info
login start
login fail handler
login success handler
13
Login
Manager
login API
AccountKit login API
Facebook login API
LoginManagerDelegate
save Token
tracking event
set User Info
dependency injection
15
模擬 login API ⾏行行為
protocol LoginAPIDelegate {
func loginToken(token:String?,callback:@escaping loginAPICompletion)
}
extension API:LoginAPIDelegate{
func loginToken(token:String?,callback:@escaping loginAPICompletion{
// call real API here
}
}
16
模擬 Facebook SDK response ⾏行行為
protocol FBLoginManagerDelegate {
var currentAccessToken:String? { get }
……
}
class FBLogInManager:FBLoginManagerDelegate {
var currentAccessToken:String? {
return FBSDKAccessToken.current()?.tokenString
}
……
}
17
class LoginManager{
static let shareInstance = LoginManager()
weak var delegate:LoginManagerDelegate?
private var loginAPI:LoginAPIDelegate
private let accountKit:AccountKitDelegate
private let fb:FBLoginManagerDelegate
init(api:LoginAPIDelegate = API.shareInstance,
ac:AccountKitDelegate = AKFAccountKit(responseType: .accessToken),
fblogin:FBLoginManagerDelegate = FBLogInManager()) {
self.loginAPI = api
self.accountKit = ac
self.fb = fblogin
}
}
Real Instance
Prepare stub module
19
class MockLoginAPI:LoginAPIDelegate {
var loginCallback:(LoginStatus,[String:Any]?, AccessToken?, NSError?)
func loginToken(token:String?,callback:@escaping loginAPICompletion {
callback(loginCallback.0,loginCallback.1,
loginCallback.2,loginCallback.3)
}
}
class MockFBLogin:FBLoginManagerDelegate {
var currentAccessToken: String?
}
20
func testFBloginSuccess() {
//1.prepare input
mockFBLogin.currentAccessToken = "123456789"
let loginJSON = loadData(fileName: "loginSuccess", type: "json")
mockLoginAPI.loginCallback = (.loginSuccess,loginJSON,authToken,nil)
//2.Compose Login Manager
self.loginManager = KTLoginManager(api: mockLoginAPI,
ac: mockAC,
fblogin: mockFBLogin)
self.loginManager.delegate = mockLoginVC
//3.trigger login
loginManager.loginFB()
//4.Verify output
……..
}
21
func testFBloginSuccess() {
……..
//4.Verify output
XCTAssertTrue(mockLoginVC.isLoginSuccess)
XCTAssertNotNil(mockAccessTokenManager.lastAccessToken)
XCTAssertEqual(KTUser.shareUser.info.memberShip, .premium)
XCTAssertNotNil(KTUser.shareUser.info.userID)
XCTAssertEqual(mockTracker.lastStatus, .loginSuccess)
XCTAssertEqual(mockTracker.lastLoginType, .facebook)
XCTAssertNotNil(mockTracker.lastLoginUserInfo.userID)
}
dependency injection 問題
inject 的 module 太多,不確定有沒有漏
23
class LoginManager{
static let shareInstance = LoginManager()
weak var delegate:LoginManagerDelegate?
private var loginAPI:LoginAPIDelegate
private let accountKit:AccountKitDelegate
private let fb:FBLoginManagerDelegate
init(api:LoginAPIDelegate = API.shareInstance,
ac:AccountKitDelegate = AKFAccountKit(responseType: .accessToken),
fblogin:FBLoginManagerDelegate = FBLogInManager()) {
self.loginAPI = api
self.accountKit = ac
self.fb = fblogin
}
}
Real Instance
24
struct LoginManagerInject{
var loginAPI:KTLoginAPIDelegate = KTAPI.shareInstance
var accountKit:KTAccountKitDelegate = AKFAccountKit(responseType: .accessTok
var fb:KTFBLoginManagerDelegate = FBLogInManager()
}
class LoginManager{
static let shareInstance = LoginManager()
weak var delegate:LoginManagerDelegate?
private let inject:LoginManagerInject
init(inject:LoginManagerInject = LoginManagerInject() ){
self.inject = inject
}
}
24
struct LoginManagerInject{
var loginAPI:KTLoginAPIDelegate = KTAPI.shareInstance
var accountKit:KTAccountKitDelegate = AKFAccountKit(responseType: .accessTok
var fb:KTFBLoginManagerDelegate = FBLogInManager()
}
class LoginManager{
static let shareInstance = LoginManager()
weak var delegate:LoginManagerDelegate?
private let inject:LoginManagerInject
init(inject:LoginManagerInject = LoginManagerInject() ){
self.inject = inject
}
}
self.loginAPI
24
struct LoginManagerInject{
var loginAPI:KTLoginAPIDelegate = KTAPI.shareInstance
var accountKit:KTAccountKitDelegate = AKFAccountKit(responseType: .accessTok
var fb:KTFBLoginManagerDelegate = FBLogInManager()
}
class LoginManager{
static let shareInstance = LoginManager()
weak var delegate:LoginManagerDelegate?
private let inject:LoginManagerInject
init(inject:LoginManagerInject = LoginManagerInject() ){
self.inject = inject
}
}
self.injec.loginAPIself.loginAPI
inject init function
26
User
UserInfo
UserSetting
User Info save in memory
Ex:User ID, Member Ship
User Setting save in NSUserDefault
Ex:IsShowXXPage
When UserInfo change , some UserSetting Variable also change
Requirement
AppDelegate
27
class UserInfo {
private var userSetting:UserSettingDelegate
init(userSetting:UserSettingDelegate = shareAppDelegate.user.setting){
self.userSetting = userSetting
}
}
User UserInfoAppDelegat
User UserSettingAppDelegate
28
class UserInfo {
private var userSetting:UserSettingDelegate
init(userSetting:UserSettingDelegate = shareAppDelegate.user.setting){
self.userSetting = userSetting
}
}
User UserInfoAppDelegat
User
UserSetting
AppDelegate
UserInfo
29
class UserInfo {
private var userSetting:UserSettingDelegate
init(userSetting:UserSettingDelegate = shareAppDelegate.user.setting){
self.userSetting = userSetting
}
}
User UserInfoAppDelegat
User
UserSetting
AppDelegate
UserInfo
User UserSettingAppDelegate
30
class UserInfo:{
private var injectFunc:()->UserSetting
private var setting:UserSetting {
return self.injectFunc()
}
init(injectFunc:@escaping ()->UserSetting =
{return shareDelegate.user.setting}){
self.injectFunc = injectFunc
}
}
30
class UserInfo:{
private var injectFunc:()->UserSetting
private var setting:UserSetting {
return self.injectFunc()
}
init(injectFunc:@escaping ()->UserSetting =
{return shareDelegate.user.setting}){
self.injectFunc = injectFunc
}
}
stored property
30
class UserInfo:{
private var injectFunc:()->UserSetting
private var setting:UserSetting {
return self.injectFunc()
}
init(injectFunc:@escaping ()->UserSetting =
{return shareDelegate.user.setting}){
self.injectFunc = injectFunc
}
}
stored property
computed property
30
class UserInfo:{
private var injectFunc:()->UserSetting
private var setting:UserSetting {
return self.injectFunc()
}
init(injectFunc:@escaping ()->UserSetting =
{return shareDelegate.user.setting}){
self.injectFunc = injectFunc
}
}
self.setting
stored property
computed property
31
殘酷的事實
花了了⼀一倍的時間寫 Feature,但是邏輯都混在⼀一起沒有拆 module,
要 refactor + 補 Unit Test 的時間,⼤大概就跟原本 Feature 的時間⼀一樣
32
循環
寫了了⼀一個 Feature,但是邏輯都混在⼀一起沒有拆 module
要補 Unit Test 好累喔,要 refactor 好危險
不敢動,不知道怎麼拆 module
有了了新 Request
寫了了⼀一個新Feature,但是邏輯都混在⼀一起沒有拆 module
…………….
33
今天開始寫 Unit Test
• 列列出 priority
• 絕對不可以錯的 module + 會⼤大量量 refuse 的 model
• 下次寫新 module 的時候先想 input output
• 做了了⼀一堆 feature ,User 可能會喜歡可能不會喜歡,

但是重要功能出 bug,那就死定了了

Refactor code to testable

  • 1.
    Refactor code totestable Hokila
 Cocoa heads Taipei 2017.11
  • 2.
  • 3.
  • 4.
    4 現實上的難關 實際上 不會寫 Input 無法控制 (API, 不是⾃自⼰己寫的 SDK) ViewController 超級無敵⼤大
  • 5.
    5 Benefit • 同樣的 code⽤用兩兩種思考⽅方式看過 • ⾃自然⽽而然學會切 module,或者合併商業邏輯 • ⼀一次跑完幾百個幾千的 check,令⼈人安⼼心 • ⽽而且很快
  • 6.
  • 7.
    7 LoginViewController login API AccountKit loginAPI Facebook login API save Token after login success tracking event set User Info
  • 8.
    8 問題 - dependency太多 login API,成功 or 失敗 facebook / AccountKit SDK ⾏行行為 是否有存下 AccessToken User 資料是不是該有的都有 (UserID , MemberShip) 該有的 tracking 是不是有送 login 時 login 後
  • 9.
  • 10.
    拆出 login manager 不知道在幹嘛的module 叫 manager 就對了了
  • 11.
    10 protocol LoginManagerDelegate:class{ func loginStart() funcloginSuccess(status:LoginStatus) func loginFail(json:[String:Any]?,error:NSError?) } class LoginManager{ static let shareInstance = LoginManager() weak var delegate:LoginManagerDelegate? func loginFB() func loginAccountKit() }
  • 12.
    11 class LoginController: BaseViewController{ functouchFBlogin(_ sender: AnyObject) { self.loginManager.loginFB() } func touchAClogin(_ sender: AnyObject){ self.loginManager.loginAccountKit() } } extension LoginController:LoginManagerDelegate{ func loginStart(){ //show indicator } func loginFail(json:[String:Any]?,error:NSError?){ //error handling} func loginSuccess(status:LoginStatus){ //success flow } }
  • 13.
    12 LoginViewController LoginManager login API AccountKitlogin API Facebook login API save Token after login success tracking event set User Info login start login fail handler login success handler
  • 14.
    12 LoginViewController LoginManager login API AccountKitlogin API Facebook login API save Token after login success tracking event set User Info login start login fail handler login success handler
  • 15.
    12 LoginViewController LoginManager login API AccountKitlogin API Facebook login API save Token after login success tracking event set User Info login start login fail handler login success handler
  • 16.
    13 Login Manager login API AccountKit loginAPI Facebook login API LoginManagerDelegate save Token tracking event set User Info
  • 17.
  • 18.
    15 模擬 login API⾏行行為 protocol LoginAPIDelegate { func loginToken(token:String?,callback:@escaping loginAPICompletion) } extension API:LoginAPIDelegate{ func loginToken(token:String?,callback:@escaping loginAPICompletion{ // call real API here } }
  • 19.
    16 模擬 Facebook SDKresponse ⾏行行為 protocol FBLoginManagerDelegate { var currentAccessToken:String? { get } …… } class FBLogInManager:FBLoginManagerDelegate { var currentAccessToken:String? { return FBSDKAccessToken.current()?.tokenString } …… }
  • 20.
    17 class LoginManager{ static letshareInstance = LoginManager() weak var delegate:LoginManagerDelegate? private var loginAPI:LoginAPIDelegate private let accountKit:AccountKitDelegate private let fb:FBLoginManagerDelegate init(api:LoginAPIDelegate = API.shareInstance, ac:AccountKitDelegate = AKFAccountKit(responseType: .accessToken), fblogin:FBLoginManagerDelegate = FBLogInManager()) { self.loginAPI = api self.accountKit = ac self.fb = fblogin } } Real Instance
  • 21.
  • 22.
    19 class MockLoginAPI:LoginAPIDelegate { varloginCallback:(LoginStatus,[String:Any]?, AccessToken?, NSError?) func loginToken(token:String?,callback:@escaping loginAPICompletion { callback(loginCallback.0,loginCallback.1, loginCallback.2,loginCallback.3) } } class MockFBLogin:FBLoginManagerDelegate { var currentAccessToken: String? }
  • 23.
    20 func testFBloginSuccess() { //1.prepareinput mockFBLogin.currentAccessToken = "123456789" let loginJSON = loadData(fileName: "loginSuccess", type: "json") mockLoginAPI.loginCallback = (.loginSuccess,loginJSON,authToken,nil) //2.Compose Login Manager self.loginManager = KTLoginManager(api: mockLoginAPI, ac: mockAC, fblogin: mockFBLogin) self.loginManager.delegate = mockLoginVC //3.trigger login loginManager.loginFB() //4.Verify output …….. }
  • 24.
    21 func testFBloginSuccess() { …….. //4.Verifyoutput XCTAssertTrue(mockLoginVC.isLoginSuccess) XCTAssertNotNil(mockAccessTokenManager.lastAccessToken) XCTAssertEqual(KTUser.shareUser.info.memberShip, .premium) XCTAssertNotNil(KTUser.shareUser.info.userID) XCTAssertEqual(mockTracker.lastStatus, .loginSuccess) XCTAssertEqual(mockTracker.lastLoginType, .facebook) XCTAssertNotNil(mockTracker.lastLoginUserInfo.userID) }
  • 25.
    dependency injection 問題 inject的 module 太多,不確定有沒有漏
  • 26.
    23 class LoginManager{ static letshareInstance = LoginManager() weak var delegate:LoginManagerDelegate? private var loginAPI:LoginAPIDelegate private let accountKit:AccountKitDelegate private let fb:FBLoginManagerDelegate init(api:LoginAPIDelegate = API.shareInstance, ac:AccountKitDelegate = AKFAccountKit(responseType: .accessToken), fblogin:FBLoginManagerDelegate = FBLogInManager()) { self.loginAPI = api self.accountKit = ac self.fb = fblogin } } Real Instance
  • 27.
    24 struct LoginManagerInject{ var loginAPI:KTLoginAPIDelegate= KTAPI.shareInstance var accountKit:KTAccountKitDelegate = AKFAccountKit(responseType: .accessTok var fb:KTFBLoginManagerDelegate = FBLogInManager() } class LoginManager{ static let shareInstance = LoginManager() weak var delegate:LoginManagerDelegate? private let inject:LoginManagerInject init(inject:LoginManagerInject = LoginManagerInject() ){ self.inject = inject } }
  • 28.
    24 struct LoginManagerInject{ var loginAPI:KTLoginAPIDelegate= KTAPI.shareInstance var accountKit:KTAccountKitDelegate = AKFAccountKit(responseType: .accessTok var fb:KTFBLoginManagerDelegate = FBLogInManager() } class LoginManager{ static let shareInstance = LoginManager() weak var delegate:LoginManagerDelegate? private let inject:LoginManagerInject init(inject:LoginManagerInject = LoginManagerInject() ){ self.inject = inject } } self.loginAPI
  • 29.
    24 struct LoginManagerInject{ var loginAPI:KTLoginAPIDelegate= KTAPI.shareInstance var accountKit:KTAccountKitDelegate = AKFAccountKit(responseType: .accessTok var fb:KTFBLoginManagerDelegate = FBLogInManager() } class LoginManager{ static let shareInstance = LoginManager() weak var delegate:LoginManagerDelegate? private let inject:LoginManagerInject init(inject:LoginManagerInject = LoginManagerInject() ){ self.inject = inject } } self.injec.loginAPIself.loginAPI
  • 30.
  • 31.
    26 User UserInfo UserSetting User Info savein memory Ex:User ID, Member Ship User Setting save in NSUserDefault Ex:IsShowXXPage When UserInfo change , some UserSetting Variable also change Requirement AppDelegate
  • 32.
    27 class UserInfo { privatevar userSetting:UserSettingDelegate init(userSetting:UserSettingDelegate = shareAppDelegate.user.setting){ self.userSetting = userSetting } } User UserInfoAppDelegat User UserSettingAppDelegate
  • 33.
    28 class UserInfo { privatevar userSetting:UserSettingDelegate init(userSetting:UserSettingDelegate = shareAppDelegate.user.setting){ self.userSetting = userSetting } } User UserInfoAppDelegat User UserSetting AppDelegate UserInfo
  • 34.
    29 class UserInfo { privatevar userSetting:UserSettingDelegate init(userSetting:UserSettingDelegate = shareAppDelegate.user.setting){ self.userSetting = userSetting } } User UserInfoAppDelegat User UserSetting AppDelegate UserInfo User UserSettingAppDelegate
  • 35.
    30 class UserInfo:{ private varinjectFunc:()->UserSetting private var setting:UserSetting { return self.injectFunc() } init(injectFunc:@escaping ()->UserSetting = {return shareDelegate.user.setting}){ self.injectFunc = injectFunc } }
  • 36.
    30 class UserInfo:{ private varinjectFunc:()->UserSetting private var setting:UserSetting { return self.injectFunc() } init(injectFunc:@escaping ()->UserSetting = {return shareDelegate.user.setting}){ self.injectFunc = injectFunc } } stored property
  • 37.
    30 class UserInfo:{ private varinjectFunc:()->UserSetting private var setting:UserSetting { return self.injectFunc() } init(injectFunc:@escaping ()->UserSetting = {return shareDelegate.user.setting}){ self.injectFunc = injectFunc } } stored property computed property
  • 38.
    30 class UserInfo:{ private varinjectFunc:()->UserSetting private var setting:UserSetting { return self.injectFunc() } init(injectFunc:@escaping ()->UserSetting = {return shareDelegate.user.setting}){ self.injectFunc = injectFunc } } self.setting stored property computed property
  • 39.
    31 殘酷的事實 花了了⼀一倍的時間寫 Feature,但是邏輯都混在⼀一起沒有拆 module, 要refactor + 補 Unit Test 的時間,⼤大概就跟原本 Feature 的時間⼀一樣
  • 40.
    32 循環 寫了了⼀一個 Feature,但是邏輯都混在⼀一起沒有拆 module 要補Unit Test 好累喔,要 refactor 好危險 不敢動,不知道怎麼拆 module 有了了新 Request 寫了了⼀一個新Feature,但是邏輯都混在⼀一起沒有拆 module …………….
  • 41.
    33 今天開始寫 Unit Test •列列出 priority • 絕對不可以錯的 module + 會⼤大量量 refuse 的 model • 下次寫新 module 的時候先想 input output • 做了了⼀一堆 feature ,User 可能會喜歡可能不會喜歡,
 但是重要功能出 bug,那就死定了了