この記事は、 Swift.org のオープンソース Swift の開発に参加してみたい方のためのものです。普通にSwiftでアプリの開発をするのにはまったく役立たない知識です。

Swift コンパイラのテスト環境は普通のプロジェクトではあまりなじみの無い物であり、取っつきづらいと思います。この記事では、実際にテストケースを追加しながら、Swiftのテスト環境がどういう仕組みになっているのかを解説していきます。

実際に試してみたい方は、別記事を読んで初回ビルドまで済ませておいてください。ここではその環境を前提としています。

テストのバリエーション

swift の build-script にはテストのためのオプションがいくつかあります。

オプション 内容
-t 一般的なテストです。 test/ ディレクトリにあるテストケースとユニットテストが実行されます。
-T バリデーションテスト。 -t に加え、 validation-test/ 内のテストケースもテストされます
-T --long-test バリデーションテスト内に非常に時間のかかるテストケースがあるのですが、それも含めテストします。
--benchmark ベンチマークテストを実行します。これに関してはここでは扱いません。

Swift の PR のテストに使用されるのは -T 相当です。結構時間がかかるので、通常の開発は -t で行い、 PR 投げる前に念のため -T を通しておく程度でしょうか。

ビルドとテスト
$ utils/build-script -Rt

テストだけ実行したいからといって、 -R(や -r) なしで実行してはいけません。 デバッグ版がビルド・テストされてしまいます。ビルドを飛ばしてテストだけ実行する手段は後述します。

実際には build-script がビルド後に cmake --build でテストターゲットをキックする形で実行されます1。結果的に lit.py というテスト実行環境をキックします。

LLVM lit によるテスト

swift のテストは LLVM プロジェクトの成果物である、 lit(LLVM Integrated Tester) を使用しています。 lit は test/ ディレクトリ内のファイルを走査し、テストケースを探して、テストしていくという仕組みになっています。

なんとなくどんなものかを把握するために、まずは test/ ディレクトリに新しいテストケースを作ってみましょう。

$ cd Document/swift-source/swift
$ vim test/mytest.swift
test/mytest.swift
// RUN: FOOBAR

とりあえずこれだけです。 litは各ファイルの RUN: を探し、そこに記載されたコマンドを実行します。それが成功すればテスト成功、終了コードが 0 以外だったら失敗となります。

この mytest.swift の場合 FOOBAR というコマンドは存在しないので、テスト失敗になるはずです。テストしてみます。

テスト実行
$ utils/build-script -Rt

...

FAIL: Swift(macosx-x86_64) :: mytest.swift (1 of 3781)
******************** TEST 'Swift(macosx-x86_64) :: mytest.swift' FAILED ********************
Script:
--
FOOBAR
--
Exit Code: 127

Command Output (stderr):
--
/Users/rintaro/Documents/swift-source/build/Ninja-ReleaseAssert/swift-macosx-x86_64/test-macosx-x86_64/Output/mytest.swift.script: line 1: FOOBAR: command not found

--

********************
                                                            -- Testing: 3781 tests, 4 threads --                                                           
  5% [=======-------------------------------------------------------------------------------------------------------------------------------] ETA: 00:00:38

失敗してますね。未テストのファイルを優先してテストしてくれるはずなので、わりと早くこんな感じになると思います。テストは続いていますが、全部のテストを流す必要ないので、 Ctrl+c で終了させてしまいます。

毎回全てのテストを走らせるのは面倒なので、特定のテストだけ実行する方法もあります。 docs/Testing.md には lit.py コマンドを直接実行する方法が記載されているのですが、面倒なのでそれを簡単に実行する utils/run-test というツールがあります。

test/mytest.swiftのみテスト実行
$ utils/run-test --build-dir ../build/Ninja-ReleaseAssert/ test/mytest.swift

--build-dir でテスト対象のビルドディレクトリを指定します。build-script -Rでビルドした場合にはこのディレクトリ名のはずです。次に、実行するテストのファイル名を指定して実行します。テストが相変わらず失敗することを確認してください。

テストを成功させてみましょう。 mytest.swift を下記のように変更して、

test/mytest.swift
// RUN: echo FOOBAR

同様にテストしてみます:

テスト実行
$ utils/run-test --build-dir ../build/Ninja-ReleaseAssert/ test/mytest.swift

...

Testing Time: 0.02s
  Expected Passes    : 1

成功しました。

一つのテストファイルに RUN: を複数記載することも出来ます。

