Skip to content
Merged
Show file tree
Hide file tree
Changes from 61 commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
3de3edf
add skeleton implementation, imported from airbyte-pytest
aaronsteers Feb 10, 2025
3b88490
tidy imports
aaronsteers Feb 11, 2025
1250472
fix some imports
aaronsteers Feb 15, 2025
9dec6e3
Merge branch 'main' into aj/feat/mini-cat-test-suites
aaronsteers Feb 19, 2025
11d89f6
working test of tests
aaronsteers Feb 20, 2025
42d8a4e
remove old file
aaronsteers Feb 24, 2025
c8e7722
misc fixes
aaronsteers Feb 24, 2025
6248994
allow subclasses of abstractsource
aaronsteers Feb 24, 2025
a0f8ed5
Merge remote-tracking branch 'origin/main' into aj/feat/mini-cat-test…
aaronsteers Apr 7, 2025
fcfe697
fix test handling for expected errors
aaronsteers Apr 7, 2025
03f3360
Auto-fix lint and format issues
Apr 7, 2025
460c40a
remove unused code
aaronsteers Apr 7, 2025
8918bfe
fix tests
aaronsteers Apr 8, 2025
83344f3
use pep440 style dynamic versioning
aaronsteers Apr 9, 2025
76a92a9
update ci job (temporarily run on all pushes)
aaronsteers Apr 9, 2025
e6911e5
Merge remote-tracking branch 'origin/main' into aj/feat/mini-cat-test…
aaronsteers Apr 10, 2025
039eef0
Merge remote-tracking branch 'origin/main' into aj/feat/mini-cat-test…
aaronsteers Apr 10, 2025
5658441
Update .github/workflows/pypi_publish.yml
aaronsteers Apr 10, 2025
310ea68
fix fixture resource paths
aaronsteers Apr 10, 2025
1e74eef
lint fixes
aaronsteers Apr 10, 2025
c9ad3bd
fix imports
aaronsteers Apr 10, 2025
f8b581e
fix lint issues
aaronsteers Apr 12, 2025
4a05f46
fix lint and typing
aaronsteers Apr 12, 2025
e58dfad
[cherry-pick-me][chore]: resolve pytest warnings undeclared marks, an…
aaronsteers Apr 12, 2025
418dcb9
fix relative path logic
aaronsteers Apr 12, 2025
8d75d0d
ruff fix
aaronsteers Apr 12, 2025
c3a32ae
[cherry-pick-me][fix]: dataclasses constructor break with __test__ me…
aaronsteers Apr 12, 2025
0897b83
fix paths
aaronsteers Apr 12, 2025
b282e5d
[cherry-pick-me]: use kw args
aaronsteers Apr 12, 2025
9a8b5eb
Merge branch 'main' into aj/feat/mini-cat-test-suites
aaronsteers Apr 12, 2025
d5aaf3c
[cherry-pick-me]: kw args
aaronsteers Apr 12, 2025
7a00ff4
[cherry-pick-me]: use kw args for Test* dataclasses
aaronsteers Apr 12, 2025
5dff39e
Merge remote-tracking branch 'origin/main' into aj/feat/mini-cat-test…
aaronsteers Apr 12, 2025
3160691
fix relative resource paths
aaronsteers Apr 12, 2025
015ced9
fix imports
aaronsteers Apr 12, 2025
e2a9fb9
fix more tests
aaronsteers Apr 12, 2025
bfc1943
format fix
aaronsteers Apr 12, 2025
f82b76a
[cherry-pick-me][chore]: resolve pytest warnings undeclared marks, an…
aaronsteers Apr 12, 2025
f091b53
[cherry-pick-me][fix]: dataclasses constructor break with __test__ me…
aaronsteers Apr 12, 2025
12afc3d
[cherry-pick-me]: use kw args
aaronsteers Apr 12, 2025
cca55f7
[cherry-pick-me]: kw args
aaronsteers Apr 12, 2025
4f7a84e
[cherry-pick-me]: use kw args for Test* dataclasses
aaronsteers Apr 12, 2025
e24594b
Merge branch 'devin/1744436819-cherry-pick-test-fixes' into aj/feat/m…
aaronsteers Apr 12, 2025
9389cc5
reduce code needed for inheritance
aaronsteers Apr 12, 2025
e4cae20
fix type hint
aaronsteers Apr 12, 2025
18ff2e8
Use ClassVar[bool] for __test__ instead of kw_only=True
devin-ai-integration[bot] Apr 12, 2025
40fddcc
Use ClassVar[bool] for __test__ attributes instead of kw_only=True
devin-ai-integration[bot] Apr 12, 2025
e2c69df
Update more classes to use ClassVar[bool] for __test__ attributes
devin-ai-integration[bot] Apr 12, 2025
e79e901
Apply ruff formatting
devin-ai-integration[bot] Apr 12, 2025
c26345e
Fix remaining files with ruff formatting
devin-ai-integration[bot] Apr 12, 2025
eb0f643
addl cleanup
aaronsteers Apr 12, 2025
7a750d6
Merge branch 'devin/1744436819-cherry-pick-test-fixes' into aj/feat/m…
aaronsteers Apr 13, 2025
b8407e5
Merge branch 'main' into devin/1744436819-cherry-pick-test-fixes
aaronsteers Apr 14, 2025
88acba3
Merge branch 'main' into aj/feat/mini-cat-test-suites
aaronsteers Apr 14, 2025
4b6c53e
poe lock
aaronsteers Apr 14, 2025
f0bea0c
remove unrelated changes
aaronsteers Apr 14, 2025
8c91964
Merge branch 'devin/1744436819-cherry-pick-test-fixes' into aj/feat/m…
aaronsteers Apr 14, 2025
4f84c14
revert unnecessary changes
aaronsteers Apr 14, 2025
d0aba9f
Merge branch 'devin/1744436819-cherry-pick-test-fixes' into aj/feat/m…
aaronsteers Apr 14, 2025
1468a48
clean up naming, remove unused
aaronsteers Apr 14, 2025
2812e7d
tidy up pr
aaronsteers Apr 14, 2025
c4e6655
clean up IConnector interface
aaronsteers Apr 14, 2025
f6d6ccd
ruff fix
aaronsteers Apr 14, 2025
d20d91f
poe lock
aaronsteers Apr 14, 2025
b363b52
Merge branch 'main' into aj/feat/mini-cat-test-suites
aaronsteers Apr 15, 2025
b045532
clean up test module structure and pytest hooks
aaronsteers Apr 15, 2025
dc95a71
remove extra pytest files
aaronsteers Apr 15, 2025
a36d6a9
add usage docs
aaronsteers Apr 15, 2025
da442a9
clean up
aaronsteers Apr 15, 2025
c532a92
Merge branch 'main' into aj/feat/mini-cat-test-suites
aaronsteers Apr 16, 2025
594be48
Auto-fix lint and format issues
Apr 16, 2025
f39f480
cleaner error prints
aaronsteers Apr 16, 2025
9fac89f
finish rename module 'declarative' -> 'standard_tests'
aaronsteers Apr 16, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion airbyte_cdk/connector_builder/connector_builder_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@


