Skip to content

Commit 11d89f6

Browse files
committed
working test of tests
1 parent 9dec6e3 commit 11d89f6

26 files changed

+358
-151
lines changed

airbyte_cdk/test/declarative/__init__.py

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,3 @@
44
This module provides fixtures and utilities for testing Airbyte sources and destinations
55
in a declarative way.
66
"""
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: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from airbyte_cdk.test.declarative.models.scenario import (
2-
AcceptanceTestScenario,
2+
ConnectorTestScenario,
33
)
44

55
__all__ = [
6-
"AcceptanceTestScenario",
6+
"ConnectorTestScenario",
77
]

airbyte_cdk/test/declarative/models/scenario.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from pydantic import BaseModel
1717

1818

19-
class AcceptanceTestScenario(BaseModel):
19+
class ConnectorTestScenario(BaseModel):
2020
"""Acceptance test instance, as a Pydantic model.
2121
2222
This class represents an acceptance test instance, which is a single test case
@@ -32,17 +32,43 @@ class AcceptanceTestFileTypes(BaseModel):
3232
skip_test: bool
3333
bypass_reason: str
3434

35-
config_path: Path
35+
config_path: Path | None = None
36+
config_dict: dict | None = None
37+
38+
id: str | None = None
39+
3640
configured_catalog_path: Path | None = None
3741
timeout_seconds: int | None = None
3842
expect_records: AcceptanceTestExpectRecords | None = None
3943
file_types: AcceptanceTestFileTypes | None = None
4044
status: Literal["succeed", "failed"] | None = None
4145

46+
def get_config_dict(self) -> dict:
47+
"""Return the config dictionary.
48+
49+
If a config dictionary has already been loaded, return it. Otherwise, load
50+
Otherwise, load the config file and return the dictionary.
51+
"""
52+
if self.config_dict:
53+
return self.config_dict
54+
55+
if self.config_path:
56+
return yaml.safe_load(self.config_path.read_text())
57+
58+
raise ValueError("No config dictionary or path provided.")
59+
4260
@property
4361
def expect_exception(self) -> bool:
4462
return self.status and self.status == "failed"
4563

4664
@property
4765
def instance_name(self) -> str:
4866
return self.config_path.stem
67+
68+
def __str__(self) -> str:
69+
if self.id:
70+
return f"'{self.id}' Test Scenario"
71+
if self.config_path:
72+
return f"'{self.config_path.name}' Test Scenario"
73+
74+
return f"'{hash(self)}' Test Scenario"