test/mytest.swift
// RUN: rm -rf %t && mkdir -p %t
// RUN: echo 'Hello Swift!' > %t/out1.txt
// RUN: echo -n 'Hello' > %t/out2.txt
// RUN: echo ' Swift!' >> %t/out2.txt
// RUN: diff %t/out1.txt %t/out2.txt

%t については後述しますが、一時ディレクトリに out1.txtout2.txt を作って、その内容が同じであることをテストしています。コマンドはシェルスクリプトとして解釈されるので、このようにリダイレクトなども使用できます。

utils/run-test ですが、これでテストを実行した場合も最新のソースをビルドしてからテストが実行されます。差分ビルドなので、ソースを更新していなければ実際にビルドが走る事はないのですが、「ソース更新したけど前回のビルドのままテストを回したい」というときには --build=skip というオプション2があります。

再ビルドせずにテストだけ実行
$ utils/run-test --build=skip --build-dir ../build/Ninja-ReleaseAssert/ test/

このテストケースだと何も面白くないので、次項からはより実践的なテストを書いていきます。

lit substitution

テストファイルを下記のように変更してみましょう。

test/mytest.swift
// RUN: %target-swiftc_driver %s -o %t

func foo(x: String) {}

foo(x: 12)

lit には substitution と呼ばれる、RUN: で指定された文字列を他の文字列に置き換えてからコマンドとして実行する機能があります。組み込みの substitution の中でよく使われるのは下記の通りです。

置き換え先文字列
%s ソースパス(現在のテストファイル)
%S ソースディレクトリ(現在のテストファイルのディレクトリ)
%t このテストに割り当てられたユニークなファイル名

その他に Swift のテストスイートが用意している substitution もあります。一覧は docs/Testing.md に記載されています。

%target-swiftc_driver はビルドされた swiftc を適切なオプションをつけて実行するコマンドに置き換えられます。つまり、このテストケースは現在のテストファイルを正常にコンパイルできることを確認するテストケースになります。
ただ、このテストケースでは foo(x:)String しか受け取らないのでエラーになるはずです。

$ utils/run-test --build-dir ../build/Ninja-ReleaseAssert/ test/mytest.swift

...

-- Testing: 1 tests, 1 threads --
FAIL: Swift(macosx-x86_64) :: mytest.swift (1 of 1)
******************** TEST 'Swift(macosx-x86_64) :: mytest.swift' FAILED ********************
Script:
--
xcrun --toolchain default --sdk /Applications/Xcode-beta.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.13.sdk /Users/rintaro/Documents/swift-source/build/Ninja-ReleaseAssert/swift-macosx-x86_64/bin/swiftc -target x86_64-apple-macosx10.9  -module-cache-path '/var/folders/_d/gtqqvvnd6x5_q7hr8ybhw39r0000gn/T/swift-testsuite-clang-module-cachep3QYyq' /Users/rintaro/Documents/swift-source/swift/test/mytest.swift -o /Users/rintaro/Documents/swift-source/build/Ninja-ReleaseAssert/swift-macosx-x86_64/test-macosx-x86_64/Output/mytest.swift.tmp
--
Exit Code: 1

Command Output (stderr):
--
/Users/rintaro/Documents/swift-source/swift/test/mytest.swift:5:8: error: cannot convert value of type 'Int' to expected argument type 'String'
foo(x: 12)
       ^~

--

********************
Testing Time: 0.11s
********************
Failing Tests (1):
    Swift(macosx-x86_64) :: mytest.swift

  Unexpected Failures: 1
utils/run-test: fatal error: command terminated with a non-zero exit status 1, aborting

Script: の下の行の xcrun --toolchain ... が substitution によって置き換えられたコマンドです。

念のため、きちんとテストを通しておきましょう。

test/mytest.swift
// RUN: %target-swiftc_driver %s -o %t

func foo(x: Int) {}

foo(x: 12)

同じコマンドを実行すれば正常にテストが完了するはずです。

REQUIRES

一部の環境だけでテストを実行し、それ以外ではスキップしたい場合に使用できる lit の機能として REQUIRES というものがあります。

test/mytest.swift
// RUN: %target-run-simple-swift
// REQUIRES: CPU=x86_64
// REQUIRES: executable_test

assert(MemoryLayout<Int>.size == 8)

この指定だと、CPU が X86_64 かつ コンパイルしたプログラムを実行できる環境のときだけテストを実行するという意味になります。REQUIRES に指定できる値の一覧は docs/Testing.md にあります。

また、すべての環境で一時的にテストケースをスキップする時にも REQUIRES が使用されます。その場合は、任意の文字列を指定します。

