Skip to content

Commit a1b575f

Browse files
authored
fix(tests): resolves false-positive failures in FAST tests (defer failure expectations) (#587)
1 parent e44362a commit a1b575f

File tree

14 files changed

+262
-71
lines changed

14 files changed

+262
-71
lines changed

airbyte_cdk/test/entrypoint_wrapper.py

Lines changed: 39 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
Type,
4545
)
4646
from airbyte_cdk.sources import Source
47+
from airbyte_cdk.test.models.scenario import ExpectedOutcome
4748

4849

4950
class EntrypointOutput:
@@ -157,8 +158,22 @@ def is_not_in_logs(self, pattern: str) -> bool:
157158

158159

159160
def _run_command(
160-
source: Source, args: List[str], expecting_exception: bool = False
161+
source: Source,
162+
args: List[str],
163+
expecting_exception: bool | None = None, # Deprecated, use `expected_outcome` instead.
164+
*,
165+
expected_outcome: ExpectedOutcome | None = None,
161166
) -> EntrypointOutput:
167+
"""Internal function to run a command with the AirbyteEntrypoint.
168+
169+
Note: Even though this function is private, some connectors do call it directly.
170+
171+
Note: The `expecting_exception` arg is now deprecated in favor of the tri-state
172+
`expected_outcome` arg. The old argument is supported (for now) for backwards compatibility.
173+
"""
174+
expected_outcome = expected_outcome or ExpectedOutcome.from_expecting_exception_bool(
175+
expecting_exception,
176+
)
162177
log_capture_buffer = StringIO()
163178
stream_handler = logging.StreamHandler(log_capture_buffer)
164179
stream_handler.setLevel(logging.INFO)
@@ -175,35 +190,41 @@ def _run_command(
175190
for message in source_entrypoint.run(parsed_args):
176191
messages.append(message)
177192
except Exception as exception:
178-
if not expecting_exception:
193+
if expected_outcome.expect_success():
179194
print("Printing unexpected error from entrypoint_wrapper")
180195
print("".join(traceback.format_exception(None, exception, exception.__traceback__)))
196+
181197
uncaught_exception = exception
182198

183199
captured_logs = log_capture_buffer.getvalue().split("\n")[:-1]
184200

185201
parent_logger.removeHandler(stream_handler)
186202

187-
return EntrypointOutput(messages + captured_logs, uncaught_exception)
203+
return EntrypointOutput(messages + captured_logs, uncaught_exception=uncaught_exception)
188204

189205

190206
def discover(
191207
source: Source,
192208
config: Mapping[str, Any],
193-
expecting_exception: bool = False,
209+
expecting_exception: bool | None = None, # Deprecated, use `expected_outcome` instead.
210+
*,
211+
expected_outcome: ExpectedOutcome | None = None,
194212
) -> EntrypointOutput:
195213
"""
196214
config must be json serializable
197-
:param expecting_exception: By default if there is an uncaught exception, the exception will be printed out. If this is expected, please
198-
provide expecting_exception=True so that the test output logs are cleaner
215+
:param expected_outcome: By default if there is an uncaught exception, the exception will be printed out. If this is expected, please
216+
provide `expected_outcome=ExpectedOutcome.EXPECT_FAILURE` so that the test output logs are cleaner
199217
"""
200218

201219
with tempfile.TemporaryDirectory() as tmp_directory:
202220
tmp_directory_path = Path(tmp_directory)
203221
config_file = make_file(tmp_directory_path / "config.json", config)
204222

205223
return _run_command(
206-
source, ["discover", "--config", config_file, "--debug"], expecting_exception
224+
source,
225+
["discover", "--config", config_file, "--debug"],
226+
expecting_exception=expecting_exception, # Deprecated, but still supported.
227+
expected_outcome=expected_outcome,
207228
)
208229

209230

@@ -212,13 +233,15 @@ def read(
212233
config: Mapping[str, Any],
213234
catalog: ConfiguredAirbyteCatalog,
214235
state: Optional[List[AirbyteStateMessage]] = None,
215-
expecting_exception: bool = False,
236+
expecting_exception: bool | None = None, # Deprecated, use `expected_outcome` instead.
237+
*,
238+
expected_outcome: ExpectedOutcome | None = None,
216239
) -> EntrypointOutput:
217240
"""
218241
config and state must be json serializable
219242
220-
:param expecting_exception: By default if there is an uncaught exception, the exception will be printed out. If this is expected, please
221-
provide expecting_exception=True so that the test output logs are cleaner
243+
:param expected_outcome: By default if there is an uncaught exception, the exception will be printed out. If this is expected, please
244+
provide `expected_outcome=ExpectedOutcome.EXPECT_FAILURE` so that the test output logs are cleaner.
222245
"""
223246
with tempfile.TemporaryDirectory() as tmp_directory:
224247
tmp_directory_path = Path(tmp_directory)
@@ -245,7 +268,12 @@ def read(
245268
]
246269
)
247270

