Skip to content

Commit 9d1b48a

Browse files
docs(skills): test-skill cleanups and gotchas surfaced from supadata PR (#40)
* docs(skills): simplify test boilerplate and surface root conftest fixtures (writing-unit-tests) Test files in autohive-integrations don't need the heavy `importlib.util.spec_from_file_location` boilerplate when the integration source can be imported as a normal module. With a 2-line `sys.path.insert` in `tests/conftest.py`, plain `from <integration> import ...` works fine. The repo-wide `conftest.py` also already provides three fixtures every test should reuse instead of redeclaring locally: - `mock_context` — minimal ExecutionContext - `make_context` — factory for arbitrary auth shapes - `env_credentials` — `.env`-aware env-var lookup with skip support Changes: - Promote plain imports as the default boilerplate; keep the `importlib` form as a documented fallback for unusual layouts. - Replace the 'every file declares its own mock_context' guidance with the override-in-conftest pattern, so credential shapes are set once per integration and inherited by all tests. - Add a parametrize example to 'Testing Helper Functions' for collapsing tabular helper tests (e.g. ms_to_timestamp boundaries). - New 'Common Gotchas' entry: PyPI package name collision. When the integration folder name matches a PyPI package the source imports (e.g. supadata, dropbox), the empty `__init__.py` shadows the real package. The fix is to drop `__init__.py` — the validator treats it as optional, the Lambda runtime is unaffected, and tests no longer need `site.getsitepackages()` / `importlib` shims. Surfaced while reviewing Autohive-AI/autohive-integrations#280 (Supadata SDK 2.0 upgrade). * docs(skills): simplify boilerplate, prefer env_credentials, and add SDK-call variant (writing-integration-tests) Three updates aligned with the writing-unit-tests skill changes: 1. Replace the heavy `importlib` boilerplate with plain imports (matching the unit-tests skill). Cross-reference the unit-tests skill for the `importlib` fallback rather than duplicating it. 2. Promote `env_credentials` from the repo-wide `conftest.py` as the recommended way to handle API-key / token env vars. The fixture auto-loads `.env` and integrates with `pytest.skip` when a key is missing. Module-level `os.environ.get(...)` is still useful for object IDs (which env_credentials doesn't model directly), so the require_* helpers stay — but credentials should be read via env_credentials. 3. Add Variant 4: 'External Python SDK (no `context.fetch`)'. Some integrations (e.g. supadata) call a third-party Python SDK directly rather than going through `context.fetch`. For these, the aiohttp-wrapping fixture is irrelevant — the SDK does its own networking. The new variant is a 4-line fixture that just injects credentials via `make_context(auth=...)`. Updated the 'How to choose' guidance to a decision table covering all four variants by auth shape and networking pattern. Surfaced while reviewing Autohive-AI/autohive-integrations#280. * docs(skills): add auth-default and PyPI-collision gotchas (upgrading-sdk-v2) Two new entries in the 'Common Gotchas' section, both surfaced while reviewing Autohive-AI/autohive-integrations#280 (Supadata 1.0.0 → 2.0.0 upgrade) and the underlying production crash report in Autohive-AI/autohive-integrations#316: 1. Audit auth lookup defaults during the upgrade. Several 1.0.x integrations shipped with the wrong default *type* in their auth lookup, e.g.: context.auth.get('credentials', {}).get('api_key', {}) The `{}` default returns a dict when the field is empty, which crashes the upstream SDK that expects a string with TypeError — surfacing as a Lambda 500 / Raygun crash instead of the user-facing auth error it actually is. The 2.0.0 upgrade is the right moment to fix this because the auth path is being touched anyway to convert error returns to ActionError. 2. PyPI package name collision. When the integration folder is named after a PyPI package the source imports (e.g. `from supadata import Supadata` inside `supadata/`), the empty `__init__.py` shadows the real PyPI package and every test fails with ImportError. Drop the `__init__.py` — the validator treats it as optional, the Lambda runtime is unaffected, and tests can use plain imports. Cross-references the writing-unit-tests skill where the matching test-side gotcha now lives. * docs(skills): add missing os import in module-level constants snippet The new file-header boilerplate dropped `import os` (it's no longer needed once the importlib path-juggling is gone), but the 'Module-level constants' snippet a few sections later still uses `os.environ.get(...)`. Readers who copy both sections verbatim would hit `NameError` at import time. Add `import os` directly to the constants snippet and call out in prose that it should join the file-header imports. Keeps each snippet self-contained and executable. Caught by the codex review bot on PR #40.
1 parent 21c96bb commit 9d1b48a

3 files changed

Lines changed: 137 additions & 41 deletions

File tree

