データ接続と統合Python基本的なトランスフォームユニットテスト

注: 以下の翻訳の正確性は検証されていません。AIPを利用して英語版の原文から機械的に翻訳されたものです。

単体テスト

このページで説明する Python リポジトリの単体テストは、バッチパイプラインにのみ適用でき、ストリーミングパイプラインには対応していません。

Python リポジトリでは、チェックの一環としてテストを実行するオプションを利用できます。これらのテストは、広く使われている Python テストフレームワークの pytest ↗ を使用して実行されます。

CI タスク: condaPackRun

すべての CI チェックには、ほかのタスクとともに condaPackRun が含まれます。

リポジトリの CI チェックのビルド時間の概要。

condaPackRun タスクは、環境のインストールを行います。各アーティファクトは適切なチャネルから取得され、conda はこれらのアーティファクトを使用して環境を構築します。

このタスクの実行時間は、リポジトリに追加されたパッケージの数に大きく依存します。追加するパッケージが多いほど、タスクの実行は遅くなります。

このタスクには、次の3つの段階があります。

  1. 依存関係の解決済み環境に含まれるすべてのパッケージをダウンロードして展開します。
  2. パッケージの内容を検証します。設定に応じて、conda はチェックサムを使用するか、ファイルサイズが正しいことを検証します。
  3. パッケージを環境にリンクします。

環境仕様は、以降のビルド用のキャッシュとして、以下の隠しファイルに保存されます。

  • conda-version-run.linux-64.lock
  • conda-version-test.linux-64.lock

キャッシュは7日間保存されます。meta.yaml ファイルに変更が加えられると、再度キャッシュされます。

スタイルチェックを有効にする

Python プロジェクトの build.gradle ファイルで com.palantir.conda.pep8 および com.palantir.conda.pylint Gradle プラグインを適用すると、PEP8 および Pylint のスタイルチェックを有効にできます。トランスフォームリポジトリでは、このファイルは Python サブプロジェクトにあります。ライブラリリポジトリでは、ルートフォルダーにあります。

以下は、トランスフォームの build.gradle ファイルの例です。

Copied!
1 2 3 4 5 6 apply plugin: 'com.palantir.transforms.lang.python' apply plugin: 'com.palantir.transforms.lang.python-defaults' // pep8 の Linter で解析するプラグインを適用します apply plugin: 'com.palantir.conda.pep8' apply plugin: 'com.palantir.conda.pylint'

Pylint は、Python プロジェクトの src/.pylintrc ファイルで設定できます。たとえば、以下のように特定のメッセージを無効にできます。

[MESSAGES CONTROL]
disable =
    missing-module-docstring,
    missing-function-docstring
Pylint の制限事項

Pylint のすべての設定が Foundry で動作するとは限りません。src/.pylintrc の機能がチェックに表示されない場合、その機能には対応していないことを示します。

テストを有効にする

Python プロジェクトの build.gradle ファイルで com.palantir.transforms.lang.pytest-defaults Gradle プラグインを適用すると、テストを有効にできます。トランスフォームリポジトリでは、このファイルは Python サブプロジェクトにあります。ライブラリリポジトリでは、ルートフォルダーにあります。

以下は、トランスフォームの build.gradle ファイルの例です。

Copied!
1 2 3 4 5 apply plugin: 'com.palantir.transforms.lang.python' apply plugin: 'com.palantir.transforms.lang.python-defaults' // テスト用プラグインを適用します apply plugin: 'com.palantir.transforms.lang.pytest-defaults'

ライブラリの build.gradle ファイルは、次のようになります。

Copied!
1 2 3 4 5 6 7 8 apply plugin: 'com.palantir.transforms.lang.python-library' apply plugin: 'com.palantir.transforms.lang.python-library-defaults' // テスト用プラグインを適用します apply plugin: 'com.palantir.transforms.lang.pytest-defaults' // タグ付きリリースの場合のみ公開します(最後の git タグ以降のコミット数が0) condaLibraryPublish.onlyIf { versionDetails().commitDistance == 0 }

meta.yaml ファイルで定義された実行時の依存パッケージは、テストで利用可能になります。追加の依存パッケージは、conda の test セクション ↗でも指定できます。

テストをローカルで実行する

.maestro フォルダーは、Visual Studio Code 用の Palantir 拡張機能が Palantir: Install Python environment コマンドを実行したときに作成されます。ローカル環境の設定の詳細については、ローカル開発をセットアップするを参照してください。

