💬

Juliaでユニットテスト

2023/05/27に公開

初期設定

前回の記事でいろいろを実験したので、

  • MyPackageの依存パッケージはLinearAlgebraのみ
  • グローバル環境にはMyPackageは追加されていない
  • グローバル環境にReviseパッケージがインストールされている

状態にしましょう。以上の条件が満たされていない場合には、それぞれ、MyPackage環境、グローバル環境のパッケージモードでrmもしくはaddコマンドを使って直しましょう。

以下の様にMyPackage/Project.tomlが見えるはずです。

name = "MyPackage"
uuid = "eb66ca03-d5f6-4852-a7de-5dc300cf5d77"
authors = ["Hiroshi Shinaoka <h.shinaoka@gmail.com>"]
version = "0.1.0"

[deps]
LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e"

ユニットテストとは

簡単に言えば、ユニットテストとは、関数や構造体など、パッケージの部品をチェックするテストのことです。そのようなユニットテスト群を整備しておき、自動的に走らせるようにすることで、プログラムの品質を保ちます。

ユニットテスト (Jupyter Notebookによる下書き編)

本来はノートブックを使って、ユニットテストを作るのは適切ではありませんが、説明の便宜上ここから始めましょう。まずは、整数が偶数か判定する関数を作ってみましょう。

MyPackage/notebook/test.ipynbという空のノートブックファイルを作ります。

> cd MyPackage
> mkdir notebook
> touch notebook/test.ipynb

VS Codeでtest.ipynbを開いた後、最初のセルに以下の様に入力しましょう。

using Test # @testマクロを使うため

function myiseven(x::Int)
    return x % 2 == 0
end

@test myiseven(1) == false
@test myiseven(2) == true
@test myiseven(3) == false

このセルを実行すると、myisevenという関数が定義された後、3つのテストが実行されます。ここでは、入力1, 2, 3に対して正しい結果を与えることがテストされます。もし、@testマクロ以降の文が偽である場合 (テストに失敗)、テストの失敗が報告されます。

ユニットテスト

では、ノートブックで下書きを書いた関数の実装と、テストをMyPackageパッケージに組み込みましょう。

関数の実装は、MyPackage/src/MyPackage.jlに追記します。

module MyPackage

greet() = print("Hello World!")

# ノートブックからコピー
function myiseven(x::Int)
    return x % 2 == 0
end

end # module MyPackage

一方、慣習に従って、テストは、MyPackage/testというディレクトリを作成し、その中にruntests.jlというファイルを作って格納します。

using Test
import MyPackage: myiseven

@testset "myiseven" begin
    @test myiseven(1) == false
    @test myiseven(2) == true
    @test myiseven(3) == false
end

nothing # test実行時に標準出力に大量のメッセージが出ないようにするおまじない

なお、テスト自体は、MyPacakge module (名前空間)の外で実行されることに注意して下さい。2行目のimportは、MyPackageからテスト対象の関数をテストが実行される名前空間にimportしています。@testsetマクロは、複数のテストをまとめます。通常は、テストする関数・機能毎にまとめることが多いでしょう。

また、TestはJuliaの標準ライブラリで、基本的には追加のインストールが不要ですが、パッケージ環境においては、Project.tomlに明示的にリストする必要があります。Testは、特別な[extras]と[targets]セクションで Project.toml ファイルに追加する必要があります。これにより、Testパッケージはテスト環境でのみ利用可能になります。

Project.toml ファイルに次の内容を追加しましょう。

[extras]
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"

[targets]
test = ["Test"]

これで準備万全です。次にMyPacakge環境のパッケージモードでtestコマンドを実行しましょう。

(@v1.9) pkg> activate .
  Activating project at `~/workspace/MyPackage`

(MyPackage) pkg> test
     Testing MyPackage
      Status `/private/var/folders/1t/fxgqx0n17z33mplwpw4n1d8r0000gn/T/jl_GbvIrr/Project.toml`
  [eb66ca03] MyPackage v0.1.0 `~/workspace/MyPackage`
  [8dfed614] Test `@stdlib/Test`
      Status `/private/var/folders/1t/fxgqx0n17z33mplwpw4n1d8r0000gn/T/jl_GbvIrr/Manifest.toml`
  [eb66ca03] MyPackage v0.1.0 `~/workspace/MyPackage`
  [2a0f44e3] Base64 `@stdlib/Base64`
  [b77e0a4c] InteractiveUtils `@stdlib/InteractiveUtils`
  [56ddb016] Logging `@stdlib/Logging`
  [d6f4376e] Markdown `@stdlib/Markdown`
  [9a3f8284] Random `@stdlib/Random`
  [ea8e919c] SHA v0.7.0 `@stdlib/SHA`
  [9e88b42a] Serialization `@stdlib/Serialization`
  [8dfed614] Test `@stdlib/Test`
     Testing Running tests...