skills/upgrading-sdk-v2/SKILL.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,3 +285,17 @@ Before considering an integration upgraded, verify:
285285
7. **CI fetch pattern linter false positives**: The linter flags any variable named `response` accessed with `.get()` or `["..."]`. If a helper already unwraps `.data` and returns a plain dict, rename the variable in callers to avoid the match (e.g. `gql_result`, `body`, `api_data`).
286286

287287
8. **Ruff config mismatch**: CI uses `../autohive-integrations-tooling/ruff.toml` with `line-length = 120`. Always pass `--config` when formatting or local results will differ from CI.
288+
289+
9. **Audit auth lookup defaults during the upgrade**: 1.0.x integrations sometimes shipped with the wrong default type in their auth lookup, e.g. `context.auth.get("credentials", {}).get("api_key", {})`. The default `{}` returns a dict when the field is unset, which then crashes the upstream API client that expects a string — surfacing as a Lambda 500 / Raygun crash rather than the user-facing auth error it actually is. Fix to a string default that matches the field type:
290+
291+
```python
292+
# Before — returns {} on missing field, crashes upstream SDK with TypeError
293+
api_key = context.auth.get("credentials", {}).get("api_key", {})
294+
295+
# After — returns "" on missing field; upstream auth error becomes ActionError cleanly
296+
api_key = context.auth.get("credentials", {}).get("api_key", "")
297+
```
298+
299+
The 2.0.0 upgrade is the right time to catch this because you're already touching the auth path to convert error returns to `ActionError`.
300+
301+
10. **PyPI package name collision**: If your integration folder name matches a PyPI package the source imports (e.g. an integration in `supadata/` that does `from supadata import Supadata`), an empty `<integration>/__init__.py` will shadow the real PyPI package and every test fails with `ImportError`. Drop `<integration>/__init__.py` — the validator's "missing __init__.py" warning is correct to ignore in this case, and the Lambda runtime is unaffected (the entry point is the action source file, not the package). See the `writing-unit-tests` skill for the matching test-side guidance.

skills/writing-integration-tests/SKILL.md

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ This double exclusion ensures integration tests never run accidentally.
4949

5050
### File Header (boilerplate)
5151

52-
Every integration test file must start with this exact boilerplate. Replace `myintegration` with the actual integration name:
52+
With `sys.path` set up in `tests/conftest.py` (see the unit tests skill for the standard shape), an integration test file can use plain imports:
5353

5454
```python
5555
"""
@@ -65,42 +65,49 @@ Never runs in CI — the default pytest marker filter (-m unit) excludes these,
6565
and the file naming (test_*_integration.py) is not matched by python_files.
6666
"""
6767

68-
import os
69-
import sys
70-
import importlib
68+
import pytest
69+
from unittest.mock import AsyncMock, MagicMock
70+
from autohive_integrations_sdk import FetchResponse
71+
from autohive_integrations_sdk.integration import ResultType
72+
73+
from myintegration import myintegration
7174

72-
_parent = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
73-
_deps = os.path.abspath(os.path.join(os.path.dirname(__file__), "../dependencies"))
74-
sys.path.insert(0, _parent)
75-
sys.path.insert(0, _deps)
75+
pytestmark = pytest.mark.integration
76+
```
7677

77-
import pytest # noqa: E402
78-
from unittest.mock import MagicMock, AsyncMock # noqa: E402
79-
from autohive_integrations_sdk import FetchResponse # noqa: E402
78+
Use the `importlib` fallback boilerplate from the unit tests skill only when plain imports won't work for the integration's layout.
8079

81-
_spec = importlib.util.spec_from_file_location("myintegration_mod", os.path.join(_parent, "myintegration.py"))
82-
_mod = importlib.util.module_from_spec(_spec)
83-
_spec.loader.exec_module(_mod)
80+
## Environment Variables
8481

85-
myintegration = _mod.myintegration # the Integration instance
82+
### env_credentials Fixture (preferred)
8683

