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 を定義しました。
import XCTest
protocol PageObject {
// (1)
var app: XCUIApplication { get }
}
- UI 要素は、XCUIApplication 経由で取得するので、各ページで 保持します
PageObject に準拠する形で、ビューごとに PageObject を作っていきます。考慮すべき点は以下です。
- UI操作を public なメソッドとして提供する
- ビュー内部の情報を不必要に外部に提供しない
- Page Object 内で、assertion (テスト) しない
現在のページ向けの PageObject (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
}
}
- UI 操作に必要なコンポーネントを、計算プロパティで定義しています
- 追加操作をメソッドにして提供しています
- Edit ボタン押下経由での要素削除メソッドです
- 左スワイプによる要素削除メソッドです
UITest のリファクタリング
この PageObject を使って、テストコードを追加します。
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 経由で入力できるようにします。
テストコードは以下のようになる予定です。
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 を作ります。
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 に戻ります。
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 を必要に応じて表示するように修正します。
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 を作成するテストを修正します。
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
}
モデルをアップデート
テストができたので、モデルのコードもアップデートし、テストがパスするか確認します。
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 メソッドのアップデートも必要となります。
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 のアップデートも忘れずに行います。
// 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 を作っておきます。
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 できるとして、その動作により、イメージ名が変更されるかをテストします。
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 を作成して表示するようにします。
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