iT邦幫忙

2023 iThome 鐵人賽

DAY 14
1
Mobile Development

在 iOS 專案上加上 Unit testing - 因為 You need testing系列 第 14

D14 - 在 iOS 專案加上測試-You need testing {台股小工具 app-加上日期}

  • 分享至 

  • xImage
  •  

在之前的實作中,我們並沒有加上成交日期,所以我們來模擬一個情境,追加 feature。

模擬情境:在開發過程中追加原來沒和你講的需求

先假設,我們一開始只收到股票代號、股票名稱、買進賣出、成交金額,的 spec,並希望能在這週完成 demo。不過,今天早上開個會議,然後說要加「成交日期」。

如何讓開發者有信心的加上追加的需求,並同時加上 Unit testing?

DatePicker 展開前

https://ithelp.ithome.com.tw/upload/images/20230925/20140622uQ1BGKizvN.png

DatePicker 展開後

https://ithelp.ithome.com.tw/upload/images/20230925/20140622fOleON8euA.png

在原來的下方補一個 DatePicker

struct StockTradingInputView: View {
    
    @State var stockID: String = ""
    @State var stockName: String = ""
    
    @State var tradingSideIndex: Int = 0
    
    var tradingSide: TradingSide {
        return TradingSide(rawValue: tradingSideIndex) ?? .buy
    }
    
    @State var tradingShares: String = ""
    @State var tradingAmount: String = ""
    
    @State var isShowingDatePicker = false
    @State var emptyCalendarString = ""
    
    @State var tradingDate: Date = .now
    
    private var dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        return formatter
    }()
    
    var body: some View {
        VStack {
            
            Text("股票交易紀錄")
                .font(.title2)
                .fontWeight(.semibold)
                .padding(.top, 10)
            
            HStack {
                Text("股票代號")
                    .padding(.horizontal)
                TextField("請輸入股票代號", text: $stockID)
                    .textFieldStyle(.roundedBorder)
                    .padding(.horizontal)
            }
            
            HStack {
                Text("股票名稱")
                    .padding(.horizontal)
                TextField("請輸入股票名稱", text: $stockName)
                    .textFieldStyle(.roundedBorder)
                    .padding(.horizontal)
            }
            
            Picker("買進賣出", selection: $tradingSideIndex) {
                Text("買進").tag(0)
                Text("賣出").tag(1)
            }
            .pickerStyle(.segmented)
            .padding()
            
            HStack {
                Text("成交股數")
                    .padding(.horizontal)
                TextField("請輸入成交股數", text: $tradingShares)
                    .textFieldStyle(.roundedBorder)
                    .padding(.horizontal)
            }
            
            HStack {
                Text("成交金額")
                    .padding(.horizontal)
                TextField("請輸入成交金額", text: $tradingAmount)
                    .textFieldStyle(.roundedBorder)
                    .padding(.horizontal)
            }
            
            ZStack {
                HStack {
                    Text("成交日期")
                        .padding(.horizontal)
                    
                    ZStack {
                        TextField("", text: $emptyCalendarString)
                            .disabled(true)
                            .textFieldStyle(.roundedBorder)
                        Button {
                            isShowingDatePicker.toggle()
                        } label: {
                            Text(tradingDate, formatter: dateFormatter)
                                .frame(minWidth: 200)
                        }
                    }
                    .padding(.horizontal)
                }
            }
            
            if isShowingDatePicker {
                
                DatePicker(
                    "Start Date",
                    selection: $tradingDate,
                    in: ...Date(),
                    displayedComponents: [.date]
                )
                .datePickerStyle(.graphical)
                
            }
            
            Spacer()
            
            HStack {
                
                Spacer()
                
                Button {
                    print("cancel did tap")
                } label: {
                    Text("取消")
                        .frame(minWidth: 140, minHeight: 40)
                        .border(.red)
                }
                
                Spacer()

                Button {
                    print("cancel did tap")
                } label: {
                    Text("新增")
                        .frame(minWidth: 140, minHeight: 40)
                        .border(.blue)
                }
                
                Spacer()
            }
            .padding()
        }
    }
}

交易日期規格

  • StockTradingRecord 追加屬性,表示日期,型別為 String,格式為 yyyy-mm-dd
  • 在紀錄輸入頁面,新增紀錄時預設為當下的時間日期
  • 使用者可以在輸入頁面上,更改這一筆交易成交時間,但如果不改,就用時當下的時間日期

step1: 修改 StockTradingRecord,追加屬性

struct StockTradingRecord {
    
    let stockID: String
    let stockName: String
    let tradingSide: TradingSide
    
