Apple

テストがアプリ本体の設定を書き換えていたので、UserDefaultsとDateを外から渡すようにした

  • Swift
  • Swift Testing
  • テスト
  • macOS
  • iOS

iOS や macOS のアプリで単体テストを書くなら、テストより先に UserDefaults.standardDate() を直接呼んでいるところを外しておきます。イニシャライザで渡すようにするだけで、テストがアプリ本体の設定を書き換えなくなり、日付をまたぐ処理も実際に日付が変わるのを待たずに確かめられます。

final class CompletionRepository {
  private let defaults: UserDefaults
  private let dateGenerator: () -> Date

  init(
    userDefaults: UserDefaults = .standard,
    dateGenerator: @escaping () -> Date = Date.init
  ) {
    self.defaults = userDefaults
    self.dateGenerator = dateGenerator
  }
}

デフォルト値に本物を置いておけばアプリのコードは今までどおりで、テストだけが別のものを渡せます。

直接呼んだままだと何が起きるのか、実際に踏んだ例から順に見ていきます。

通っているのに何も確かめていないテストがある

テストが通っていても、そのテストが何かを確かめているとは限りません。利用に許可が要る OS のフレームワークを直接呼ぶと、許可が下りていない環境では空の結果が返ります。空の配列を回すループの中身は実行されませんし、空かどうかを見るアサーションはそのまま通ります。

macOS のメニューバーアプリにあった、カレンダー機能のテストがこれでした。

func testEventsForDateUsesDisplayCalendars() {
  let displayCalendars: Set<String> = ["cal1", "cal2", "cal3"]
  settings.updateDisplayCalendars(displayCalendars)

  let events = calendarService.events(for: Date())

  for event in events {
    XCTAssertTrue(displayCalendars.contains(event.calendarIdentifier))
  }
}

events(for:) は EventKit を経由していて、テストを走らせる環境ではカレンダーへのアクセスが許可されておらず、返ってくるのは常に空の配列です。空なら for の中身は1度も実行されません。XCTAssertTrue に到達しないまま、このテストは成功します。

同じファイルには XCTAssertTrue(events.isEmpty) を確かめるテストや、2つの結果の包含関係を isSubset(of:) で確かめるテストもありました。どちらも、中身が空でありさえすれば必ず通ります。

カレンダーの絞り込みを確かめているつもりで、実際には EventKit が何も返さないことだけを繰り返し確かめていたわけです。

Swift Testing に移しても事情は同じです。#expect は渡された式をそのまま受け取って、満たされなかったときに何がどう違ったかを報告するマクロなので(Expectations and confirmations)、その行に到達しなければ何も報告されません。

落ちないテストが増えるほど、実際に何が確かめられているのか分からなくなります。

このテストの setUp はこうなっていました。

override func setUp() {
  super.setUp()
  let defaults = UserDefaults.standard
  defaults.removeObject(forKey: "displayCalendars")
  defaults.removeObject(forKey: "notificationCalendars")
}

テストを始める前の掃除のつもりですが、消しているのは手元で動かしているアプリ本体の設定です。テストを走らせるたびにカレンダーの選択が飛びます。

このテストを直すには、CalendarService を EventKit から切り離すところからやり直すことになります。テストは消して、依存の持ち方から先に直しました。テストより先にコードの作りを触ることになるのは、依存が外れていないと書き直しようがないからです。

テストは何もしなければ並列に走る

Swift Testing のドキュメントにこう書いてあります。

“By default, tests run in parallel with respect to each other. Parallelization is accomplished by the testing library using task groups, and tests generally all run in the same process.”

Running tests serially or in parallel

XCTest から移すと、この前提が変わります(Migrating a test from XCTest)。たいていは同じプロセスの中で複数のテストが同時に動くので、プロセス全体で1つしかないものは、どのテストからも同じインスタンスを触ることになります。

UserDefaults.standard がまさにその1つです。

“Each app maintains a single, shared defaults object for you to use in your code.” “When you write settings using the shared object, it writes them to the current app’s settings.”

UserDefaults.standard

アプリごとに1つ、しかも書き込み先はそのアプリの設定です。テスト A が消したキーをテスト B が読む事故も起きますし、手元でアプリを起動すれば、テストが書いた値がそのまま画面に出てきます。

.serialized を suite に付ければ、その中では並列が止まります。ただ、順番が決まるのはその suite の中だけです。どこまで効くのかは、同じページにこう書かれています。

