Test

Julia の基礎部分のテスト

Julia の開発は高速に進んでおり、複数のプラットフォームで機能を検証する幅広いテストスイートを持ちます。このテストスイートは Julia をソースからビルドしたときは make test で実行でき、バイナリインストールしたときは Base.runtests() で実行できます。

Base.runtests ── 関数

Base.runtests(tests=["all"]; ncores=ceil(Int, Sys.CPU_THREADS / 2),
              exit_on_error=false, revise=false, [seed])

ncores 個のプロセッサを使って tests が指定する Julia のユニットテストを実行します。tests は文字列または文字列の配列です。exit_on_errorfalse だといずれかのテストが失敗した場合でも他のファイルのテストは実行され、true だとテストが一つ失敗した時点で全ての実行が終了します。revisetrue だと、テストを実行する前に Base および標準ライブラリに対する変更が Revise パッケージを使って読み込まれます。キーワード引数 seed が与えられると、テストを実行するコンテキストにおけるグローバルの RNG がその値でシードされます。seed が与えなければシードはランダムに選ばれます。

基礎的なユニットテスト

Test モジュールは簡単なユニットテストの機能を提供します。ユニットテストとは書いたコードが予想通りの結果を返すことを確認するコードのことです。開発中に完成したコードが持つべき振る舞いを書き留めたり、将来コードを変更した後にも正しく動作することを保証するために利用します。

簡単なユニットテストは @test マクロと @test_throws マクロで行います:

[email protected] ── マクロ

@test ex
@test f(args...) key=val ...

extrue に評価されることをテストします。ex の評価結果が true なら Pass 型の値、false なら Fail 型の値、評価できなかったときは Error 型の値を返します (Pass, Fail, ErrorResult の部分型です)。

julia> @test true
Test Passed

julia> @test [1, 2] + [2, 1] == [3, 3]
Test Passed

@test f(args...) key=val... の形式は @test f(args..., key=val...) と等価であり、近似比較のような中置記法の構文を持つ関数の呼び出しをテストするときに有用です:

julia> @test π  3.14 atol=0.01
Test Passed

これは @test ≈(π, 3.14, atol=0.01) とも書けますが、読みにくくなります。二つ目以降の式に代入文 (k=v) でない式を渡すとエラーになります。

[email protected]_throws ── マクロ

@test_throws exception expr

expr が例外 exception を送出することをテストします。例外は型もしくは値で指定でき、値で指定するとフィールドの比較で等価性が判定されます。@test_throws は末尾にキーワード引数を取る形式をサポートしないことに注意してください。

julia> @test_throws BoundsError [1, 2, 3][4]
Test Passed
      Thrown: BoundsError

julia> @test_throws DimensionMismatch [1, 2, 3] + [1, 2]
Test Passed
      Thrown: DimensionMismatch

例えば、次の関数 foo(x) が予想通りに動作することを確認したいとします:

julia> using Test

julia> foo(x) = length(x)^2
foo (generic function with 1 method)

このときは @test を使います。このマクロに渡した条件が true なら Pass オブジェクトが返ります:

julia> @test foo("bar") == 9
Test Passed

julia> @test foo("fizz") >= 10
Test Passed

条件が false だと Fail オブジェクトが返り、例外が送出されます:

julia> @test foo("f") == 20
Test Failed at none:1
  Expression: foo("f") == 20
   Evaluated: 1 == 20
ERROR: There was an error during testing

例外が送出されたために条件が評価できなかった場合は Error オブジェクトが返ります。今の例では length がシンボルに対して定義されていないので、foo にシンボルを渡すと例外が送出されます:

julia> @test foo(:cat) == 1
Error During Test
  Test threw an exception of type MethodError
  Expression: foo(:cat) == 1
  MethodError: no method matching length(::Symbol)
  Closest candidates are:
    length(::SimpleVector) at essentials.jl:256
    length(::Base.MethodList) at reflection.jl:521
    length(::MethodTable) at reflection.jl:597
    ...
  Stacktrace:
  [...]