.maestro ディレクトリ内のテスト実行ファイルを使用して、単体テストをローカルで実行します。

Copied!
1 .maestro/bin/pytest

このコマンドは、テストを記述するで説明する標準的な規約に従うテストを検出して実行するため、コミットして CI チェックを実行する前に問題を特定できます。

テストを記述する

Pytest は、名前が test_ で始まるか _test.py で終わる Python ファイル内のテストを識別します。すべてのテストを、プロジェクトの src ディレクトリ内の test パッケージに配置することを推奨します。テストは、名前に test_ 接頭辞が付き、Python の assert 文を使用したアサーションを含む Python 関数です。Pytest は、Python 組み込みの unittest ↗ モジュールを使用して記述したテストも実行します。テストの記述に関する詳細については、pytest ドキュメント ↗を参照してください。

単純なテストの例

以下は、transforms-python/src/test/test_increment.py に配置できる単純なテストの例です。このテストは、この例の目的上、失敗するように設計されています。

Copied!
1 2 3 4 5 def increment(num): return num + 1 def test_increment(): assert increment(3) == 5

このテストを実行すると、以下のメッセージが表示され、チェックに失敗します。

============================= test session starts =============================
collected 1 item

test_increment.py F                                                       [100%]

================================== FAILURES ===================================
_______________________________ test_increment ________________________________

    def test_increment():
>       assert increment(3) == 5
E       assert 4 == 5
E        +  where 4 = increment(3)

test_increment.py:5: AssertionError
========================== 1 failed in 0.08 seconds ===========================

DataFrame テストの例

以下の例のように、DataFrame を使用してテストを記述することもできます。

Copied!
1 2 3 4 5 6 7 8 9 import polars as pl def test_dataframe(): df = pl.DataFrame( [["a", 1], ["b", 2]], schema=["letter", "number"], orient="row" ) assert df.columns == ['letter', 'number']
Copied!
1 2 3 4 5 6 7 8 import duckdb def test_dataframe(): conn = duckdb.connect() df = conn.sql("""SELECT * FROM VALUES ('a', 1), ('b', 2) AS t(letter, number)""").fetchdf() assert list(df.columns) == ['letter', 'number']
Copied!
1 2 3 4 5 import pandas as pd def test_dataframe(): df = pd.DataFrame([['a', 1], ['b', 2]], columns=['letter', 'number']) assert list(df.columns) == ['letter', 'number']
Copied!
1 2 3 def test_dataframe(spark_session): df = spark_session.createDataFrame([['a', 1], ['b', 2]], ['letter', 'number']) assert df.schema.names == ['letter', 'number']

上記の Spark の例では、Pytest フィクスチャ ↗の使用に注目してください。これは、同じ名前のパラメーターを追加することで、テスト関数に値を注入できる強力な機能です。この機能を使用して、テスト関数で使用する spark_session フィクスチャを提供しています。

spark_session フィクスチャのスコープは、個々のテストではなくテストセッション全体です。そのため、テストは毎回新しい Spark セッションを受け取るのではなく、1つの Spark セッションを共有します。ローカルセッションのタイムゾーンの設定など、共有セッションの設定にテストが加えた変更は、そのセッションを使用する後続のテストでも保持されます。テストで Spark の設定を変更する場合は、テストが終了する前に元の値に戻してください。

CSV からテスト入力と期待される出力を作成する

CSV ファイルはコードリポジトリに保存でき、データ変換のテストで入力および期待される出力として使用できます。以下の例では、サンプルのデータ変換を示し、CSV ファイルをテストの入力および期待される出力として使用する方法を説明します。

データトランスフォームの例

以下の変換は、transforms-python/src/myproject/datasets/ フォルダー内の find_aircraft.py というファイルにあります。