test/mytest.swift
// RUN: %target-run-simple-swift

// REQUIRES: SR-XXXX

foobar

「SR-XXXX が解決しないとテストが通らないので一時的に無効にしますよ。」というような意味になります。

XFAIL

一部の環境で失敗することが分かっている場合に使用する lit の ディレクティブです。

test/mytest.swift
// RUN: %target-swift-frontend -typecheck %s
// XFAIL: linux

import Darwin

Linux には Darwin モジュールがありませんのでコンパイルが失敗するはずです。(実際には Linux 以外の非 Darwin 環境も考えなければならないのですが、とりあえず。)
REQUIRES との違いは、 REQUIRES は要求する環境を記載するのに対し、XFAILは失敗する環境を記載するということ。また、 REQUIRES は該当しない環境ではテスト自体がスキップされるのに対し、 XFAIL は該当する環境でもテスト自体は実行され、失敗することが期待されます。成功すると、unexpected pass として テスト自体は失敗になります。

使いどころとしては、今は失敗するけど将来的に成功させるようにしたいという場合に使用されることが多いようです。

XFAIL: * という書き方も出来て、これは全ての環境で失敗することが期待されます。成功したら検知できるのが REQUIRES との違いです。

テスト補助ツール

Swift のテストでは、 lit と組み合わせてテストするためのいろいろな補助ツールを使用しています。その中でもコンパイラのテストによく使用されるものを紹介していきます。

FileCheck

FileCheck も LLVM プロジェクトの成果物で、主に出力結果のテストに使用されます。
FileCheck は 標準入力の文字列が指定されたファイル中の CHECK: と一致すれば成功となります。例:

test/mytest.swift
// RUN: %target-run-simple-swift | %FileCheck %s

print("OK!!")
print("Hello Swift!")
// CHECK: OK
// CHECK-NEXT: Hello Swift!

ここで、 %target-run-simple-swift は lit の substitution で、現在のテストファイルをコンパイルして、実行するコマンドになります。その結果を | で FileCheck の標準入力に入れ、また %FileCheck %s で このテストファイル内の CHECK: ディレクティブを検査するという指定をしています。

CHECK: OK は出力結果に OK という文字列があれば成功します。部分一致です。また、 CHECK: 直後の連続した空白文字は無視されます。
CHECK-NEXT: は直前の FileCheck ディレクティブ(この場合は CHECK: OK)の「次の行」に指定された文字列が出現するかを検査します。

CHECK:, CHECK-NEXT の他にも、出現順を問わずに検査する CHECK-DAG: など、いくつかのディレクティブがあります。llvm.org の FileCheck マニュアル を参照してください。

また、 CHECK の部分は --check-prefix オプションにより、任意の文字列に変更できます。

test/mytest.swift
// RUN: %target-run-simple-swift | %FileCheck %s --check-prefix=OUTPUT

print("OK!!")
print("Hello Swift!")
// OUTPUT: OK
// OUTPUT-NEXT: Hello Swift!

これだと何の意味も無いですが、一つのテストファイルで複数パターンの出力をテストする場合に使用されます。

正規表現チェック

{{pattern}} によって正規表現でチェックを行います。

CHECK: Int{{[0-9]+}}:

Int の後に 1つ以上の数字が続き、その後に : が存在することを検査します。

変数

[[NAME:pattern]] という形式で、正規表現に一致すること確認し、また [[NAME]] でそれと同一の文字列がその後に出現することを確認することが出来ます。

test/mytest.swift
// RUN: %target-run-simple-swift | %FileCheck %s

let val: Int = 12

print("size of type:", MemoryLayout<Int>.size)
// CHECK: size of type: [[SIZE:[0-9]+]]
print("size of value:", MemoryLayout.size(ofValue: val))
// CHECK: size of value: [[SIZE]]

行番号

テストによっては、ソース上の行番号が出力されることを期待するテストがあります。そのときに、テストファイルを変更しても CHECK の値を書き換える必要がないように、 [[@LINE]], [[@LINE-<offset>]], [[@LINE+offset]] という表現が使えます。これは、チェック時にその CHECK: の行数(+-オフセット)に置き換えられます。

test/mytest.swift
// RUN: %target-run-simple-swift | %FileCheck %s

func printWithLine(_ message: String, line: Int = #line) {
    print("\(line):\(message)")
}

printWithLine("Hello")
// CHECK: [[@LINE-1]]:Hello

この例では CHECK: 行は8行目なので、 7:Hello が出力されることを期待しています。

