🐼

AutoMockableの自動生成するコードは長いが型にすればいいのかもしれない

2023/10/21に公開1

はじめに

iOSアプリ開発でテストコードを作成する時、AutoMockableというライブラリを使うこともあるんですが、
このライブラリが自動生成するコードがメソッド名と引数名によって長くなってしまう。なのでその改善案/代替案を型で表現することを考えてるっていう話です。

具体例

具体的にはMyProtocolがあるとき

protocol MyProtocol {
    func sayHelloWith(name: String)
}

これが自動で次のようなコードを生成する。しかし、sayHelloWithNameReceivedInvocationssayHelloWithNameReceivedName変数はこれだけで結構長い。


class MyProtocolMock: MyProtocol {

    //MARK: - sayHelloWith
    var sayHelloWithNameCallsCount = 0
    var sayHelloWithNameCalled: Bool {
        return sayHelloWithNameCallsCount > 0
    }
    var sayHelloWithNameReceivedName: String?
    var sayHelloWithNameReceivedInvocations: [String] = []
    var sayHelloWithNameClosure: ((String) -> Void)?

    func sayHelloWith(name: String) {
        sayHelloWithNameCallsCount += 1
        sayHelloWithNameReceivedName = name
        sayHelloWithNameReceivedInvocations.append(name)
        sayHelloWithNameClosure?(name)
    }
}

長いと何が困るかというとテストコードで呼び出す側のコードが読みづらくなるんすわ。

解決案: 型にする

このMyProtocolMockのプロパティをもっと型を利用した感じにしたのが次の通り。

class MyProtocolMock: MyProtocol {
    struct SayHelloWith: Equatable {
        struct Input: Equatable {
            var name: String
        }
        var input: Input? {
            invocations.last
        }
        var invocations: [Input] = []
        var callsCount: Int {
            invocations.count
        }
        var called: Bool {
            !invocations.isEmpty
        }
    }

    var sayHelloWith: SayHelloWith?

    func sayHelloWith(name: String) {
        if sayHelloWith != nil {
            sayHelloWith?.invocations.append(SayHelloWith.Input(name: name))
        } else {
            sayHelloWith = SayHelloWith(invocations: [.init(name: name)])
        }
    }
}

型にする際のポイント

  • 入力の型を作る
  • そもそもcountとか動的にinvocations数えれば良いのでは?
  • calledは0より大きいかどうかより!isEmpty使えばいいのでは?
  • invocationsに入ってるんだからcountと同じでnameも個別にセットする必要がないのでは?
  • メソッドを呼ばれた際の型SayHelloWithをEqutableにすると良いかも

「メソッドを呼ばれた際の型SayHelloWithをEqutableにすると良いかも」というのはAssertionする際に便利になるはずで、具体的にはこういうことができると思います。

assertEqual(mock.usayHelloWith, SayHelloWith(invocations: [.init(name: "A")]))

// apple/swift-testingでやるなら
#expect(
    mock.usayHelloWith == SayHelloWith(invocations: [.init(name: "A")])
)

おわりに

AutoMockableをforkする前に意見が聞きたいので書いてみました。また、他にもテストコードが別スレッドで実行された際に自動生成されたコードがスレッドセーフでないためにデータ競合する課題も感じていて(テスト実行するとEXC_BADACESSとなる)、やるんだったらまだ別の改善も必要かなと思っています。

ほかには、AutoMockableではなくマクロでやるという手段もあるかもしれません。swift-sypableというライブラリがあります。

https://github.com/Matejkob/swift-spyable

なんか意見があったらください。2023/10/27のios test nightで直接会った時に直接なんか意見もらうのもいいかもしれないです。

https://testnight.connpass.com/event/295913/

Discussion

IcemanIceman

関数名でローカルスコープに型を定義してしまうと、同名の型をシャドウしてしまう問題があります。
例えば

protocol P {
  func date()
  var today: Date { get }
}

のようなプロトコルに対したコード生成結果を考えた場合に、todayが参照するDateが生成された関係のないDateに変化してしまいます。

幸い、被らないような複雑な型名を利用したり、型自体は別のスコープに定義するなどすれば回避できます。