Test Summary: | Pass  Total  Time
myiseven      |    3      3  0.0s
     Testing MyPackage tests passed

(MyPackage) pkg>

testコマンドを実行すると、Pkgはパッケージのテストを実行するための一時的な環境を作成し、そのパッケージとその依存関係をそこにインストールします。これは、JuliaのPkgシステムがテストの再現性を確保するために行う動作です。これは、テストがそれぞれのパッケージが想定している環境で実行されることを保証します。テストが一時的な環境で行われるため、テスト中に行われる変更はその環境外に影響を及ぼさないというメリットもあります。

ユニットテストの試行錯誤

試行錯誤必要です。src以下の実装にミスがあるかもしれませんし、テスト自体も間違っているかもしれません。一般に、MyPacakge/src以下の変更を反映するには、REPLを再起動してMyPackageを再importする必要があります。ただ、これは面倒ですし、特に巨大なライブラリ・パッケージを開発する際には、importやパッケージのコンパイル時間が無駄になります。そこで、Reviseパッケージを使って、REPLの再起動無しに、src以下の変更を自動再読込する方法を紹介しましょう。

例えば、src/MyPacakge.jlの実装が以下の様に間違っていたとしましょう。

function myiseven(x::Int)
    return x % 2 == 1 # 右辺は0が正しい...
end

テスト実行用のREPLを起動した直後にReviseをimportし、テストを実行します。すると、以下の様にテストに失敗します。

julia> using Revise

(@v1.9) pkg> activate .
  Activating project at `~/workspace/MyPackage`
(MyPackage) pkg> test
     Testing MyPackage
      Status `/private/var/folders/1t/fxgqx0n17z33mplwpw4n1d8r0000gn/T/jl_gWQpJg/Project.toml`
  [eb66ca03] MyPackage v0.1.0 `~/workspace/MyPackage`
  [8dfed614] Test `@stdlib/Test`
      Status `/private/var/folders/1t/fxgqx0n17z33mplwpw4n1d8r0000gn/T/jl_gWQpJg/Manifest.toml`
  [eb66ca03] MyPackage v0.1.0 `~/workspace/MyPackage`
  [2a0f44e3] Base64 `@stdlib/Base64`
  [b77e0a4c] InteractiveUtils `@stdlib/InteractiveUtils`
  [56ddb016] Logging `@stdlib/Logging`
  [d6f4376e] Markdown `@stdlib/Markdown`
  [9a3f8284] Random `@stdlib/Random`
  [ea8e919c] SHA v0.7.0 `@stdlib/SHA`
  [9e88b42a] Serialization `@stdlib/Serialization`
  [8dfed614] Test `@stdlib/Test`
Precompiling project...
  1 dependency successfully precompiled in 1 seconds
     Testing Running tests...
myiseven: Test Failed at /Users/hiroshi/workspace/MyPackage/test/runtests.jl:5
  Expression: myiseven(1) == false
   Evaluated: true == false

次に、テスト用のRERLを起動したまま、エディタでsrc/MyPackage.jlの実装を直します。
そこで、テスト用のREPLで再度テストを実行すると、修正後のソースコードが自動的に再読込されて、テストが通ることが分かります。

(MyPackage) pkg> test
     Testing MyPackage
      Status `/private/var/folders/1t/fxgqx0n17z33mplwpw4n1d8r0000gn/T/jl_ibqI4m/Project.toml`
  [eb66ca03] MyPackage v0.1.0 `~/workspace/MyPackage`
  [8dfed614] Test `@stdlib/Test`
      Status `/private/var/folders/1t/fxgqx0n17z33mplwpw4n1d8r0000gn/T/jl_ibqI4m/Manifest.toml`
  [eb66ca03] MyPackage v0.1.0 `~/workspace/MyPackage`
  [2a0f44e3] Base64 `@stdlib/Base64`
  [b77e0a4c] InteractiveUtils `@stdlib/InteractiveUtils`
  [56ddb016] Logging `@stdlib/Logging`
  [d6f4376e] Markdown `@stdlib/Markdown`
  [9a3f8284] Random `@stdlib/Random`
  [ea8e919c] SHA v0.7.0 `@stdlib/SHA`
  [9e88b42a] Serialization `@stdlib/Serialization`
  [8dfed614] Test `@stdlib/Test`