not

not も LLVM プロジェクトの成果物です。コマンドが「失敗」する(終了ステータスが 0 以外である)ことをテストします。

not <COMMAND> [OPTIONS...] で、オプションは全てコマンドに引き渡されます。

test/mytest.swift
// RUN: not %target-swift-frontend -typecheck %s

public func foo(arg: Int?) {
  guard let arg = arg {
    return 0
  }
  return arg + 42
}

上記のコードでは、guard の条件の後ろの else を忘れてしまっています。これはコンパイルできてしまっては駄目なので、コンパイル出来ないことを確認するテストケースになります。また、FileCheck と組み合わせて、失敗するコマンドの出力をチェックする時にも使用されます。

他に not --crash <COMMAND> というオプションもあり、これは単なる失敗では無く、コマンドがクラッシュする(終了ステータスが 128 以上である)ことをテストします。Swiftのテストスイートには、 compiler_crashers3 というコンパイラのバグによりクラッシュしてしまうテストケースが集められていて、主にここで使用されます。バグを修正すると、このテストが失敗するので、クラッシュしなくなった事が検知出来ます。

Diagnostic verify

Swiftのコンパイラには、文法エラーや型チェックのエラーなど、プログラムの診断をして、エラーやワーニングを出力する機能があります。 Diagnostic verify はこれらの診断メッセージが正常に、意図された場所に出力されていることをテストする機能です。

test/mytest.swift
// RUN: %target-typecheck-verify-swift

func foo(x: Int, y: Int) -> Int {
    return x + y
}

let sum = foo(x: 12, x: 42)
// expected-error @-1 {{incorrect argument label in call (have 'x:x:', expected 'x:y:')}} {{22-23=y}} {{none}}

print(sum)

%target-typecheck-verify-swift はこのテストファイルをタイプチェックして Diagnostic verify を行うコマンドです。expected-error の行が診断メッセージをチェックしている部分です。

  • expected-error で、診断タイプを指定しています。他に warningnote があります。
  • @-1 で 診断の対象が一行前であることを指定しています。一行後なら @+1 、同じ行のときは省略します。
  • {{incorrect argument...}} でメッセージ内容の指定、ちなみに部分一致です。
  • {{22-23=y}} は fix-it と呼ばれる物で、 Xcode などでコードにエラーがあると 「Fix」 ボタンで修正してくれる機能がありますが、その検査です。この場合、22から23桁目の文字列を y に変更するという fix-it が発行されているということを検査します。 fix-it は一つの診断メッセージで複数発行されることがあるので、その場合はこれを複数記載します。ちなみに置き換えではなく、挿入の際は {{22-22=foo}} の様に開始桁と終了桁を同じに、削除の際は {{22-24=}} の様に置き換え文字列を空にします。
  • {{none}} でそれ以外に fix-it が発行されていないことを確認します。これを省略した場合は他に fix-it が発行されていても無視されます。実際には、プログラムのロジック上、いくつの fix-it が発行されるかは自明なことが多いので、省略されることが多いです。一つも fix-it がされないことを確認するには {{none}} だけを記載します。

文法としては、

diag-verify: 'expected-' diag-type line-offset? message fixit* fixit-none?
diag-type: 'error' | 'warning' | 'note'
line-offset: '@' ('+' | '-') NUMBER
message: '{{' STRING '}}'
fixit: '{{' NUMBER '-' NUMBER '=' STRING '}}'
fixit-none: '{{none}}'

こんな感じでしょうか。

Diagnostic verify テストは発生したすべてのエラーやワーニングについて、このディレクティブがマッチすることを確認し、一つでも意図しない(ディレクティブが記載されていない)エラーが発生したり、メッセージ内容が違っていたりするとテストが失敗します。

ちなみに、普通は全く必要ないですが、この機能は Swift のコンパイラ自体に組み込まれている機能です。

test.swift
func foo() -> Int { return 1 }
foo()

これを普通にコンパイルすると、

$ swift test.swift
test.swift:2:1: warning: result of call to 'foo()' is unused
foo()
^  ~~

ですが、-verify モードでコンパイルすると、予期しないワーニングが発生したというエラーになります。

$ swift -frontend -typecheck -verify test.swift
test.swift:2:1: error: unexpected warning produced: result of call to 'foo()' is unused
foo()
^

ファイルを修正して再度