from dataclasses import asdict, dataclass, field
from typing import Any, Dict, List, Mapping
from typing import Any, ClassVar, Dict, List, Mapping

from airbyte_cdk.connector_builder.test_reader import TestReader
from airbyte_cdk.models import (
Expand Down Expand Up @@ -37,6 +37,8 @@

@dataclass
class TestLimits:
__test__: ClassVar[bool] = False # Tell Pytest this is not a Pytest class, despite its name

max_records: int = field(default=DEFAULT_MAXIMUM_RECORDS)
max_pages_per_slice: int = field(default=DEFAULT_MAXIMUM_NUMBER_OF_PAGES_PER_SLICE)
max_slices: int = field(default=DEFAULT_MAXIMUM_NUMBER_OF_SLICES)
Expand Down
4 changes: 3 additions & 1 deletion airbyte_cdk/connector_builder/test_reader/reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@


import logging
from typing import Any, Dict, Iterator, List, Mapping, Optional, Union
from typing import Any, ClassVar, Dict, Iterator, List, Mapping, Optional, Union

from airbyte_cdk.connector_builder.models import (
AuxiliaryRequest,
Expand Down Expand Up @@ -66,6 +66,8 @@ class TestReader:

"""

__test__: ClassVar[bool] = False # Tell Pytest this is not a Pytest class, despite its name

logger = logging.getLogger("airbyte.connector-builder")

def __init__(
Expand Down
6 changes: 6 additions & 0 deletions airbyte_cdk/test/declarative/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
"""Declarative tests framework.

This module provides fixtures and utilities for testing Airbyte sources and destinations
in a declarative way.
"""
7 changes: 7 additions & 0 deletions airbyte_cdk/test/declarative/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
from airbyte_cdk.test.declarative.models.scenario import (
ConnectorTestScenario,
)

__all__ = [
"ConnectorTestScenario",
]
74 changes: 74 additions & 0 deletions airbyte_cdk/test/declarative/models/scenario.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
"""Run acceptance tests in PyTest.

These tests leverage the same `acceptance-test-config.yml` configuration files as the
acceptance tests in CAT, but they run in PyTest instead of CAT. This allows us to run
the acceptance tests in the same local environment as we are developing in, speeding
up iteration cycles.
"""

from __future__ import annotations

from pathlib import Path
from typing import Any, Literal, cast

import yaml
from pydantic import BaseModel


class ConnectorTestScenario(BaseModel):
"""Acceptance test scenario, as a Pydantic model.

This class represents an acceptance test scenario, which is a single test case
that can be run against a connector. It is used to deserialize and validate the
acceptance test configuration file.
"""

class AcceptanceTestExpectRecords(BaseModel):
path: Path
exact_order: bool = False

class AcceptanceTestFileTypes(BaseModel):
skip_test: bool
bypass_reason: str

config_path: Path | None = None
config_dict: dict[str, Any] | None = None

id: str | None = None

configured_catalog_path: Path | None = None
timeout_seconds: int | None = None
expect_records: AcceptanceTestExpectRecords | None = None
file_types: AcceptanceTestFileTypes | None = None
status: Literal["succeed", "failed"] | None = None

def get_config_dict(self) -> dict[str, Any]:
"""Return the config dictionary.

If a config dictionary has already been loaded, return it. Otherwise, load
the config file and return the dictionary.
"""
if self.config_dict:
return self.config_dict

if self.config_path:
return cast(dict[str, Any], yaml.safe_load(self.config_path.read_text()))

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

@property
def expect_exception(self) -> bool:
return self.status and self.status == "failed" or False

@property
def instance_name(self) -> str:
return self.config_path.stem if self.config_path else "Unnamed Scenario"

def __str__(self) -> str:
if self.id:
return f"'{self.id}' Test Scenario"
if self.config_path:
return f"'{self.config_path.name}' Test Scenario"

return f"'{hash(self)}' Test Scenario"
25 changes: 25 additions & 0 deletions airbyte_cdk/test/declarative/test_suites/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
"""Declarative test suites.

Here we have base classes for a robust set of declarative connector test suites.
"""

from airbyte_cdk.test.declarative.test_suites.connector_base import (
ConnectorTestScenario,
ConnectorTestSuiteBase,
generate_tests,
)
from airbyte_cdk.test.declarative.test_suites.declarative_sources import (
DeclarativeSourceTestSuite,
)
from airbyte_cdk.test.declarative.test_suites.destination_base import DestinationTestSuiteBase
from airbyte_cdk.test.declarative.test_suites.source_base import SourceTestSuiteBase

__all__ = [
"ConnectorTestScenario",
"ConnectorTestSuiteBase",
"DeclarativeSourceTestSuite",
"DestinationTestSuiteBase",
"SourceTestSuiteBase",
"generate_tests",
]
193 changes: 193 additions & 0 deletions airbyte_cdk/test/declarative/test_suites/connector_base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
# Copyright (c) 2024 Airbyte, Inc., all rights reserved.
"""Base class for connector test suites."""

from __future__ import annotations

import abc
import inspect
import sys
from collections.abc import Callable
from pathlib import Path
from typing import cast

import pytest
import yaml
from boltons.typeutils import classproperty

from airbyte_cdk.models import (
AirbyteMessage,
Type,
)
from airbyte_cdk.test import entrypoint_wrapper
from airbyte_cdk.test.declarative.models import (
ConnectorTestScenario,
)
from airbyte_cdk.test.declarative.utils.job_runner import IConnector, run_test_job

ACCEPTANCE_TEST_CONFIG = "acceptance-test-config.yml"
MANIFEST_YAML = "manifest.yaml"


def generate_tests(metafunc: pytest.Metafunc) -> None:
"""
A helper for pytest_generate_tests hook.

If a test method (in a class subclassed from our base class)
declares an argument 'scenario', this function retrieves the
'scenarios' attribute from the test class and parametrizes that
test with the values from 'scenarios'.

## Usage

```python
from airbyte_cdk.test.declarative.test_suites.connector_base import (
generate_tests,
ConnectorTestSuiteBase,
)

def pytest_generate_tests(metafunc):
generate_tests(metafunc)

class TestMyConnector(ConnectorTestSuiteBase):
...

```
"""
# Check if the test function requires an 'scenario' argument
if "scenario" in metafunc.fixturenames:
# Retrieve the test class
test_class = metafunc.cls
if test_class is None:
raise ValueError("Expected a class here.")
# Get the 'scenarios' attribute from the class
scenarios_attr = getattr(test_class, "get_scenarios", None)
if scenarios_attr is None:
raise ValueError(
f"Test class {test_class} does not have a 'scenarios' attribute. "
"Please define the 'scenarios' attribute in the test class."
)

scenarios = test_class.get_scenarios()
ids = [str(scenario) for scenario in scenarios]
metafunc.parametrize("scenario", scenarios, ids=ids)


class ConnectorTestSuiteBase(abc.ABC):
"""Base class for connector test suites."""

connector: type[IConnector] | Callable[[], IConnector] | None = None
"""The connector class or a factory function that returns an scenario of IConnector."""

@classmethod
def get_test_class_dir(cls) -> Path:
"""Get the file path that contains the class."""
module = sys.modules[cls.__module__]
# Get the directory containing the test file
return Path(inspect.getfile(module)).parent

@classmethod
def create_connector(
cls,
scenario: ConnectorTestScenario,
) -> IConnector:
"""Instantiate the connector class."""
connector = cls.connector # type: ignore
if connector:
if callable(connector) or isinstance(connector, type):
# If the connector is a class or factory function, instantiate it:
return cast(IConnector, connector()) # type: ignore [redundant-cast]

# Otherwise, we can't instantiate the connector. Fail with a clear error message.
raise NotImplementedError(
"No connector class or connector factory function provided. "
"Please provide a class or factory function in `cls.connector`, or "
"override `cls.create_connector()` to define a custom initialization process."
)

# Test Definitions

def test_check(
self,
scenario: ConnectorTestScenario,
) -> None:
"""Run `connection` acceptance tests."""
result: entrypoint_wrapper.EntrypointOutput = run_test_job(
self.create_connector(scenario),
"check",
test_scenario=scenario,
)
conn_status_messages: list[AirbyteMessage] = [
msg for msg in result._messages if msg.type == Type.CONNECTION_STATUS
] # noqa: SLF001 # Non-public API
assert len(conn_status_messages) == 1, (
f"Expected exactly one CONNECTION_STATUS message. Got: {result._messages}"
)

@classmethod
def get_connector_root_dir(cls) -> Path:
"""Get the root directory of the connector."""
for parent in cls.get_test_class_dir().parents:
if (parent / MANIFEST_YAML).exists():
return parent
if (parent / ACCEPTANCE_TEST_CONFIG).exists():
return parent
if parent.name == "airbyte_cdk":
break
# If we reach here, we didn't find the manifest file in any parent directory
# Check if the manifest file exists in the current directory
for parent in Path.cwd().parents:
if (parent / MANIFEST_YAML).exists():
return parent
if (parent / ACCEPTANCE_TEST_CONFIG).exists():
return parent
if parent.name == "airbyte_cdk":
break

raise FileNotFoundError(
"Could not find connector root directory relative to "
f"'{str(cls.get_test_class_dir())}' or '{str(Path.cwd())}'."
)

@classproperty
def acceptance_test_config_path(cls) -> Path:
"""Get the path to the acceptance test config file."""
result = cls.get_connector_root_dir() / ACCEPTANCE_TEST_CONFIG
if result.exists():
return result

raise FileNotFoundError(f"Acceptance test config file not found at: {str(result)}")

@classmethod
def get_scenarios(
cls,
) -> list[ConnectorTestScenario]:
"""Get acceptance tests for a given category.

This has to be a separate function because pytest does not allow
parametrization of fixtures with arguments from the test class itself.
"""
category = "connection"
all_tests_config = yaml.safe_load(cls.acceptance_test_config_path.read_text())
if "acceptance_tests" not in all_tests_config:
raise ValueError(
f"Acceptance tests config not found in {cls.acceptance_test_config_path}."
f" Found only: {str(all_tests_config)}."
)
if category not in all_tests_config["acceptance_tests"]:
return []
if "tests" not in all_tests_config["acceptance_tests"][category]:
raise ValueError(f"No tests found for category {category}")

tests_scenarios = [
ConnectorTestScenario.model_validate(test)
for test in all_tests_config["acceptance_tests"][category]["tests"]
if "iam_role" not in test["config_path"]
]
connector_root = cls.get_connector_root_dir().absolute()
for test in tests_scenarios:
if test.config_path:
test.config_path = connector_root / test.config_path
if test.configured_catalog_path:
test.configured_catalog_path = connector_root / test.configured_catalog_path

return tests_scenarios
Loading
Loading