    /// 成交股數
    let tradingShares: Int
    
    /// 成交金額
    let tradingAmount: Int
    
    /// 成交日期,格式為 yyyy-mm-dd
    let tradingDateStr: String
}

step2: 按下 Xcode 的 build,並開始祈禱

果然,跳出了一堆紅色 error。

https://ithelp.ithome.com.tw/upload/images/20230925/20140622bdY586JqNF.png

step3: 先將 StockRecordTests Unit testing 的 Error 修掉

將 StockRecord 加上 tradingDateStr 並補上相關 XCTAssert。屬性 tradingDateStr 追加後,再補上對應的 XCTAssertEqual

func testStockTradingRecordInit() {
        
        let model = StockTradingRecord(stockID: "1101",
                                       stockName: "台泥",
                                       tradingSide: .buy,
                                       tradingShares: 1000,
                                       tradingAmount: 35000,
                                       tradingDateStr: "2023-09-08")
        
        XCTAssertEqual(model.stockID, "1101")
        XCTAssertEqual(model.stockName, "台泥")
        XCTAssertEqual(model.tradingSide, .buy)
        XCTAssertEqual(model.tradingShares, 1000)
        XCTAssertEqual(model.tradingAmount, 35000)
        XCTAssertEqual(model.tradingDateStr, "2023-09-08")
    }

step4: 修正 StockRecordUtility 的實作,因為這個物件會碰到 StockRecord

https://ithelp.ithome.com.tw/upload/images/20230925/20140622D8A4cWvppZ.png

https://ithelp.ithome.com.tw/upload/images/20230925/201406223L8M5MjsB8.png

先使用 “”,不用擔心,我們只要一補上相關測試,測試就會逼我們寫到正確的實作。

return .success(StockTradingRecord(stockID: stockID,
                                           stockName: stockName,
                                           tradingSide: tradingSide,
                                           tradingShares: stockSharesInt,
                                           tradingAmount: amount, 
																					 tradingDateStr: ""))

開始寫 StockRecordUtilityTests 轉換 StockTradingRecord 的這一段

step1: 在 StockRecordUtilityTests 加上固定的時間

單元測試有所謂 F.I.R.S.T 原則

F: First 要快

I: Independent 或 Isolated,每個 Unit testing method 是獨立的,彼此不應該有關聯

R: Repeatable,當輸入條件一樣與實作一樣,就應該得到一樣的結果

S: self validating,不應有人為介入的狀況

T: Thorough 或 Timely,讓可以快速的進行 unit testing

下面這一段是 AI 寫的

  • Fast:Unit test 應該快速執行,不要花費太多時間或資源。
  • Independent:Unit test 應該獨立於其他測試,不要互相影響或依賴。
  • Repeatable:Unit test 應該可以在任何環境或情況下重複執行,並得到一致的結果。
  • Self-validating:Unit test 應該有明確的通過或失敗的標準,不要需要人工檢查或驗證。
  • Timely:Unit test 應該及時編寫,最好在開發程式碼之前或同時進行,以驅動設計和重構。

在這個股票交易紀錄中,雖然規格在寫入時間如下

  • 在紀錄輸入頁面,新增紀錄時預設為當下的時間日期
  • 使用者可以在輸入頁面上,更改這一筆交易成交時間,但如果不改,就用時當下的時間日期

但如果在 Unit testing 時塞入的值為 Date(),每次跑測試時,都是當下的時間。為了達到 Repeatable 的狀態,需要固定每次的測試輸入時間,這樣才能確保每個測項輸出。在 StockRecordUtilityTests 加上 timeInterval 的參數,並設定成 2023-09-05 05:09:55 的 unix time。只要使用這個 time,就能確保要確認的轉換後時間。

final class StockRecordUtilityTests: XCTestCase {

    override func setUpWithError() throws {
    }

    override func tearDownWithError() throws {
    }
    
