はじめに
こちらは個人開発アプリができるまで by am10 Advent Calendar 2024の 21 日目の記事です。
21 日目は単体テストについてです。
以下の記事を参考に Swift Testing でテストを作成していきます。

公式ドキュメントは下記です。

やること
今回テストするのは Models の部分になります。
全部書くのは大変なので必要そうな下記のみでいいでしょう。
- ExpenseLogAPI(HTTP リクエストを生成するやつ)
 - Database(日付や項目で家計簿データをフィルターするやつ)
 - ReceiptDataFormatter(読み取ったレシートの文字列を解析するやつ)
 
上記がこのアプリのメイン機能部分になるのでここが動けばまあ問題ないでしょう。
ExpenseLogAPI のテスト
ExpenseLogAPITests.swift ファイルを作成して下記のように実装します。
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43  | 
						import Testing @testable import ExpenseLog import Foundation struct ExpenseLogAPITests {     @Test func fetch() {         let year = "2024"         let month = "12"         let api = ExpenseLogAPI.fetch(year: year, month: month)         #expect(api.httpMethod == .get)         #expect(api.httpBody == nil)         #expect(api.timeout == APIDefaultValue.timeout)         #expect(api.accept == APIDefaultValue.accept)         #expect(api.contentType == APIDefaultValue.contentType)         let queryItems = api.queryItems         #expect(queryItems.count == 2)         #expect(queryItems["year"] as? String == year)         #expect(queryItems["month"] as? String == month)     }     @Test func register() {         let item = ResponseItem(id: "1", date: Date(), name: "name", price: 100, category: 1, categorySub: 1)         let api = ExpenseLogAPI.register(items: [item])         #expect(api.httpMethod == .post)         #expect(api.timeout == APIDefaultValue.timeout)         #expect(api.accept == APIDefaultValue.accept)         #expect(api.contentType == APIDefaultValue.contentType)         #expect(api.queryItems.isEmpty)         let parameters = try? JSONSerialization.jsonObject(with: api.httpBody!, options: []) as? [[String: Any]]         #expect(parameters?.count == 1)         let parameter = parameters?.first         #expect(parameter?.count == 6)         #expect(parameter?["id"] as? String == item.id)         #expect(parameter?["date"] as? String == api.dateStrategyFormatter.string(from: item.date))         #expect(parameter?["name"] as? String == item.name)         #expect(parameter?["price"] as? Int == item.price)         #expect(parameter?["category"] as? Int == item.category)         #expect(parameter?["categorySub"] as? Int == item.categorySub)     } }  | 
					
ここでは家計簿データの取得・登録処理 API の値が想定通り設定されていることを確認しています。
Database のテスト
DatabaseTests.swift ファイルを作成して下記のように実装します。
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81  | 
						import Testing @testable import ExpenseLog import SwiftData import Foundation struct DatabaseTests {     @MainActor @Test func fetchYear() {         let config = ModelConfiguration(isStoredInMemoryOnly: true)         let container = try! ModelContainer(for: Item.self, configurations: config)         let context = container.mainContext         let calendar = Calendar(identifier: .gregorian)         context.addItems([             .init(id: "0", date: DateComponents(calendar: calendar, year: 2023, month: 12, day: 31).date!, name: "", price: 0, category: 0, categorySub: nil),             .init(id: "1", date: DateComponents(calendar: calendar, year: 2024, month: 1, day: 1).date!, name: "", price: 0, category: 0, categorySub: nil),             .init(id: "2", date: DateComponents(calendar: calendar, year: 2024, month: 6, day: 15).date!, name: "", price: 0, category: 0, categorySub: nil),             .init(id: "3", date: DateComponents(calendar: calendar, year: 2025, month: 1, day: 1).date!, name: "", price: 0, category: 0, categorySub: nil)         ])         let result = context.fetchItems(year: "2024", category: nil)         #expect(result.count == 2)     }     @MainActor @Test func fetchYearAndCategory() {         let config = ModelConfiguration(isStoredInMemoryOnly: true)         let container = try! ModelContainer(for: Item.self, configurations: config)         let context = container.mainContext         let calendar = Calendar(identifier: .gregorian)         context.addItems([             .init(id: "0", date: DateComponents(calendar: calendar, year: 2023, month: 12, day: 31).date!, name: "", price: 0, category: 0, categorySub: nil),             .init(id: "1", date: DateComponents(calendar: calendar, year: 2024, month: 1, day: 1).date!, name: "", price: 0, category: 0, categorySub: nil),             .init(id: "2", date: DateComponents(calendar: calendar, year: 2024, month: 6, day: 15).date!, name: "", price: 0, category: 1, categorySub: nil),             .init(id: "3", date: DateComponents(calendar: calendar, year: 2024, month: 7, day: 15).date!, name: "", price: 0, category: 0, categorySub: 0),             .init(id: "4", date: DateComponents(calendar: calendar, year: 2024, month: 8, day: 15).date!, name: "", price: 0, category: 0, categorySub: 1),             .init(id: "5", date: DateComponents(calendar: calendar, year: 2025, month: 1, day: 1).date!, name: "", price: 0, category: 0, categorySub: 0)         ])         let result = context.fetchItems(year: "2024", category: .food(nil))         #expect(result.count == 3)     }     @MainActor @Test func fetchMonth() {         let config = ModelConfiguration(isStoredInMemoryOnly: true)         let container = try! ModelContainer(for: Item.self, configurations: config)         let context = container.mainContext         let calendar = Calendar(identifier: .gregorian)         context.addItems([             .init(id: "0", date: DateComponents(calendar: calendar, year: 2023, month: 12, day: 31).date!, name: "", price: 0, category: 0, categorySub: nil),             .init(id: "1", date: DateComponents(calendar: calendar, year: 2024, month: 1, day: 1).date!, name: "", price: 0, category: 0, categorySub: nil),             .init(id: "2", date: DateComponents(calendar: calendar, year: 2024, month: 6, day: 15).date!, name: "", price: 0, category: 1, categorySub: nil),             .init(id: "3", date: DateComponents(calendar: calendar, year: 2024, month: 7, day: 15).date!, name: "", price: 0, category: 0, categorySub: 0),             .init(id: "4", date: DateComponents(calendar: calendar, year: 2024, month: 8, day: 15).date!, name: "", price: 0, category: 0, categorySub: 1),             .init(id: "5", date: DateComponents(calendar: calendar, year: 2025, month: 1, day: 1).date!, name: "", price: 0, category: 0, categorySub: 0),             .init(id: "6", date: DateComponents(calendar: calendar, year: 2025, month: 2, day: 1).date!, name: "", price: 0, category: 0, categorySub: nil),             .init(id: "7", date: DateComponents(calendar: calendar, year: 2025, month: 2, day: 15).date!, name: "", price: 0, category: 0, categorySub: nil),             .init(id: "8", date: DateComponents(calendar: calendar, year: 2025, month: 2, day: 28).date!, name: "", price: 0, category: 0, categorySub: nil),         ])         let result = context.fetchItems(year: "2025", month: "2", category: nil)         #expect(result.count == 3)     }     @MainActor @Test func fetchMonthAndCategory() {         let config = ModelConfiguration(isStoredInMemoryOnly: true)         let container = try! ModelContainer(for: Item.self, configurations: config)         let context = container.mainContext         let calendar = Calendar(identifier: .gregorian)         context.addItems([             .init(id: "0", date: DateComponents(calendar: calendar, year: 2023, month: 12, day: 31).date!, name: "", price: 0, category: 0, categorySub: nil),             .init(id: "1", date: DateComponents(calendar: calendar, year: 2024, month: 1, day: 1).date!, name: "", price: 0, category: 0, categorySub: nil),             .init(id: "2", date: DateComponents(calendar: calendar, year: 2024, month: 6, day: 15).date!, name: "", price: 0, category: 1, categorySub: nil),             .init(id: "3", date: DateComponents(calendar: calendar, year: 2024, month: 7, day: 15).date!, name: "", price: 0, category: 0, categorySub: 0),             .init(id: "4", date: DateComponents(calendar: calendar, year: 2024, month: 8, day: 15).date!, name: "", price: 0, category: 0, categorySub: 1),             .init(id: "5", date: DateComponents(calendar: calendar, year: 2025, month: 1, day: 1).date!, name: "", price: 0, category: 0, categorySub: 0),             .init(id: "6", date: DateComponents(calendar: calendar, year: 2025, month: 2, day: 1).date!, name: "", price: 0, category: 0, categorySub: nil),             .init(id: "7", date: DateComponents(calendar: calendar, year: 2025, month: 2, day: 8).date!, name: "", price: 0, category: 1, categorySub: nil),             .init(id: "8", date: DateComponents(calendar: calendar, year: 2025, month: 2, day: 12).date!, name: "", price: 0, category: 1, categorySub: 0),             .init(id: "9", date: DateComponents(calendar: calendar, year: 2025, month: 2, day: 15).date!, name: "", price: 0, category: 0, categorySub: 1),             .init(id: "10", date: DateComponents(calendar: calendar, year: 2025, month: 2, day: 28).date!, name: "", price: 0, category: 0, categorySub: 2),         ])         let result = context.fetchItems(year: "2025", month: "2", category: .food(nil))         #expect(result.count == 3)     } }  | 
					
ここでは年画面・月画面で表示するデータの抽出処理に問題ないか確認しています。失敗しやすそうな 2 月のデータを重点的にみています。
ReceiptDataFormatter のテスト
ReceiptDataFormatterTests.swift ファイルを作成して下記のように実装します。
| 
					 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43  | 
						import Testing @testable import ExpenseLog struct ReceiptDataFormatterTests {     @Test func formatForMandai() {         let formatter = ReceiptDataFormatter()         let name1 = "hoge"         let name2 = "fuga"         let price1 = 123         let price2 = 12345         let result = formatter.format([             "ソ#12 \(name1) ¥\(price1)",             "ソ#10 \(name2) ¥12,345",         ])         #expect(result.count == 2)         #expect(result[0].name == name1)         #expect(result[0].price == price1)         #expect(result[1].name == name2)         #expect(result[1].price == price2)     }     @Test func formatForKohnan() {         let formatter = ReceiptDataFormatter()         let name1 = "hoge"         let name2 = "fuga"         let price1 = 123         let price2 = 12345         let result = formatter.format([             "#015 JAN1234567890123",             name1,             "\(price1)内",             "#012 JAN1231231290123",             name2,             "12,345内",         ])         #expect(result.count == 2)         #expect(result[0].name == name1)         #expect(result[0].price == price1)         #expect(result[1].name == name2)         #expect(result[1].price == price2)     } }  | 
					
ここでは 2 パターンの形式のレシートデータから品名と金額が取得できることを確認しています。
コードカバレッジ確認
最後に cmd + U でテストを起動しコードカバレッジを確認します。公式ドキュメントに見方が書いてあります。

左の Navigator の一番右の「Show the Report navigator」をクリックすると見られます。
31%!!!

こちらの記事を見ると下記のように書いてあります。
Although there is no “ideal code coverage number,” at Google we offer the general guidelines of 60% as “acceptable”, 75% as “commendable” and 90% as “exemplary.”
60% が許容範囲のようですがまあ今回はいいでしょう。
おわりに
テストもばっちりなのでいよいよリリースが見えてきました!もうアップデートもこわくないです!!
明日はストア用のスクショを作成します。
  
  
  
  
コメント