Skip to content

Commit 9ec30dc

Browse files
tests: improve fast tests error logging, skip 'read' on destinations (#644)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent acb92dd commit 9ec30dc

File tree

9 files changed

+208
-148
lines changed

9 files changed

+208
-148
lines changed

.github/workflows/connector-tests.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ jobs:
7575
- connector: source-google-drive
7676
cdk_extra: file-based
7777
- connector: destination-motherduck
78-
cdk_extra: sql
78+
# For now, we mark as 'n/a' to always test this connector
79+
cdk_extra: n/a # change to 'sql' to test less often
7980
# source-amplitude failing for unrelated issue "date too far back"
8081
# e.g. https://github.com/airbytehq/airbyte-python-cdk/actions/runs/16053716569/job/45302638848?pr=639
8182
# - connector: source-amplitude

.github/workflows/python_lint.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ jobs:
2626
- name: Install dependencies
2727
run: poetry install --all-extras
2828

29-
# Job-specifc step(s):
29+
# Job-specific step(s):
3030
- name: Run lint check
3131
run: poetry run ruff check .
3232

@@ -49,9 +49,9 @@ jobs:
4949
- name: Install dependencies
5050
run: poetry install --all-extras
5151

52-
# Job-specifc step(s):
52+
# Job-specific step(s):
5353
- name: Check code format
54-
run: poetry run ruff format --check .
54+
run: poetry run ruff format --diff .
5555

5656
mypy-check:
5757
name: MyPy Check

airbyte_cdk/cli/airbyte_cdk/_connector.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@
6767

6868
TEST_FILE_TEMPLATE = '''
6969
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
70-
"""FAST Airbyte Standard Tests for the {connector_name} source."""
70+
"""FAST Airbyte Standard Tests for the {connector_name} connector."""
7171
7272
#from airbyte_cdk.test.standard_tests import {base_class_name}
7373
from airbyte_cdk.test.standard_tests.util import create_connector_test_suite
@@ -81,11 +81,13 @@
8181
connector_directory=Path(),
8282
)
8383
84+
# Uncomment the following lines to create a custom test suite class:
85+
#
8486
# class TestSuite({base_class_name}):
85-
# """Test suite for the {connector_name} source.
86-
87-
# This class inherits from SourceTestSuiteBase and implements all of the tests in the suite.
88-
87+
# """Test suite for the `{connector_name}` connector.
88+
#
89+
# This class inherits from `{base_class_name}` and implements all of the tests in the suite.
90+
#
8991
# As long as the class name starts with "Test", pytest will automatically discover and run the
9092
# tests in this class.
9193
# """

airbyte_cdk/test/entrypoint_wrapper.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import traceback
2222
from collections import deque
2323
from collections.abc import Generator, Mapping
24+
from dataclasses import dataclass
2425
from io import StringIO
2526
from pathlib import Path
2627
from typing import Any, List, Literal, Optional, Union, final, overload
@@ -50,6 +51,21 @@
5051
from airbyte_cdk.test.models.scenario import ExpectedOutcome
5152

5253

54+
@dataclass
55+
class AirbyteEntrypointException(Exception):
56+
"""Exception raised for errors in the AirbyteEntrypoint execution.
57+
58+
Used to provide details of an Airbyte connector execution failure in the output
59+
captured in an `EntrypointOutput` object. Use `EntrypointOutput.as_exception()` to
60+
convert it to an exception.
61+
62+
Example Usage:
63+
output = EntrypointOutput(...)
64+
if output.errors:
65+
raise output.as_exception()
66+
"""
67+
68+
5369
class EntrypointOutput:
5470
"""A class to encapsulate the output of an Airbyte connector's execution.
5571
@@ -67,13 +83,15 @@ def __init__(
6783
messages: list[str] | None = None,
6884
uncaught_exception: Optional[BaseException] = None,
6985
*,
86+
command: list[str] | None = None,
7087
message_file: Path | None = None,
7188
) -> None:
7289
if messages is None and message_file is None:
7390
raise ValueError("Either messages or message_file must be provided")
7491
if messages is not None and message_file is not None:
7592
raise ValueError("Only one of messages or message_file can be provided")
7693

94+
self._command = command
7795
self._messages: list[AirbyteMessage] | None = None
7896
self._message_file: Path | None = message_file
7997
if messages:
@@ -182,6 +200,39 @@ def analytics_messages(self) -> List[AirbyteMessage]:
182200
def errors(self) -> List[AirbyteMessage]:
183201
return self._get_trace_message_by_trace_type(TraceType.ERROR)
184202

203+
def get_formatted_error_message(self) -> str:
204+
"""Returns a human-readable error message with the contents.
205+
206+
If there are no errors, returns an empty string.
207+
"""
208+
errors = self.errors
209+
if not errors:
210+
# If there are no errors, return an empty string.
211+
return ""
212+
213+
result = "Failed to run airbyte command"
214+
result += ": " + " ".join(self._command) if self._command else "."
215+
result += "\n" + "\n".join(
216+
[str(error.trace.error).replace("\\n", "\n") for error in errors if error.trace],
217+
)
218+
return result
219+
220+
def as_exception(self) -> AirbyteEntrypointException:
221+
"""Convert the output to an exception."""
222+
return AirbyteEntrypointException(self.get_formatted_error_message())
223+
224+
def raise_if_errors(
225+
self,
226+
) -> None:
227+
"""Raise an exception if there are errors in the output.
228+
229+
Otherwise, do nothing.
230+
"""
231+
if not self.errors:
232+
return None
233+
234+
raise self.as_exception()
235+
185236
@property
186237
def catalog(self) -> AirbyteMessage:
187238
catalog = self.get_message_by_types([Type.CATALOG])

airbyte_cdk/test/standard_tests/_job_runner.py

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,26 +21,6 @@
2121
)
2222

2323

24-
def _errors_to_str(
25-
entrypoint_output: entrypoint_wrapper.EntrypointOutput,
26-
) -> str:
27-
"""Convert errors from entrypoint output to a string."""
28-
if not entrypoint_output.errors:
29-
# If there are no errors, return an empty string.
30-
return ""
31-
32-
return "\n" + "\n".join(
33-
[
34-
str(error.trace.error).replace(
35-
"\\n",
36-
"\n",
37-
)
38-
for error in entrypoint_output.errors
39-
if error.trace
40-
],
41-
)
42-
43-
4424
@runtime_checkable
4525
class IConnector(Protocol):
4626
"""A connector that can be run in a test scenario.
@@ -125,9 +105,7 @@ def run_test_job(
125105
expected_outcome=test_scenario.expected_outcome,
126106
)
127107
if result.errors and test_scenario.expected_outcome.expect_success():
128-
raise AssertionError(
129-
f"Expected no errors but got {len(result.errors)}: \n" + _errors_to_str(result)
130-
)
108+
raise result.as_exception()
131109

132110
if verb == "check":
133111
# Check is expected to fail gracefully without an exception.
@@ -137,7 +115,7 @@ def run_test_job(
137115
"Expected exactly one CONNECTION_STATUS message. Got "
138116
f"{len(result.connection_status_messages)}:\n"
139117
+ "\n".join([str(msg) for msg in result.connection_status_messages])
140-
+ _errors_to_str(result)
118+
+ result.get_formatted_error_message()
141119
)
142120
if test_scenario.expected_outcome.expect_exception():
143121
conn_status = result.connection_status_messages[0].connectionStatus
@@ -161,7 +139,8 @@ def run_test_job(
161139

162140
if test_scenario.expected_outcome.expect_success():
163141
assert not result.errors, (
164-
f"Expected no errors but got {len(result.errors)}: \n" + _errors_to_str(result)
142+
f"Test job failed with {len(result.errors)} error(s): \n"
143+
+ result.get_formatted_error_message()
165144
)
166145

167146
return result

airbyte_cdk/test/standard_tests/connector_base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def connector(cls) -> type[IConnector] | Callable[[], IConnector] | None:
4545
specific connector class to be tested.
4646
"""
4747
connector_root = cls.get_connector_root_dir()
48-
connector_name = connector_root.absolute().name
48+
connector_name = cls.connector_name
4949

5050
expected_module_name = connector_name.replace("-", "_").lower()
5151
expected_class_name = connector_name.replace("-", "_").title().replace("_", "")

airbyte_cdk/test/standard_tests/docker_base.py

Lines changed: 40 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from dataclasses import asdict
1212
from pathlib import Path
1313
from subprocess import CompletedProcess, SubprocessError
14-
from typing import Literal
14+
from typing import Literal, cast
1515

1616
import orjson
1717
import pytest
@@ -25,19 +25,18 @@
2525
DestinationSyncMode,
2626
SyncMode,
2727
)
28-
from airbyte_cdk.models.airbyte_protocol_serializers import (
29-
AirbyteCatalogSerializer,
30-
AirbyteStreamSerializer,
31-
)
3228
from airbyte_cdk.models.connector_metadata import MetadataFile
3329
from airbyte_cdk.test.entrypoint_wrapper import EntrypointOutput
3430
from airbyte_cdk.test.models import ConnectorTestScenario
35-
from airbyte_cdk.test.utils.reading import catalog
3631
from airbyte_cdk.utils.connector_paths import (
3732
ACCEPTANCE_TEST_CONFIG,
3833
find_connector_root,
3934
)
40-
from airbyte_cdk.utils.docker import build_connector_image, run_docker_command
35+
from airbyte_cdk.utils.docker import (
36+
build_connector_image,
37+
run_docker_airbyte_command,
38+
run_docker_command,
39+
)
4140

4241

4342
class DockerConnectorTestSuite:
@@ -55,6 +54,17 @@ def get_connector_root_dir(cls) -> Path:
5554
"""Get the root directory of the connector."""
5655
return find_connector_root([cls.get_test_class_dir(), Path.cwd()])
5756

57+
@classproperty
58+
def connector_name(self) -> str:
59+
"""Get the name of the connector."""
60+
connector_root = self.get_connector_root_dir()
61+
return connector_root.absolute().name
62+
63+
@classmethod
64+
def is_destination_connector(cls) -> bool:
65+
"""Check if the connector is a destination."""
66+
return cast(str, cls.connector_name).startswith("destination-")
67+
5868
@classproperty
5969
def acceptance_test_config_path(cls) -> Path:
6070
"""Get the path to the acceptance test config file."""
@@ -145,23 +155,16 @@ def test_docker_image_build_and_spec(
145155
no_verify=False,
146156
)
147157

148-
try:
149-
result: CompletedProcess[str] = run_docker_command(
150-
[
151-
"docker",
152-
"run",
153-
"--rm",
154-
connector_image,
155-
"spec",
156-
],
157-
check=True, # Raise an error if the command fails
158-
capture_stderr=True,
159-
capture_stdout=True,
160-
)
161-
except SubprocessError as ex:
162-
raise AssertionError(
163-
f"Failed to run `spec` command in docker image {connector_image!r}. Error: {ex!s}"
164-
) from None
158+
_ = run_docker_airbyte_command(
159+
[
160+
"docker",
161+
"run",
162+
"--rm",
163+
connector_image,
164+
"spec",
165+
],
166+
raise_if_errors=True,
167+
)
165168

166169
@pytest.mark.skipif(
167170
shutil.which("docker") is None,
@@ -203,7 +206,7 @@ def test_docker_image_build_and_check(
203206
with scenario.with_temp_config_file(
204207
connector_root=connector_root,
205208
) as temp_config_file:
206-
_ = run_docker_command(
209+
_ = run_docker_airbyte_command(
207210
[
208211
"docker",
209212
"run",
@@ -215,9 +218,7 @@ def test_docker_image_build_and_check(
215218
"--config",
216219
container_config_path,
217220
],
218-
check=True, # Raise an error if the command fails
219-
capture_stderr=True,
220-
capture_stdout=True,
221+
raise_if_errors=True,
221222
)
222223

223224
@pytest.mark.skipif(
@@ -242,6 +243,9 @@ def test_docker_image_build_and_read(
242243
the local docker image cache using `docker image prune -a` command.
243244
- If the --connector-image arg is provided, it will be used instead of building the image.
244245
"""
246+
if self.is_destination_connector():
247+
pytest.skip("Skipping read test for destination connector.")
248+
245249
if scenario.expected_outcome.expect_exception():
246250
pytest.skip("Skipping (expected to fail).")
247251

@@ -295,7 +299,7 @@ def test_docker_image_build_and_read(
295299
) as temp_dir_str,
296300
):
297301
temp_dir = Path(temp_dir_str)
298-
discover_result = run_docker_command(
302+
discover_result = run_docker_airbyte_command(
299303
[
300304
"docker",
301305
"run",
@@ -307,20 +311,12 @@ def test_docker_image_build_and_read(
307311
"--config",
308312
container_config_path,
309313
],
310-
check=True, # Raise an error if the command fails
311-
capture_stderr=True,
312-
capture_stdout=True,
314+
raise_if_errors=True,
313315
)
314-
parsed_output = EntrypointOutput(messages=discover_result.stdout.splitlines())
315-
try:
316-
catalog_message = parsed_output.catalog # Get catalog message
317-
assert catalog_message.catalog is not None, "Catalog message missing catalog."
318-
discovered_catalog: AirbyteCatalog = parsed_output.catalog.catalog
319-
except Exception as ex:
320-
raise AssertionError(
321-
f"Failed to load discovered catalog from {discover_result.stdout}. "
322-
f"Error: {ex!s}"
323-
) from None
316+
317+
catalog_message = discover_result.catalog # Get catalog message
318+
assert catalog_message.catalog is not None, "Catalog message missing catalog."
319+
discovered_catalog: AirbyteCatalog = catalog_message.catalog
324320
if not discovered_catalog.streams:
325321
raise ValueError(
326322
f"Discovered catalog for connector '{connector_name}' is empty. "
@@ -355,7 +351,7 @@ def test_docker_image_build_and_read(
355351
configured_catalog_path.write_text(
356352
orjson.dumps(asdict(configured_catalog)).decode("utf-8")
357353
)
358-
read_result: CompletedProcess[str] = run_docker_command(
354+
read_result: EntrypointOutput = run_docker_airbyte_command(
359355
[
360356
"docker",
361357
"run",
@@ -371,18 +367,5 @@ def test_docker_image_build_and_read(
371367
"--catalog",
372368
container_catalog_path,
373369
],
374-
check=False,
375-
capture_stderr=True,
376-
capture_stdout=True,
370+
raise_if_errors=True,
377371
)
378-
if read_result.returncode != 0:
379-
raise AssertionError(
380-
f"Failed to run `read` command in docker image {connector_image!r}. "
381-
"\n-----------------"
382-
f"EXIT CODE: {read_result.returncode}\n"
383-
"STDERR:\n"
384-
f"{read_result.stderr}\n"
385-
f"STDOUT:\n"
386-
f"{read_result.stdout}\n"
387-
"\n-----------------"
388-
) from None

0 commit comments

Comments
 (0)