💯

Swiftでテストフレームワークを自作する

2024/06/03に公開

はじめに

この記事では、故・石井勝氏の記事(Kent Beck Testing Framework 入門)に触発されて作成した、Swiftで自作した簡単なテストフレームワークについて紹介します。

普段、XcodeでiOSアプリ開発を行っていると、XCTestフレームワークが多くの作業を自動化してくれるため、テストフレームワークの内部実装についてあまり意識することはありません。しかし、興味を持って自作してみることで、テストのしくみやXCTestの裏側に迫ることができるかもしれません。

開発着手時点、元記事を参考にデザインパターンを駆使した実装を進めるつもりだったのですが、どうもXCTestの書き心地・書き味が得られず、試行錯誤のうちに元記事とはかけ離れた実装となりました。

今回作成するテストフレームワークは、以下の機能を持ちます。

できること

  • SetUp -> Run Test -> Tear Down のテストの流れ。
  • 各種基本のアサート。
  • 遅延処理のテスト。
  • テスト結果のログ出力。
  • 失敗したアサートのテストクラス名と行をログ出力。

できないこと

  • 非同期処理のテスト。
  • XCTestRunnerによるテスト起動。
  • コマンドによるテスト起動。
  • prefixによるtestメソッド判定。

アサート

先に、アサートを行うクラスを作ります。
各種アサートメソッドを実装し、アサート失敗時には結果falseを変数cuurentSuccessに上書きして、行とメッセージを配列failuresに格納します。使い方は後述するテストケースの実装を参照のこと。

import Foundation

class Assert {
  static var currentTestSuccess: Bool = true
  static var failures: [(line: Int, message: String)] = []
  
  static func reset() {
    currentTestSuccess = true
    failures.removeAll()
  }
  
  static func assertEqual<T: Equatable>(_ a: T, _ b: T, _ message: String? = nil, file: StaticString = #file, line: Int = #line) {
    if a != b {
      let failureMessage = "Test failed: \(message ?? "\(a) is not equal to \(b)")."
      currentTestSuccess = false
      failures.append((line: line, message: failureMessage))
    }
  }
  
  static func assertNotEqual<T: Equatable>(_ a: T, _ b: T, _ message: String? = nil, file: StaticString = #file, line: Int = #line) {
    if a == b {
      let failureMessage = "Test failed: \(message ?? "\(a) is equal to \(b)")."
      currentTestSuccess = false
      failures.append((line: line, message: failureMessage))
    }
  }
  
  static func assertTrue(_ condition: Bool, _ message: String? = nil, file: StaticString = #file, line: Int = #line) {
    if !condition {
      let failureMessage = "Test failed: \(message ?? "Condition is not true")."
      currentTestSuccess = false
      failures.append((line: line, message: failureMessage))
    }
  }
  
  static func assertFalse(_ condition: Bool, _ message: String? = nil, file: StaticString = #file, line: Int = #line) {
    if condition {
      let failureMessage = "Test failed: \(message ?? "Condition is not false")."
      currentTestSuccess = false
      failures.append((line: line, message: failureMessage))
    }
  }
  
  static func assertNil(_ value: Any?, _ message: String? = nil, file: StaticString = #file, line: Int = #line) {
    if value != nil {
      let failureMessage = "Test failed: \(message ?? "Value is not nil")."
      currentTestSuccess = false
      failures.append((line: line, message: failureMessage))
    }
  }
  
  static func assertNotNil(_ value: Any?, _ message: String? = nil, file: StaticString = #file, line: Int = #line) {
    if value == nil {
      let failureMessage = "Test failed: \(message ?? "Value is nil")."
      currentTestSuccess = false
      failures.append((line: line, message: failureMessage))
    }
  }
  
  static func result() -> Bool {
    return currentTestSuccess
  }
  
  static func getFailures() -> [(line: Int, message: String)] {
    return failures
  }
}

テストケース

次に、テストを書くための基本クラスであるテストケースを実装します。このクラスを継承してテストを記述します。
setUp()tearDown()はオーバーライドして使用します。
テスト結果のログ出力のために色々名前を取得しています。

import Foundation

class TestCase {
    func setUp() {}

    func tearDown() {}

    func runTest(_ testName: String, _ testMethod: () -> Void) -> (testName: String, className: String, success: Bool, [(line: Int, message: String)]) {
        setUp()
        Assert.reset()
        testMethod()
        let success = Assert.result()
        let failures = Assert.getFailures()
        tearDown()
        let className = String(describing: type(of: self))
        return (testName, className, success, failures)
    }
}