Copied!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 from transforms.api import transform, Input, Output import polars as pl @transform.using( output=Output("<output_dataset_rid>"), aircraft=Input("<input_dataset_rid>"), ) def compute(output, aircraft): aircraft_df = aircraft.polars() filtered_df = aircraft_df.filter( (pl.col("number_of_seats") > 300) & (pl.col("operating_status") == "Yes") ) output.write_table(filtered_df)
Copied!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 from transforms.api import transform, Input, Output @transform.using( output=Output("<output_dataset_rid>"), aircraft=Input("<input_dataset_rid>"), ) def compute(ctx, output, aircraft): conn = ctx.duckdb().conn filtered_query = conn.sql(""" SELECT * FROM aircraft WHERE number_of_seats > 300 AND operating_status = 'Yes' """) output.write_table(filtered_query)
Copied!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 from transforms.api import transform, Input, Output import pandas as pd @transform.using( output=Output("<output_dataset_rid>"), aircraft=Input("<input_dataset_rid>"), ) def compute(output, aircraft): aircraft_df = aircraft.pandas() filtered_df = aircraft_df[ (aircraft_df["number_of_seats"] > 300) & (aircraft_df["operating_status"] == "Yes") ] output.write_table(filtered_df)
Copied!
1 2 3 4 5 6 7 8 9 10 11 12 13 from pyspark.sql import functions as F from transforms.api import transform, Input, Output @transform.spark.using( output=Output("<output_dataset_rid>"), aircraft=Input("<input_dataset_rid>"), ) def compute(output, aircraft): aircraft_df = aircraft.dataframe() filtered_df = aircraft_df.filter((F.col("number_of_seats") > F.lit(300)) & (F.col("operating_status") == F.lit("Yes"))) output.write_dataframe(filtered_df)

検証用 CSV ファイルの例

以下の2個の CSV ファイルとそれぞれの内容は、transforms-python/src/test/resources/ フォルダーに配置します。

aircraft_mock.csv 内のテスト入力:

Copied!
1 2 3 4 5 6 tail_number,serial_number,manufacture_year,manufacturer,model,number_of_seats,capacity_in_pounds,operating_status,aircraft_status,acquisition_date,model_type AAA1,20809,1990,Manufacturer_1,M1-100,1,3500,Yes,Owned,13/8/90,208 BBB1,46970,2013,Manufacturer_2,M2-300,310,108500,No,Owned,10/15/14,777 CCC1,44662,2013,Manufacturer_2,M2-300,310,108500,Yes,Owned,6/23/13,777 DDD1,58340,2014,Manufacturer_3,M3-200,294,100000,Yes,Leased,11/21/13,330 EEE1,58600,2013,Manufacturer_2,M2-300,300,47200,Yes,Leased,12/2/13,777

expected_filtered_aircraft.csv 内の期待される出力:

Copied!
1 2 tail_number,serial_number,manufacture_year,manufacturer,model,number_of_seats,capacity_in_pounds,operating_status,aircraft_status,acquisition_date,model_type CCC1,44662,2013,Manufacturer_2,M2-300,310,108500,Yes,Owned,6/23/13,777

CSV ファイルに対して検証するテストの例

以下のテストは、transforms-python/src/test/ フォルダー内の test_find_aircraft.py というファイルにあります。

変換関数が出力に書き込むデータを捕捉するために、MagicMock ↗ を使用している点に注目してください。これにより、実際に出力データセットにデータを書き込むことなく、テスト内で結果を直接調べて検証できます。この方法では、テストの独立性を保持し、データへの副次的効果なしに、_書き込まれるはずの_データをテストできます。