“When applied to a non-parameterized test function, this trait has no effect.” “This trait doesn’t affect the execution of a test relative to its peers or to unrelated tests.”

— Running tests serially or in parallel

その suite の外にある別のテストや、無関係なテストとは並列のままですし、パラメーター化していない @Test func に付けても効きません。そもそも、UserDefaults.standard を直接触っていること自体は変わりません。

左はテストがアプリのコードを通って、アプリ本体の設定といまの時刻まで触ってしまうので、設定が書き換わり、時刻も固定できない。右はアプリのコードが同じまま、テストが渡したテスト用の UserDefaults と固定した Date までしか触らない

文字列キーを1つの型に閉じ込める

UserDefaults を直接呼ぶ箇所が散らばっていると、そもそも差し替えようがありません。最初にやるのは、UserDefaults.standard.object(forKey: "…") という呼び出しをコードから消して、キーを enum に集めた1つの型の中だけで扱うようにすることです。

final class UserDefaultsStorage {
  static let shared = UserDefaultsStorage()

  private let defaults: UserDefaults

  init(userDefaults: UserDefaults = .standard) {
    self.defaults = userDefaults
  }

  enum Key: String {
    case launchAtLogin
    case notificationEnabled
    case selectedCalendarIDs
  }

  func object(for key: Key) -> Any? {
    defaults.object(forKey: key.rawValue)
  }

  func set<T>(_ value: T?, for key: Key) {
    if let value {
      defaults.set(value, forKey: key.rawValue)
    } else {
      defaults.removeObject(forKey: key.rawValue)
    }
  }
}

この記事のコードは Swift 5 言語モードで動かしていて、Swift 6 言語モードだと static let shared が「Sendable でない型が共有の可変状態を持ちうる」という並行性のエラーになります。

呼び出す箇所はこう変わります。

// 変更前
let raw = UserDefaults.standard.object(forKey: "launchAtLogin") as? Bool

// 変更後
let raw = UserDefaultsStorage.shared.object(for: .launchAtLogin) as? Bool

shared を使っている限り中身は UserDefaults.standard のままなので、テストがアプリ本体の設定を触る問題そのものは残ります。それでも、差し替える場所が1箇所に決まります。設定モデルにも通知の処理にも設定画面にも直接の呼び出しが散っている状態では、外から渡す作りに変えようがありません。

UserDefaultsStorage のイニシャライザは UserDefaults を受け取るようにしてあります。この型を使う設定モデルも、shared を直接読まずに UserDefaultsStorage を同じように受け取ります。そうすれば、テストは別のドメインを見る UserDefaults を渡せます。shared はアプリ本体から使う分にはそのまま残しておけます。

キーを enum にまとめたのは、別の狙いがあってのことです。文字列で書いていると、保存するときと読み込むときでつづりが違っても誰も気づきません。

UserDefaults(suiteName:) を使えば、テスト専用のドメインを作ってアプリ本体の設定と分けられます。

“Use this method to create a defaults object that reads settings from the custom domain you specify.”

init(suiteName:)

Apple が例に挙げているのはアプリと App Extension で設定を共有する使い方ですが、アプリ本体とは別に設定の置き場を作る仕組みなので、テストにもそのまま使えます。同じページには、globalDomain やアプリの bundle identifier を渡してはいけない、とも書かれています。

現在時刻は外から渡す

日付が変わったら状態をリセットする処理は、素直に書くとこうなります。

private func resetCompletedStatesIfNeeded() {
  let calendar = Calendar.current
  let today = calendar.startOfDay(for: Date())

  if let lastResetDate {
    if calendar.startOfDay(for: lastResetDate) < today {
      completedIDs.removeAll()
      self.lastResetDate = Date()
    }
  } else {
    lastResetDate = Date()
  }
}

これはテストできません。private なので外から呼べませんし、Date() を関数の中で作っているので「昨日リセットした状態で今日を迎えた」という状況も作れません。日付が実際に変わるのを待つしかありません。

直すのは簡単で、Date を作る処理をイニシャライザで受け取り、Date() と書いていた箇所を dateGenerator() に置き換えます。リセット処理からも private を外して、テストから呼べるようにします。

ただ、これだけだと足りません。最終リセット日もプロパティから UserDefaults に移すところまでやります。日付をまたいだかどうかは、前回いつリセットしたかを覚えていて初めて判定できます。プロパティに持たせたままだと、インスタンスを作り直した時点で忘れるので、いつ呼んでも else に入って日付を入れ直すだけになり、リセットは1度も走りません。