airbyte_cdk/test/declarative/test_suites/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,21 @@
44
Here we have base classes for a robust set of declarative connector test suites.
55
"""
66

7-
from airbyte_cdk.test.declarative.test_suites.connector_base import ConnectorTestSuiteBase
7+
from airbyte_cdk.test.declarative.test_suites.connector_base import (
8+
ConnectorTestScenario,
9+
generate_tests,
10+
)
11+
from airbyte_cdk.test.declarative.test_suites.declarative_sources import (
12+
DeclarativeSourceTestSuite,
13+
)
814
from airbyte_cdk.test.declarative.test_suites.destination_base import DestinationTestSuiteBase
915
from airbyte_cdk.test.declarative.test_suites.source_base import SourceTestSuiteBase
1016

1117
__all__ = [
18+
"ConnectorTestScenario",
1219
"ConnectorTestSuiteBase",
20+
"DeclarativeSourceTestSuite",
1321
"DestinationTestSuiteBase",
1422
"SourceTestSuiteBase",
23+
"generate_tests",
1524
]

airbyte_cdk/test/declarative/test_suites/connector_base.py

Lines changed: 130 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@
44
from __future__ import annotations
55

66
import abc
7+
import functools
8+
import inspect
9+
import sys
710
from pathlib import Path
8-
from typing import Any, Literal
11+
from typing import Any, Callable, Literal
912

1013
import pytest
1114
import yaml
1215
from pydantic import BaseModel
16+
from typing_extensions import override
1317

1418
from airbyte_cdk import Connector
1519
from airbyte_cdk.models import (
@@ -18,30 +22,55 @@
1822
)
1923
from airbyte_cdk.test import entrypoint_wrapper
2024
from airbyte_cdk.test.declarative.models import (
21-
AcceptanceTestScenario,
25+
ConnectorTestScenario,
2226
)
2327
from airbyte_cdk.test.declarative.utils.job_runner import run_test_job
2428

25-
ACCEPTANCE_TEST_CONFIG_PATH = Path("acceptance-test-config.yml")
29+
ACCEPTANCE_TEST_CONFIG = "acceptance-test-config.yml"
2630

2731

28-
def _get_acceptance_tests(
29-
category: str,
30-
accept_test_config_path: Path = ACCEPTANCE_TEST_CONFIG_PATH,
31-
) -> list[AcceptanceTestScenario]:
32-
all_tests_config = yaml.safe_load(accept_test_config_path.read_text())
33-
if "acceptance_tests" not in all_tests_config:
34-
raise ValueError(f"Acceptance tests config not found in {accept_test_config_path}")
35-
if category not in all_tests_config["acceptance_tests"]:
36-
return []
37-
if "tests" not in all_tests_config["acceptance_tests"][category]:
38-
raise ValueError(f"No tests found for category {category}")
32+
def generate_tests(metafunc):
33+
"""
34+
A helper for pytest_generate_tests hook.
35+
36+
If a test method (in a class subclassed from our base class)
37+
declares an argument 'instance', this function retrieves the
38+
'scenarios' attribute from the test class and parametrizes that
39+
test with the values from 'scenarios'.
40+
41+
## Usage
42+
43+
```python
44+
from airbyte_cdk.test.declarative.test_suites.connector_base import (
45+
generate_tests,
46+
ConnectorTestSuiteBase,
47+
)
48+
49+
def pytest_generate_tests(metafunc):
50+
generate_tests(metafunc)
3951
40-
return [
41-
AcceptanceTestScenario.model_validate(test)
42-
for test in all_tests_config["acceptance_tests"][category]["tests"]
43-
if "iam_role" not in test["config_path"]
44-
]
52+
class TestMyConnector(ConnectorTestSuiteBase):
53+
...
54+
55+
```
56+
"""
57+
# Check if the test function requires an 'instance' argument
58+
if "instance" in metafunc.fixturenames:
59+
# Retrieve the test class
60+
test_class = metafunc.cls
61+
if test_class is None:
62+
raise ValueError("Expected a class here.")
63+
# Get the 'scenarios' attribute from the class
64+
scenarios_attr = getattr(test_class, "get_scenarios", None)
65+
if scenarios_attr is None:
66+
raise ValueError(
67+
f"Test class {test_class} does not have a 'scenarios' attribute. "
68+
"Please define the 'scenarios' attribute in the test class."
69+
)
70+
71+
scenarios = test_class.get_scenarios()
72+
ids = [str(scenario) for scenario in scenarios]
73+
metafunc.parametrize("instance", scenarios, ids=ids)
4574

4675

4776
class ConnectorTestSuiteBase(abc.ABC):
@@ -57,52 +86,101 @@ class ConnectorTestSuiteBase(abc.ABC):
5786
connector_class: type[Connector]
5887
"""The connector class to test."""
5988

60-
# Public Methods - Subclasses may override these
61-
62-
@abc.abstractmethod
63-
def new_connector(self, **kwargs: dict[str, Any]) -> Connector:
64-
"""Create a new connector instance.
89+
working_dir: Path | None = None
90+
"""The root directory of the connector source code."""
91+
92+
@override
93+
@classmethod
94+
def create_connector(cls, scenario: ConnectorTestScenario) -> ConcurrentDeclarativeSource:
95+
"""Instantiate the connector class."""
96+
return ConcurrentDeclarativeSource(
97+
config=scenario.get_config_dict(),
98+
catalog={},
99+
state=None,
100+
source_config={},
101+
)
65102

