Skip to content

Commit 820d9e8

Browse files
committed
tests: improve fast test outputs, skip 'read' tests for destinations
1 parent 625cd1e commit 820d9e8

File tree

5 files changed

+162
-111
lines changed

5 files changed

+162
-111
lines changed

airbyte_cdk/test/entrypoint_wrapper.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,21 @@
5050
from airbyte_cdk.test.models.scenario import ExpectedOutcome
5151

5252

53+
@dataclass
54+
class AirbyteEntrypointException(Exception):
55+
"""Exception raised for errors in the AirbyteEntrypoint execution.
56+
57+
Used to provide details of an Airbyte connector execution failure in the output
58+
captured in an `EntrypointOutput` object. Use `EntrypointOutput.as_exception()` to
59+
convert it to an exception.
60+
61+
Example Usage:
62+
output = EntrypointOutput(...)
63+
if output.errors:
64+
raise output.as_exception("An error occurred during the connector execution.")
65+
"""
66+
67+
5368
class EntrypointOutput:
5469
"""A class to encapsulate the output of an Airbyte connector's execution.
5570
@@ -67,13 +82,15 @@ def __init__(
6782
messages: list[str] | None = None,
6883
uncaught_exception: Optional[BaseException] = None,
6984
*,
85+
command: list[str] | None = None,
7086
message_file: Path | None = None,
7187
) -> None:
7288
if messages is None and message_file is None:
7389
raise ValueError("Either messages or message_file must be provided")
7490
if messages is not None and message_file is not None:
7591
raise ValueError("Only one of messages or message_file can be provided")
7692

93+
self._command = command
7794
self._messages: list[AirbyteMessage] | None = None
7895
self._message_file: Path | None = message_file
7996
if messages:
@@ -182,6 +199,40 @@ def analytics_messages(self) -> List[AirbyteMessage]:
182199
def errors(self) -> List[AirbyteMessage]:
183200
return self._get_trace_message_by_trace_type(TraceType.ERROR)
184201

202+
def get_formatted_error_message(self) -> str:
203+
"""Returns a human-readable error message with the contents.
204+
205+
If there are no errors, returns an empty string.
206+
"""
207+
errors = self.errors
208+
if not errors:
209+
# If there are no errors, return an empty string.
210+
return ""
211+
212+
result = "Failed to run airbyte command"
213+
result += ": " + " ".join(self._command) if self._command else "."
214+
result += "\n" + "\n".join(
215+
[str(error.trace.error).replace("\\n", "\n") for error in errors if error.trace],
216+
)
217+
return result
218+
219+
def as_exception(self) -> AirbyteEntrypointException:
220+
"""Convert the output to an exception with a custom message.
221+
222+
This is useful for raising an exception in tests or other scenarios where you want to
223+
provide a specific error message.
224+
"""
225+
return AirbyteEntrypointException(self.get_formatted_error_message())
226+
227+
def raise_if_errors(
228+
self,
229+
) -> None:
230+
"""Raise an exception if there are errors in the output."""
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 & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,7 @@ def connector(cls) -> type[IConnector] | Callable[[], IConnector] | None:
4444
This assumes a python connector and should be overridden by subclasses to provide the
4545
specific connector class to be tested.
4646
"""
47-
connector_root = cls.get_connector_root_dir()
48-
connector_name = connector_root.absolute().name
47+
connector_name = cls.connector_name
4948

5049
expected_module_name = connector_name.replace("-", "_").lower()
5150
expected_class_name = connector_name.replace("-", "_").title().replace("_", "")

airbyte_cdk/test/standard_tests/docker_base.py

Lines changed: 39 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -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 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+
check=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+
check=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+
check=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+
check=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)