44from __future__ import annotations
55
66import abc
7+ import functools
8+ import inspect
9+ import sys
710from pathlib import Path
8- from typing import Any , Literal
11+ from typing import Any , Callable , Literal
912
1013import pytest
1114import yaml
1215from pydantic import BaseModel
16+ from typing_extensions import override
1317
1418from airbyte_cdk import Connector
1519from airbyte_cdk .models import (
1822)
1923from airbyte_cdk .test import entrypoint_wrapper
2024from airbyte_cdk .test .declarative .models import (
21- AcceptanceTestScenario ,
25+ ConnectorTestScenario ,
2226)
2327from 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
4776class 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
0 commit comments