test.swift
func foo() -> Int { return 1 }
foo() // expected-warning {{xxxx}}
$ swift -frontend -typecheck -verify test.swift
test.swift:2:29: error: incorrect message found
foo() // expected-warning {{xxxx}}
                            ^~~~
                            result of call to 'foo()' is unused

今度はメッセージが違うというエラーになりました。

test.swift
func foo() -> Int { return 1 }
foo() // expected-warning {{result of call to 'foo()' is unused}}

これで正常終了するようになります。

StdlibUnittest

コンパイラのテストには使用しませんが、標準ライブラリのテストのために
StdlibUnittest という Swift 製テストフレームワークがあります。 stdlib 専用 XCTest のようなもので、 stdlib/private/StdlibUnittest にソースがあります。

test/mytest.swift
// RUN: %target-run-simple-swift
// REQUIRES: executable_test

import StdlibUnittest

let MyTests = TestSuite("MyTest")

MyTests.test("Array count") {
    let arry: [Int] = [1,2,3]
    expectTrue(arry.count == 3)
}
MyTests.test("Dictionary count") {
    let dict: [String: Int] = ["foo": 1, "bar": 12]
    expectTrue(dict.count == 2)
}

runAllTests()

こんな雰囲気です。知っている限りではマニュアルやAPIリファレンスは有りません。docs/Testing.mdに数行だけ記載がある程度です。習うより慣れろ的な。

その他

他にも、シンタックスハイライトや、コード補完をテストするツール、SIL 最適化をテストするツールなど、いろいろありますが、全部を把握は出来ていません。他の似たようなテストファイルを見ながら「なんとなくこんな感じ」で大抵のことは済みます。

テストのディレクトリ構成

ディレクトリ
test/CMakeLists.txt テストスイートの CMake 設定
test/lit.cfg テストスイートの lit 設定
test/lit.site.cfg.in ビルド設定によって変化するテスト設定のテンプレート
test/stdlib/ 標準ライブラリのテストケース群
test/Inputs/ テストケースから使用される外部ファイル群。このディレクトリ内のファイルは lit の直接のテストファイルとしては扱われません。
test/他サブディレクトリ/ コンパイラのテストケース群。テストのカテゴリによってフォルダがわかれている
test/他サブディレクトリ/Inputs/ サブディレクトリの中からのみ使用される外部ファイル群。このディレクトリ内のファイルは lit の直接のテストファイルとしては扱われません。
test/Compatibility -swift-version によっての挙動差異のテスト

validation-test/ も大体同じような構成になっているのですが、主にクラッシュケース、ストレステスト的な負荷の高いテストが設置されているようです。

テストケースの設置場所

実際に開発に手を出し始めてみると、テストケースを何処に置くかが悩みの一つとなります。既存のファイルに追加すべきか?新規のテストファイルを作るべきか?どのディレクトリが適切なのか?などなど。

明文化されたガイドラインとしては docs/Testing.md にあるだけで:

When adding a new testcase, try to find an existing test file focused on the same topic rather than starting a new test file. There is a fixed runtime cost for every test file. On the other hand, avoid dumping new tests in a file that is only remotely related to the purpose of the new tests.

「テストケースを追加するときには、新たなテストファイルを追加するのではなく、似たような内容のテストファイルを探してテストを追加ください。テストファイルの追加は実行に一定のコスト増を伴います。とはいえ、あまり関係のないファイルに新たなテストを追加するのは避けてください。」

とまぁ、あいまいこの上ない感じです。

迷ったら適当なところに置いて PR 出してしまってもいいです。不適切だったら誰かが指示してくれると思います。不安なら PR 出す前に僕に訊いてください(正しい答えを出せるとは限りませんが。)

まとめ

とりあえずテストファイルを読むのに必要な知識はこの程度です。

実際にテストを書く際には、他のテストファイルを参考にすればなんとなく書けるので、あまり深掘りしないで、他のPRなど見てマネしつつどんどん PR 出していきましょう!


  1. これも正確ではなく、cmake --build で実行されるスクリプトを取得し、それを build-script がサブシェルで実行するという形になっています。 lit.py のプログレスバーを正常に表示するためのハックです。 

  2. ローカルの変更を git commit すると git の hash 値が変わり、 lib/Basic がリビルドされてしまい、それに引きずられてコンパイラのリンク、 stdlib 以下のリビルドが走ってしまうので、結構使います。 

  3. これは practicalswift による swift-compiler-crashes プロジェクトの成果物です。オープンソース前からリポジトリに取り入れられ、中の人がクラッシュバグを修正するのに使ってました。オープンソース後は practicalswift が直接コミットしてます。 

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.