87-
pytestmark = pytest.mark.integration
84+
The repo-wide [`conftest.py`](https://github.com/Autohive-AI/autohive-integrations/blob/master/conftest.py) provides an `env_credentials` fixture that auto-loads the project `.env` and reads variables on demand:
85+
86+
```python
87+
@pytest.fixture
88+
def live_context(env_credentials, make_context):
89+
api_key = env_credentials("MYINTEGRATION_API_KEY")
90+
if not api_key:
91+
pytest.skip("MYINTEGRATION_API_KEY not set — skipping integration tests")
92+
return make_context(auth={"credentials": {"api_key": api_key}})
8893
```
8994

90-
## Environment Variables
95+
This is the recommended pattern for any test that needs API credentials: it skips automatically when the env var is missing, integrates with the project `.env`, and avoids per-test boilerplate.
9196

92-
### Token and ID Setup
97+
### Module-level constants (when you need them everywhere)
9398

94-
Define environment variables at module level. Use `os.environ.get` with an empty string default:
99+
If multiple fixtures or helpers need the same env var, define them at module level — but still drive skip behaviour off `env_credentials` inside the fixture, not at import time. Add `import os` to the file header alongside the other top-level imports:
95100

96101
```python
97-
ACCESS_TOKEN = os.environ.get("MYINTEGRATION_ACCESS_TOKEN", "")
102+
import os
103+
98104
TEST_ITEM_ID = os.environ.get("MYINTEGRATION_TEST_ITEM_ID", "")
105+
TEST_PROJECT_ID = os.environ.get("MYINTEGRATION_TEST_PROJECT_ID", "")
99106
```
100107

101108
### require_* Skip Helpers
102109

103-
For tests that need specific object IDs, create `require_*` helpers that skip gracefully:
110+
For tests that need specific object IDs (which `env_credentials` doesn't model directly), create `require_*` helpers that skip gracefully:
104111

105112
```python
106113
def require_item_id():
@@ -215,7 +222,29 @@ def live_context():
215222
return ctx
216223
```
217224

218-
**How to choose**: Check the integration's `config.json` — if `auth.type` is `"platform"`, use Variant 3. If the action handler reads an API key from `context.auth` or env vars and sets headers manually, use Variant 2. If no auth is needed, use Variant 1.
225+
### Variant 4: External Python SDK (no `context.fetch`)
226+
227+
Some integrations don't go through `context.fetch` at all — instead they instantiate a third-party Python SDK and let it make the HTTP calls (e.g. `from supadata import Supadata`). For these, the `aiohttp` wrapper is irrelevant: the SDK does its own networking. Just inject the credentials via `make_context` and the upstream library handles the rest:
228+
229+
```python
230+
@pytest.fixture
231+
def live_context(env_credentials, make_context):
232+
api_key = env_credentials("MYINTEGRATION_API_KEY")
233+
if not api_key:
234+
pytest.skip("MYINTEGRATION_API_KEY not set — skipping integration tests")
235+
return make_context(auth={"credentials": {"api_key": api_key}})
236+
```
237+
238+
This variant is the simplest of the four — no `real_fetch` definition needed. Use it whenever the integration's source imports a vendor SDK and calls it directly rather than calling `context.fetch`.
239+
240+
**How to choose**:
241+
242+
| Auth shape | Networking | Variant |
243+
|---|---|---|
244+
| None (public API) | `context.fetch` | 1 — No auth |
245+
| API key in `context.auth` or env | `context.fetch` | 2 — API key |
246+
| Platform OAuth (`config.auth.type == "platform"`) | `context.fetch` | 3 — Platform OAuth |
247+
| Any | External Python SDK call | 4 — External SDK |
219248

220249
## The Destructive Marker
221250

skills/writing-unit-tests/SKILL.md

Lines changed: 72 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -42,21 +42,55 @@ hubspot/tests/
4242

4343
### conftest.py
4444

45-
Every `tests/` directory should include a `conftest.py` with this standard content:
45+
Every `tests/` directory should include a `conftest.py`. At minimum it puts the integration source on `sys.path` so test files can use plain imports:
4646

4747
```python
48-
import sys
4948
import os
49+
import sys
50+
51+
# Make <integration>.py importable as a top-level module.
52+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
53+
```
54+
55+
This lets test files do `from myintegration import myintegration` (or `from myintegration_module import ...`) without per-file boilerplate.
5056

51-
# Allow 'from context import ...' to work when pytest runs from repo root
52-
sys.path.insert(0, os.path.dirname(__file__))
57+
If the integration reads credentials from `context.auth`, also override the repo-wide `mock_context` fixture here so every test in this directory inherits credentials of the right shape:
58+
59+
```python
60+
from unittest.mock import AsyncMock, MagicMock
61+
import pytest
62+
63+
@pytest.fixture
64+
def mock_context():
65+
"""Mock ExecutionContext pre-loaded with this integration's credentials."""
66+
ctx = MagicMock(name="ExecutionContext")
67+
ctx.fetch = AsyncMock(name="fetch")
68+
ctx.auth = {"credentials": {"api_key": "test_api_key"}} # nosec B105
69+
return ctx
5370
```
5471

5572
The `_unit.py` suffix is required — CI uses it to discover unit tests.
5673

5774
### File Header (boilerplate)
5875

59-
Every test file must start with this exact boilerplate. Replace `myintegration` with the actual integration name and `myintegration.py` with the actual entry point file:
76+
Every test file starts with the same shape: imports, an `pytestmark = pytest.mark.unit` line, and the integration imports. With `sys.path` set up in `tests/conftest.py` (above), test files can use plain imports:
77+
78+
```python
79+
import pytest
80+
from unittest.mock import AsyncMock, MagicMock, patch
81+
from autohive_integrations_sdk import FetchResponse
82+
from autohive_integrations_sdk.integration import ResultType
83+
84+
from myintegration import myintegration
85+
# Also import any helpers you want to test directly:
86+
# from myintegration import parse_response, my_helper
87+
88+
pytestmark = pytest.mark.unit
89+
```
90+
91+
#### Fallback boilerplate (only when plain imports won't work)
92+
93+
If the integration source can't be imported as a normal module — for example when the file lives at an unusual path or has been renamed away from the integration's folder name — use the explicit `importlib` loader:
6094

6195
```python
6296
import os
@@ -77,31 +111,31 @@ _spec = importlib.util.spec_from_file_location("myintegration_mod", os.path.join
77111
_mod = importlib.util.module_from_spec(_spec)
78112
_spec.loader.exec_module(_mod)
79113

80-
myintegration = _mod.myintegration # the Integration instance
81-
# Also import any helper functions you need to test directly:
82-
# parse_response = _mod.parse_response
83-
# my_helper = _mod.my_helper
114+
myintegration = _mod.myintegration
84115

85116
pytestmark = pytest.mark.unit
86117
```
87118

88-
Add `from unittest.mock import patch` if you need to patch `asyncio.sleep` or environment variables.
119+
Prefer plain imports — reach for the `importlib` form only when there's a concrete reason it's needed.
89120

90121
### mock_context Fixture
91122

92-
Every test file needs this fixture:
123+
The repo-wide [`conftest.py`](https://github.com/Autohive-AI/autohive-integrations/blob/master/conftest.py) already provides three fixtures every test can use:
124+
125+
- `mock_context` — minimal `ExecutionContext` with `ctx.auth = {}` and `ctx.fetch` as an `AsyncMock`
126+
- `make_context` — factory for building a context with arbitrary `auth=...`
127+
- `env_credentials` — helper that reads env vars (with `.env` autoloaded), returns `None` if missing
128+
129+
You don't need to redeclare these. Override `mock_context` only when the integration reads credentials from `context.auth` and you want every test to inherit the shape — see the snippet in the conftest.py section above.
130+
131+
For one-off credential shapes inside a single test, use `make_context`:
93132

94133
```python
95-
@pytest.fixture
96-
def mock_context():
97-
ctx = MagicMock(name="ExecutionContext")
98-
ctx.fetch = AsyncMock(name="fetch")
99-
ctx.auth = {}
100-
return ctx
134+
async def test_with_custom_auth(make_context):
135+
ctx = make_context(auth={"credentials": {"api_key": "different_key"}}) # nosec B105
136+
...
101137
```
102138

103-
If the integration reads credentials from `context.auth`, populate it to match the auth shape in `config.json`.
104-
105139
**Platform OAuth** (`config.auth.type == "platform"`): The SDK wraps OAuth tokens in a standard envelope:
106140

107141
```python
@@ -361,6 +395,23 @@ class TestParseResponse:
361395
assert result == {"key": "value"}
362396
```
363397

398+
For helpers with several near-identical input/output cases, prefer `@pytest.mark.parametrize` over a separate test method per case — same coverage, easier to extend with new boundary rows:
399+
400+
```python
401+
class TestMsToTimestamp:
402+
@pytest.mark.parametrize(
403+
"milliseconds, expected",
404+
[
405+
(0, "00:00:00,000"),
406+
(1000, "00:00:01,000"),
407+
(60_000, "00:01:00,000"),
408+
(3_600_000, "01:00:00,000"),
409+
],
410+
)
411+
def test_ms_to_timestamp(self, milliseconds: int, expected: str):
412+
assert ms_to_timestamp(milliseconds) == expected
413+
```
414+
364415
## Test Organization
365416

366417
### One class per action
@@ -448,6 +499,8 @@ Every action should have at minimum:
448499

449500
7. **Unused variables**: If you call `execute_action` only to verify `mock_context.fetch.call_args`, don't assign the result to a variable — ruff will flag it as unused. Use `await integration.execute_action(...)` without assignment.
450501

502+
8. **PyPI package name collision**: If your integration folder is named after a PyPI package the source imports (e.g. an integration in `supadata/` that does `from supadata import Supadata`), an empty `<integration>/__init__.py` will shadow the real PyPI package — every test fails with `ImportError`. The fix is to **delete `<integration>/__init__.py`**: the validator treats it as optional, the Lambda runtime is unaffected, and `from <package> import ...` then resolves cleanly to site-packages. Don't paper over the shadow with `site.getsitepackages()` / `importlib` shims in the test files.
503+
451504
## Reference Implementations
452505

453506
Look at these integrations for well-tested examples:

0 commit comments

Comments
 (0)