		/// 2023-09-05 05:09:55
    private var timeInterval: TimeInterval {
        1693890595
    }
    /// 2023-09-05 05:09:55
    private var date: Date {
        Date(timeIntervalSince1970: timeInterval)
    }

step2: 在 getStockRecord 的接口上,加 tradingDate 參數

func getStockRecord(stockID: String,
                        stockName: String,
                        tradingSide: TradingSide,
                        stockShares: String,
                        stockCostPerShare: String,
                        tradingDate: Date) -> Result<StockTradingRecord, Error> {

step3: 跑 unit testing,會發現 unit testing 有六個 test case 要修正

https://ithelp.ithome.com.tw/upload/images/20230925/20140622htiLB6LQ5c.png

step4: 修正 Test Case,在每個 getStockRecord() 都要補上 tradingDate: date

let result = StockRecordUtility().getStockRecord(stockID: "0050", stockName: "元大50", tradingSide: .buy, stockShares: "1000", stockCostPerShare: "130", tradingDate: date)

step5: 修正 testStockRecordStockProperties,因為在當時的測項,並沒有測 dateStr,所以需在 case .success(let record): 之後,補上 dateStr 的 XCTAssertEqual。

補上後跑了 unit testing,就會找到你沒實作的地方

https://ithelp.ithome.com.tw/upload/images/20230925/20140622PxnSEvza0P.png

step6: 修正 StockRecordUtility

我們需要一個轉換 Date → String 的 func,在 StockRecordUtility 新增一個 func

private func getDateStr(from date: Date) -> String {
        
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyy-MM-dd"
        return dateFormatter.string(from: date)
    }

並在 getStockRecord 的 return .success 前加上這個 String

func getStockRecord(stockID: String,
                        stockName: String,
                        tradingSide: TradingSide,
                        stockShares: String,
                        stockCostPerShare: String,
                        tradingDate: Date) -> Result<StockTradingRecord, Error> {
        
        /// 不可以輸入空值
        if stockID.isEmpty ||
            stockName.isEmpty ||
            stockShares.isEmpty ||
            stockCostPerShare.isEmpty {
            return .failure(StockRecordInputError.noValue)
        }
        
        /// 需通過 stockShares 能轉換成 Int
        /// 需通過 stockCostPerShare 能轉換成 Int
        /// stockShares 需大於等於 0
        /// stockCostPerShare 需大於等於 0
        guard let stockSharesInt = Int(stockShares),
              let stockCostPerShareInt = Int(stockCostPerShare),
                stockSharesInt > 0,
              stockCostPerShareInt > 0 else {
            return .failure(StockRecordInputError.castingError)
        }
        
        /// amount 成交金額 是 成交股數 * 每股價價
        let amount = stockSharesInt * stockCostPerShareInt
				/// 將 Date 轉換成 String
        let tradingDateStr = getDateStr(from: tradingDate)
        
        return .success(StockTradingRecord(stockID: stockID,
                                           stockName: stockName,
                                           tradingSide: tradingSide,
                                           tradingShares: stockSharesInt,
                                           tradingAmount: amount,
                                           tradingDateStr: tradingDateStr))
    }

step7: 跑測試

目前的 unit testing 都通過了,對這一份擴充的需求,就更有信心

https://ithelp.ithome.com.tw/upload/images/20230925/20140622DapehHmPam.png

step8: 如果有必要的話,需進行重構

https://ithelp.ithome.com.tw/upload/images/20230925/20140622VMbe21sfOI.png

在下面這五個步驟中,當第一版的程式碼完成時,應當思考目前已通過測試的程式碼,是否有需要進行重構。重構的程式碼,仍然要通過下圖的第四個步驟寫的測試。

Untitled

SOLID 原則中的 S,Single Responsiblity Principle (SRP) 單一職責原則。

單一職責原則的主要思想是,一個類別(或模組、函數等程式碼結構)應該只有一個原因引起變更,或者說一個類別應該只有一個職責。換句話說,一個類別應該專注於一個特定的功能或工作,並且不應該有多個不相關的功能。

以下是單一職責原則的一些關鍵概念和好處:

  1. 高內聚性:當一個類別只負責一個職責時,它的內部成員和方法通常都與該職責高度相關。這種高內聚性使得程式碼更容易理解、維護和測試。
  2. 減少耦合度:當類別具有單一職責時,與其他類別之間的相互依賴通常會減少,從而減少了程式碼的耦合度。這意味著更容易修改一個類別,而不會對其他部分產生意外影響。
  3. 容易重用:具有單一職責的類別通常更容易在不同的上下文中重用,因為它們的功能更清晰且更獨立。
  4. 易於測試:單一職責原則使得單元測試更容易進行,因為您只需要測試一個特定功能的類別。
  5. 代碼的可讀性:程式碼變得更容易閱讀和理解,因為每個類別都有明確的目的,不需要瀏覽大量不相關的程式碼來理解它的功能。

讓 StockRecordUtility 處理 Date 轉換成 String 的任務,這樣是符合 SRP 原則嗎?關於這一點,我們下一篇進行展開。


上一篇
D13 - 在 iOS 專案加上測試-You need testing {台股小工具 app-股票紀錄轉換par2}
下一篇
D15 - 在 iOS 專案加上測試-You need testing {單一責則原則 - DateUtility}
系列文
在 iOS 專案上加上 Unit testing - 因為 You need testing32
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言