Sponsor Link
目次
環境&対象
- macOS Catalina 10.15.7
- Xcode 12.2
- iOS 14.2
以下の点を改良していきます。
- TODOItem 追加時に、タイトル等を指定できるようにする
- TODOItem に、完了フラグを追加する
- TODOItem 表示の改良(タイトル、詳細を表示)
リファクタリング
UITest では、要素を取得してから操作やテストを行う必要があるため、コードが煩雑になりがちです。
解決策の1つとして こちらで紹介されている Page Object というパターンがあります。
UITest でも、XML の XPath 等と同様に、要素取得が煩雑になりがちなので、同じパターンが使えます。
この Page Object を使って、UITest を読みやすくなるように リファクタリングしていきます。
TODOList のビューを Page Object 化
まずは、PageObject の Protocol を定義しました。
1 2 3 4 5 6 7 8 |
import XCTest protocol PageObject { // (1) var app: XCUIApplication { get } } |
- UI 要素は、XCUIApplication 経由で取得するので、各ページで 保持します
PageObject に準拠する形で、ビューごとに PageObject を作っていきます。考慮すべき点は以下です。
- UI操作を public なメソッドとして提供する
- ビュー内部の情報を不必要に外部に提供しない
- Page Object 内で、assertion (テスト) しない
現在のページ向けの PageObject (TODOListPageObject) を作りました。
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 |
import XCTest class TODOListPageObject: PageObject { var app: XCUIApplication init(_ app: XCUIApplication) { self.app = app } // (1) private var todoList: XCUIElement { app.tables["TODOList"]} var todoListRows:XCUIElementQuery { todoList.cells } private var navBar: XCUIElement { app.navigationBars["MyTODO"]} private var addButton: XCUIElement { navBar.buttons.element(boundBy: 1) } private var editButton: XCUIElement { navBar.buttons.element(boundBy: 0) } private var doneButton: XCUIElement { navBar.buttons.element(boundBy: 0) } // (2) func addButtonTap() -> TODOListPageObject { addButton.tap() return self } // (3) func removeItemWithEdit(index: Int) -> TODOListPageObject { editButton.tap() todoListRows.element(boundBy: index).buttons["Delete "].tap() todoListRows.element(boundBy: index).buttons["Delete"].tap() doneButton.tap() return self } // (4) func removeItemWithSwipe(index:Int) -> TODOListPageObject { todoListRows.element(boundBy: index).swipeLeft() todoListRows.element(boundBy: index).buttons["Delete"].tap() return self } } |
- UI 操作に必要なコンポーネントを、計算プロパティで定義しています
- 追加操作をメソッドにして提供しています
- Edit ボタン押下経由での要素削除メソッドです
- 左スワイプによる要素削除メソッドです
UITest のリファクタリング
この PageObject を使って、テストコードを追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
func test_addAndRemoveItem_afterLaunch_todoItemsShouldBeUpdated() throws { let app = XCUIApplication() app.launchArguments.append("TestWithInMemory") app.launch() let mainPage = TODOListPageObject(app) XCTAssertEqual(mainPage.todoListRows.count, 0) // (1) // add _ = mainPage.addButtonTap() XCTAssertEqual(mainPage.todoListRows.count, 1) _ = mainPage.addButtonTap() XCTAssertEqual(mainPage.todoListRows.count, 2) // (2) // remove _ = mainPage.removeItemWithEdit(index: 1) XCTAssertEqual(mainPage.todoListRows.count, 1) _ = mainPage.removeItemWithSwipe(index: 0) XCTAssertEqual(mainPage.todoListRows.count, 0) } |
- Add ボタンを押すことで、行が1づつ増えていくことをテスト
- Edit ボタン経由、行を左スワイプ それぞれで要素を削除し、行が減ることをテスト
随分テストが読みやすくなったと思います。
新規 TODOItem 作成時に、Title や Detail を指定可能にする
これまでは、固定文字列で TODOItem を作成していましたが、UI で指定できるようにします。
新しいビューを作り、TODOItem 作成時に、指定できるようにします。
Title/Detail 指定時のUITest
先ほどの UITest を変更していきます。
アイテム追加時に、Title と Detail を TextField 経由で入力できるようにします。
テストコードは以下のようになる予定です。
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 |
func test_addAndRemoveItem_afterLaunch_todoItemsShouldBeUpdated() throws { let app = XCUIApplication() app.launchArguments.append("TestWithInMemory") app.launch() let mainPage = TODOListPageObject(app) XCTAssertEqual(mainPage.todoListRows.count, 0) // (1) // add let newItem1Page = mainPage.addButtonTap() // (2) newItem1Page .typeTitle("Item1") .typeDetail("Item1Detail") .tapOk() XCTAssertEqual(mainPage.todoListRows.count, 1) let newItem2Page = mainPage.addButtonTap() newItem2Page.typeTitle("Item2") .typeDetail("Item2Detail") .tapOk() XCTAssertEqual(mainPage.todoListRows.count, 2) // remove _ = mainPage.removeItemWithEdit(index: 1) XCTAssertEqual(mainPage.todoListRows.count, 1) _ = mainPage.removeItemWithSwipe(index: 0) XCTAssertEqual(mainPage.todoListRows.count, 0) } |
- Add ボタンをタップすると、新規 TODOItem 作成ビューに遷移しますので、そのビューの PageObject が返されます
- Title や Detail を入力し、OK ボタンをおすテスト
新規 TODOItem 作成ビュー向け PageObject
上記のテスト向けに、新規 TODOItem 作成ビュー向けの PageObject を作ります。
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 |
class TODOListNewItemPageObject: PageObject { var app: XCUIApplication init(_ app: XCUIApplication) { self.app = app } // (1) private var titleField: XCUIElement { app.textFields["NewTODOItemTitle"] } private var detailField: XCUIElement { app.textFields["NewTODOItemDetail"] } private var okButton: XCUIElement { app.buttons["NewTODOItemOK"] } private var cancelButton: XCUIElement { app.buttons["NewTODOItemCancel"] } // (2) func typeTitle(_ title: String) -> TODOListNewItemPageObject{ titleField.tap() titleField.typeText(title) return self } func typeDetail(_ detail: String) -> TODOListNewItemPageObject{ detailField.tap() detailField.typeText(detail) return self } // (2) func tapOk() { okButton.tap() } func tapCancel() { cancelButton.tap() } } |
- ビューの要素をテスト用に取得します
- Title, Detail を TextField に入力するためのメソッドです
- OKボタン、CANCELボタンの入力メソッドです
また作成していないビューの PageObject を作成するのは不思議な感じですが、こうして コードにしてみると、新しいビューのキーとなる要素を明確に理解することができます。
新規 TODOItem 作成ビューの作成
View のテストをアップデートし、新しい View の PageObject も作成しましたので、View の実装を進めていきます。
新規 TODOItem 作成ビュー は、ContentView から開かれて、新規 TODOItem を作る or キャンセル で ContentView に戻ります。
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 |
struct NewTODOItemView: View { @EnvironmentObject var viewModel: MyTODOViewModel @Binding var showNewItemView: Bool @State private var itemTitle: String = "" @State private var itemDetail: String = "" var body: some View { VStack { Spacer() // (1) HStack { Text("Title : ") TextField("title", text: $itemTitle) .accessibility(identifier: "NewTODOItemTitle") } .padding() // (2) HStack { Text("Detail: ") TextField("detail", text: $itemDetail) .accessibility(identifier: "NewTODOItemDetail") } .padding() Spacer() // (3) HStack { Button(action: { withAnimation { _ = viewModel.createTODOItem(itemTitle, itemDetail) } showNewItemView.toggle() }, label: { Text("OK") }) .accessibility(identifier: "NewTODOItemOK") .padding() Button(action: { showNewItemView.toggle() }, label: { Text("Cancel") }) .accessibility(identifier: "NewTODOItemCancel") .padding() } .padding() Spacer() } .padding() } } |
- 新しい TODOItem のタイトル入力の TextField です
- 新しい TODOItem の詳細入力の TextField です
- OK ボタンと、Cancel ボタンです。OK 時には、新しい TODOItem を作成し、Cancel 時には、何もせずに、戻ります
続いて、ContentView を先ほど作成した NewTODOItemView を必要に応じて表示するように修正します。
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 |
struct ContentView: View { @EnvironmentObject var viewModel: MyTODOViewModel @State private var showNewItemView = false var body: some View { NavigationView { List { ForEach(viewModel.todoItems, id:\.self) { item in Text("\(item.id?.uuidString ?? "noID") \(item.title)") } .onDelete(perform: deleteItems) } .accessibility(identifier: "TODOList") .navigationTitle("MyTODO") .toolbar { #if os(iOS) ToolbarItem(placement: .navigationBarLeading) { EditButton() } #endif ToolbarItem(placement: .navigationBarTrailing) { Button(action: { showNewItemView.toggle() }) { Label("Add Item", systemImage: "plus") } } } } .sheet(isPresented: $showNewItemView) { NewTODOItemView(showNewItemView: $showNewItemView) } } private func deleteItems(offsets: IndexSet) { withAnimation { offsets.map { viewModel.todoItems[$0] }.forEach(viewModel.deleteTODOItem) } } } |
上記の実装で、先ほど作成したテストがパスするようになります。
ここまでで、以下ができました。
- テストコードのリファクタリング
- TODOItem 追加時に、タイトル等を指定できるようにする
終了フラグの追加
TODOItem なのに、終了しているかどうかのフラグを設定し忘れてました。
モデルテストのアップデート
終了しているかのフラグを isDone という名前のプロパティにするので、テストを更新します。
Initializer で値を指定することもできますが、デフォルトは false ということにします。
以前作成した Model に Item を作成するテストを修正します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
func test_createItem_newItem_withCorrectValues() { // prep model with coredata let model = TODOItemStore(true) XCTAssertEqual(model.items.count, 0) // create new item _ = model.createTODOItem("Item Title", "item detail", false) // <- new argument XCTAssertEqual(model.items.count, 1) // get item from model let item = model.items.first! // test : compare properties XCTAssertEqual(item.title, "Item Title") XCTAssertEqual(item.detail, "item detail") XCTAssertEqual(item.isDone, false) // <- NEW line } |
モデルをアップデート
テストができたので、モデルのコードもアップデートし、テストがパスするか確認します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
struct TODOItem : Identifiable, Hashable { static let logger = Logger(subsystem: "com.smalldesksoftware.MyTODO.TODOItem", category: "TODOItem") var id: UUID? = nil var title: String = "" var detail: String = "" // (1) var isDone: Bool = false // (2) init(_ title: String,_ detail: String = "",_ isDone: Bool = false) { self.id = UUID() self.title = title self.detail = detail // (3) self.isDone = isDone } } |
- 新しいプロパティ isDone を追加します
- initializer の引数にも、isDone を追加します
- initailizer で、isDone プロパティを初期化します
上記にプラスして、CoreData のモデルもアップデートする必要があります。
以下のように修正しました。

アプリは、直接 TODOItem を作成せずに、TODOItemStore 経由で作成します。ですので、TODOItemStore.createTODOItem メソッドのアップデートも必要となります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
extension TODOItemStore { // (1) // create Item , then put into coredata func createTODOItem(_ title: String, _ detail: String = "",_ isDone: Bool = false) -> TODOItem { let newItem = TODOItem(title, detail, isDone) let description = NSEntityDescription.entity(forEntityName: "CDTODOItem", in: container.viewContext)! let newCDItem = CDTODOItem(entity: description, insertInto: container.viewContext) newCDItem.id = newItem.id newCDItem.title = newItem.title newCDItem.detail = newItem.detail // (2) newCDItem.isDone = newItem.isDone save() return newItem } |
- 引数で、isDone を設定できるようにしました。
- TODOItem と同じ情報を CoreData の Entity にも設定します
CoreData の Entity から TODOItem を作る initializer のアップデートも忘れずに行います。
1 2 3 4 5 6 7 8 9 10 11 |
// MARK: CoreData dependent part extension TODOItem { init(_ cdItem: CDTODOItem) { self.id = cdItem.id self.title = cdItem.title ?? "" self.detail = cdItem.detail ?? "" self.isDone = cdItem.isDone } } |
上記の変更を入れることで、Model_Tests がパスすることが確認できます。
アプリは、ViewModel 経由で操作することになります。
ですが、どのような API が必要になるかまだ分からないので、必要になった時に作ることにします。
- TODOItem 追加時に、タイトル等を指定できるようにする
- TODOItem に、完了フラグを追加する
- TODOItem 表示の改良(タイトル、詳細を表示)
上記の3つのうち、最初の2つができたので、残りの1つに着手します。
現在の表示は、あくまで 確認用なので、きちんと 作っていきます。
TODOItem の表示改善
TODOItem 用 PageObject の作成
テストから着手していきたいのですが、TODOItem の表示は何度か修正が入る気がするので、TODOItem を表す Page Object を作っておきます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
class TODOItemPageObject: PageObject { var app: XCUIApplication var cell: XCUIElement init(_ app: XCUIApplication, cell: XCUIElement) { self.app = app self.cell = cell } private var titleText: XCUIElement { cell.staticTexts["TODOItemTitleText"] } private var detailText: XCUIElement { cell.staticTexts["TODOItemDetailText"] } private var isDoneImage: XCUIElement { cell.images["TODOItemIsDoneImage"] } func toggleIsDone() -> TODOItemPageObject { isDoneImage.tap() return self } var itemTitle: String { titleText.label } var itemDetail: String { detailText.label } var itemIsDoneImageTitle: String { isDoneImage.label } } |
TODOItem は、List 内の行として複数表示されるので、List の行を表す cell から表示要素を取得するようにします。
この TODOItemPageObject を使って、メインビューでの表示の正しさを確認するテストを作成します。
TODOListView テストの修正
入力として指定した String が表示されていることをテストします。isDone フラグは、作成時には false であるため、そのためのイメージ名が 指定通りかをテストします。
イメージをタップすると、isDone フラグを toggle できるとして、その動作により、イメージ名が変更されるかをテストします。
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 |
func test_addOneElement_withSpecifiedData_allDataShouldBeDisplayedCorrectly() throws { let app = XCUIApplication() app.launchArguments.append("TestWithInMemory") app.launch() let mainPage = TODOListPageObject(app) XCTAssertEqual(mainPage.todoListRows.count, 0) // create not-done todoitem let newItemPage = mainPage.addButtonTap() newItemPage .typeTitle("TypedItemTitle") .typeDetail("TypedItemDetail") .tapOk() let cellPage = mainPage.itemPageObjectAtIndex(index: 0) XCTAssertEqual(cellPage.itemTitle, "TypedItemTitle") XCTAssertEqual(cellPage.itemDetail, "TypedItemDetail") XCTAssertEqual(cellPage.itemIsDoneImageTitle, "square") _ = cellPage.toggleIsDone() XCTAssertEqual(cellPage.itemIsDoneImageTitle, "checkmark.square") } |
View の実装
テストができましたので、View を実装していきます。
TODOItem 用に View を作成して表示するようにします。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
struct TODOItemView: View { let todoItem: TODOItem var body: some View { HStack { Image(systemName: todoItem.isDone ? "checkmark.square" : "square") .accessibility(identifier: "TODOItemIsDoneImage") VStack { Text(todoItem.title) .font(.largeTitle) .accessibility(identifier: "TODOItemTitleText") Text(todoItem.detail) .font(.caption) .accessibility(identifier: "TODOItemDetailText") } } } } |
こんな感じになりました。



先に作ったテストを実行すると、パスすることも確認できました。
なんとなく TODO アプリっぽくなってきました。
次回以降で、以下を実装していきます。
- チェックされた要素は非表示にする
- メインビューリストで、「未実行 TODOItem のみ」「全ての TODOItem」を選択表示できるようにする
- 既存の TODOItem を編集する
説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。
Sponsor Link