ERROR: There was an error during testing

式の評価で例外が送出されるべきなら、そのことは @test_throws を使って確認できます:

julia> @test_throws MethodError foo(:cat)
Test Passed
      Thrown: MethodError

テストセットの利用

関数が様々な入力に対して正しく動作することの確認には通常たくさんのテストが使われます。テストが失敗した場合のデフォルトの振る舞いはすぐに例外を送出するというものですが、普通は残りのテストを全て実行してテストされているコードに存在するエラーの全体像を把握した方が望ましいでしょう。

@testset マクロを使うと、いくつかのテストをテストセットとしてグループ化できます。テストセットに含まれるテストはまとめて実行され、結果はテストセットの終わりにまとめて出力されます。いずれかのテストが失敗あるいはエラーを送出すると、テストセットに含まれるテストが全て実行されてから TestSetException が送出されます。

[email protected] ── マクロ

@testset [CustomTestSet] [option=val  ...] ["description"] begin ... end
@testset [CustomTestSet] [option=val  ...] ["description $v"] for v in (...) ... end
@testset [CustomTestSet] [option=val  ...] ["description $v, $w"] for v in (...), w in (...) ... end

新しいテストセットを開始します。for ループが与えられた場合は反復ごとに新しいテストセットを開始します。

独自のテストセット型が CustomTestSet に与えられなければデフォルトの DefaultTestSet が使われます。DefaultTestSet は全てのテスト結果を記録してテストセットの終了時にテスト結果を出力し、もし FailError があればトップレベルの (入れ子になっていない) テストセットが終わるときに例外を送出します。

CustomTestSet には独自のテストセット型 (AbstractTestSet の部分型) を指定でき、指定すると入れ子になった @testset の呼び出しでも同じ CustomTestSet が使われます。独自のテストセット型が使われるのはそれが指定されたテストセットでのみです。省略するとデフォルトのテストセット型が使われます。

テストセットを説明する文字列ではループ変数の補間が可能です。説明文字列を与えないと、ループ変数を使って自動的に構築されます。

デフォルトで @testset マクロはテストセットを表すオブジェクトをそのまま返しますが、この振る舞いは独自のテストセット型を使ってカスタマイズできます。for ループを与えると、@testset マクロは各反復に対応するテストセットに対する finish メソッドの返り値を収集してそれを返します。デフォルトの finish は受け取ったテストセットオブジェクトをそのまま返すので、for ループではテストセットオブジェクトの配列が返ります。

@testset の本体を実行する前に Random.seed!(seed) が自動的に呼ばれます。ここで seed は現在のグローバル RNG の現在のシードです。さらに本体の実行が終わるとグローバル RNG の状態は @testset の前の状態に復元されます。これはテストが失敗した場合にも容易に再現性が得られるようにするためであり、グローバル RNG の状態に対する副作用を持つ @testset であっても自由な並べ替えが可能になります。

julia> @testset "trigonometric identities" begin
           θ = 2/3*π
           @test sin(-θ)  -sin(θ)
           @test cos(-θ)  cos(θ)
           @test sin(2θ)  2*sin(θ)*cos(θ)
           @test cos(2θ)  cos(θ)^2 - sin(θ)^2
       end;
Test Summary:            | Pass  Total
trigonometric identities |    4      4

前に示した foo(x) 関数のテストをテストセットに入れると次のようになります:

julia> @testset "Foo Tests" begin
           @test foo("a")   == 1
           @test foo("ab")  == 4
           @test foo("abc") == 9
       end;
Test Summary: | Pass  Total
Foo Tests     |    3      3

テストセットは入れ子にできます:

julia> @testset "Foo Tests" begin
           @testset "Animals" begin
               @test foo("cat") == 9
               @test foo("dog") == foo("cat")
           end
           @testset "Arrays $i" for i in 1:3
               @test foo(zeros(i)) == i^2
               @test foo(fill(1.0, i)) == i^2
           end
       end;
Test Summary: | Pass  Total
Foo Tests     |    8      8

