Skip to content

Commit 3de3edf

Browse files
committed
add skeleton implementation, imported from airbyte-pytest
1 parent e38f914 commit 3de3edf

File tree

15 files changed

+508
-0
lines changed

15 files changed

+508
-0
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
2+
"""Declarative tests framework.
3+
4+
This module provides fixtures and utilities for testing Airbyte sources and destinations
5+
in a declarative way.
6+
"""
7+
8+
from airbyte_cdk.test.declarative.test_suites import (
9+
ConnectorTestSuiteBase,
10+
DestinationTestSuiteBase,
11+
SourceTestSuiteBase,
12+
)
13+
14+
__all__ = [
15+
"ConnectorTestSuiteBase",
16+
"DestinationTestSuiteBase",
17+
"SourceTestSuiteBase",
18+
]
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from dataclasses import dataclass
2+
from typing import Protocol, Type
3+
4+
5+
class ConnectorInterface(Protocol):
6+
"""Protocol for Airbyte connectors."""
7+
8+
@classmethod
9+
def launch(cls, args: list[str] | None): ...
10+
11+
12+
@dataclass
13+
class PythonWrapper:
14+
"""Wrapper for Python source and destination connectors."""
15+
16+
connector_class: Type["ConnectorInterface"]

airbyte_cdk/test/declarative/models/__init__.py

