在之前的實作中,我們並沒有加上成交日期,所以我們來模擬一個情境,追加 feature。
先假設,我們一開始只收到股票代號、股票名稱、買進賣出、成交金額,的 spec,並希望能在這週完成 demo。不過,今天早上開個會議,然後說要加「成交日期」。
如何讓開發者有信心的加上追加的需求,並同時加上 Unit testing?
DatePicker 展開前
DatePicker 展開後
在原來的下方補一個 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()
}
}
}
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。
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
先使用 “”,不用擔心,我們只要一補上相關測試,測試就會逼我們寫到正確的實作。
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 寫的
在這個股票交易紀錄中,雖然規格在寫入時間如下
但如果在 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 要修正
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,就會找到你沒實作的地方
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 都通過了,對這一份擴充的需求,就更有信心
step8: 如果有必要的話,需進行重構
在下面這五個步驟中,當第一版的程式碼完成時,應當思考目前已通過測試的程式碼,是否有需要進行重構。重構的程式碼,仍然要通過下圖的第四個步驟寫的測試。
SOLID 原則中的 S,Single Responsiblity Principle (SRP) 單一職責原則。
單一職責原則的主要思想是,一個類別(或模組、函數等程式碼結構)應該只有一個原因引起變更,或者說一個類別應該只有一個職責。換句話說,一個類別應該專注於一個特定的功能或工作,並且不應該有多個不相關的功能。
以下是單一職責原則的一些關鍵概念和好處:
讓 StockRecordUtility 處理 Date 轉換成 String 的任務,這樣是符合 SRP 原則嗎?關於這一點,我們下一篇進行展開。