この例のように入れ子になったテストセットで失敗が起こらないと、そのセットは要約に表示されません。失敗が起こると、そのテストセットに関する詳細だけが表示されます:

julia> @testset "Foo Tests" begin
           @testset "Animals" begin
               @testset "Felines" begin
                   @test foo("cat") == 9
               end
               @testset "Canines" begin
                   @test foo("dog") == 9
               end
           end
           @testset "Arrays" begin
               @test foo(zeros(2)) == 4
               @test foo(fill(1.0, 4)) == 15
           end
       end

Arrays: Test Failed
  Expression: foo(fill(1.0, 4)) == 15
   Evaluated: 16 == 15
[...]
Test Summary: | Pass  Fail  Total
Foo Tests     |    3     1      4
  Animals     |    2            2
  Arrays      |    1     1      2
ERROR: Some tests did not pass: 3 passed, 1 failed, 0 errored, 0 broken.

その他のテストマクロ

浮動小数点数の計算は正確でないので、近似的な等価判定は @test a ≈ b または直接 isapprox を使って行います (\approx をタブ補完することで入力できます)。

julia> @test 1  0.999999999
Test Passed

julia> @test 1  0.999999
Test Failed at none:1
  Expression: 1  0.999999
   Evaluated: 1  0.999999
ERROR: There was an error during testing

[email protected] ── マクロ

@inferred [AllowedType] f(x)

関数呼び出しの式 f(x) がコンパイラが推論するのと同じ型の値を返すことをテストします。型安全性を確認するときに有用です。

f(x) には任意の関数呼び出し式を指定できます。型が一致するなら f(x) の評価結果を返し、一致しないなら Error 型の値を返します (ErrorResult の部分型です)。

省略可能な引数 AllowedType を渡すとテストの条件が緩和されます。具体的には、推論された型の AllowedType を法としたモジュロと f(x) の返り値の型が一致するとき1、および返り値の型が AllowedType の部分型であるときにテストがパスするようになります。これは Union{Nothing, T}Union{Missing, T} といった小さな型共用体を返す関数の型安定性をテストするときに有用です。

julia> f(a) = a > 1 ? 1 : 1.0
f (generic function with 1 method)

julia> typeof(f(2))
Int64

julia> @code_warntype f(2)
Variables
  #self#::Core.Compiler.Const(f, false)
  a::Int64

Body::UNION{FLOAT64, INT64}
1  %1 = (a > 1)::Bool
└──      goto #3 if not %1
2       return 1
3       return 1.0

julia> @inferred f(2)
ERROR: return type Int64 does not match inferred return type Union{Float64, Int64}
[...]

julia> @inferred max(1, 2)
2

julia> g(a) = a < 10 ? missing : 1.0
g (generic function with 1 method)

julia> @inferred g(20)
ERROR: return type Float64 does not match inferred return type Union{Missing, Float64}
[...]

julia> @inferred Missing g(20)
1.0

julia> h(a) = a < 10 ? missing : f(a)
h (generic function with 1 method)

julia> @inferred Missing h(20)
ERROR: return type Int64 does not match inferred return type Union{Missing, Float64, Int64}
[...]

[email protected]_logs ── マクロ

@test_logs [log_patterns...] [keywords] expression

expression を実行し、生成されるログレコードを collect_test_logs で収集し、それが log_patterns とマッチすることを確認し、expression の評価結果を返します。keywords はログレコードの簡単なフィルタリングを提供します: キーワード引数 min_level は収集/テストが行われる最小ログレベルを制御し、キーワード引数 match_mode はログレコードと log_pattern のマッチを判定する方法を定義します。match_mode がデフォルトの :all だとログレコードの各要素と log_pattern の各要素に対して組ごとにマッチすることが確認され、:any だとログレコードのどれか一つと log_pattern がマッチすることが確認されます。

