asyncなSwiftのユニットテストをLinuxで実行する
背景
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
イメージを使用しています。
先に結論から
下記のファイルをテストターゲットごとのディレクトリにコピペで設置していってください。
オーバーロードによる白魔術で自動的にXCTestがasyncサポートされます。追記(2021/12/15)
Swift 5.5.2がリリースされ、標準のXCTestでもasync関数をテストできるようになりました。よってこの記事の内容は今後不要です。
解説
既存コードの状況整理
下記のような実装と、そのユニットテストを実行することを想定します。
func hello() async throws -> String {
try await Task.sleep(nanoseconds: 1000 * 1000 * 10)
return "Hello, world!"
}
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
を実行すると確認することができます。
今回のプロジェクトでは次のようなコードが生成されました。
#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
が用意されている様子
どうやって解決するか
このテスト実行時のコード生成結果に対して処理を割り込みます。
生成コードはテストターゲットごとのパッケージ空間に生成されることから、同一パッケージ内に宣言されている変数や関数と名前空間を共有しています。つまり独自のオーバーロードを定義できます。
testCase
関数を同一パッケージ内に定義して優先的にオーバーロード解決されるようにしてあげれば、通常呼び出される想定のtestCase
関数とは異なる実装を生成コードに対して差し込むことができるのです。
- 優先的に解決されるような
testCase
関数
func testCase<T: XCTestCase>(_ allTests: [(String, (T) -> () async throws -> Void)]) -> XCTestCaseEntry {
...
}
あとは、この中身にテストを実行するコードを実装するだけです。
(おそらくAppleプラットフォーム上で用いられている?)公式の実装も落ちていたので、これをベースに中身を実装しました。
最終的な実装は先に結論から
の項の通りです。
テストを実行する
❯ 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/
参考:
- Unit testing Swift code that uses async/await https://www.swiftbysundell.com/articles/unit-testing-code-that-uses-async-await/
- Swiftのオーバーロード選択のスコア規則 https://qiita.com/omochimetaru/items/82425e31410060623895
Discussion