テストスイート

テストスイートは、複数のテストをまとめて実行し、結果をログに出力します。
addTest()メソッドでテストケースとテストメソッドを追加して、run()メソッドで追加したテストを実行し、ログを出力します。

import Foundation

class TestSuite {
  private var tests: [(String, TestCase, () -> Void)] = []
  
  func addTest(_ testName: String, _ testCase: TestCase, _ testMethod: @escaping () -> Void) {
    tests.append((testName, testCase, testMethod))
  }
  
  func run() {
    var passedCount = 0
    var failedCount = 0
    var failedTests: [(testName: String, className: String, [(line: Int, message: String)])] = []
    
    for (testName, testCase, testMethod) in tests {
      let (testName, className, success, failures) = testCase.runTest(testName, testMethod)
      if success {
        passedCount += 1
      } else {
        failedCount += 1
        failedTests.append((testName, className, failures))
      }
    }
    
    print("\nTest Results: ✅ \(passedCount) passed, ❌ \(failedCount) failed.")
    if !failedTests.isEmpty {
      print("Failed Tests:")
      for (testName, className, failures) in failedTests {
        print("- \(testName) in \(className)")
        for failure in failures {
          print(" \(className) Line \(failure.line): \(failure.message)")
        }
      }
    }
    print("\n")
  }
}

テスト対象のクラス

自作したテストフレームワークでテストするためのクラスを実装します。
とりあえず、クラスで保持するvalue変数に任意の加減乗除を行い、素数判定とログ収集、遅延処理でリセット、というクラスを作ってみました。

import Foundation

class NumberHandler {
  private(set) var value: Int = 0
  private(set) var log: [String] = []
  
  var isValuePrime: Bool {
    return isPrime(value)
  }
  
  func add(_ num: Int) {
    value += num
    log.append("\(num) added. New value: \(value), isPrime: \(isValuePrime)")
  }
  
  func subtract(_ num: Int) {
    value -= num
    log.append("\(num) subtracted. New value: \(value), isPrime: \(isValuePrime)")
  }
  
  func multiply(_ num: Int) {
    value *= num
    log.append("\(num) multiplied. New value: \(value), isPrime: \(isValuePrime)")
  }
  
  func divide(_ num: Int) {
    guard num != 0 else {
      log.append("Division by zero attempted. Operation skipped.")
      return
    }
    value /= num
    log.append("\(num) divided. New value: \(value), isPrime: \(isValuePrime)")
  }
  
  func outputLog() {
    log.forEach { print($0) }
  }
  
  func resetAfterThreeSeconds() {
    DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
      self?.reset()
    }
  }
  
  private func reset() {
    value = 0
    log.removeAll()
  }
  
  private func isPrime(_ num: Int) -> Bool {
    guard num > 1 else { return false }
    if num == 2 { return true }
    if num % 2 == 0 { return false }
    
    let max = Int(Double(num).squareRoot())
    for i in stride(from: 3, through: max, by: 2) {
      if num % i == 0 {
        return false
      }
    }
    return true
  }
}

テストコード

では、テストコードを書いてみます。
testResetAfterThreeSeconds()は遅延処理メソッドのテストですが、このフレームワークではテストメソッドは勝手に処理を抜けないのでDispatchQueue.main.asyncAfter(deadline:)で固定秒遅延させてアサートできます。
testInitialValue2()は意図的に失敗するようにテストコードを書いています。

import Foundation

class NumberHandlerTests: TestCase {
  var handler: NumberHandler!
  
  override func setUp() {
    super.setUp()
    handler = NumberHandler()
  }
  
  override func tearDown() {
    handler = nil
    super.tearDown()
  }
  
  func testInitialValue() {
    Assert.assertEqual(handler.value, 0)
    Assert.assertFalse(handler.isValuePrime)
  }
  
  /// 動作確認のために意図的に失敗させるテスト
  func testInitialValue2() {
    Assert.assertEqual(handler.value, 1)
    Assert.assertTrue(handler.isValuePrime)
  }
  
  func testAdd() {
    handler.add(29)
    Assert.assertEqual(handler.value, 29)
    Assert.assertTrue(handler.isValuePrime)
    Assert.assertEqual(handler.log.count, 1)
    Assert.assertEqual(handler.log.last, "29 added. New value: 29, isPrime: true")
  }
  
  func testSubtract() {
    handler.add(29)
    handler.subtract(4)
    Assert.assertEqual(handler.value, 25)
    Assert.assertFalse(handler.isValuePrime)
    Assert.assertEqual(handler.log.count, 2)
    Assert.assertEqual(handler.log.last, "4 subtracted. New value: 25, isPrime: false")
  }
  