最も有用な log_patterns(level,message) の形をした単純なタプルです。これよりも多い要素数のタプルを指定すると、handle_message 関数を通して AbstractLogger に渡されたログメタデータの引数 (level,message,module,group,id,file,line) と (タプルの長さだけ) マッチします。デフォルトではパターンを表すタプルに存在する要素とログフィールドは要素ごとに == で比較されます。ただし例外的に標準のログレベルとのマッチではパターンにシンボルが利用でき、文字列またはシンボルのフィールドと Regex のパターンは occursin で比較されます。

一つの @info といくつかの @debug をログとして発生させる関数を考えます:

function foo(n)
    @info "Doing foo with n=$n"
    for i=1:n
        @debug "Iteration $i"
    end
    42
end

@info によるログは次のコードでテストできます:

@test_logs (:info,"Doing foo with n=2") foo(2)

@debug によるログもテストしたい場合には、キーワード引数 min_level@debug を有効化する必要があります:

@test_logs((:info,"Doing foo with n=2"),
           (:debug,"Iteration 1"),
           (:debug,"Iteration 2"),
           min_level=Debug,
           foo(2))

特定のログだけをテストして他は無視したい場合には、キーワード引数 match_mode=:any を使います:

@test_logs((:info,),
           (:debug,"Iteration 42"),
           min_level=Debug,
           match_mode=:any,
           foo(100))

@test_logs@test を連鎖させて返り値をテストすることもできます:

@test (@test_logs (:info,"Doing foo with n=2") foo(2)) == 42

[email protected]_deprecated ── マクロ

@test_deprecated [pattern] expression

コマンドライン引数で --depwarn=yes が指定されているなら、expression が非推奨警告を発生させることをテストし、expression の評価結果を返します。そのときログメッセージの文字列が pattern にマッチすることが確認されます。pattern のデフォルト値は r"deprecated"i です。

--depwarn=no のときは何もせずに expression の評価結果を返します。--depwarn=error なら ErrorExcaption が発生することが確認されます。

# Julia 0.7 で非推奨
@test_deprecated num2hex(1)

# @test を使うと返り値をテストできる。
@test (@test_deprecated num2hex(1)) == "0000000000000001"

[email protected]_warn ── マクロ

@test_warn msg expr

expr を評価したときの stderr への出力が文字列もしくは正規表現 msg とマッチすることをテストします。msg が真偽値を返す関数なら、msg(output)true を返すことがテストされます。msg がタプルまたは配列なら、エラー出力が msg の各要素とマッチすることがテストされます。expr の評価結果を返します。

エラー出力が存在しないことを確認するには @test_nowarn を使ってください。

注意: @warn が生成する警告はこのマクロでテストできません。@test_logs を使ってください。

[email protected]_nowarn ── マクロ

@test_nowarn expr

expr を評価したときに stderr に何も出力されないこと (警告などのメッセージがないこと) をテストします。expr の評価結果を返します。

注意: @warn が生成する警告が存在しないことはこのマクロでテストできません。@test_logs を使ってください。

壊れたテスト

特定のテストが失敗していて手の施しようがないなら、そのテストでは @teest_broken マクロを使うこともできます。このマクロはテストが失敗したときは Broken を返して実行を続け、成功したときにだけ Error でユーザーに報告します。

[email protected]_broken ── マクロ

@test_broken ex
@test_broken f(args...) key=val ...

「このテストはパスするべきだが、現在は確実に失敗する」という印をテストに付けます。式 exfalse に評価されることをテストし、true に評価されたら例外を送出します。テストが失敗したら Broken 型の値を返し、成功したら Error 型の値を返します (BrokenErrorResult の部分型です)。

@test_broken f(args...) key=val... の意味は @test と同様です。

julia> @test_broken 1 == 2
Test Broken
  Expression: 1 == 2

julia> @test_broken 1 == 2 atol=0.1
Test Broken
  Expression: ==(1, 2, atol = 0.1)

テストを評価せずに飛ばすマクロとして @test_skip もあります。ただしテストが実行されなくても Broken 型の値は返るので、テストセットの要約には表示されます。

[email protected]_skip ── マクロ

@test_skip ex
@test_skip f(args...) key=val ...

