-
Notifications
You must be signed in to change notification settings - Fork 0
Restructure tests into unit/e2e and add GitHub Actions CI #12
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| name: CI | ||
|
|
||
| on: | ||
| push: | ||
| branches: [main] | ||
| pull_request: | ||
| branches: [main] | ||
|
|
||
| jobs: | ||
| test: | ||
| runs-on: ubuntu-latest | ||
| strategy: | ||
| matrix: | ||
| python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] | ||
| steps: | ||
| - uses: actions/checkout@v6 | ||
| - uses: actions/setup-python@v6 | ||
| with: | ||
| python-version: ${{ matrix.python-version }} | ||
| allow-prereleases: true | ||
| - name: Install dependencies | ||
| run: | | ||
| pipx install poetry | ||
| poetry install | ||
| - name: Unit tests | ||
| run: poetry run pytest test/unit -v --cov=awsiotclient --cov-branch --cov-report=term-missing |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -108,6 +108,12 @@ job_client = jobs.client( | |
| # <wait until the client receives job> | ||
| ``` | ||
|
|
||
| ## Development | ||
|
|
||
| ### Why `poetry.lock` is not committed | ||
|
|
||
| This is a library, not an application. Users resolve dependencies via `pyproject.toml` constraints, so a lock file provides no benefit to them. Additionally, we support Python 3.8–3.14, and a single lock file cannot cover all versions. CI runs `poetry lock` on each build to test against the latest compatible dependencies. | ||
|
|
||
|
Comment on lines
+115
to
+116
|
||
| ## License | ||
|
|
||
| This library is licensed under the Apache 2.0 License. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,13 @@ flake8 = {version = "^6.0.0", python = ">=3.8.1"} | |
| flake8-pyproject = "^1.2.3" | ||
| isort = {version = "^5.12.0", python = ">=3.8"} | ||
| mypy = "^1.3.0" | ||
| pytest = "^7.0.0" | ||
| pytest-cov = {version = "^5.0.0", python = ">=3.8,<4.0.0"} | ||
|
Comment on lines
+21
to
+22
|
||
|
|
||
| [tool.pytest.ini_options] | ||
| testpaths = ["test"] | ||
| # Disable ROS2 (jazzy) pytest plugins that interfere when installed system-wide | ||
| addopts = "-p no:launch_testing -p no:launch_ros -p no:ament_copyright -p no:ament_xmllint -p no:ament_pep257 -p no:ament_mypy -p no:ament_lint -p no:ament_flake8" | ||
|
|
||
| [tool.isort] | ||
| profile = "black" | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,153 @@ | ||
| ## 準備 | ||
| ## ディレクトリ構成 | ||
|
|
||
| ``` | ||
| test/ | ||
| ├── e2e/ # AWS IoT実環境へ接続するE2Eテスト | ||
| │ ├── common.py | ||
| │ ├── test_mqtt.py | ||
| │ ├── test_pubsub.py | ||
| │ ├── test_jobs.py | ||
| │ ├── test_classic_shadow.py | ||
| │ └── test_named_shadow.py | ||
| └── unit/ # mockを使ったユニットテスト | ||
| ├── test_mqtt.py | ||
| ├── test_pubsub.py | ||
| ├── test_shadow.py | ||
| ├── test_shadow_callbacks.py | ||
| ├── test_classic_shadow.py | ||
| ├── test_named_shadow.py | ||
| ├── test_jobs.py | ||
| ├── test_jobs_callbacks.py | ||
| └── test_dictdiff.py | ||
| ``` | ||
|
|
||
| ## テスト一覧 | ||
|
|
||
| ### unit | ||
|
|
||
| | ファイル | テストクラス | テストケース | | ||
| | --- | --- | --- | | ||
| | test_dictdiff.py | DictdiffTestCase | test_dictdiff_no_difference | | ||
| | | | test_dictdiff_from_empty | | ||
| | | | test_dictdiff_to_empty | | ||
| | | | test_dictdiff_add_items | | ||
| | | | test_dictdiff_remove_items | | ||
| | | | test_dictdiff_update_items | | ||
| | | | test_dictdiff_update_dict | | ||
| | test_mqtt.py | TestConnectionParams | test_defaults | | ||
| | | | test_expands_home | | ||
| | | TestInit | test_mtls | | ||
| | | | test_websocket | | ||
| | | TestCallbacks | test_on_resubscribe_complete_raises_on_rejected | | ||
| | | | test_on_resubscribe_complete_succeeds | | ||
| | | TestConnectionCallbacks | test_on_connection_interrupted | | ||
| | | | test_on_connection_resumed_resubscribes_when_session_not_present | | ||
| | | | test_on_connection_resumed_skips_resubscribe_when_session_present | | ||
| | | TestWebsocketProxy | test_init_websocket_with_proxy_options | | ||
| | test_pubsub.py | TestSubscriber | test_subscribes_to_topic | | ||
| | | | test_callback_parses_json | | ||
| | | TestPublisher | test_publishes_payload | | ||
| | | | test_empty_payload_does_not_publish | | ||
| | test_shadow.py | TestDocumentTracker | test_get_set | | ||
| | | | test_update_same_value_returns_none | | ||
| | | | test_update_diff_only | | ||
| | | | test_update_full_doc | | ||
| | | TestShadowData | test_update_reported | | ||
| | | | test_update_desired | | ||
| | | | test_update_both | | ||
| | test_classic_shadow.py | TestClassicShadowClient | test_subscribes_on_init | | ||
| | | | test_publishes_get_shadow_on_init | | ||
| | | | test_update_shadow_request_publishes | | ||
| | | | test_update_noop_when_both_none | | ||
| | test_named_shadow.py | TestNamedShadowClient | test_subscribes_with_shadow_name | | ||
| | | | test_publishes_get_named_shadow_on_init | | ||
| | | | test_update_shadow_request_publishes | | ||
| | | | test_label_returns_shadow_name | | ||
| | | | test_update_noop_when_both_none | | ||
| | | | test_get_accepted_uses_full_document | | ||
| | test_jobs.py | TestJobsClient | test_subscribes_on_init | | ||
| | | | test_try_start_next_job_publishes | | ||
| | | | test_try_start_next_job_skips_when_already_working | | ||
| | | | test_job_thread_fn_calls_job_func_and_reports_succeeded | | ||
| | | | test_job_thread_fn_reports_failed_on_exception | | ||
| | test_shadow_callbacks.py | TestShadowCallbacks | test_delta_ignored_when_state_is_none | | ||
| | | | test_delta_deleted_property_resets_reported | | ||
| | | | test_delta_invalid_request_resets_desired | | ||
| | | | test_delta_unexpected_exception_is_reraised | | ||
| | | | test_get_shadow_accepted_uses_reported_state | | ||
| | | | test_get_shadow_accepted_ignores_when_delta_present | | ||
| | | | test_get_shadow_accepted_sets_default_when_property_missing | | ||
| | | | test_get_shadow_accepted_returns_early_when_reported_already_set | | ||
| | | | test_get_shadow_accepted_reraises_unexpected_errors | | ||
| | | | test_get_shadow_rejected_404_sets_default | | ||
| | | | test_get_shadow_rejected_non_404_raises | | ||
| | | | test_update_shadow_accepted_sets_desired_and_calls_callback | | ||
| | | | test_update_shadow_accepted_reraises_when_desired_callback_fails | | ||
| | | | test_update_shadow_rejected_raises | | ||
| | | | test_on_publish_update_shadow_raises_when_future_fails | | ||
| | | | test_change_both_values_publishes_update | | ||
| | | | test_change_desired_value_publishes_update | | ||
| | test_jobs_callbacks.py | TestJobsCallbacks | test_try_start_next_job_skips_when_disconnect_called | | ||
| | | | test_init_wraps_subscription_failure | | ||
| | | | test_done_working_on_job_retries_when_waiting | | ||
| | | | test_on_next_job_execution_changed_none | | ||
| | | | test_on_next_job_execution_changed_sets_wait_flag_while_working | | ||
| | | | test_on_next_job_execution_changed_starts_now_when_idle | | ||
| | | | test_on_next_job_execution_changed_wraps_unexpected_error | | ||
| | | | test_on_publish_start_next_pending_job_execution_raises | | ||
| | | | test_start_next_pending_job_accepted_spawns_thread | | ||
| | | | test_start_next_pending_job_accepted_wraps_thread_creation_error | | ||
| | | | test_start_next_pending_job_accepted_without_execution_marks_done | | ||
| | | | test_start_next_pending_job_rejected_raises | | ||
| | | | test_job_thread_user_defined_failure_sets_status_details | | ||
| | | | test_on_publish_update_job_execution_raises | | ||
| | | | test_on_update_job_execution_accepted_calls_done | | ||
| | | | test_on_update_job_execution_accepted_wraps_done_error | | ||
| | | | test_on_update_job_execution_rejected_raises | | ||
|
|
||
| ### e2e | ||
|
|
||
| | ファイル | テストクラス | テストケース | | ||
| | --- | --- | --- | | ||
| | test_mqtt.py | TestMqtt | test_mqtt_setup | | ||
| | test_pubsub.py | TestPubSub | test_pubsub | | ||
| | test_jobs.py | TestJobs | test_jobs | | ||
| | test_classic_shadow.py | TestClassicShadow | test_classic_shadow_reported | | ||
| | | | test_classic_shadow_reported_value_delta | | ||
| | | | test_classic_shadow_desired_matches_with_reported | | ||
| | test_named_shadow.py | TestNamedShadow | test_named_shadow_reported | | ||
| | | | test_named_shadow_reported_value_delta | | ||
| | | | test_named_shadow_desired_matches_with_reported | | ||
|
|
||
| ## テストの実行 | ||
|
|
||
| ```bash | ||
| # ユニットテストのみ | ||
| PYTHONPATH=src pytest test/unit -v | ||
|
|
||
| # E2Eテスト(要AWS認証情報) | ||
| source ./test/env.sh | ||
| PYTHONPATH=src pytest test/e2e -v | ||
|
|
||
| # 全テスト | ||
| source ./test/env.sh | ||
| PYTHONPATH=src pytest test -v | ||
|
|
||
| # ユニットテストのカバレッジ確認(推奨: pytest-cov) | ||
| PYTHONPATH=src pytest -q test/unit \ | ||
| --cov=awsiotclient \ | ||
| --cov-branch \ | ||
| --cov-report=term-missing \ | ||
| --cov-fail-under=95 | ||
|
|
||
| # 補助: pytest-cov が利用できない環境では trace を使用 | ||
| # (pytest-cov は Python 3.8+ で利用可能) | ||
| PYTHONPATH=src python -m trace --count --missing --summary --module pytest -q test/unit | ||
| ``` | ||
|
|
||
| `awsiotclient` がグローバル環境にもインストールされている場合、`PYTHONPATH=src` を付けないと `src/` ではなく `site-packages` の実装を参照する可能性があります。 | ||
|
|
||
| ## E2Eテストの準備 | ||
|
|
||
| ```bash | ||
| # モノの作成 | ||
|
Comment on lines
+150
to
153
|
||
|
|
@@ -21,10 +170,3 @@ aws iot attach-policy \ | |
| --target "$(jq -r .certificateArn < ./test/certs/cert.json)" \ | ||
| --policy-name <policy> | ||
| ``` | ||
|
|
||
| ## テストの実行 | ||
|
|
||
| ```bash | ||
| source ./test/env.sh | ||
| python3 -m unittest | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| from concurrent.futures import Future | ||
| from unittest.mock import MagicMock | ||
|
|
||
| import pytest | ||
|
|
||
|
|
||
| def _done_future(value=None): | ||
| future = Future() | ||
| future.set_result(value) | ||
| return future | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def make_done_future(): | ||
| return _done_future | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def make_classic_shadow_client(make_done_future): | ||
| def factory(): | ||
| mock_client = MagicMock() | ||
| for method_name in [ | ||
| "subscribe_to_shadow_delta_updated_events", | ||
| "subscribe_to_update_shadow_accepted", | ||
| "subscribe_to_update_shadow_rejected", | ||
| "subscribe_to_get_shadow_accepted", | ||
| "subscribe_to_get_shadow_rejected", | ||
| ]: | ||
| getattr(mock_client, method_name).return_value = (make_done_future(), 1) | ||
|
|
||
| mock_client.publish_get_shadow.return_value = make_done_future() | ||
| mock_client.publish_update_shadow.return_value = make_done_future() | ||
| return mock_client | ||
|
|
||
| return factory | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def make_named_shadow_client(make_done_future): | ||
| def factory(): | ||
| mock_client = MagicMock() | ||
| for method_name in [ | ||
| "subscribe_to_named_shadow_delta_updated_events", | ||
| "subscribe_to_update_named_shadow_accepted", | ||
| "subscribe_to_update_named_shadow_rejected", | ||
| "subscribe_to_get_named_shadow_accepted", | ||
| "subscribe_to_get_named_shadow_rejected", | ||
| ]: | ||
| getattr(mock_client, method_name).return_value = (make_done_future(), 1) | ||
|
|
||
| mock_client.publish_get_named_shadow.return_value = make_done_future() | ||
| mock_client.publish_update_named_shadow.return_value = make_done_future() | ||
| return mock_client | ||
|
|
||
| return factory | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def make_jobs_client(make_done_future): | ||
| def factory(): | ||
| mock_client = MagicMock() | ||
| for method_name in [ | ||
| "subscribe_to_next_job_execution_changed_events", | ||
| "subscribe_to_start_next_pending_job_execution_accepted", | ||
| "subscribe_to_start_next_pending_job_execution_rejected", | ||
| "subscribe_to_update_job_execution_accepted", | ||
| "subscribe_to_update_job_execution_rejected", | ||
| ]: | ||
| getattr(mock_client, method_name).return_value = (make_done_future(), 1) | ||
|
|
||
| mock_client.publish_start_next_pending_job_execution.return_value = ( | ||
| make_done_future() | ||
| ) | ||
| mock_client.publish_update_job_execution.return_value = make_done_future() | ||
| return mock_client | ||
|
|
||
| return factory | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def make_pubsub_connection(make_done_future): | ||
| def factory(qos): | ||
| connection = MagicMock() | ||
| connection.subscribe.return_value = (make_done_future({"qos": qos}), 1) | ||
| connection.publish.return_value = (make_done_future(None), 1) | ||
| return connection | ||
|
|
||
| return factory |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| from unittest.mock import MagicMock, patch | ||
|
|
||
| from awscrt import mqtt | ||
|
|
||
| import awsiotclient.classic_shadow as classic_shadow | ||
|
|
||
|
|
||
| class TestClassicShadowClient: | ||
| @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") | ||
| def test_subscribes_on_init(self, MockShadowClient, make_classic_shadow_client): | ||
| mock_client = make_classic_shadow_client() | ||
| MockShadowClient.return_value = mock_client | ||
|
|
||
| conn = MagicMock(spec=mqtt.Connection) | ||
| classic_shadow.client(conn, "thing1", "prop1") | ||
|
|
||
| mock_client.subscribe_to_shadow_delta_updated_events.assert_called_once() | ||
| mock_client.subscribe_to_update_shadow_accepted.assert_called_once() | ||
| mock_client.subscribe_to_update_shadow_rejected.assert_called_once() | ||
| mock_client.subscribe_to_get_shadow_accepted.assert_called_once() | ||
| mock_client.subscribe_to_get_shadow_rejected.assert_called_once() | ||
|
|
||
| @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") | ||
| def test_publishes_get_shadow_on_init(self, MockShadowClient, make_classic_shadow_client): | ||
| mock_client = make_classic_shadow_client() | ||
| MockShadowClient.return_value = mock_client | ||
|
|
||
| conn = MagicMock(spec=mqtt.Connection) | ||
| classic_shadow.client(conn, "thing1", "prop1") | ||
|
|
||
| mock_client.publish_get_shadow.assert_called_once() | ||
|
|
||
| @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") | ||
| def test_update_shadow_request_publishes( | ||
| self, MockShadowClient, make_classic_shadow_client | ||
| ): | ||
| mock_client = make_classic_shadow_client() | ||
| MockShadowClient.return_value = mock_client | ||
|
|
||
| conn = MagicMock(spec=mqtt.Connection) | ||
| c = classic_shadow.client(conn, "thing1", "prop1") | ||
|
|
||
| mock_client.publish_update_shadow.reset_mock() | ||
| c.change_reported_value({"temp": 25}) | ||
|
|
||
| mock_client.publish_update_shadow.assert_called_once() | ||
|
|
||
| @patch("awsiotclient.classic_shadow.iotshadow.IotShadowClient") | ||
| def test_update_noop_when_both_none(self, MockShadowClient, make_classic_shadow_client): | ||
| mock_client = make_classic_shadow_client() | ||
| MockShadowClient.return_value = mock_client | ||
|
|
||
| conn = MagicMock(spec=mqtt.Connection) | ||
| c = classic_shadow.client(conn, "thing1", "prop1") | ||
|
|
||
| mock_client.publish_update_shadow.reset_mock() | ||
| c.update_shadow_request(desired=None, reported=None) | ||
|
|
||
| mock_client.publish_update_shadow.assert_not_called() |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This says CI runs
poetry lockon each build, but the workflow only runspoetry install. Either add an explicitpoetry lockstep in CI (if that’s the intent) or adjust this documentation to match the actual CI behavior.