Copied!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 import os from pathlib import Path import polars as pl from polars.testing import assert_frame_equal from unittest.mock import MagicMock, patch from myproject.datasets.find_aircraft import compute TEST_RESOURCES_DIRECTORY_PATH = Path(os.path.dirname(__file__)).joinpath('resources') def test_find_aircrafts(): aircraft_mock_df = pl.read_csv(TEST_RESOURCES_DIRECTORY_PATH / 'aircraft_mock.csv') expected_filtered_aircraft_df = pl.read_csv(TEST_RESOURCES_DIRECTORY_PATH / 'expected_filtered_aircraft.csv') # 入力と出力のモックオブジェクトを作成します results_output_mock = MagicMock() aircraft_mock_input = MagicMock() aircraft_mock_input.polars = MagicMock(return_value=aircraft_mock_df) aircraft_mock_input.read_table = MagicMock(return_value=aircraft_mock_df) # データセットの入出力をモック化します datasets = {"results_output": results_output_mock, "aircraft_input": aircraft_mock_input} module = MagicMock() with patch.dict("sys.modules", {"foundry": MagicMock(), "foundry.transforms": module}): module.Dataset.get.side_effect = datasets.get compute() result_df = results_output_mock.write_polars.call_args[0][0] assert result_df.columns == expected_filtered_aircraft_df.columns assert result_df.height == expected_filtered_aircraft_df.height result_sorted = result_df.sort(result_df.columns) expected_sorted = expected_filtered_aircraft_df.sort(expected_filtered_aircraft_df.columns) assert_frame_equal(result_sorted, expected_sorted)
Copied!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 import os from pathlib import Path import duckdb import pandas as pd from unittest.mock import MagicMock, patch from myproject.datasets.find_aircraft import compute TEST_RESOURCES_DIRECTORY_PATH = Path(os.path.dirname(__file__)).joinpath('resources') def test_find_aircrafts(): conn = duckdb.connect() # DuckDB を使用して CSV データを読み込みます aircraft_mock_df = conn.sql(f"SELECT * FROM read_csv('{TEST_RESOURCES_DIRECTORY_PATH / 'aircraft_mock.csv'}', header=true)").fetchdf() expected_filtered_aircraft_df = conn.sql(f"SELECT * FROM read_csv('{TEST_RESOURCES_DIRECTORY_PATH / 'expected_filtered_aircraft.csv'}', header=true)").fetchdf() # 入力と出力のモックオブジェクトを作成します results_output_mock = MagicMock() aircraft_mock_input = MagicMock() # DuckDB 接続を持つモックコンテキストを作成します ctx_mock = MagicMock() duckdb_mock = MagicMock() duckdb_mock.conn = conn ctx_mock.duckdb = MagicMock(return_value=duckdb_mock) # DuckDB 接続にテストデータを登録します conn.register('aircraft', aircraft_mock_df) # データセットの入出力をモック化します datasets = {"results_output": results_output_mock, "aircraft_input": aircraft_mock_input} module = MagicMock() with patch.dict("sys.modules", {"foundry": MagicMock(), "foundry.transforms": module}): module.Dataset.get.side_effect = datasets.get compute(ctx_mock, results_output_mock, aircraft_mock_input) # モック化した write_table 呼び出しから結果を取得します result_query = results_output_mock.write_table.call_args[0][0] result_df = result_query.fetchdf() assert list(result_df.columns) == list(expected_filtered_aircraft_df.columns) assert len(result_df) == len(expected_filtered_aircraft_df) pd.testing.assert_frame_equal( result_df.sort_values(by=list(result_df.columns)).reset_index(drop=True), expected_filtered_aircraft_df.sort_values(by=list(expected_filtered_aircraft_df.columns)).reset_index(drop=True), check_dtype=False )
Copied!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 import os from pathlib import Path import pandas as pd from myproject.datasets.find_aircraft import compute TEST_RESOURCES_DIRECTORY_PATH = Path(os.path.dirname(__file__)).joinpath('resources') def test_find_aircrafts(): aircraft_mock_df = pd.read_csv(TEST_RESOURCES_DIRECTORY_PATH / 'aircraft_mock.csv') expected_filtered_aircraft_df = pd.read_csv(TEST_RESOURCES_DIRECTORY_PATH / 'expected_filtered_aircraft.csv') # 入力と出力のモックオブジェクトを作成します results_output_mock = MagicMock() aircraft_mock_input = MagicMock() aircraft_mock_input.pandas = MagicMock(return_value=aircraft_mock_df) aircraft_mock_input.read_table = MagicMock(return_value=aircraft_mock_df) # データセットの入出力をモック化します datasets = {"results_output": results_output_mock, "aircraft_input": aircraft_mock_input} module = MagicMock() with patch.dict("sys.modules", {"foundry": MagicMock(), "foundry.transforms": module}): module.Dataset.get.side_effect = datasets.get compute() result_df = results_output_mock.write_pandas.call_args[0][0] assert list(result_df.columns) == list(expected_filtered_aircraft_df.columns) assert len(result_df) == len(expected_filtered_aircraft_df) pd.testing.assert_frame_equal( result_df.sort_values(by=list(result_df.columns)).reset_index(drop=True), expected_filtered_aircraft_df.sort_values(by=list(expected_filtered_aircraft_df.columns)).reset_index(drop=True), check_dtype=False )
Copied!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 import os from pathlib import Path from unittest.mock import MagicMock from transforms.api import Input from myproject.datasets.find_aircraft import compute TEST_RESOURCES_DIRECTORY_PATH = Path(os.path.dirname(__file__)).joinpath('resources') def test_find_aircrafts(spark_session): aircraft_mock_df = spark_session.read.csv( str(TEST_RESOURCES_DIRECTORY_PATH.joinpath('aircraft_mock.csv')), inferSchema=True, header=True ) expected_filtered_aircraft_df = spark_session.read.csv( str(TEST_RESOURCES_DIRECTORY_PATH.joinpath('expected_filtered_aircraft.csv')), inferSchema=True, header=True ) # 出力のモックオブジェクトを作成します results_output_mock = MagicMock() # 入力のラッパーを作成し、返されるデータセットを設定します aircraft_mock_input = Input() aircraft_mock_input.dataframe = MagicMock(return_value=aircraft_mock_df) # モック出力オブジェクトを使用してトランスフォームを実行します compute( results_output=results_output_mock, aircraft_input=aircraft_mock_input ) # write_dataframe の呼び出しに使用された引数を取得し、 # トランスフォームの結果として書き込み用に送信された DataFrame を抽出します args, kwargs = results_output_mock.write_dataframe.call_args result_df = args[0] assert result_df.columns == expected_filtered_aircraft_df.columns assert result_df.count() == expected_filtered_aircraft_df.count() assert result_df.exceptAll(expected_filtered_aircraft_df).count() == 0 assert expected_filtered_aircraft_df.exceptAll(result_df).count() == 0