Precompiling project...
  1 dependency successfully precompiled in 1 seconds
     Testing Running tests...
Test Summary: | Pass  Total  Time
myiseven      |    3      3  0.0s
     Testing MyPackage tests passed

ただし、Reviseによる再読込は完全ではありません。例えば、構造体の定義を変更した場合、関数の引数を変えた場合など、再読込に失敗しまし、明示的なエラーメッセージが出ない場合もあります。その場合は、テスト用のREPLを再起動する必要があります。コツが必要ですが、生産性を大幅に高めてくれます。

ユニットテストを作るこつ

  • 関数の入力は、引数経由で行う。グローバル変数を使わないようにしてください。
    理由 = 関数の動作が、引数だけで決定されないと部品としてテストできません。
  • テストと実装は同時に書いても構いません。

    本来の「テスト駆動型開発」では、テストを先にしっかり書くが、数値計算・研究の場においては、同時に書くことが多いです。

  • 数値計算の結果をテストするときは、数値誤差の許容範囲をしっかり考えましょう。

    例: 浮動小数点の比較には、エラーを許容する比較関数 (Juliaの場合はisapprox)を使いましょう。

  • 1つのテストは長くても数秒で終わるように、基本的なものを選定してほうがいいです。実行時間が短い=基本的機能を1つずつ試す

  • 関数の型安定性をチェックすることは、数値計算のパフォーマンスを維持する上で重要です。@inferredマクロによるチェックの仕方は公式ドキュメントを参考にして下さい。また、@code_warntypeも、グローバル変数の混入、型安定性のチェックに有用です。

テストの実行に必要な依存性の扱い

パッケージの利用には不要で、パッケージのテストのみに必要な依存性をJuliaのパッケージシステムは扱うこと出来ます。例えば、テストでQuadGKパッケージ (数値積分)を使いたいとしましょう。

(方法1) MyPackage/Project.tomlに記述

この場合、MyPackage/Project.tomlにこのように書きます。

[extras]
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
QuadGK = "1fd47b50-473d-5c70-9696-f719f8f3bcdc"

[targets]
test = ["Test", "QuadGK"]

ここでは、"Test" と "QuadGK" パッケージがテストターゲットの追加の依存関係であると指定されています。つまり、テストコマンド (@v1.9) pkg> test を実行するときにのみ、これらのパッケージが必要となります。パッケージを通常の方法で利用する際には、これらのパッケージは必要とされません。

注意点としては、これらの追加の依存関係が "extras" セクションにリストされ、その上で "targets" セクションで適切なターゲット(この場合は "test")に関連付けられていることが重要です。

(方法2) test/Project.tomlに記述

パッケージのテスト用に専用のProject.tomlファイルをtestディレクトリ以下に配置することもできます。その場合には、MyPackage/Project.tomlにはテスト用の依存関係を記述せず、MyPackage/test/Project.tomlに以下の様に記述します。

MyPackage/test/Project.toml:

[deps]
Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40"
QuadGK = "1fd47b50-473d-5c70-9696-f719f8f3bcdc"

テストの実行

上記のいずれかの方法を採用した場合、test/runtests.jl側では、以下の様にQuadGKをimportすることができます。

using Test
import MyPackage: myiseven
import QuadGK # ここでimportできる。この例では実際に使用していない

@testset "myiseven" begin
    @test myiseven(1) == false
    @test myiseven(2) == true
    @test myiseven(3) == false
end

nothing

演習問題

以前の記事で作成したmydot関数のテストを作成してみましょう。

巨大なテストの分割

開発が進んでいくと、テストの数や実行時間が長くなることがあります。その際、開発の試行錯誤の際に、修正されていない機能のテストを繰り返し実行するのは、非効率な場合があります。このような場合には、テストを分割して、試行錯誤の際には一部のみ実行するというテクニックがあります。

例として、SparseIR.jlが参考になるでしょう。高度な話題なので、詳しく説明しませんが、

  • 複数のテスト用のファイルを作成し、test/runtests.jlではそれらをimportする。
  • 各テスト用のファイルでは、Test, テスト対象パッケージのimportを行う

点がポイントです。複数の流儀があるので、詳しい人に聞いてみましょう。

Discussion