final class CompletionRepository {
  private enum Key {
    static let completedIDs = "completedIDs"
    static let lastResetDate = "lastResetDate"
  }

  private let defaults: UserDefaults
  private let dateGenerator: () -> Date

  init(
    userDefaults: UserDefaults = .standard,
    dateGenerator: @escaping () -> Date = Date.init
  ) {
    self.defaults = userDefaults
    self.dateGenerator = dateGenerator
  }

  func getCompletedIDs() -> Set<String> {
    Set(defaults.stringArray(forKey: Key.completedIDs) ?? [])
  }

  func markAsCompleted(_ id: String) {
    var ids = getCompletedIDs()
    ids.insert(id)
    defaults.set(Array(ids), forKey: Key.completedIDs)
  }

  func resetIfNeeded() {
    let calendar = Calendar.current
    let today = calendar.startOfDay(for: dateGenerator())

    if let lastResetDate = defaults.object(forKey: Key.lastResetDate) as? Date {
      if calendar.startOfDay(for: lastResetDate) < today {
        defaults.removeObject(forKey: Key.completedIDs)
        defaults.set(dateGenerator(), forKey: Key.lastResetDate)
      }
    } else {
      defaults.set(dateGenerator(), forKey: Key.lastResetDate)
    }
  }
}

ここまで来ると、テストが日付を決められます。

final class CompletionRepositoryTests {
  private let suiteName = "test.completion.\(UUID().uuidString)"

  deinit {
    UserDefaults.standard.removePersistentDomain(forName: suiteName)
  }

  @Test func resetIfNeeded_WhenDayChanged_ClearsCompletedIDs() throws {
    let defaults = try #require(UserDefaults(suiteName: suiteName))
    let day1 = Date(timeIntervalSince1970: 1_700_000_000)
    let day2 = day1.addingTimeInterval(60 * 60 * 24)

    let sut = CompletionRepository(userDefaults: defaults, dateGenerator: { day1 })
    sut.resetIfNeeded()
    sut.markAsCompleted("event-1")

    let sutOnNextDay = CompletionRepository(userDefaults: defaults, dateGenerator: { day2 })
    sutOnNextDay.resetIfNeeded()

    #expect(sutOnNextDay.getCompletedIDs().isEmpty)
  }
}

2つ目の CompletionRepository は、翌日にアプリを起動し直した状態にあたります。印も最終リセット日も同じドメインに入っているので、インスタンスを作り直しても両方そのまま読めます。1つ目が書いた最終リセット日を2つ目が読み、日付が変わったと判定してリセットが走るところまでを、1本のテストで確かめられます。

ドメインの名前を毎回変えて、終わったら消すのには理由があります。suite で作ったドメインはディスクに残るからです。Apple のドメイン一覧を見ると、アプリが書き込むドメインは、その端末に保存され続けるものとして挙げられています。

“Each UserDefaults object writes settings to this group, associating them with the app itself or the app group you used to initialize the object. The system saves these settings persistently on the current device.”

UserDefaults

固定の名前を使うと、1回目に書いた最終リセット日が2回目の実行にそのまま残ります。すると2回目は「日付が変わっていない」と判断されてリセットが走らず、テストは落ちます。名前に UUID を混ぜれば同時に走るテストどうしもぶつからず、終わったあとは removePersistentDomain(forName:) で消せます。

“Removes the keys and values from the specified persistent domain.”

removePersistentDomain(forName:)

後片付けを deinit に置いたので、この suite は struct ではなく class にしています。移行ガイドにもそう書かれています。

“If teardown is needed, declare your test suite as a class or as an actor rather than as a structure and implement deinit:”

Migrating a test from XCTest

protocol にするか closure にするかは、何を渡すかで決めています。DateUUID のように呼ぶと値が1つ返るだけのものは closure、Timer のようにメソッドが複数あるものは protocol です。closure なら protocol も Mock も要らず、イニシャライザに1行足すだけで済みます。

プロパティに持たせた状態は Repository に切り出す

機能を足すときに、状態をとりあえずクラスのプロパティに置くことがあります。動くので、そのまま残ります。困るのは、テストを書くときです。

たとえば、会議が予定より早く終わったときに、その予定へ完了の印を付けられる機能を足すとします。完了した ID を Set<String> にして、CalendarService のプロパティに持たせれば動きます。

ただし、アプリを再起動すると印が消えます。永続化していないからです。

