Sponsor Link
環境&対象
- macOS Catalina 10.15.7
- Xcode 12.2
- iOS 14.2
前回までで、以下のような TODO アプリができました。

今回は、以下を実装していきます。
- チェックされた要素は非表示にする
- メインビューリストで、「未実行 TODOItem」「実行済み TODOItem」を選択表示できるようにする
リスト表示対象を isDone == false に限定する
まずは、テストから作成していきます。
テストの作成
3つの要素を作成し、1、2番目の要素にチェックを入れて、リストが適切に更新されるかをテストします。
func test_filterTodoItemsOnlyUndone_addAndCheck_todoItemsShouldBeUpdated() throws {
let app = XCUIApplication()
app.launchArguments.append("TestWithInMemory")
app.launch()
let mainPage = TODOListPageObject(app)
XCTAssertEqual(mainPage.todoListRows.count, 0)
// (1)
// add
mainPage.addButtonTap().typeTitle("Item0").typeDetail("Item0Detail").tapOk()
mainPage.addButtonTap().typeTitle("Item1").typeDetail("Item1Detail").tapOk()
mainPage.addButtonTap().typeTitle("Item2").typeDetail("Item2Detail").tapOk()
XCTAssertEqual(mainPage.todoListRows.count, 3)
// (2)
_ = mainPage.itemPageObjectAtIndex(index: 0).toggleIsDone()
XCTAssertEqual(mainPage.todoListRows.count, 2)
_ = mainPage.itemPageObjectAtIndex(index: 1).toggleIsDone()
XCTAssertEqual(mainPage.todoListRows.count, 1)
}
- 先に作成した PageObject を使うと、UI 経由の要素作成もこんなに簡単に書けます
- 要素をタップし、isDone フラグを 設定し、表示リストが更新されることをテストしてます
現在は、すべての TODOItem をリスト表示しているので、上記のテストは、failed になります。
Model の修正
現在の List に表示される要素は、TODOItemStore.items が返す要素です。
全ての要素を取得する方法は今後も必要ですので、指定されたフィルターを適用した要素を返すメソッドを作ることにします。
デフォルトは、フィルターなしです。
struct TODOItemStore {
// .. snip ..
func filteredItems(_ predicate: NSPredicate? = nil) -> [TODOItem] {
var items:[TODOItem] = []
let request:NSFetchRequest = CDTODOItem.fetchRequest()
if let predicate = predicate {
request.predicate = predicate
}
do {
items = try container.viewContext.fetch(request).map(TODOItem.init)
} catch {
TODOItemStore.logger.error("error in fetching data from coredata")
}
return items
}
}
これまでは、TODOItemStore.items を使って、ViewModel から View に渡すデータを保存していましたが、切り替える必要があります。
具体的には、MyTODOViewModel 中で、要素を修正したときに、”todoItems = todoItemStore.items” としていたところを、” todoItems = todoItemStore.filteredItems(NSPredicate(format: “isDone == false”)) “とする必要があります。
これまでのテストを確認すると ほとんどパスするのですが、以下のテストで fail することに気づきます。
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")
}
TODOItem の各プロパティが正しく表示されているかを確認するテストに、TODOItem をタップすることで、チェックマークが入るか確認するテストも入れていたのですが、チェックされると非表示になるので、fail するようになったということです。
別のテストでチェックマークを確認することにして、このテストからは、削除します。
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")
}
# 削除部分をわかりやすくするためにコメントアウトしていますが、削除してしまって問題ありません。
これで、TODOItem はチェックされると非表示になり、テストも全てパスするようになりました。
ここまでで作ったアプリは、以下のような動作をします。
リストに表示する TODOItem を選択可能に
次に、以下を実装します。
- メインビューリストで、「未実行 TODOItem」「実行済み TODOItem」を選択表示できるようにする
現在の仕様では、TODOItem にチェックをいれてしまうと、それ以降 表示することができなくなってしまいます。
実施済み TODOItem と 未実施 TODOItem のどちらを表示するかを設定できるようにして、チェックを入れた後にも表示する方法を作ります。
実装の方針としては、ViewModel に、どちらを表示するか表すフラグを保持することにします。
UI 的には、NavigationBar に、ボタンを配置して、切り替えられるようにします。
テストの作成 (Model テスト)
Model, View のそれぞれでテストすることにします。
まずは、テストを書きます。
モデルに要素を作成し、チェックを操作して、返されるリストが正しいことをテストします。
func test_checkFilteredList_createAndCheck_listShouldNotContainCheckedItem() throws {
let model = TODOItemStore(true)
XCTAssertEqual(model.items.count, 0)
_ = model.createTODOItem("item0", "item0 detail")
_ = model.createTODOItem("item1", "item1 detail")
_ = model.createTODOItem("item2", "item2 detail")
let predicate = NSPredicate(format: "isDone == false")
XCTAssertEqual(model.filteredItems(nil).count, 3)
XCTAssertEqual(model.filteredItems(predicate).count, 3)
model.toggleTODOItem(model.items[1])
XCTAssertEqual(model.filteredItems(nil).count, 3)
XCTAssertEqual(model.filteredItems(predicate).count, 2)
model.toggleTODOItem(model.items[2])
XCTAssertEqual(model.filteredItems(nil).count, 3)
XCTAssertEqual(model.filteredItems(predicate).count, 1)
model.toggleTODOItem(model.items[1])
XCTAssertEqual(model.filteredItems(nil).count, 3)
XCTAssertEqual(model.filteredItems(predicate).count, 2)
}
モデルのテストは、これまでのコードでパスします。
次に、View のテストを作成します。
テスト作成 (View, ViewModel)
アプリ設計として、ViewModel が 何を表示するかのフラグを持ち、そのフラグに沿う NSPredicate を提供します。View からは、ViewModel のフラグを操作し、Model は ViewModel の提供する NSPredicate を使ってフィルターすることを想定します。
動作としては、View の切り替えボタンが押された時に、リストの表示が切り替わるということになります。
View のテストとしては、ボタン切り替えで、その時に表示されるべき要素が表示されるかをテストします。
func test_filterTodoItems_toggleFilter_todoItemsShouldBeUpdated() throws {
let app = XCUIApplication()
app.launchArguments.append("TestWithInMemory")
app.launch()
let mainPage = TODOListPageObject(app)
XCTAssertEqual(mainPage.todoListRows.count, 0)
// add
// (1)
sleep(1)
// (2)
mainPage.addButtonTap().typeTitle("Item0").typeDetail("Item0Detail").tapOk()
// (3)
_ = mainPage.itemPageObjectAtIndex(index: 0).toggleIsDone()
// (4)
XCTAssertEqual(mainPage.todoListRows.count, 0)
// (5)
mainPage.toggleFilter()
XCTAssertEqual(mainPage.todoListRows.count, 1)
// (6)
mainPage.toggleFilter()
XCTAssertEqual(mainPage.todoListRows.count, 0)
}
- UITest の動作が安定しなかったので、1秒待つようにしました
- テスト用の要素を作成しています
- 要素のチェックマークをつけています
- リストの行が 非表示になっていることをテスト
- リストの表示を切り替えると、1行表示されることをテスト
- 再度 表示を切り替えると、非表示になることをテスト
テストコードがコンパイルできないので、TODOListPageObject に toggle するボタンを追加します。
class TODOListPageObject: PageObject {
// .. snip ..
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) }
// (1)
private var toggleButton: XCUIElement { navBar.buttons.element(boundBy: 2)}
// .. snip ..
// (2)
func toggleFilter() -> TODOListPageObject {
toggleButton.tap()
return self
}
}
- NavigationBar の一番右に追加されると仮定して、Toggle ボタンを取得できるようにしました
- ボタンを使って、filter を toggle する操作をメソッドとして作りました
テストコードのコンパイルはできるようになりました。
ViewModel の実装
先ほど想定した設計「ViewModel が 何を表示するかのフラグを持ち、そのフラグに沿う NSPredicate を提供する。View からは、ViewModel のフラグを操作し、Model は ViewModel の提供する NSPredicate を使ってフィルターする」を実装していきます。
class MyTODOViewModel : ObservableObject {
var todoItemStore: TODOItemStore
@Published var todoItems:[TODOItem] = []
// (1)
@Published var showUndone:Bool = true
// (2)
var predicate: NSPredicate {
if showUndone {
return NSPredicate(format: "isDone == false")
}
return NSPredicate(format: "isDone == true")
}
// .. snip ..
// (3)
func toggleDisplay() {
showUndone.toggle()
// (4)
self.todoItems = self.todoItemStore.filteredItems(predicate)
}
}
- undone な TODOItem を表示するかのフラグ
- フラグに応じて、NSPredicate を返します
- リストの表示を切り替えるメソッド。View から呼ばれる予定
- 表示に変更が発生する時はこのように、self.todoItems を更新します
View の実装
View のテストの fail の理由は、ボタンが未配置のためです。リストの表示を切り替えるボタンをつけます。
struct ContentView: View {
@EnvironmentObject var viewModel: MyTODOViewModel
@State private var showNewItemView = false
var body: some View {
NavigationView {
List {
// .. snip ..
}
.accessibility(identifier: "TODOList")
.navigationTitle("MyTODO")
.toolbar {
// .. snip ..
ToolbarItem(placement: .navigationBarTrailing) {
HStack {
Button(action: {
showNewItemView.toggle()
}) {
Label("Add Item", systemImage: "plus")
}
// (1)
Button(action: {
viewModel.toggleDisplay()
}) {
// (2)
Label("Toggle", systemImage:viewModel.showUndone ? "checkmark.square" : "square")
}
}
}
}
}
// .. snip ..
- 新規追加ボタンの右側に切り替えボタンを追加します
- どちらを表示しているモードかに応じて表示するアイコンを切り替えます
先ほどの UITest を実行するとパスすることが確認できます。
次回は、以下を予定してます。
- リファクタリング
- 既存の TODOItem を編集できるようにする
説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。
Sponsor Link