bigtesty 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bigtesty/__init__.py ADDED
File without changes
bigtesty/arguments.py ADDED
@@ -0,0 +1,7 @@
1
+ import argparse
2
+
3
+ parser = argparse.ArgumentParser()
4
+ parser.add_argument('--root_folder', dest='root_folder', type=str, help='Root folder')
5
+ parser.add_argument('--project_id', dest='project_id', type=str, help='GCP Project ID')
6
+ parser.add_argument('--datasets_hash', dest='datasets_hash', type=str, help='Unique hash to isolate datasets')
7
+ args = parser.parse_args()
File without changes
@@ -0,0 +1,13 @@
1
+ GOOGLE_PROJECT_KEY = "GOOGLE_PROJECT"
2
+ GOOGLE_REGION_KEY = "GOOGLE_REGION"
3
+ IAC_BACKEND_URL_KEY = "IAC_BACKEND_URL"
4
+ ROOT_TEST_FOLDER_KEY = "ROOT_TEST_FOLDER"
5
+ ROOT_TABLES_FOLDER_KEY = "ROOT_TABLES_FOLDER"
6
+ TABLES_CONFIG_FILE_KEY = "TABLES_CONFIG_FILE"
7
+ KEEP_INFRA_KEY = "KEEP_INFRA"
8
+ BIGTESTY_STACK_NAME_KEY = "BIGTESTY_STACK_NAME"
9
+ PULUMI_CONFIG_PASSPHRASE_KEY = "PULUMI_CONFIG_PASSPHRASE"
10
+ PULUMI_BACKEND_URL_KEY = "PULUMI_BACKEND_URL"
11
+
12
+ BIGTESTY_STACK_NAME_VALUE = "bigtesty"
13
+ PULUMI_CONFIG_PASSPHRASE_VALUE = "gcp_fake_passphrase"
bigtesty/cli/main.py ADDED
@@ -0,0 +1,102 @@
1
+ import os
2
+
3
+ import typer
4
+ from typing_extensions import Annotated
5
+
6
+ from bigtesty.cli.cli_env_vars import ROOT_TEST_FOLDER_KEY, ROOT_TABLES_FOLDER_KEY, TABLES_CONFIG_FILE_KEY, \
7
+ KEEP_INFRA_KEY, BIGTESTY_STACK_NAME_KEY, PULUMI_CONFIG_PASSPHRASE_KEY, PULUMI_BACKEND_URL_KEY, IAC_BACKEND_URL_KEY, \
8
+ BIGTESTY_STACK_NAME_VALUE, PULUMI_CONFIG_PASSPHRASE_VALUE, GOOGLE_PROJECT_KEY, GOOGLE_REGION_KEY
9
+ from bigtesty.infra.launch_tests_ephemeral_infra import launch_tests_ephemeral_infra
10
+
11
+ app = typer.Typer()
12
+
13
+
14
+ @app.command("test", help="Run tests")
15
+ def run_tests(
16
+ project: Annotated[
17
+ str,
18
+ typer.Option(
19
+ "--project",
20
+ envvar=GOOGLE_PROJECT_KEY,
21
+ help="GCP project ID."
22
+ )
23
+ ],
24
+ region: Annotated[
25
+ str,
26
+ typer.Option(
27
+ "--region",
28
+ envvar=GOOGLE_REGION_KEY,
29
+ help="GCP region."
30
+ )
31
+ ],
32
+ iac_backend_url: Annotated[
33
+ str,
34
+ typer.Option(
35
+ "--iac-backend-url",
36
+ envvar=IAC_BACKEND_URL_KEY,
37
+ help="IaC backend URL for the ephemeral infra used for the tests."
38
+ )
39
+ ],
40
+ root_test_folder: Annotated[
41
+ str,
42
+ typer.Option(
43
+ "--root-test-folder",
44
+ envvar=ROOT_TEST_FOLDER_KEY,
45
+ help="Directory that contains test scenarios."
46
+ )
47
+ ],
48
+ root_tables_folder: Annotated[
49
+ str,
50
+ typer.Option(
51
+ "--root-tables-folder",
52
+ envvar=ROOT_TABLES_FOLDER_KEY,
53
+ help="Directory that contains schema files."
54
+ )
55
+ ],
56
+ tables_config_file_path: Annotated[
57
+ str,
58
+ typer.Option(
59
+ "--tables-config-file",
60
+ envvar=TABLES_CONFIG_FILE_KEY,
61
+ help="Path to configuration file."
62
+ )
63
+ ],
64
+ keep_infra: Annotated[
65
+ bool,
66
+ typer.Option(
67
+ "--keep-infra",
68
+ envvar=KEEP_INFRA_KEY,
69
+ help="Keep infrastructure after exit."
70
+ )
71
+ ] = False):
72
+ print(f"####################### The CLI is invoked with params : ")
73
+
74
+ print(f"project : {project}")
75
+ print(f"region : {region}")
76
+ print(f"iac-backend-url : {iac_backend_url}")
77
+ print(f"root-test-folder : {root_test_folder}")
78
+ print(f"root-tables-folder : {root_tables_folder}")
79
+ print(f"tables-config-file : {tables_config_file_path}")
80
+ print(f"keep-infra : {keep_infra}")
81
+
82
+ os.environ[GOOGLE_PROJECT_KEY] = project
83
+ os.environ[GOOGLE_REGION_KEY] = region
84
+ os.environ[PULUMI_BACKEND_URL_KEY] = iac_backend_url
85
+ os.environ[PULUMI_CONFIG_PASSPHRASE_KEY] = PULUMI_CONFIG_PASSPHRASE_VALUE
86
+ os.environ[BIGTESTY_STACK_NAME_KEY] = BIGTESTY_STACK_NAME_VALUE
87
+
88
+ launch_tests_ephemeral_infra(
89
+ root_test_folder=root_test_folder,
90
+ root_tables_folder=root_tables_folder,
91
+ tables_config_file_path=tables_config_file_path,
92
+ keep_infra=keep_infra
93
+ )
94
+
95
+
96
+ @app.command("help")
97
+ def help():
98
+ print("BigTesty is an integration testing framework for BigQuery")
99
+
100
+
101
+ def run():
102
+ app()
@@ -0,0 +1,4 @@
1
+ def build_unique_dataset_id_for_scenario(dataset_id: str,
2
+ scenario_hash: str,
3
+ datasets_hash: str) -> str:
4
+ return f'{dataset_id}_{scenario_hash}_{datasets_hash}'
@@ -0,0 +1,57 @@
1
+ import glob
2
+ import json
3
+ from typing import List, Dict
4
+
5
+ from deepdiff import DeepHash
6
+
7
+ from bigtesty.definition_test_file_exception import DefinitionTestFileException
8
+ from bigtesty.lambda_functions import flat_map
9
+
10
+ DEFINITION_TEST_FILE_PATTERN = "**/definition_*.json"
11
+
12
+
13
+ def get_definition_test_dicts_from_path(data_testing_root_path: str) -> List[Dict]:
14
+ """
15
+ Gets all the test definition files existing in the given folder path and then map them to dicts.
16
+ The result is a list of dict, and each dict represents a test definition.
17
+ If there is no definition files in the given folder, a :class:`~DefinitionTestFileException` is raised.
18
+ :param data_testing_root_path: the root folder path containing test definition files
19
+ :return: a list of test definition dict
20
+ """
21
+ print("#############ROOT DIR")
22
+ print(data_testing_root_path)
23
+
24
+ definition_test_file_paths: List[str] = get_definition_tests_file_paths(data_testing_root_path)
25
+
26
+ if not definition_test_file_paths:
27
+ raise DefinitionTestFileException(
28
+ f'There is no definition files in the given folder path {data_testing_root_path}'
29
+ )
30
+
31
+ return list(map(lambda path: to_definition_test_dict(path), definition_test_file_paths))
32
+
33
+
34
+ def get_scenarios(data_testing_root_path: str):
35
+ return list(
36
+ flat_map(lambda deftest: deftest['scenarios'], get_definition_test_dicts_from_path(data_testing_root_path))
37
+ )
38
+
39
+
40
+ def get_definition_tests_file_paths(data_testing_root_path: str) -> List[str]:
41
+ """
42
+ Gets all the test definitions file paths existing in the given folder path.
43
+ The search of files is recursive.
44
+ """
45
+ return glob.glob(
46
+ pathname=f'{data_testing_root_path}/{DEFINITION_TEST_FILE_PATTERN}',
47
+ recursive=True
48
+ )
49
+
50
+
51
+ def to_definition_test_dict(definition_test_file_path: str):
52
+ with open(definition_test_file_path) as definition_test_file:
53
+ return json.load(definition_test_file)
54
+
55
+
56
+ def get_scenario_hash(scenario_dict: Dict) -> str:
57
+ return DeepHash(scenario_dict)[scenario_dict]
@@ -0,0 +1,2 @@
1
+ class DefinitionTestFileException(Exception):
2
+ pass
@@ -0,0 +1,12 @@
1
+ import json
2
+ from typing import List, Dict
3
+
4
+
5
+ def load_file_as_dicts(file_path: str) -> List[Dict]:
6
+ with open(file_path) as file:
7
+ return json.load(file)
8
+
9
+
10
+ def load_file_as_string(file_path: str) -> str:
11
+ with open(file_path, 'r') as file:
12
+ return file.read().rstrip()
File without changes
@@ -0,0 +1,43 @@
1
+ from typing import List, Dict
2
+
3
+ from google.cloud import bigquery
4
+
5
+ from bigtesty.dataset_helper import build_unique_dataset_id_for_scenario
6
+ from bigtesty.definition_test_config_helper import get_scenario_hash
7
+ from bigtesty.files_loader_helper import load_file_as_dicts
8
+
9
+
10
+ def insert_test_data_to_bq_tables(
11
+ project_id: str,
12
+ root_test_folder: str,
13
+ datasets_hash: str,
14
+ scenarios: List[Dict]) -> None:
15
+ client = bigquery.Client(project=project_id)
16
+
17
+ for scenario in scenarios:
18
+ for given in scenario['given']:
19
+ print("###### GIVEN")
20
+ print(given)
21
+
22
+ input = given.get('input')
23
+ input_as_dicts: List[Dict] = (
24
+ input if input
25
+ else load_file_as_dicts(f"{root_test_folder}/{given['input_file_path']}")
26
+ )
27
+
28
+ print("####### INPUT AS DICT")
29
+ print(input_as_dicts)
30
+
31
+ dataset_id_with_hash = build_unique_dataset_id_for_scenario(
32
+ dataset_id=given['destination_dataset'],
33
+ scenario_hash=get_scenario_hash(scenario),
34
+ datasets_hash=datasets_hash
35
+ )
36
+
37
+ results = client.insert_rows_json(
38
+ f"{dataset_id_with_hash}.{given['destination_table']}",
39
+ input_as_dicts
40
+ )
41
+
42
+ print("###### RESULTS")
43
+ print(results)
File without changes
@@ -0,0 +1,50 @@
1
+ from typing import Dict, List
2
+
3
+ from pulumi_gcp.bigquery import Dataset
4
+
5
+ from bigtesty.dataset_helper import build_unique_dataset_id_for_scenario
6
+ from bigtesty.definition_test_config_helper import get_scenario_hash
7
+ from bigtesty.infra.datasets_with_tables_factory import get_dataset, get_table_with_partitioning, get_table
8
+
9
+
10
+ def create_datasets_and_tables(root_tables_folder: str,
11
+ datasets_with_tables_config: List[Dict],
12
+ datasets_hash: str,
13
+ scenarios: List[Dict]) -> None:
14
+ for dataset_config in datasets_with_tables_config:
15
+ for scenario in scenarios:
16
+ dataset_id_with_hash = build_unique_dataset_id_for_scenario(
17
+ dataset_id=dataset_config["datasetId"],
18
+ scenario_hash=get_scenario_hash(scenario),
19
+ datasets_hash=datasets_hash
20
+ )
21
+
22
+ bq_dataset = get_dataset(dataset_id_with_hash, dataset_config)
23
+
24
+ create_tables(
25
+ root_tables_folder=root_tables_folder,
26
+ dataset_config=dataset_config,
27
+ dataset=bq_dataset,
28
+ dataset_id_with_hash=dataset_id_with_hash
29
+ )
30
+
31
+
32
+ def create_tables(root_tables_folder: str,
33
+ dataset_config: Dict,
34
+ dataset: Dataset,
35
+ dataset_id_with_hash: str) -> None:
36
+ for table_config in dataset_config["tables"]:
37
+ (
38
+ get_table_with_partitioning(
39
+ root_tables_folder=root_tables_folder,
40
+ dataset_id_with_hash=dataset_id_with_hash,
41
+ dataset=dataset,
42
+ table=table_config
43
+ ) if table_config.get("partitionType") is not None
44
+ else get_table(
45
+ root_tables_folder=root_tables_folder,
46
+ dataset_id_with_hash=dataset_id_with_hash,
47
+ dataset=dataset,
48
+ table=table_config
49
+ )
50
+ )
@@ -0,0 +1,28 @@
1
+ import json
2
+ import random
3
+ import string
4
+ from typing import List, Dict
5
+
6
+
7
+ def _load_file_as_dicts(file_path: str) -> List[Dict]:
8
+ with open(file_path) as file:
9
+ return json.load(file)
10
+
11
+
12
+ def _load_file_as_string(file_path: str) -> str:
13
+ with open(file_path) as file:
14
+ return file.read()
15
+
16
+
17
+ def get_table_schema(root_tables_folder: str, table: Dict) -> str:
18
+ datasets_tables_config_file_path = f'{root_tables_folder}/{table["tableSchemaPath"]}'
19
+
20
+ return _load_file_as_string(datasets_tables_config_file_path)
21
+
22
+
23
+ def get_datasets_hash(length) -> str:
24
+ letters = string.ascii_lowercase
25
+ datasets_hash = ''.join(random.choice(letters) for _ in range(length))
26
+ print(f'The generated datasets hash is : {datasets_hash}')
27
+
28
+ return datasets_hash
@@ -0,0 +1,55 @@
1
+ from typing import Dict
2
+
3
+ import pulumi_gcp as gcp
4
+ from pulumi import ResourceOptions
5
+ from pulumi_gcp.bigquery import Dataset
6
+
7
+ from bigtesty.infra.datasets_with_tables_config_file_loader import get_table_schema
8
+
9
+
10
+ def get_table(root_tables_folder: str,
11
+ dataset_id_with_hash: str,
12
+ dataset: Dataset,
13
+ table: Dict):
14
+ return gcp.bigquery.Table(
15
+ get_table_resource_name(dataset_id_with_hash, table['tableId']),
16
+ deletion_protection=False,
17
+ dataset_id=dataset.dataset_id,
18
+ table_id=table["tableId"],
19
+ clusterings=table.get("clustering"),
20
+ schema=get_table_schema(root_tables_folder, table),
21
+ opts=ResourceOptions(depends_on=[dataset])
22
+ )
23
+
24
+
25
+ def get_table_with_partitioning(root_tables_folder: str,
26
+ dataset_id_with_hash: str,
27
+ dataset: Dataset,
28
+ table: Dict):
29
+ return gcp.bigquery.Table(
30
+ get_table_resource_name(dataset_id_with_hash, table['tableId']),
31
+ deletion_protection=False,
32
+ dataset_id=dataset.dataset_id,
33
+ table_id=table["tableId"],
34
+ clusterings=table.get("clustering"),
35
+ time_partitioning=gcp.bigquery.TableTimePartitioningArgs(
36
+ type=table["partitionType"],
37
+ field=table["partitionField"],
38
+ ),
39
+ schema=get_table_schema(root_tables_folder, table),
40
+ opts=ResourceOptions(depends_on=[dataset])
41
+ )
42
+
43
+
44
+ def get_table_resource_name(dataset_id: str, table_id: str) -> str:
45
+ return f'{dataset_id}_{table_id}'
46
+
47
+
48
+ def get_dataset(dataset_id_with_hash: str, dataset: Dict):
49
+ return gcp.bigquery.Dataset(
50
+ dataset_id_with_hash,
51
+ dataset_id=dataset_id_with_hash,
52
+ friendly_name=dataset["datasetFriendlyName"],
53
+ description=dataset["datasetDescription"],
54
+ location=dataset["datasetRegion"]
55
+ )
@@ -0,0 +1,134 @@
1
+ import json
2
+ import os
3
+ import sys
4
+ import traceback
5
+ from typing import List, Dict
6
+
7
+ from pulumi import automation as auto
8
+
9
+ from bigtesty.definition_test_config_helper import get_scenarios
10
+ from bigtesty.given.insertion_test_data_bigquery import insert_test_data_to_bq_tables
11
+ from bigtesty.infra.create_iac_for_datasets_with_tables import create_datasets_and_tables
12
+ from bigtesty.infra.datasets_with_tables_config_file_loader import get_datasets_hash, \
13
+ _load_file_as_dicts
14
+ from bigtesty.then.assertion_and_tests_reports_result import execute_query_and_build_reports_result, \
15
+ check_any_failed_test_in_reports
16
+
17
+
18
+ def launch_tests_ephemeral_infra(root_test_folder: str,
19
+ root_tables_folder: str,
20
+ tables_config_file_path: str,
21
+ keep_infra: bool):
22
+ project_id = os.environ["GOOGLE_PROJECT"]
23
+ region = os.environ["GOOGLE_REGION"]
24
+ stack_name = os.environ["BIGTESTY_STACK_NAME"]
25
+
26
+ datasets_hash = get_datasets_hash(5)
27
+
28
+ scenarios = get_scenarios(root_test_folder)
29
+
30
+ stack = auto.create_or_select_stack(
31
+ stack_name=stack_name,
32
+ project_name=project_id,
33
+ program=lambda: pulumi_program(scenarios,
34
+ datasets_hash,
35
+ root_tables_folder,
36
+ tables_config_file_path)
37
+ )
38
+
39
+ print("Successfully initialized stack")
40
+
41
+ # for inline programs, we must manage plugins ourselves
42
+ print("Installing plugins...")
43
+ stack.workspace.install_plugin("gcp", "v7.24.0")
44
+ print("Plugins installed")
45
+
46
+ print("Setting up config")
47
+ stack.set_config("gcp:project", auto.ConfigValue(value=project_id))
48
+ stack.set_config("gcp:region", auto.ConfigValue(value=region))
49
+ print("Config set")
50
+
51
+ print("Refreshing stack...")
52
+ stack.refresh(on_output=print)
53
+ print("Refresh complete")
54
+
55
+ print("#################### Creating the ephemeral infra...")
56
+ up_res = stack.up(
57
+ on_output=print,
58
+ color="always",
59
+ show_secrets=False,
60
+ diff=True
61
+ )
62
+ print(f"Update summary: \n{json.dumps(up_res.summary.resource_changes, indent=4)}")
63
+
64
+ reports_result: List[Dict]
65
+ try:
66
+ print("#################### Inserting Test data to Tables...")
67
+ insert_test_data_to_bq_tables(
68
+ project_id=project_id,
69
+ root_test_folder=root_test_folder,
70
+ datasets_hash=datasets_hash,
71
+ scenarios=scenarios
72
+ )
73
+
74
+ print("#################### Execute SQL queries and generate reports result...")
75
+ reports_result = execute_query_and_build_reports_result(
76
+ project_id=project_id,
77
+ root_test_folder=root_test_folder,
78
+ datasets_hash=datasets_hash,
79
+ scenarios=scenarios
80
+ )
81
+
82
+ print("################## The reports result is #################")
83
+ print(reports_result)
84
+ print("#################")
85
+
86
+ except Exception as e:
87
+ print(
88
+ "################# Oops! Error in the pipeline, we will rollback the created ephemeral infra :",
89
+ file=sys.stderr
90
+ )
91
+
92
+ exception_traceback = ''.join(traceback.format_tb(e.__traceback__))
93
+ print(exception_traceback, file=sys.stderr)
94
+
95
+ stack.destroy(
96
+ on_output=print,
97
+ color="always",
98
+ show_secrets=False,
99
+ log_flow=True,
100
+ log_verbosity=3
101
+ )
102
+
103
+ sys.exit(-1)
104
+
105
+ print(f"################### Parameter to keep the infra (true/false) : {keep_infra}")
106
+
107
+ if not keep_infra:
108
+ print("################### Destroying the ephemeral infra and tests assertions...")
109
+ stack.destroy(
110
+ on_output=print,
111
+ color="always",
112
+ show_secrets=False,
113
+ log_flow=True,
114
+ log_verbosity=3
115
+ )
116
+ print("############### Destroy ephemeral infra complete")
117
+
118
+ print("############### Checking if there is any failed test...")
119
+ check_any_failed_test_in_reports(reports_result)
120
+ print("############### Tests assertions finished")
121
+
122
+
123
+ def pulumi_program(scenarios: List[Dict],
124
+ datasets_hash: str,
125
+ root_tables_folder: str,
126
+ tables_config_file_path: str):
127
+ datasets_with_tables_config = _load_file_as_dicts(tables_config_file_path)
128
+
129
+ create_datasets_and_tables(
130
+ root_tables_folder=root_tables_folder,
131
+ datasets_with_tables_config=datasets_with_tables_config,
132
+ datasets_hash=datasets_hash,
133
+ scenarios=scenarios
134
+ )
@@ -0,0 +1 @@
1
+ flat_map = lambda f, xs: (y for ys in xs for y in f(ys))
File without changes
@@ -0,0 +1,227 @@
1
+ import importlib.util
2
+ import re
3
+ import traceback
4
+ from typing import List, Dict
5
+
6
+ import deepdiff
7
+ from google.cloud import bigquery
8
+ from google.cloud.bigquery import Client
9
+ from toolz.curried import pipe, map
10
+
11
+ from bigtesty.dataset_helper import build_unique_dataset_id_for_scenario
12
+ from bigtesty.definition_test_config_helper import get_scenario_hash
13
+ from bigtesty.files_loader_helper import load_file_as_string, load_file_as_dicts
14
+ from bigtesty.then.assertion_types import AssertionType
15
+ from bigtesty.then.failure_test_exception import FailureTestException
16
+
17
+
18
+ def execute_query_and_build_reports_result(
19
+ project_id: str,
20
+ root_test_folder: str,
21
+ datasets_hash: str,
22
+ scenarios: List[Dict]) -> List[Dict]:
23
+ client = bigquery.Client(project=project_id)
24
+
25
+ return list(pipe(
26
+ scenarios,
27
+ map(lambda scenario: _execute_query_and_build_report_result(
28
+ bigquery_python_client=client,
29
+ scenario=scenario,
30
+ root_test_folder=root_test_folder,
31
+ datasets_hash=datasets_hash
32
+ ))
33
+ ))
34
+
35
+
36
+ def check_any_failed_test_in_reports(reports_result: List[Dict]) -> bool:
37
+ any_failed_test: bool = any(report['result'] is False for report in reports_result)
38
+
39
+ if any_failed_test:
40
+ print("There is any failed test in the reports result, the pipeline is in failure status !!")
41
+ raise FailureTestException('Error : there is any failed test !!!')
42
+
43
+ print("There is no failed test in the reports result, the pipeline is in success status !!")
44
+ return any_failed_test
45
+
46
+
47
+ def _execute_query_and_build_report_result(bigquery_python_client: Client,
48
+ scenario: Dict,
49
+ root_test_folder: str,
50
+ datasets_hash: str) -> Dict:
51
+ given_list: List[Dict] = scenario["given"]
52
+
53
+ print(f"######################################## treating the scenario {scenario['description']}... ##########")
54
+
55
+ for then in scenario["then"]:
56
+ query = _build_query(
57
+ scenario=scenario,
58
+ datasets_hash=datasets_hash,
59
+ root_test_folder=root_test_folder,
60
+ given_list=given_list,
61
+ then=then
62
+ )
63
+
64
+ print("#########SQL QUERY")
65
+ print(query)
66
+
67
+ query_job = bigquery_python_client.query(query)
68
+ actual_list: List[Dict] = list(map(lambda r: dict(r), query_job.result()))
69
+ print("#########ACTUAL")
70
+ print(actual_list)
71
+
72
+ expected_list = _get_expected_list(root_test_folder, then)
73
+ print("#########EXPECTED")
74
+ print(expected_list)
75
+
76
+ assertion_type: str = then['assertion_type']
77
+ print(f"######### The assertion type is: {assertion_type}")
78
+
79
+ if assertion_type == AssertionType.ROW_MATCH.value:
80
+ return _build_report_row_match_assertion(
81
+ scenario=scenario,
82
+ then=then,
83
+ actual_list=actual_list,
84
+ expected_list=expected_list
85
+ )
86
+ elif assertion_type == AssertionType.FUNCTION_ASSERTION.value:
87
+ return _build_report_functions_assertion(
88
+ root_test_folder=root_test_folder,
89
+ scenario=scenario,
90
+ then=then,
91
+ actual_list=actual_list,
92
+ expected_list=expected_list
93
+ )
94
+ else:
95
+ assertion_types = [e.value for e in AssertionType]
96
+ raise ValueError(
97
+ f"The then bloc doesn't contains a known assertion_type field.The known types are {assertion_types}"
98
+ )
99
+
100
+
101
+ def _build_report_row_match_assertion(scenario: Dict,
102
+ then: Dict,
103
+ actual_list: List[Dict],
104
+ expected_list: List[Dict]) -> Dict:
105
+ fields_to_ignore_compiled_regex = list(
106
+ map(lambda field_regex: re.compile(field_regex), then['fields_to_ignore'])
107
+ )
108
+
109
+ result: Dict = deepdiff.DeepDiff(actual_list,
110
+ expected_list,
111
+ ignore_order=True,
112
+ exclude_regex_paths=fields_to_ignore_compiled_regex,
113
+ view='tree')
114
+ print(f"#########TEST RESULT for scenario: {scenario['description']}")
115
+ print(result)
116
+
117
+ return {
118
+ 'scenario': scenario,
119
+ 'actual': actual_list,
120
+ 'expected': expected_list,
121
+ 'comparison_info': result,
122
+ 'result': result == {}
123
+ }
124
+
125
+
126
+ def _build_report_functions_assertion(root_test_folder: str,
127
+ scenario: Dict,
128
+ then: Dict,
129
+ actual_list: List[Dict],
130
+ expected_list: List[Dict]) -> Dict:
131
+ assertions_result: List[Dict] = list(pipe(
132
+ then['expected_functions'],
133
+ map(
134
+ lambda fn: _execute_assertions_functions_and_get_result(
135
+ root_test_folder=root_test_folder,
136
+ actual_list=actual_list,
137
+ expected_list=expected_list,
138
+ expected_function=fn)
139
+ )
140
+ ))
141
+
142
+ any_assertion_failed: bool = any(result['assertion_error'] is True for result in assertions_result)
143
+
144
+ print(f"#########TEST RESULT for scenario: {scenario['description']}")
145
+
146
+ return {
147
+ 'scenario': scenario,
148
+ 'actual': actual_list,
149
+ 'expected': expected_list,
150
+ 'comparison_info': assertions_result,
151
+ 'result': not any_assertion_failed
152
+ }
153
+
154
+
155
+ def _execute_assertions_functions_and_get_result(root_test_folder: str,
156
+ actual_list: List[Dict],
157
+ expected_list: List[Dict],
158
+ expected_function: Dict) -> Dict:
159
+ assertion_module = expected_function["module"]
160
+ assertion_function = expected_function["function"]
161
+
162
+ module_spec = importlib.util.spec_from_file_location(
163
+ "assertion_functions", f'{root_test_folder}/{assertion_module}'
164
+ )
165
+ gfg_module = importlib.util.module_from_spec(module_spec)
166
+ module_spec.loader.exec_module(gfg_module)
167
+
168
+ current_function = getattr(gfg_module, assertion_function)
169
+
170
+ assertion_error = False
171
+ assertion_error_message = ""
172
+ try:
173
+ print(f"Executing Assertion Function {assertion_function} from module {assertion_module}...")
174
+ current_function(actual_list, expected_list)
175
+ print(f"The Assertion Function {assertion_function} from module {assertion_module} is in success status...")
176
+ except AssertionError as e:
177
+ assertion_error = True
178
+ assertion_error_message = ''.join(traceback.format_tb(e.__traceback__))
179
+ print(
180
+ f"The Assertion Function {assertion_function} from module {assertion_module} "
181
+ f"has the following error {assertion_error_message}"
182
+ )
183
+
184
+ return {
185
+ 'assertion_module': assertion_module,
186
+ 'assertion_function': assertion_function,
187
+ 'assertion_error': assertion_error,
188
+ 'assertion_error_message': assertion_error_message
189
+ }
190
+
191
+
192
+ def _build_query(scenario: Dict,
193
+ datasets_hash: str,
194
+ root_test_folder: str,
195
+ given_list: List[Dict],
196
+ then: Dict) -> str:
197
+ actual = then.get('actual')
198
+ sql_query = actual if actual else load_file_as_string(f"{root_test_folder}/{then['actual_file_path']}")
199
+
200
+ sql_query_result = ""
201
+ for given in given_list:
202
+ sql_query_result = _replace_current_dataset_by_unique_dataset_for_scenario(
203
+ sql_query=sql_query,
204
+ current_dataset=given["destination_dataset"],
205
+ scenario_hash=get_scenario_hash(scenario),
206
+ datasets_hash=datasets_hash
207
+ )
208
+
209
+ return sql_query_result
210
+
211
+
212
+ def _replace_current_dataset_by_unique_dataset_for_scenario(sql_query: str,
213
+ current_dataset: str,
214
+ scenario_hash: str,
215
+ datasets_hash: str) -> str:
216
+ unique_dataset_with_for_scenario = build_unique_dataset_id_for_scenario(
217
+ dataset_id=current_dataset,
218
+ scenario_hash=scenario_hash,
219
+ datasets_hash=datasets_hash
220
+ )
221
+
222
+ return sql_query.replace(f"{current_dataset}.", f"{unique_dataset_with_for_scenario}.")
223
+
224
+
225
+ def _get_expected_list(root_test_folder: str, then: Dict) -> List[Dict]:
226
+ expected = then.get('expected')
227
+ return expected if expected else load_file_as_dicts(f"{root_test_folder}/{then['expected_file_path']}")
@@ -0,0 +1,6 @@
1
+ from enum import Enum
2
+
3
+
4
+ class AssertionType(Enum):
5
+ ROW_MATCH = 'row_match'
6
+ FUNCTION_ASSERTION = 'function_assertion'
@@ -0,0 +1,2 @@
1
+ class FailureTestException(Exception):
2
+ pass
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Mazlum Tosun
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,226 @@
1
+ Metadata-Version: 2.1
2
+ Name: bigtesty
3
+ Version: 0.1.0
4
+ Summary: BigTesty is an integration testing framework for BigQuery
5
+ Home-page: https://github.com/tosun-si/bigtesty
6
+ Author: Mazlum TOSUN
7
+ Author-email: mazlum.tosun@gmail.com
8
+ License: UNKNOWN
9
+ Platform: UNKNOWN
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.9.19
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: pulumi-gcp ==7.24.0
17
+ Requires-Dist: google-cloud-bigquery ==3.23.1
18
+ Requires-Dist: typer[all] ==0.12.3
19
+ Requires-Dist: pytest ==8.2.1
20
+ Requires-Dist: deepdiff ==7.0.1
21
+ Requires-Dist: toolz ==0.12.1
22
+
23
+ # BigTesty
24
+
25
+ BigTesty is a framework that allows to create Integration Tests with BigQuery on a real and short-lived Infrastructure.
26
+
27
+ Integration and End-to-End tests are a robust way to validate if SQL queries work as expected.\
28
+ There is no an emulator in this case and the queries are executed directly in the BigQuery Engine.
29
+
30
+ BigTesty isolates the tests for each execution to prevent collisions.\
31
+ Multiples developers or CI CD pipelines can execute tests at the same time.
32
+
33
+ The infrastructure proposed for the tests is ephemeral by default, but we can keep it if needed, to analyse the\
34
+ result in BigQuery.
35
+
36
+ After each test, a report result is returned to indicate the good and failure cases.
37
+
38
+ ![bigtesty_pulumi_automation.png](diagram%2Fbigtesty_pulumi_automation.png)
39
+
40
+ ## Getting started
41
+
42
+ There is a `Python` package for BigTesty and it can be installed from `PiPy`:
43
+
44
+ ```bash
45
+ pip install bigtesty
46
+ ```
47
+
48
+ ## How to run tests
49
+
50
+ **You need to be authenticated with Google Cloud Platform before running command.**
51
+
52
+ We recommend to be authenticated with [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials)
53
+
54
+ ```bash
55
+ gcloud auth application-default login
56
+ ```
57
+
58
+ ### Example of code structure
59
+
60
+ ![bigtesty_examples_code_structure.png](bigtesty-doc%2Fstatic%2Fimg%2Fbigtesty_examples_code_structure.png)
61
+
62
+ **The root test folder**
63
+
64
+ This folder contains all the testing definition files and the tests scenarios. The format is Json.
65
+
66
+ Example of a scenarios with a nominal case `definition_spec_failure_by_feature_name_no_error.json`:
67
+
68
+ ```json
69
+ {
70
+ "description": "Test of monitoring data",
71
+ "scenarios": [
72
+ {
73
+ "description": "Nominal case find failure by feature name",
74
+ "given": [
75
+ {
76
+ "input_file_path": "monitoring/given/input_failures_feature_name.json",
77
+ "destination_dataset": "monitoring",
78
+ "destination_table": "job_failure"
79
+ }
80
+ ],
81
+ "then": [
82
+ {
83
+ "fields_to_ignore": [
84
+ "\\[\\d+\\]\\['dwhCreationDate']"
85
+ ],
86
+ "actual_file_path": "monitoring/when/find_failures_by_feature_name.sql",
87
+ "expected_file_path": "monitoring/then/expected_failures_feature_name.json"
88
+ }
89
+ ]
90
+ }
91
+ ]
92
+ }
93
+ ```
94
+
95
+ In this example, there is only one scenario with 3 blocs:
96
+ - `given`: a list of input test data to ingest to the BigQuery tables. The input data can be proposed in a separate `Json` file or directly embedded. `input_file_path` for a separate file and `input` for an embedded object.
97
+ - `then`: a list of objects contains the SQL query to test and execute and the expected data. `actual/actual_file_path` => SQL query | `expected/expected_file_path` => expected data
98
+
99
+ **The root tables folder**
100
+
101
+ This folder contains the resources concerning the `BigQuery` `datasets` and `tables` to create.\
102
+ For example, all the `BigQuery` schemas are proposed in this folder.
103
+
104
+ **The tables config file**
105
+
106
+ The config file that lists all the `BigQuery` `datasets` and `tables` to create in a `Json` format.
107
+
108
+ Example:
109
+
110
+ ```json
111
+ [
112
+ {
113
+ "datasetId": "monitoring",
114
+ "datasetRegion": "EU",
115
+ "datasetFriendlyName": "Monitoring Dataset",
116
+ "datasetDescription": "Monitoring Dataset description",
117
+ "tables": [
118
+ {
119
+ "tableId": "job_failure",
120
+ "autodetect": false,
121
+ "tableSchemaPath": "schema/monitoring/job_failure.json",
122
+ "partitionType": "DAY",
123
+ "partitionField": "dwhCreationDate",
124
+ "clustering": [
125
+ "featureName",
126
+ "jobName",
127
+ "componentType"
128
+ ]
129
+ }
130
+ ]
131
+ }
132
+ ]
133
+ ```
134
+
135
+ In this example, we have a dataset called `monitoring` with the metadata.\
136
+ This dataset contains a table called `job_failure` with the metadata. Some fields can target on the `BigQuery` schemas proposed in the `root tables folder`.
137
+
138
+ ### Run with CLI
139
+
140
+ We need to pass the 3 parameters indicated in the previous section, in the command line to launch the tests:
141
+
142
+ - root-test-folder: the root folder containing all the testing files
143
+ - root-tables-folder: the root folder containing all the needed files to create the datasets and tables in BigQuery (Json schema...)
144
+ - tables-config-file: the Json configuration file that lists all the datasets and tables to create in BigQuery
145
+
146
+ Also, common GCP parameters like:
147
+ - project: the GCP project ID
148
+ - region: the GCP region
149
+
150
+ `BigTesty` uses an ephemeral infra internally via the concept of Infra As Code and the backend to host the state must be a `cloud Storage` bucket.\
151
+ We need to pass the backend URL via parameter in the CLI:
152
+ - iac-backend-url
153
+
154
+ The tests can be executed with the following command line:
155
+
156
+ ```bash
157
+ bigtesty test \
158
+ --project $PROJECT_ID \
159
+ --region $LOCATION \
160
+ --iac-backend-url gs://$IAC_BUCKET_STATE/bigtesty \
161
+ --root-test-folder $(pwd)/examples/tests \
162
+ --root-tables-folder $(pwd)/examples/tests/tables \
163
+ --tables-config-file $(pwd)/examples/tests/tables/tables.json
164
+ ```
165
+
166
+ **All the testing files showed in the documentation are accessible from the `examples` folder proposed at the root of the `BigTesty` repo.**
167
+
168
+ ### Run with Docker
169
+
170
+ Instead of pass the arguments by the CLI, we can also pass them with environment variables.
171
+
172
+ ```bash
173
+ export PROJECT_ID={{project_id}}
174
+ export LOCATION={{region}}
175
+ export IAC_BACKEND_URL=gs://{{gcs_state_bucket}}/bigtesty
176
+ export ROOT_TEST_FOLDER=/opt/bigtesty/tests
177
+ export ROOT_TABLES_FOLDER=/opt/bigtesty/tests/tables
178
+ export TABLES_CONFIG_FILE_PATH=/opt/bigtesty/tests/tables/tables.json
179
+
180
+ docker run -it \
181
+ -e GOOGLE_PROJECT=$PROJECT_ID \
182
+ -e GOOGLE_REGION=$LOCATION \
183
+ -e IAC_BACKEND_URL=$IAC_BACKEND_URL \
184
+ -e TABLES_CONFIG_FILE="$TABLES_CONFIG_FILE_PATH" \
185
+ -e ROOT_TEST_FOLDER=$ROOT_TEST_FOLDER \
186
+ -e ROOT_TABLES_FOLDER="$ROOT_TABLES_FOLDER" \
187
+ -v $(pwd)/examples/tests:/opt/bigtesty/tests \
188
+ -v $(pwd)/examples/tests/tables:/opt/bigtesty/tests/tables \
189
+ -v $HOME/.config/gcloud:/opt/bigtesty/.config/gcloud \
190
+ groupbees/bigtesty test
191
+ ```
192
+
193
+ Some explanations:
194
+
195
+ All the parameters are passed as environment variables.\
196
+ We need also to mount as volumes:
197
+ - the tests root folder : `-v $(pwd)/examples/tests:/opt/bigtesty/tests`
198
+ - the tables root folder: `-v $(pwd)/examples/tests/tables:/opt/bigtesty/tests/tables`
199
+ - the `gcloud` configuration: `-v $HOME/.config/gcloud:/opt/bigtesty/.config/gcloud`
200
+
201
+ When the authentication is done with Applications Default Credentials via the following command `gcloud auth application-default login`,\
202
+ a short-lived credential is generated in the local `gcloud` configuration: `$HOME/.config/gcloud`
203
+
204
+ To prevent the use of a long-lived SA token key, we can share and mount as volume the local `gcloud` configuration with the `Docker` container: `-v $HOME/.config/gcloud:/opt/bigtesty/.config/gcloud`\
205
+ With this technic, the container will be authenticated in Google Cloud securely, with your current user in the Shell session.
206
+
207
+ ### Run with Cloud Build
208
+
209
+ ```bash
210
+ export PROJECT_ID={{project_id}}
211
+ export LOCATION={{region}}
212
+ export IAC_BACKEND_URL=gs://{{gcs_state_bucket}}/bigtesty
213
+
214
+ gcloud builds submit \
215
+ --project=$PROJECT_ID \
216
+ --region=$LOCATION \
217
+ --config examples/ci/cloud_build/run-tests-cloud-build.yaml \
218
+ --substitutions _IAC_BACKEND_URL=$IAC_BACKEND_URL \
219
+ --verbosity="debug" .
220
+ ```
221
+
222
+ ## Contributing
223
+
224
+ cf. [CONTRIBUTING](CONTRIBUTING.md).
225
+
226
+
@@ -0,0 +1,27 @@
1
+ bigtesty/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ bigtesty/arguments.py,sha256=JwwMPFkRSSrWILzO6mY9Xlbbajk4cs6ddFbC4zyrQs0,365
3
+ bigtesty/dataset_helper.py,sha256=BrX1ZUv4IDvS9lCMwidlsT7DKrfKyhtXoq06N4-38w4,247
4
+ bigtesty/definition_test_config_helper.py,sha256=WaqpouC7tvIwC8LcKvaHXlyGJt9IcOJ8-PXH0ifea8E,2055
5
+ bigtesty/definition_test_file_exception.py,sha256=2ojAbfQ_FSN4Gq-f82ARynQvFYDBaAOy4bAZ4tqUZB0,55
6
+ bigtesty/files_loader_helper.py,sha256=hUBQ8jVuIULc0auAd0g2Bw2HEHOckg2WKo89VlamWe4,288
7
+ bigtesty/lambda_functions.py,sha256=Kha-s1_P_BU5d-PAY2HOW11tnZNa0EiGVSFyn9HquYI,57
8
+ bigtesty/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ bigtesty/cli/cli_env_vars.py,sha256=gBW38CwQOKrHlCKt9A_1n63jdA0N5bJ1d02HXCYY0CM,525
10
+ bigtesty/cli/main.py,sha256=M-apIOyZZOM44uMSCp0yyaYHp-hS5vFvRz5sUlWNP28,3263
11
+ bigtesty/given/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ bigtesty/given/insertion_test_data_bigquery.py,sha256=-lobytt7Pwvyw1RRPoX9AI5T_7VABDuofmgtu0zXXRo,1382
13
+ bigtesty/infra/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ bigtesty/infra/create_iac_for_datasets_with_tables.py,sha256=ESi8WuAxO8PIj2FdCTccKpyR92U4dkreefkvWB40nZc,1971
15
+ bigtesty/infra/datasets_with_tables_config_file_loader.py,sha256=zIsQkq-_ygZ8ffYWyPo1z31v_wlBTYrDxvNwafzMZfQ,771
16
+ bigtesty/infra/datasets_with_tables_factory.py,sha256=xxKn7H6e5ufxtneG_svYvDwUutZm1UZcXnrlrMGYmQ0,1903
17
+ bigtesty/infra/launch_tests_ephemeral_infra.py,sha256=qzSjtcDMBCaVIFNlDhNIWoUpDcj_fmmtZMoSqYaKuns,4607
18
+ bigtesty/then/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
+ bigtesty/then/assertion_and_tests_reports_result.py,sha256=L5pCb_n4bhli5gzPq4J3ygdgGDOalMKFJoCMkYeLSoI,8749
20
+ bigtesty/then/assertion_types.py,sha256=DIrxmA5utBGUIUHy29exenMdWr1wa9ATqvJryMPKQLQ,125
21
+ bigtesty/then/failure_test_exception.py,sha256=XGdZ75LXL3TpNV7zaZvSj-6S21pUBRkh-J_ygaQDIIE,48
22
+ bigtesty-0.1.0.dist-info/LICENSE,sha256=aBCOCCCvY_F67UK2dRXZ9vJccUsM1hyU34OTSo_CgJI,1069
23
+ bigtesty-0.1.0.dist-info/METADATA,sha256=qBdmU4oWuSYgaOrmOL9YxLJluMhk_xM6EHME1aUOCEQ,8033
24
+ bigtesty-0.1.0.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
25
+ bigtesty-0.1.0.dist-info/entry_points.txt,sha256=kjIHnEfQrvmIoEceW6l5xLvzqQNjbVJ2almYITl9RFw,70
26
+ bigtesty-0.1.0.dist-info/top_level.txt,sha256=iAitepHcGGQQNekCFAr2NrkMPd8rvWichurwR-mrxXw,9
27
+ bigtesty-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.43.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,4 @@
1
+
2
+ [console_scripts]
3
+ bigtesty=bigtesty.cli.main:run
4
+
@@ -0,0 +1 @@
1
+ bigtesty