Whitespace-only changes.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
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 pathlib import Path
13+
from typing import Literal
14+
15+
import yaml
16+
from pydantic import BaseModel
17+
18+
19+
class AcceptanceTestScenario(BaseModel):
20+
"""Acceptance test instance, as a Pydantic model.
21+
22+
This class represents an acceptance test instance, which is a single test case
23+
that can be run against a connector. It is used to deserialize and validate the
24+
acceptance test configuration file.
25+
"""
26+
27+
class AcceptanceTestExpectRecords(BaseModel):
28+
path: Path
29+
exact_order: bool = False
30+
31+
class AcceptanceTestFileTypes(BaseModel):
32+
skip_test: bool
33+
bypass_reason: str
34+
35+
config_path: Path
36+
configured_catalog_path: Path | None = None
37+
timeout_seconds: int | None = None
38+
expect_records: AcceptanceTestExpectRecords | None = None
39+
file_types: AcceptanceTestFileTypes | None = None
40+
status: Literal["succeed", "failed"] | None = None
41+
42+
@property
43+
def expect_exception(self) -> bool:
44+
return self.status and self.status == "failed"
45+
46+
@property
47+
def instance_name(self) -> str:
48+
return self.config_path.stem
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
2+
"""Declarative test suites.
3+
4+
Here we have base classes for a robust set of declarative connector test suites.
5+
"""
6+
7+
from airbyte_cdk.test.declarative.test_suites.connector_base import ConnectorTestSuiteBase
8+
from airbyte_cdk.test.declarative.test_suites.destination_base import DestinationTestSuiteBase
9+
from airbyte_cdk.test.declarative.test_suites.source_base import SourceTestSuiteBase
10+
11+
__all__ = [
12+
"ConnectorTestSuiteBase",
13+
"DestinationTestSuiteBase",
14+
"SourceTestSuiteBase",
15+
]
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
2+
"""Base class for connector test suites."""
3+
4+
from __future__ import annotations
5+
6+
import abc
7+
from pathlib import Path
8+
from typing import Any, Literal
9+
10+
import pytest
11+
import yaml
12+
from airbyte_connector_tester.instances import (
13+
AcceptanceTestScenario,
14+
get_acceptance_tests,
15+
)
16+
from airbyte_connector_tester.job_runner import run_test_job
17+
from pydantic import BaseModel
18+
19+
from airbyte_cdk import Connector
20+
from airbyte_cdk.models import (
21+
AirbyteMessage,
22+
Type,
23+
)
24+
from airbyte_cdk.test import entrypoint_wrapper
25+
26+
ACCEPTANCE_TEST_CONFIG_PATH = Path("acceptance-test-config.yml")
27+
28+
29+
class ConnectorTestSuiteBase(abc.ABC):
30+
"""Base class for connector test suites."""
31+
32+
acceptance_test_file_path = Path("./acceptance-test-config.json")
33+
"""The path to the acceptance test config file.
34+
35+
By default, this is set to the `acceptance-test-config.json` file in
36+
the root of the connector source directory.
37+
"""
38+
39+
connector_class: type[Connector]
40+
"""The connector class to test."""
41+
42+
# Public Methods - Subclasses may override these
43+
44+
@abc.abstractmethod
45+
def new_connector(self, **kwargs: dict[str, Any]) -> Connector:
46+
"""Create a new connector instance.
47+
48+
By default, this returns a new instance of the connector class. Subclasses
49+
may override this method to generate a dynamic connector instance.
50+
"""
51+
return self.connector_factory()
52+
53+
# Internal Methods - We don't expect subclasses to override these
54+
55+
def _get_acceptance_tests(
56+
category: str,
57+
accept_test_config_path: Path = ACCEPTANCE_TEST_CONFIG_PATH,
58+
) -> list[AcceptanceTestScenario]:
59+
all_tests_config = yaml.safe_load(accept_test_config_path.read_text())
60+
if "acceptance_tests" not in all_tests_config:
61+
raise ValueError(f"Acceptance tests config not found in {accept_test_config_path}")
62+
if category not in all_tests_config["acceptance_tests"]:
63+
return []
64+
if "tests" not in all_tests_config["acceptance_tests"][category]:
65+
raise ValueError(f"No tests found for category {category}")
66+
67+
return [
68+
AcceptanceTestScenario.model_validate(test)
69+
for test in all_tests_config["acceptance_tests"][category]["tests"]
70+
if "iam_role" not in test["config_path"]
71+
]
72+
73+
# Test Definitions
74+
75+
@pytest.mark.parametrize(
76+
"test_input,expected",
77+
[
78+
("3+5", 8),
79+
("2+4", 6),
80+
("6*9", 54),
81+
],
82+
)
83+
def test_use_plugin_parametrized_test(
84+
self,
85+
test_input,
86+
expected,
87+
):
88+
assert eval(test_input) == expected
89+
90+
@pytest.mark.parametrize(
91+
"instance",
92+
get_acceptance_tests("connection"),
93+
ids=lambda instance: instance.instance_name,
94+
)
95+
def test_check(
96+
self,
97+
instance: AcceptanceTestScenario,
98+
) -> None:
99+
"""Run `connection` acceptance tests."""
100+
result: entrypoint_wrapper.EntrypointOutput = run_test_job(
101+
self.new_connector(),
102+
"check",
103+
test_instance=instance,
104+
)
105+
conn_status_messages: list[AirbyteMessage] = [
106+
msg for msg in result._messages if msg.type == Type.CONNECTION_STATUS
107+
] # noqa: SLF001 # Non-public API
108+
assert len(conn_status_messages) == 1, (
109+
"Expected exactly one CONNECTION_STATUS message. Got: \n" + "\n".join(result._messages)
110+
)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
2+
"""Base class for destination test suites."""
3+
4+
from airbyte_connector_tester.connector_tests import ConnectorTestSuiteBase
5+
6+
7+
class DestinationTestSuiteBase(ConnectorTestSuiteBase):
8+
"""Base class for destination test suites.
9+
10+
This class provides a base set of functionality for testing destination connectors, and it
11+
inherits all generic connector tests from the `ConnectorTestSuiteBase` class.
12+
"""
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
2+
"""Base class for source test suites."""
3+
4+
from dataclasses import asdict
5+
from pathlib import Path
6+
7+
import pytest
8+
from airbyte_connector_tester.connector_tests import ConnectorTestSuiteBase
9+
from airbyte_connector_tester.instances import (
10+
AcceptanceTestScenario,
11+
get_acceptance_tests,
12+
)
13+
from airbyte_connector_tester.job_runner import run_test_job
14+
15+
from airbyte_cdk.models import (
16+
AirbyteStream,
17+
ConfiguredAirbyteCatalog,
18+
ConfiguredAirbyteStream,
19+
DestinationSyncMode,
20+
SyncMode,
21+
)
22+
23+
24+
class SourceTestSuiteBase(ConnectorTestSuiteBase):
25+
"""Base class for source test suites.
26+
27+
This class provides a base set of functionality for testing source connectors, and it
28+
inherits all generic connector tests from the `ConnectorTestSuiteBase` class.
29+
"""
30+
31+
@pytest.mark.parametrize(
32+
"instance",
33+
get_acceptance_tests("full_refresh"),
34+
ids=lambda instance: instance.instance_name,
35+
)
36+
def test_full_refresh(
37+
self,
38+
instance: AcceptanceTestScenario,
39+
) -> None:
40+
"""Run acceptance tests."""
41+
result = run_test_job(
42+
self.new_connector(),
43+
"read",
44+
test_instance=instance,
45+
)
46+
if not result.records:
47+
raise AssertionError("Expected records but got none.") # noqa: TRY003
48+
49+
@pytest.mark.parametrize(
50+
"instance",
51+
get_acceptance_tests("basic_read"),
52+
ids=lambda instance: instance.instance_name,
53+
)
54+
def test_basic_read(
55+
self,
56+
instance: AcceptanceTestScenario,
57+
) -> None:
58+
"""Run acceptance tests."""
59+
discover_result = run_test_job(
60+
self.new_connector(),
61+
"discover",
62+
test_instance=instance,
63+
)
64+
assert discover_result.catalog, "Expected a non-empty catalog."
65+
configured_catalog = ConfiguredAirbyteCatalog(
66+
streams=[
67+
ConfiguredAirbyteStream(
68+
stream=stream,
69+
sync_mode=SyncMode.full_refresh,
70+
destination_sync_mode=DestinationSyncMode.append_dedup,
71+
)
72+
for stream in discover_result.catalog.catalog.streams
73+
]
74+
)
75+
result = run_test_job(
76+
self.new_connector(),
77+
"read",
78+
test_instance=instance,
79+
catalog=configured_catalog,
80+
)
81+
82+
if not result.records:
83+
raise AssertionError("Expected records but got none.") # noqa: TRY003
84+
85+
@pytest.mark.parametrize(
86+
"instance",
87+
get_acceptance_tests("basic_read"),
88+
ids=lambda instance: instance.instance_name,
89+
)
90+
def test_fail_with_bad_catalog(
91+
self,
92+
instance: AcceptanceTestScenario,
93+
) -> None:
94+
"""Test that a bad catalog fails."""
95+
invalid_configured_catalog = ConfiguredAirbyteCatalog(
96+
streams=[
97+
# Create ConfiguredAirbyteStream which is deliberately invalid
98+
# with regard to the Airbyte Protocol.
99+
# This should cause the connector to fail.
100+
ConfiguredAirbyteStream(
101+
stream=AirbyteStream(
102+
name="__AIRBYTE__stream_that_does_not_exist",
103+
json_schema={
104+
"type": "object",
105+
"properties": {"f1": {"type": "string"}},
106+
},
107+
supported_sync_modes=[SyncMode.full_refresh],
108+
),
109+
sync_mode="INVALID",
110+
destination_sync_mode="INVALID",
111+
)
112+
]
113+
)
114+
# Set expected status to "failed" to ensure the test fails if the connector.
115+
instance.status = "failed"
116+
result = run_test_job(
117+
self.new_connector(),
118+
"read",
119+
test_instance=instance,
120+
catalog=asdict(invalid_configured_catalog),
121+
)
122+
assert result.errors, "Expected errors but got none."
123+
assert result.trace_messages, "Expected trace messages but got none."
124+
125+
@pytest.mark.parametrize(
126+
"instance",
127+
get_acceptance_tests("full_refresh"),
128+
ids=lambda instance: instance.instance_name,
129+
)
130+
def test_discover(
131+
self,
132+
instance: AcceptanceTestScenario,
133+
) -> None:
134+
"""Run acceptance tests."""
135+
run_test_job(
136+
self.new_connector(),
137+
"check",
138+
test_instance=instance,
139+
)

airbyte_cdk/test/declarative/utils/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)