  func testMultiply() {
    handler.add(5)
    handler.multiply(3)
    Assert.assertEqual(handler.value, 15)
    Assert.assertFalse(handler.isValuePrime)
    Assert.assertEqual(handler.log.count, 2)
    Assert.assertEqual(handler.log.last, "3 multiplied. New value: 15, isPrime: false")
  }
  
  func testDivide() {
    handler.add(10)
    handler.divide(2)
    Assert.assertEqual(handler.value, 5)
    Assert.assertTrue(handler.isValuePrime)
    Assert.assertEqual(handler.log.count, 2)
    Assert.assertEqual(handler.log.last, "2 divided. New value: 5, isPrime: true")
  }
  
  func testDivideByZero() {
    handler.add(10)
    handler.divide(0)
    Assert.assertEqual(handler.value, 10)
    Assert.assertEqual(handler.log.count, 2)
    Assert.assertEqual(handler.log.last, "Division by zero attempted. Operation skipped.")
  }
  
  func testLog() {
    handler.add(29)
    handler.subtract(4)
    handler.multiply(2)
    handler.divide(5)
    handler.divide(0)
    
    let expectedLog = [
      "29 added. New value: 29, isPrime: true",
      "4 subtracted. New value: 25, isPrime: false",
      "2 multiplied. New value: 50, isPrime: false",
      "5 divided. New value: 10, isPrime: false",
      "Division by zero attempted. Operation skipped."
    ]
    
    Assert.assertEqual(handler.log, expectedLog)
  }
  
  func testResetAfterThreeSeconds() {
    handler.add(10)
    Assert.assertEqual(handler.value, 10)
    handler.resetAfterThreeSeconds()
    Assert.assertEqual(handler.value, 10)
    
    DispatchQueue.main.asyncAfter(deadline: .now() + 3.1) { [weak self] in
      Assert.assertEqual(self?.handler.value, 0)
    }
  }
}

テストの実行

最後に、テストのセットアップと実行のメソッドをアプリのエントリーポイントに仕込みます。
テストがアプリ起動を邪魔しないように、バックグラウンド実行します。
デバグビルド以外では実行しないようにもしておきます。

これで、テストフレームワークの自作が完了しました。

import SwiftUI

class AppDelegate: NSObject, UIApplicationDelegate {
  func application(_ application: UIApplication,
                   didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
#if DEBUG
    runTests()
#endif
    
    return true
  }
  
  private func runTests() {
    DispatchQueue.global().async {
      let testSuite = TestSuite()
      let testCase = NumberHandlerTests()
      
      testSuite.addTest("InitialValueTest", testCase, testCase.testInitialValue)
      testSuite.addTest("InitialValueTest2", testCase, testCase.testInitialValue2)
      testSuite.addTest("AddTest", testCase, testCase.testAdd)
      testSuite.addTest("SubtractTest", testCase, testCase.testSubtract)
      testSuite.addTest("MultiplyTest", testCase, testCase.testMultiply)
      testSuite.addTest("DivideTest", testCase, testCase.testDivide)
      testSuite.addTest("DevideByZeroTest", testCase, testCase.testDivideByZero)
      testSuite.addTest("ResetAsyncTest", testCase, testCase.testResetAfterThreeSeconds)
      
      testSuite.run()
    }
  }
}

@main
struct TestFrameworkSampleApp: App {
  @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
  
  var body: some Scene {
    WindowGroup {
      ContentView()
    }
  }
}

実行結果(デバグコンソール)

アプリを実行すると、テスト結果がコンソールに出力されます。
出力結果はばっちりです!

Test Results: ✅ 7 passed, ❌ 1 failed.
Failed Tests:
- InitialValueTest2 in NumberHandlerTests
 NumberHandlerTests Line 29: Test failed: 0 is not equal to 1.
 NumberHandlerTests Line 30: Test failed: Condition is not true.

あとがき

概ね満足いくものができました。

これを仕込んでおけば、CIでテストを回さなくてもアプリを実行するたびにバックグラウンドでテスト実行して結果を出力してくれます!便利✨✨✨

私は使いませんが。

手動でテストケースやメソッドを追加する手間や、非同期処理のテストの複雑さに耐え得ない貧弱な機能から、実用性は低いです。
いつか私自身がもっと成長して気が向いたときには、改良を加えて実用化したいと思います。

以上、テストフレームワークの自作でした。

Discussion