248-
return _run_command(source, args, expecting_exception)
271+
return _run_command(
272+
source,
273+
args,
274+
expecting_exception=expecting_exception, # Deprecated, but still supported.
275+
expected_outcome=expected_outcome,
276+
)
249277

250278

251279
def make_file(

airbyte_cdk/test/models/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Copyright (c) 2025 Airbyte, Inc., all rights reserved.
2+
"""Models used for standard tests."""
3+
4+
from airbyte_cdk.test.models.outcome import ExpectedOutcome
5+
from airbyte_cdk.test.models.scenario import ConnectorTestScenario
6+
7+
__all__ = [
8+
"ConnectorTestScenario",
9+
"ExpectedOutcome",
10+
]

airbyte_cdk/test/models/outcome.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
2+
"""Run acceptance tests in PyTest.
3+
4+
These tests leverage the same `acceptance-test-config.yml` configuration files as the
5+
acceptance tests in CAT, but they run in PyTest instead of CAT. This allows us to run
6+
the acceptance tests in the same local environment as we are developing in, speeding
7+
up iteration cycles.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from enum import Enum, auto
13+
14+
15+
class ExpectedOutcome(Enum):
16+
"""Enum to represent the expected outcome of a test scenario.
17+
18+
Class supports comparisons to a boolean or None.
19+
"""
20+
21+
EXPECT_EXCEPTION = auto()
22+
EXPECT_SUCCESS = auto()
23+
ALLOW_ANY = auto()
24+
25+
@classmethod
26+
def from_status_str(cls, status: str | None) -> ExpectedOutcome:
27+
"""Convert a status string to an ExpectedOutcome."""
28+
if status is None:
29+
return ExpectedOutcome.ALLOW_ANY
30+
31+
try:
32+
return {
33+
"succeed": ExpectedOutcome.EXPECT_SUCCESS,
34+
"failed": ExpectedOutcome.EXPECT_EXCEPTION,
35+
}[status]
36+
except KeyError as ex:
37+
raise ValueError(f"Invalid status '{status}'. Expected 'succeed' or 'failed'.") from ex
38+
39+
@classmethod
40+
def from_expecting_exception_bool(cls, expecting_exception: bool | None) -> ExpectedOutcome:
41+
"""Convert a boolean indicating whether an exception is expected to an ExpectedOutcome."""
42+
if expecting_exception is None:
43+
# Align with legacy behavior where default would be 'False' (no exception expected)
44+
return ExpectedOutcome.EXPECT_SUCCESS
45+
46+
return (
47+
ExpectedOutcome.EXPECT_EXCEPTION
48+
if expecting_exception
49+
else ExpectedOutcome.EXPECT_SUCCESS
50+
)
51+
52+
def expect_exception(self) -> bool:
53+
"""Return whether the expectation is that an exception should be raised."""
54+
return self == ExpectedOutcome.EXPECT_EXCEPTION
55+
56+
def expect_success(self) -> bool:
57+
"""Return whether the expectation is that the test should succeed without exceptions."""
58+
return self == ExpectedOutcome.EXPECT_SUCCESS

airbyte_cdk/test/standard_tests/models/scenario.py renamed to airbyte_cdk/test/models/scenario.py

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@
99

1010
from __future__ import annotations
1111

12-
from pathlib import Path
12+
from pathlib import Path # noqa: TC003 # Pydantic needs this (don't move to 'if typing' block)
1313
from typing import Any, Literal, cast
1414

1515
import yaml
16-
from pydantic import BaseModel
16+
from pydantic import BaseModel, ConfigDict
17+
18+
from airbyte_cdk.test.models.outcome import ExpectedOutcome
1719

1820

1921
class ConnectorTestScenario(BaseModel):
@@ -24,6 +26,10 @@ class ConnectorTestScenario(BaseModel):
2426
acceptance test configuration file.
2527
"""
2628

29+
# Allows the class to be hashable, which PyTest will require
30+
# when we use to parameterize tests.
31+
model_config = ConfigDict(frozen=True)
32+
2733
class AcceptanceTestExpectRecords(BaseModel):
2834
path: Path
2935
exact_order: bool = False
@@ -46,6 +52,7 @@ class AcceptanceTestFileTypes(BaseModel):
4652
def get_config_dict(
4753
self,
4854
*,
55+
connector_root: Path,
4956
empty_if_missing: bool,
5057
) -> dict[str, Any]:
5158
"""Return the config dictionary.
@@ -61,16 +68,29 @@ def get_config_dict(
6168
return self.config_dict
6269

6370
if self.config_path is not None:
64-
return cast(dict[str, Any], yaml.safe_load(self.config_path.read_text()))
71+
config_path = self.config_path
72+
if not config_path.is_absolute():
73+
# We usually receive a relative path here. Let's resolve it.
74+
config_path = (connector_root / self.config_path).resolve().absolute()
75+
76+
return cast(
77+
dict[str, Any],
78+
yaml.safe_load(config_path.read_text()),
79+
)
6580

6681
if empty_if_missing:
6782
return {}
6883

6984
raise ValueError("No config dictionary or path provided.")
7085

7186
@property
72-
def expect_exception(self) -> bool:
73-
return self.status and self.status == "failed" or False
87+
def expected_outcome(self) -> ExpectedOutcome:
88+
"""Whether the test scenario expects an exception to be raised.
89+
90+
Returns True if the scenario expects an exception, False if it does not,
91+
and None if there is no set expectation.
92+
"""
93+
return ExpectedOutcome.from_status_str(self.status)
7494

7595
@property
7696
def instance_name(self) -> str:
@@ -83,3 +103,38 @@ def __str__(self) -> str:
83103
return f"'{self.config_path.name}' Test Scenario"
84104

85105
return f"'{hash(self)}' Test Scenario"
106+
107+
def without_expected_outcome(self) -> ConnectorTestScenario:
108+
"""Return a copy of the scenario that does not expect failure or success.
109+
110+
This is useful when running multiple steps, to defer the expectations to a later step.
111+
"""
112+
return ConnectorTestScenario(
113+
**self.model_dump(exclude={"status"}),
114+
)
115+
116+
def with_expecting_failure(self) -> ConnectorTestScenario:
117+
"""Return a copy of the scenario that expects failure.
118+
119+
This is useful when deriving new scenarios from existing ones.
120+
"""
121+
if self.status == "failed":
122+
return self
123+
124+
return ConnectorTestScenario(
125+
**self.model_dump(exclude={"status"}),
126+
status="failed",
127+
)
128+
129+
def with_expecting_success(self) -> ConnectorTestScenario:
130+
"""Return a copy of the scenario that expects success.
131+
132+
This is useful when deriving new scenarios from existing ones.
133+
"""
134+
if self.status == "succeed":
135+
return self
136+
137+
return ConnectorTestScenario(
138+
**self.model_dump(exclude={"status"}),
139+
status="succeed",
140+
)

airbyte_cdk/test/standard_tests/_job_runner.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
Status,
1717
)
1818
from airbyte_cdk.test import entrypoint_wrapper
19-
from airbyte_cdk.test.standard_tests.models import (
19+
from airbyte_cdk.test.models import (
2020
ConnectorTestScenario,
2121
)
2222

@@ -58,6 +58,7 @@ def run_test_job(
5858
connector: IConnector | type[IConnector] | Callable[[], IConnector],
5959
verb: Literal["spec", "read", "check", "discover"],
6060
*,
61+
connector_root: Path,
6162
test_scenario: ConnectorTestScenario | None = None,
6263
catalog: ConfiguredAirbyteCatalog | dict[str, Any] | None = None,
6364
) -> entrypoint_wrapper.EntrypointOutput:
@@ -84,7 +85,10 @@ def run_test_job(
8485
)
8586

8687
args: list[str] = [verb]
87-
config_dict = test_scenario.get_config_dict(empty_if_missing=True)
88+
config_dict = test_scenario.get_config_dict(
89+
empty_if_missing=True,
90+
connector_root=connector_root,
91+
)
8892
if config_dict and verb != "spec":
8993
# Write the config to a temp json file and pass the path to the file as an argument.
9094
config_path = (
@@ -118,9 +122,9 @@ def run_test_job(
118122
result: entrypoint_wrapper.EntrypointOutput = entrypoint_wrapper._run_command( # noqa: SLF001 # Non-public API
119123
source=connector_obj, # type: ignore [arg-type]
120124
args=args,
121-
expecting_exception=test_scenario.expect_exception,
125+
expected_outcome=test_scenario.expected_outcome,
122126
)
123-
if result.errors and not test_scenario.expect_exception:
127+
if result.errors and test_scenario.expected_outcome.expect_success():
124128
raise AssertionError(
125129
f"Expected no errors but got {len(result.errors)}: \n" + _errors_to_str(result)
126130
)
@@ -135,7 +139,7 @@ def run_test_job(
135139
+ "\n".join([str(msg) for msg in result.connection_status_messages])
136140
+ _errors_to_str(result)
137141
)
138-
if test_scenario.expect_exception:
142+
if test_scenario.expected_outcome.expect_exception():
139143
conn_status = result.connection_status_messages[0].connectionStatus
140144
assert conn_status, (
141145
"Expected CONNECTION_STATUS message to be present. Got: \n"
@@ -149,14 +153,15 @@ def run_test_job(
149153
return result
150154

151155
# For all other verbs, we assert check that an exception is raised (or not).
152-
if test_scenario.expect_exception:
156+
if test_scenario.expected_outcome.expect_exception():
153157
if not result.errors:
154158
raise AssertionError("Expected exception but got none.")
155159

156160
return result
157161

158-
assert not result.errors, (
159-
f"Expected no errors but got {len(result.errors)}: \n" + _errors_to_str(result)
160-
)
162+
if test_scenario.expected_outcome.expect_success():
163+
assert not result.errors, (
164+
f"Expected no errors but got {len(result.errors)}: \n" + _errors_to_str(result)
165+
)
161166

162167
return result

0 commit comments

Comments
 (0)