[SwiftUI][CoreData] SwiftUI と MVVM で始める CoreData 入門 (その6:View の改良)

SwiftUI と CoreData を組み合わせたアプリの作り方を説明します。

環境&対象

以下の環境で動作確認を行なっています。

  • 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 を定義しました。

TestPage protocol

import XCTest

protocol PageObject {
    // (1)
    var app: XCUIApplication { get }
}
コード解説
  1. UI 要素は、XCUIApplication 経由で取得するので、各ページで 保持します

PageObject に準拠する形で、ビューごとに PageObject を作っていきます。考慮すべき点は以下です。

  • UI操作を public なメソッドとして提供する
  • ビュー内部の情報を不必要に外部に提供しない
  • Page Object 内で、assertion (テスト) しない

現在のページ向けの PageObject (TODOListPageObject) を作りました。

TODOListPageObject

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
    }
}
コード解説
  1. UI 操作に必要なコンポーネントを、計算プロパティで定義しています
  2. 追加操作をメソッドにして提供しています
  3. Edit ボタン押下経由での要素削除メソッドです
  4. 左スワイプによる要素削除メソッドです

UITest のリファクタリング

この PageObject を使って、テストコードを追加します。

test_addAndRemoveItem_afterLaunch_todoItemsShouldBeUpdated

    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)
    }
コード解説
  1. Add ボタンを押すことで、行が1づつ増えていくことをテスト
  2. Edit ボタン経由、行を左スワイプ それぞれで要素を削除し、行が減ることをテスト

随分テストが読みやすくなったと思います。

新規 TODOItem 作成時に、Title や Detail を指定可能にする

これまでは、固定文字列で TODOItem を作成していましたが、UI で指定できるようにします。

新しいビューを作り、TODOItem 作成時に、指定できるようにします。

Title/Detail 指定時のUITest

先ほどの UITest を変更していきます。

アイテム追加時に、Title と Detail を TextField 経由で入力できるようにします。

テストコードは以下のようになる予定です。

test_addAndRemoveItem_afterLaunch_todoItemsShouldBeUpdated(updated)

    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)
    }
コード解説
  1. Add ボタンをタップすると、新規 TODOItem 作成ビューに遷移しますので、そのビューの PageObject が返されます
  2. Title や Detail を入力し、OK ボタンをおすテスト

新規 TODOItem 作成ビュー向け PageObject

上記のテスト向けに、新規 TODOItem 作成ビュー向けの PageObject を作ります。

TODOListNewItemPageObject

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()
    }
}
コード解説
  1. ビューの要素をテスト用に取得します
  2. Title, Detail を TextField に入力するためのメソッドです
  3. OKボタン、CANCELボタンの入力メソッドです

また作成していないビューの PageObject を作成するのは不思議な感じですが、こうして コードにしてみると、新しいビューのキーとなる要素を明確に理解することができます。

新規 TODOItem 作成ビューの作成

View のテストをアップデートし、新しい View の PageObject も作成しましたので、View の実装を進めていきます。

新規 TODOItem 作成ビュー は、ContentView から開かれて、新規 TODOItem を作る or キャンセル で ContentView に戻ります。

NewTODOItemView

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()
        
    }
}
コード解説
  1. 新しい TODOItem のタイトル入力の TextField です
  2. 新しい TODOItem の詳細入力の TextField です
  3. OK ボタンと、Cancel ボタンです。OK 時には、新しい TODOItem を作成し、Cancel 時には、何もせずに、戻ります

続いて、ContentView を先ほど作成した NewTODOItemView を必要に応じて表示するように修正します。

ContentView

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 を作成するテストを修正します。

test_createItem_newItem_withCorrectValues

    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
    }

モデルをアップデート

テストができたので、モデルのコードもアップデートし、テストがパスするか確認します。

example

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
    }
}
コード解説
  1. 新しいプロパティ isDone を追加します
  2. initializer の引数にも、isDone を追加します
  3. initailizer で、isDone プロパティを初期化します

上記にプラスして、CoreData のモデルもアップデートする必要があります。

以下のように修正しました。

CoreDataモデル修正後
CoreDataモデル修正後

アプリは、直接 TODOItem を作成せずに、TODOItemStore 経由で作成します。ですので、TODOItemStore.createTODOItem メソッドのアップデートも必要となります。

TODOItemStore.createTODOItem

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
    }
コード解説
  1. 引数で、isDone を設定できるようにしました。
  2. TODOItem と同じ情報を CoreData の Entity にも設定します

CoreData の Entity から TODOItem を作る initializer のアップデートも忘れずに行います。

init from CoreData entity

// 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 を作っておきます。

TODOItemPageObject

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 できるとして、その動作により、イメージ名が変更されるかをテストします。

example

    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 を作成して表示するようにします。

TODOItemView

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")
            }
        }
    }
}

こんな感じになりました。

TODOItemView view
TODOItemView

先に作ったテストを実行すると、パスすることも確認できました。

なんとなく TODO アプリっぽくなってきました。

次回以降で、以下を実装していきます。

  • チェックされた要素は非表示にする
  • メインビューリストで、「未実行 TODOItem のみ」「全ての TODOItem」を選択表示できるようにする
  • 既存の TODOItem を編集する

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です