66-
By default, this returns a new instance of the connector class. Subclasses
67-
may override this method to generate a dynamic connector instance.
68-
"""
69-
return self.connector_factory()
103+
def run_test_scenario(
104+
self,
105+
verb: Literal["read", "check", "discover"],
106+
test_scenario: ConnectorTestScenario,
107+
*,
108+
catalog: dict | None = None,
109+
) -> entrypoint_wrapper.EntrypointOutput:
110+
"""Run a test job from provided CLI args and return the result."""
111+
return run_test_job(
112+
self.create_connector(test_scenario),
113+
verb,
114+
test_instance=test_scenario,
115+
catalog=catalog,
116+
)
70117

71118
# Test Definitions
72119

73-
@pytest.mark.parametrize(
74-
"test_input,expected",
75-
[
76-
("3+5", 8),
77-
("2+4", 6),
78-
("6*9", 54),
79-
],
80-
)
81-
def test_use_plugin_parametrized_test(
82-
self,
83-
test_input,
84-
expected,
85-
):
86-
assert eval(test_input) == expected
87-
88-
@pytest.mark.parametrize(
89-
"instance",
90-
_get_acceptance_tests("connection"),
91-
ids=lambda instance: instance.instance_name,
92-
)
93120
def test_check(
94121
self,
95-
instance: AcceptanceTestScenario,
122+
instance: ConnectorTestScenario,
96123
) -> None:
97124
"""Run `connection` acceptance tests."""
98-
result: entrypoint_wrapper.EntrypointOutput = run_test_job(
99-
self.new_connector(),
125+
result = self.run_test_scenario(
100126
"check",
101-
test_instance=instance,
127+
test_scenario=instance,
102128
)
103129
conn_status_messages: list[AirbyteMessage] = [
104130
msg for msg in result._messages if msg.type == Type.CONNECTION_STATUS
105131
] # noqa: SLF001 # Non-public API
106132
assert len(conn_status_messages) == 1, (
107133
"Expected exactly one CONNECTION_STATUS message. Got: \n" + "\n".join(result._messages)
108134
)
135+
136+
@classmethod
137+
@property
138+
def acceptance_test_config_path(self) -> Path:
139+
"""Get the path to the acceptance test config file.
140+
141+
Check vwd and parent directories of cwd for the config file, and return the first one found.
142+
143+
Give up if the config file is not found in any parent directory.
144+
"""
145+
current_dir = Path.cwd()
146+
for parent_dir in current_dir.parents:
147+
config_path = parent_dir / ACCEPTANCE_TEST_CONFIG
148+
if config_path.exists():
149+
return config_path
150+
raise FileNotFoundError(
151+
f"Acceptance test config file not found in any parent directory from : {Path.cwd()}"
152+
)
153+
154+
@classmethod
155+
def get_scenarios(
156+
cls,
157+
) -> list[ConnectorTestScenario]:
158+
"""Get acceptance tests for a given category.
159+
160+
This has to be a separate function because pytest does not allow
161+
parametrization of fixtures with arguments from the test class itself.
162+
"""
163+
category = "connection"
164+
all_tests_config = yaml.safe_load(cls.acceptance_test_config_path.read_text())
165+
if "acceptance_tests" not in all_tests_config:
166+
raise ValueError(
167+
f"Acceptance tests config not found in {cls.acceptance_test_config_path}."
168+
f" Found only: {str(all_tests_config)}."
169+
)
170+
if category not in all_tests_config["acceptance_tests"]:
171+
return []
172+
if "tests" not in all_tests_config["acceptance_tests"][category]:
173+
raise ValueError(f"No tests found for category {category}")
174+
175+
tests_scenarios = [
176+
ConnectorTestScenario.model_validate(test)
177+
for test in all_tests_config["acceptance_tests"][category]["tests"]
178+
if "iam_role" not in test["config_path"]
179+
]
180+
working_dir = cls.working_dir or Path()
181+
for test in tests_scenarios:
182+
if test.config_path:
183+
test.config_path = working_dir / test.config_path
184+
if test.configured_catalog_path:
185+
test.configured_catalog_path = working_dir / test.configured_catalog_path
186+
return tests_scenarios
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import os
2+
from hashlib import md5
3+
from pathlib import Path
4+
from typing import Any, cast
5+
6+
import yaml
7+
8+
from airbyte_cdk.sources.declarative.concurrent_declarative_source import (
9+
ConcurrentDeclarativeSource,
10+
)
11+
from airbyte_cdk.test.declarative.models import ConnectorTestScenario
12+
from airbyte_cdk.test.declarative.test_suites.source_base import (
13+
SourceTestSuiteBase,
14+
)
15+
16+
17+
def md5_checksum(file_path: Path) -> str:
18+
with open(file_path, "rb") as file:
19+
return md5(file.read()).hexdigest()
20+
21+
class DeclarativeSourceTestSuite(SourceTestSuiteBase):
22+
23+
manifest_path = Path("manifest.yaml")
24+
components_py_path: Path | None = None
25+
26+
def create_connector(self, connector_test: ConnectorTestScenario) -> ConcurrentDeclarativeSource:
27+
config = connector_test.get_config_dict()
28+
# catalog = connector_test.get_catalog()
29+
# state = connector_test.get_state()
30+
# source_config = connector_test.get_source_config()
31+
32+
manifest_dict = yaml.safe_load(self.manifest_path.read_text())
33+
if self.components_py_path and self.components_py_path.exists():
34+
os.environ["AIRBYTE_ALLOW_CUSTOM_CODE"] = "true"
35+
config["__injected_components_py"] = self.components_py_path.read_text()
36+
config["__injected_components_py_checksums"] = {
37+
"md5": md5_checksum(self.components_py_path),
38+
}
39+
40+
return ConcurrentDeclarativeSource(
41+
config=config,
42+
catalog=None,
43+
state=None,
44+
source_config=manifest_dict,
45+
)

0 commit comments

Comments
 (0)