🥏

asyncなSwiftのユニットテストをLinuxで実行する

2021/11/20に公開

背景

Swiftにおける標準的なユニットテストの実装にはXCTestが用いられます。
Swift5.5でasync/awaitがサポートされ、XCTestでも func testFoo() async throws のようにテストメソッドのシグネチャにasyncを付与することでasync関数をテストできるようになりました。
しかしながらこのXCTestのasync関数サポートは執筆時点ではAppleプラットフォームでしかサポートされていないようでした。Linuxでテストを実行しようとするとコンパイルエラーになってしまったので、その対応方法のメモです。

おそらく次のSwiftバージョンが出るころにはLinuxでもサポートされると思うので、できるだけテストコードに影響を及ぼさないように対応したいと思います。

環境

  • macOS 12.0.1(21A559)(Intel)
  • Swift 5.5.1

LinuxはmacOS上のDockerでswift:5.5.1イメージを使用しています。

先に結論から

下記のファイルをテストターゲットごとのディレクトリにコピペで設置していってください。
https://gist.github.com/sidepelican/e1b802f22bd5e94294ff05154d7ef849
オーバーロードによる白魔術で自動的にXCTestがasyncサポートされます。

追記(2021/12/15)

Swift 5.5.2がリリースされ、標準のXCTestでもasync関数をテストできるようになりました。よってこの記事の内容は今後不要です。

解説

既存コードの状況整理

下記のような実装と、そのユニットテストを実行することを想定します。

MyServer.swift
func hello() async throws -> String {
    try await Task.sleep(nanoseconds: 1000 * 1000 * 10)
    return "Hello, world!"
}
MyServerTests.swift
import XCTest
@testable import MyServer

final class MyServerTests: XCTestCase {
    func testHello() async throws {
        let result = try await MyServer.hello()
        XCTAssertEqual(result, "Hello, world!")
    }
}

macOSで実行すると普通にテストが通りますが、Linuxの場合は次のようにコンパイルエラーとなります。

❯ docker run -it --rm -v `pwd`:/work -w /work swift:5.5.1 /usr/bin/swift test
/work/.build/x86_64-unknown-linux-gnu/debug/MyServerPackageTests.derived/MyServerTests.swift:11:32: error: invalid conversion from 'async' function of type '() async throws -> ()' to synchronous function type '() throws -> Void'
        testCase(MyServerTests.__allTests__MyServerTests),
                               ^
[7/9] Compiling MyServerPackageTests main.swift
error: fatalError

見覚えのないswiftファイルでコンパイルエラーが起こっています。

コンパイルエラーが起こっているコードを確認する

Linuxにおけるswift testの実行にはコード生成が用いられています。
昔は実行するテストコードを手で1つずつ列挙していたのですが、Swift5.1くらいからこのあたりのコードがコンパイラにより自動生成されるようになりました。
この自動生成コードはswift test --generate-linuxmainを実行すると確認することができます。

今回のプロジェクトでは次のようなコードが生成されました。

XCTestManifests.swift
#if !canImport(ObjectiveC)
import XCTest

extension MyServerTests {
    // DO NOT MODIFY: This is autogenerated, use:
    //   `swift test --generate-linuxmain`
    // to regenerate.
    static let __allTests__MyServerTests = [
        ("testHello", testHello),
    ]
}

public func __allTests() -> [XCTestCaseEntry] {
    return [
        testCase(MyServerTests.__allTests__MyServerTests),
    ]
}
#endif

Linuxにおけるswift test時のコンパイルエラーは、この自動生成されるコードにおいて起こっているようです。
testHelloはasync関数であるためMyServerTests.__allTests__MyServerTests[(String, (MyServerTests) -> () async throws -> ())]型となっていますが、testCase関数にはasyncに対するオーバーロードが提供されていなかったためにエラーになっているようです。

参考: (T) -> () -> Void(T) -> () throws -> Voidが用意されている様子
https://github.com/apple/swift-corelibs-xctest/blob/1b0e65c2a3453636b5b9bdf24111285426187cca/Sources/XCTest/Public/XCTestCase.swift#L301-L308

どうやって解決するか

このテスト実行時のコード生成結果に対して処理を割り込みます。
生成コードはテストターゲットごとのパッケージ空間に生成されることから、同一パッケージ内に宣言されている変数や関数と名前空間を共有しています。つまり独自のオーバーロードを定義できます。
testCase関数を同一パッケージ内に定義して優先的にオーバーロード解決されるようにしてあげれば、通常呼び出される想定のtestCase関数とは異なる実装を生成コードに対して差し込むことができるのです。

  • 優先的に解決されるようなtestCase関数
func testCase<T: XCTestCase>(_ allTests: [(String, (T) -> () async throws -> Void)]) -> XCTestCaseEntry {
    ...
}

あとは、この中身にテストを実行するコードを実装するだけです。
(おそらくAppleプラットフォーム上で用いられている?)公式の実装も落ちていたので、これをベースに中身を実装しました。
https://github.com/apple/swift-corelibs-xctest/blob/1b0e65c2a3453636b5b9bdf24111285426187cca/Sources/XCTest/Public/XCTestCase.swift#L323-L377

最終的な実装は先に結論からの項の通りです。

テストを実行する

❯ docker run -it --rm -v `pwd`:/work -w /work swift:5.5.1 /usr/bin/swift test
[13/13] Build complete!
Test Suite 'All tests' started at 2021-11-20 11:27:31.293
Test Suite 'debug.xctest' started at 2021-11-20 11:27:31.295
Test Suite 'MyServerTests' started at 2021-11-20 11:27:31.295
Test Case 'MyServerTests.testHello' started at 2021-11-20 11:27:31.296
Test Case 'MyServerTests.testHello' passed (0.012 seconds)
Test Suite 'MyServerTests' passed at 2021-11-20 11:27:31.307
	Executed 1 test, with 0 failures (0 unexpected) in 0.012 (0.012) seconds
Test Suite 'debug.xctest' passed at 2021-11-20 11:27:31.308
	Executed 1 test, with 0 failures (0 unexpected) in 0.012 (0.012) seconds
Test Suite 'All tests' passed at 2021-11-20 11:27:31.308
	Executed 1 test, with 0 failures (0 unexpected) in 0.012 (0.012) seconds

無事に実行できました。

おわりに

独立した1ファイルをコピペで追加するだけでよく、テストコードに一切手を加えずLinux対応できて満足しています。
どうせすぐ不要になるワークアラウンドであるため、捨てやすくて嬉しいです。

Swiftコンパイラがコード生成で実現している機能にオーバーロードで処理を差し込むテクニックは他にはCodableでも利用できます。
こちらで紹介されています。面白いので参考にしてみてください。https://www.swiftbysundell.com/tips/default-decoding-values/

参考:

Discussion