🍁

Swift: XCTContext.runActivity()の各階層で共通の事前/事後処理をまとめて書ける拡張

2022/04/07に公開
1

XCTest を書く際 setUp (setUpWithError) / tearDown (tearDownWithError) を使えば、それぞれのテストケースの前後で任意の処理を実行できます。テストケースの事前処理や事後処理が共通している場合は、賢く綺麗にテストコードが書けます。また、XCTContext.runActivity() を使うことで Spec 形式(describe / context / it)に近い形でテストコードを記述することが可能です。

ただ、Spec ではcontextの層ごとにbeforeEachafterEachを用いて共通の事前/事後処理をまとめて記述することができますが、XCTContext の層ごとに共通の処理を記述する便利な API はありません。そこで、XCTContextの層ごとにsetUptearDownを定義して実行できるようにする便利な拡張を考えました。

XCTContext を拡張

XCTContext+Extension
import XCTest

extension XCTContext {
    typealias VoidClosure = () -> Void
    typealias ThrowClosure = () throws -> Void

    private static func runBlock(block: ThrowClosure) rethrows -> Void {
        do {
            try block()
        } catch {
            throw error
        }
    }

    class func runActivities(
        _ dummyClosure: ThrowClosure = {},
        setUp: VoidClosure? = nil,
        tearDown: VoidClosure? = nil,
        _ blocks: ThrowClosure...
    ) rethrows {
        try XCTContext.runBlock {
            for block in blocks {
                do {
                    setUp?()
                    try block()
                    tearDown?()
                } catch {
                    throw error
                }
            }
        }
    }
}

XCTContext.runActivities(setUp:tearDown:blocks:) というクラス関数を生やしました。
Gist にExtension のコードをアップロードしてあります。

これまでの書き方

これまでの書き方を説明するため、テスト対象の例として簡単な四則演算ができるクラスがあったとします。

四則演算ができるクラス(とエラー)の定義
import Foundation

enum MyError: Error {
    case zeroDivided
}

public class MyMath {
    let value: Double

    init(value: Double) {
        self.value = value
    }

    public func add(value: Double) -> Double {
        return self.value + value
    }

    public func subtract(value: Double) -> Double {
        return self.value - value
    }

    public func multiply(value: Double) -> Double {
        return self.value * value
    }

    public func divide(value: Double) throws -> Double {
        if value == 0 {
            throw MyError.zeroDivided
        }
        return self.value / value
    }
}

例えば、テスト対象(sut)のクラスの初期化とnilリセットをそれぞれテストケースの前後で行いたい場合は下のようになると思います。

testMyMathLegacy
import XCTest
@testable import TestEach

class TestEachTests: XCTestCase {

    func testMyMathLegacy() throws {
        var sut: MyMath?

        XCTContext.runActivity(named: "add 3+3=6") { _ in
            sut = MyMath(value: 3) // sutの初期化
            let actual = sut!.add(value: 3)
            XCTAssertEqual(actual, 6)
            sut = nil // sutをnilでリセット
        }
        XCTContext.runActivity(named: "subtract 3-3=0") { _ in
            sut = MyMath(value: 3) // sutの初期化
            let actual = sut!.subtract(value: 3)
            XCTAssertEqual(actual, 0)
            sut = nil // sutをnilでリセット
        }
        try XCTContext.runActivity(named: "divide 3/0 throw error") { _ in
            sut = MyMath(value: 3) // sutの初期化
            XCTAssertThrowsError(try sut!.divide(value: 0)) { error in
                XCTAssertEqual(error as? MyError, MyError.zeroDivided)
            }
            sut = nil // sutをnilでリセット
        }
    }

}

XCTContext.runActivity()の中で毎回 sut の初期化と nil リセットを記述しているのがイケてません。

拡張を導入した場合の書き方

XCTContext.runActivities()の setUp クロージャーに共通の事前処理を tearDown クロージャーに共通の事後処理を書けば、blocks に渡した複数のクロージャーの処理前後でそれらが実行されます。blocks には }, { } のように追記することで何個でもクロージャーを渡すことができます。クロージャーの中でXCTContext.runActivity()を使ってもいいし、その中でコンテキストをネストしてもいいし、さらにXCTContext.runActivities()を入れて使っても大丈夫です。

testMyMathNew
import XCTest
@testable import TestEach

class TestEachTests: XCTestCase {

    func testMyMathNew() throws {
        var sut: MyMath?

        try XCTContext.runActivities(setUp: {
            // 共通の事前処理
            sut = MyMath(value: 3)
        }, tearDown: {
            // 共通の事後処理
            sut = nil
        }, {
            // このクロージャーの前後でsetUp/tearDownが呼ばれる
            XCTContext.runActivity(named: "add 3+5=8") { _ in
                let actual = sut!.add(value: 5)
                XCTAssertEqual(actual, 8)
            }
        }, {
            // コンテキストを並列にすることもできる
            XCTContext.runActivity(named: "subtract 3-2=1") { _ in
                let actual = sut!.subtract(value: 2)
                XCTAssertEqual(actual, 1)
            }
            XCTContext.runActivity(named: "subtract 3-2=1") { _ in
                let actual = sut!.subtract(value: 2)
                XCTAssertEqual(actual, 1)
            }
        }, {
            // ErrorをThrowすることもできる
            try XCTContext.runActivity(named: "multiply 3*4=12") { _ in
                let sut = try XCTUnwrap(sut)
                let actual = sut.multiply(value: 4)
                XCTAssertEqual(actual, 12)
            }
        }, {
            // コンテキストをネストすることもできる
            try XCTContext.runActivity(named: "divide") { _ in
                try XCTContext.runActivity(named: "divide 3/3=1") { _ in
                    let actual = try sut!.divide(value: 3)
                    XCTAssertEqual(actual, 1)
                }
                try XCTContext.runActivity(named: "divide 3/0 throw error") { _ in
                    XCTAssertThrowsError(try sut!.divide(value: 0)) { error in
                        XCTAssertEqual(error as? MyError, MyError.zeroDivided)
                    }
                }
            }
        })
    }

}

拡張のポイント

  • rethrows を機能させるために dummyClosure という使用しないクロージャーを引数に定義
  • dummyClosureの型についているthrowsが重要
  • デフォルト引数として {} を設定してあることで省略できる

Discussion

KyomeKyome

XCTContext.runActivityで包むパターン

import XCTest

extension XCTContext {
    typealias VoidClosure = () -> Void
    typealias ThrowsClosure = (XCTActivity) throws -> Void
    typealias ThrowsArrayClosure = (XCTActivity, VoidClosure?, VoidClosure?, [ThrowsClosure]) throws -> Void

    static let loopClosure: ThrowsArrayClosure = { (activity, setUp, tearDown, blocks) in
        for block in blocks {
            do {
                setUp?()
                try block(activity)
                tearDown?()
            } catch {
                throw error
            }
        }
    }

    class func runActivities(
        _ loopClosure: ThrowsArrayClosure = XCTContext.loopClosure,
        name named: String,
        setUp: VoidClosure? = nil,
        tearDown: VoidClosure? = nil,
        blocks: ((XCTActivity) throws -> Void)...
    ) rethrows {
        try XCTContext.runActivity(named: named, block: { activity in
            try loopClosure(activity, setUp, tearDown, blocks)
        })
    }
}