「このテストを実行してはいけないが、テストセットの要約には Broken として含まれるべきである」という印をテストに付けます。断続的に失敗するテストや実装されていない機能のテストでこのマクロは有用です。

@test_skip f(args...) key=val... の意味は @test と同様です。

julia> @test_skip 1 == 2
Test Broken
  Skipped: 1 == 2

julia> @test_skip 1 == 2 atol=0.1
Test Broken
  Skipped: ==(1, 2, atol = 0.1)

独自の AbstractTestSet 型の作成

パッケージは recordfinish を実装することで AbstractTestSet の部分型を独自に作成できます。新しい部分型はテストセットを説明する文字列を受け取る一引数のコンストラクタを持つべきです (他のオプションはキーワード引数で渡します)。

Test.record ── 関数

record(ts::AbstractTestSet, res::Result)

テストセットに結果を記録します。この関数はテストセットに含まれる @test マクロが完了するたびに @testset 機構によって呼ばれ、そのときテストの結果が渡されます (結果は Error である可能性もあります)。またテストブロック内の @test コンテキストでない場所で例外が発生したときにも、recordError と共に呼ばれます。

Test.finish ── 関数

finish(ts::AbstractTestSet)

テストセットで必要な最終処理を行います。これは @testset 機構によってテストブロックの実行が終わったときに呼ばれます。この関数では get_testset を使ってテストセットを親の結果リストに追加する処理がよく行われます。

テストセットの実行中に入れ子になったテストセットのスタックを管理する責任は Test モジュールにありますが、そのスタックにテスト結果を積むのは AbstractTestSet の部分型の責任です。このスタックには get_testset メソッドと get_testset_depth メソッドでアクセスできます。この二つの関数はエクスポートされていないことに注意してください。

Test.get_testset ── 関数

get_testset()

アクティブなテストセットをタスクローカルのストレージから取得します。アクティブなテストセットが存在しなければ、デフォルトの FallbackTestSet を返します。

Test.get_testset_depth ── 関数

get_testset_depth()

アクティブなテストセットの個数を返します。個数に FallbackTestSet は含みません。

入れ子になった @testset の呼び出しに AbstractTestSet の部分型が渡されないときは親と同じものが自動的に使われますが、この処理も Test モジュールによって行われます。ただし親のテストセットが持つプロパティは伝播しません。パッケージでテストセットが持つプロパティの継承を実装したい場合には、Test モジュールが提供するスタック機構を使って実装してください。

AbstractTestSet の部分型の定義は次のような形になるでしょう:

import Test: Test, record, finish
using Test: AbstractTestSet, Result, Pass, Fail, Error
using Test: get_testset_depth, get_testset
struct CustomTestSet <: Test.AbstractTestSet
    description::AbstractString
    foo::Int
    results::Vector
    # コンストラクタはテストセットを説明する文字列と
    # オプションを表すキーワード引数を受け取る。
    CustomTestSet(desc; foo=1) = new(desc, foo, [])
end

record(ts::CustomTestSet, child::AbstractTestSet) = push!(ts.results, child)
record(ts::CustomTestSet, res::Result) = push!(ts.results, res)
function finish(ts::CustomTestSet)
    # トップレベルでないなら、親のテストセットに自身を記録する。
    if get_testset_depth() > 0
        record(get_testset(), ts)
    end
    ts
end

テストセットは次のように使います:

@testset CustomTestSet foo=4 "custom testset inner 2" begin
    # このテストセットは CustomTestSet 型を継承するが、引数は継承しない。
    @testset "custom testset inner" begin
        @test true
    end
end

  1. 訳注: 言い換えると、推論された型から AllowedType を “取り除いた” 型が f(x) の返り値の型と一致するとき。実装では typesubtract(推論された型,AllowedType) が使われている。[return]


日本語 Julia 書籍 (Amazon アソシエイト)
1 から始める Julia プログラミング
Julia プログラミングクックブック―言語仕様からデータ分析、機械学習、数値計算まで
スタンフォード ベクトル・行列からはじめる最適化数学