最終的なリポジトリ構造は、次のようになります。

サンプル入力を使用した単体テスト。

テストは transforms-python/src/test/test_find_aircraft.py ファイルに配置し、入力と期待される出力を含む CSV リソースは transforms-python/src/test/resources フォルダーに配置します。

テスト出力を表示する

設定済みテストの出力は、チェックタブにテストごとの個別の出力として表示されます。テスト結果はデフォルトで折りたたまれ、ステータスは PASSED、FAILED、または SKIPPED になります。個々のテストまたはすべてのテストを展開すると、テスト出力と StdOut および StdErr のログが表示されます。

テスト結果の表示。

テストカバレッジ

Pytest coverage ↗ を使用して、カバレッジを計算し、リポジトリーでカバレッジの最小割合を必須にできます。

そのためには、リポジトリーの meta.yml ファイルに次の内容を追加します。

Copied!
1 2 3 test: requires: - pytest-cov

次の内容を含む pytest.ini ファイルを /transforms-python/src/pytest.ini に作成します。

Copied!
1 2 [pytest] addopts = --cov=<<package name, e.g. myproject>> --cov-report term --cov-fail-under=100

チェックの失敗を判定するカバレッジのしきい値はカスタマイズできます。--cov-fail-under 引数に指定する割合を選択します。

これで、テストの実行でカバレッジが指定した割合を下回ると、次の出力とともに失敗します。

失敗したテストカバレッジチェック。

テストを並列化する

デフォルトでは、pytest はテストを順次実行します。テストの実行を高速化するために、複数の CPU にテストを送信できます。

トランスフォームの build.gradle ファイルを編集し、numProcesses を使用するプロセス数に設定します。

Copied!
1 2 3 4 5 6 7 8 9 apply plugin: 'com.palantir.transforms.lang.python' apply plugin: 'com.palantir.transforms.lang.python-defaults' // テスト用プラグインを適用します apply plugin: 'com.palantir.transforms.lang.pytest-defaults' tasks.pytest { numProcesses "3" }

テストの並列化には、pytest-xdist ↗ テストプラグインが使用されます。

テストを並列化すると、保留中のテストが利用可能なワーカーに送信されますが、順序は保証されません。グローバルな状態や共有状態を必要とし、先行するほかのテストによる変更を前提とするテストは、それに応じて調整する必要があります。

補足事項

  1. テストを有効にすると、コミット時に CI ログで :transforms-python:pytest タスクが実行されていることを確認できるはずです。
  2. テストは、ファイル名と関数名の両方の先頭にある test_ 接頭辞に基づいて検出されます。これは pytest の規約です。
  3. サンプルレコードを手早く取得するには、Code Workbook コンソールでデータセットを開き、.collect() を呼び出します。
  4. Python 形式のスキーマを取得するには、データセットプレビューを開き、列タブを開いて、コピー > PySpark スキーマをコピーを選択します。

Spark 固有のテストガイダンス

以下は、Foundry の Spark コンピューティングエンジンを使用する際に関連する補足情報です。

Spark アンチパターンプラグインを有効にする

Spark アンチパターンリンターを有効にするには、Python プロジェクトの build.gradle ファイルで com.palantir.transforms.lang.antipattern-linter Gradle プラグインを適用します。

Copied!
1 2 // アンチパターン用の Linter を適用します apply plugin: 'com.palantir.transforms.lang.antipattern-linter'

Spark アンチパターンプラグインは、正確性の問題、Spark のパフォーマンス低下、セキュリティ上の影響など、Spark でよくあるアンチパターンの使用に対して警告します。