そのうえ、完了した予定を除く絞り込みが同じクラスの中にあるので、テストからは CalendarService ごと用意するしかありません。CalendarService は EventKit を抱えているので、許可の下りない環境では空の配列しか返らない、あの状態に戻ります。

切り出す先は Repository です。さきほどの CompletionRepository がそれにあたります。残っているのは protocol を切ることです。protocol を挟めば、UseCase は本物と Mock のどちらを渡されても同じように動きます。

protocol CompletionRepositoryProtocol {
  func getCompletedIDs() -> Set<String>
  func markAsCompleted(_ id: String)
  func markAsIncomplete(_ id: String)
  func isCompleted(_ id: String) -> Bool
  func resetIfNeeded()
}

この CompletionRepository を protocol に準拠させ、上のコードには出てこなかった markAsIncompleteisCompleted も、同じ defaults を読み書きします。アプリから呼ぶための static let shared も、ここで足します。UseCase は、具体的なクラスではなくこの protocol をイニシャライザで受け取ります。

final class FetchNotificationEventsUseCase: FetchNotificationEventsUseCaseProtocol {
  private let settings: AppSettingsProtocol
  private let completionRepository: CompletionRepositoryProtocol

  init(
    settings: AppSettingsProtocol = AppSettings.shared,
    completionRepository: CompletionRepositoryProtocol = CompletionRepository.shared
  ) {
    self.settings = settings
    self.completionRepository = completionRepository
  }
}

テストで差し替えるのは Repository だけで、UseCase は本物を動かします。UseCase をモックすると、絞り込みの条件そのもの、つまり確かめたい部分が実行されなくなるからです。自社の Swift 規約でも UseCase のモックは禁止しています。

GitHubGitHub - LabeeHive/standards: Shared standards repository for Labee LLC projects — a Claude Code plugin marketplace of skills, agents, and workflowsShared standards repository for Labee LLC projects — a Claude Code plugin marketplace of skills, agents, and workflows - LabeeHive/standards

Mock は protocol に対して作り、呼ばれたかどうかと渡された引数を記録します。

final class MockCompletionRepository: CompletionRepositoryProtocol {
  var markAsCompletedCalled = false
  var markAsCompletedID: String?
  var resetIfNeededCalled = false
  var completedIDs: Set<String> = []

  func getCompletedIDs() -> Set<String> {
    completedIDs
  }

  func markAsCompleted(_ id: String) {
    markAsCompletedCalled = true
    markAsCompletedID = id
    completedIDs.insert(id)
  }

  func markAsIncomplete(_ id: String) {
    completedIDs.remove(id)
  }

  func isCompleted(_ id: String) -> Bool {
    completedIDs.contains(id)
  }

  func resetIfNeeded() {
    resetIfNeededCalled = true
  }
}

テストは、Mock を作って本物の UseCase に渡し、その UseCase を ViewModel に渡します。

let mockSettings = MockAppSettings()
let mockCompletionRepository = MockCompletionRepository()

let viewModel = CalendarViewModel(
  fetchNotificationEventsUseCase: FetchNotificationEventsUseCase(
    settings: mockSettings,
    completionRepository: mockCompletionRepository
  ),
  completionRepository: mockCompletionRepository
)

切り出す前は、CalendarService が持つ completedIDs プロパティをテストから直接のぞくことになります。切り出したあとは、Repository の markAsCompleted が正しい ID で呼ばれたかを見ます。内部の変数ではなく、Repository とのやりとりで確かめられます。

差し替えられることと、差し替えていることは別

さきほどのリセットのテストは、こう書けるという話であって、実際に書いてあるわけではありません。dateGenerator を外から渡せるようにはしましたが、この Repository を直接使うテストは1本もありません。

もう1つは、デフォルト値を置くやり方そのものが抱える問題です。FetchNotificationEventsUseCase(settings: mockSettings) のように引数を1つ省いて呼ぶと、残りはデフォルト値、つまり CompletionRepository.shared が入ります。shared が抱えているのは UserDefaults.standard なので、そのテストはアプリ本体の設定を触ります。テストが落ちるわけでも警告が出るわけでもないので、書いた本人も気づけません。

実際、こういう呼び出しが手元のテストに20箇所を超えて残っています。

引数を省けるようにしたのは、アプリのコードを変えずに済ませるためでした。テストから見ると、そこがそのまま抜け道になります。イニシャライザからデフォルト値を外して、渡し忘れをコンパイルエラーにするところまでやるかどうかが、次に決めることです。