Skip to content
This repository was archived by the owner on Mar 31, 2026. It is now read-only.

Commit 5629bac

Browse files
authored
tests: move systests into separate modules, refactor using pytest (#474)
* tests: move instance API systests to own module Refactor to use pytest fixtures / idioms, rather than old 'Config' setup / teardown. Toward #472. * tests: move database API systests to own module Refactor to use pytest fixtures / idioms, rather than old 'Config' setup / teardown. Toward #472. * tests: move table API systests to own module Refactor to use pytest fixtures / idioms, rather than old 'Config' setup / teardown. Toward #472. * tests: move backup API systests to own module [WIP] Refactor to use pytest fixtures / idioms, rather than old 'Config' setup / teardown. Toward #472. * tests: move streaming/chunnking systests to own module Refactor to use pytest fixtures / idioms, rather than old 'Config' setup / teardown. Toward #472. * tests: move session API systests to own module Refactor to use pytest fixtures / idioms, rather than old 'Config' setup/ teardown. Toward #472. * tests: move dbapi systests to owwn module Refactor to use pytest fixtures / idioms, rather than old 'Confog' setup / teardown. Toward #472. * tests: remove legacy systest setup / teardown code Closes #472. * tests: don't pre-create datbase before restore attempt * tests: fix instance config fixtures under emulator * tests: clean up alt instnce at module scope Avoids clash with 'test_list_instances' expectatons. * tests: work around MethodNotImplemented Raised from 'ListBackups' API on the CI emulator, but not locally. * chore: drop use of pytz in systests See #479 for rationale. * chore: fix fossil in comment * chore: move '_check_batch_status' to only calling module Likewise the 'FauxCall' helper class it uses. * chore: improve testcase name * tests: replicate dbapi systest changes from #412 into new module
1 parent cbb4ee3 commit 5629bac

12 files changed

+3970
-3632
lines changed

tests/system/_helpers.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Copyright 2021 Google LLC All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import operator
16+
import os
17+
import time
18+
19+
from google.api_core import exceptions
20+
from google.cloud.spanner_v1 import instance as instance_mod
21+
from tests import _fixtures
22+
from test_utils import retry
23+
from test_utils import system
24+
25+
26+
CREATE_INSTANCE_ENVVAR = "GOOGLE_CLOUD_TESTS_CREATE_SPANNER_INSTANCE"
27+
CREATE_INSTANCE = os.getenv(CREATE_INSTANCE_ENVVAR) is not None
28+
29+
INSTANCE_ID_ENVVAR = "GOOGLE_CLOUD_TESTS_SPANNER_INSTANCE"
30+
INSTANCE_ID_DEFAULT = "google-cloud-python-systest"
31+
INSTANCE_ID = os.environ.get(INSTANCE_ID_ENVVAR, INSTANCE_ID_DEFAULT)
32+
33+
SKIP_BACKUP_TESTS_ENVVAR = "SKIP_BACKUP_TESTS"
34+
SKIP_BACKUP_TESTS = os.getenv(SKIP_BACKUP_TESTS_ENVVAR) is not None
35+
36+
SPANNER_OPERATION_TIMEOUT_IN_SECONDS = int(
37+
os.getenv("SPANNER_OPERATION_TIMEOUT_IN_SECONDS", 60)
38+
)
39+
40+
USE_EMULATOR_ENVVAR = "SPANNER_EMULATOR_HOST"
41+
USE_EMULATOR = os.getenv(USE_EMULATOR_ENVVAR) is not None
42+
43+
EMULATOR_PROJECT_ENVVAR = "GCLOUD_PROJECT"
44+
EMULATOR_PROJECT_DEFAULT = "emulator-test-project"
45+
EMULATOR_PROJECT = os.getenv(EMULATOR_PROJECT_ENVVAR, EMULATOR_PROJECT_DEFAULT)
46+
47+
48+
DDL_STATEMENTS = (
49+
_fixtures.EMULATOR_DDL_STATEMENTS if USE_EMULATOR else _fixtures.DDL_STATEMENTS
50+
)
51+
52+
retry_true = retry.RetryResult(operator.truth)
53+
retry_false = retry.RetryResult(operator.not_)
54+
55+
retry_503 = retry.RetryErrors(exceptions.ServiceUnavailable)
56+
retry_429_503 = retry.RetryErrors(
57+
exceptions.TooManyRequests, exceptions.ServiceUnavailable,
58+
)
59+
retry_mabye_aborted_txn = retry.RetryErrors(exceptions.ServerError, exceptions.Aborted)
60+
retry_mabye_conflict = retry.RetryErrors(exceptions.ServerError, exceptions.Conflict)
61+
62+
63+
def _has_all_ddl(database):
64+
# Predicate to test for EC completion.
65+
return len(database.ddl_statements) == len(DDL_STATEMENTS)
66+
67+
68+
retry_has_all_dll = retry.RetryInstanceState(_has_all_ddl)
69+
70+
71+
def scrub_instance_backups(to_scrub):
72+
try:
73+
for backup_pb in to_scrub.list_backups():
74+
bkp = instance_mod.Backup.from_pb(backup_pb, to_scrub)
75+
try:
76+
# Instance cannot be deleted while backups exist.
77+
retry_429_503(bkp.delete)()
78+
except exceptions.NotFound: # lost the race
79+
pass
80+
except exceptions.MethodNotImplemented:
81+
# The CI emulator raises 501: local versions seem fine.
82+
pass
83+
84+
85+
def scrub_instance_ignore_not_found(to_scrub):
86+
"""Helper for func:`cleanup_old_instances`"""
87+
scrub_instance_backups(to_scrub)
88+
89+
try:
90+
retry_429_503(to_scrub.delete)()
91+
except exceptions.NotFound: # lost the race
92+
pass
93+
94+
95+
def cleanup_old_instances(spanner_client):
96+
cutoff = int(time.time()) - 1 * 60 * 60 # one hour ago
97+
instance_filter = "labels.python-spanner-systests:true"
98+
99+
for instance_pb in spanner_client.list_instances(filter_=instance_filter):
100+
instance = instance_mod.Instance.from_pb(instance_pb, spanner_client)
101+
102+
if "created" in instance.labels:
103+
create_time = int(instance.labels["created"])
104+
105+
if create_time <= cutoff:
106+
scrub_instance_ignore_not_found(instance)
107+
108+
109+
def unique_id(prefix, separator="-"):
110+
return f"{prefix}{system.unique_resource_id(separator)}"

tests/system/_sample_data.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Copyright 2021 Google LLC All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import datetime
16+
import math
17+
18+
from google.api_core import datetime_helpers
19+
from google.cloud._helpers import UTC
20+
from google.cloud import spanner_v1
21+
22+
23+
TABLE = "contacts"
24+
COLUMNS = ("contact_id", "first_name", "last_name", "email")
25+
ROW_DATA = (
26+
(1, u"Phred", u"Phlyntstone", u"phred@example.com"),
27+
(2, u"Bharney", u"Rhubble", u"bharney@example.com"),
28+
(3, u"Wylma", u"Phlyntstone", u"wylma@example.com"),
29+
)
30+
ALL = spanner_v1.KeySet(all_=True)
31+
SQL = "SELECT * FROM contacts ORDER BY contact_id"
32+
33+
COUNTERS_TABLE = "counters"
34+
COUNTERS_COLUMNS = ("name", "value")
35+
36+
37+
def _assert_timestamp(value, nano_value):
38+
assert isinstance(value, datetime.datetime)
39+
assert value.tzinfo is None
40+
assert nano_value.tzinfo is UTC
41+
42+
assert value.year == nano_value.year
43+
assert value.month == nano_value.month
44+
assert value.day == nano_value.day
45+
assert value.hour == nano_value.hour
46+
assert value.minute == nano_value.minute
47+
assert value.second == nano_value.second
48+
assert value.microsecond == nano_value.microsecond
49+
50+
if isinstance(value, datetime_helpers.DatetimeWithNanoseconds):
51+
assert value.nanosecond == nano_value.nanosecond
52+
else:
53+
assert value.microsecond * 1000 == nano_value.nanosecond
54+
55+
56+
def _check_rows_data(rows_data, expected=ROW_DATA, recurse_into_lists=True):
57+
assert len(rows_data) == len(expected)
58+
59+
for row, expected in zip(rows_data, expected):
60+
_check_row_data(row, expected, recurse_into_lists=recurse_into_lists)
61+
62+
63+
def _check_row_data(row_data, expected, recurse_into_lists=True):
64+
assert len(row_data) == len(expected)
65+
66+
for found_cell, expected_cell in zip(row_data, expected):
67+
_check_cell_data(
68+
found_cell, expected_cell, recurse_into_lists=recurse_into_lists
69+
)
70+
71+
72+
def _check_cell_data(found_cell, expected_cell, recurse_into_lists=True):
73+
74+
if isinstance(found_cell, datetime_helpers.DatetimeWithNanoseconds):
75+
_assert_timestamp(expected_cell, found_cell)
76+
77+
elif isinstance(found_cell, float) and math.isnan(found_cell):
78+
assert math.isnan(expected_cell)
79+
80+
elif isinstance(found_cell, list) and recurse_into_lists:
81+
assert len(found_cell) == len(expected_cell)
82+
83+
for found_item, expected_item in zip(found_cell, expected_cell):
84+
_check_cell_data(found_item, expected_item)
85+
86+
else:
87+
assert found_cell == expected_cell

tests/system/conftest.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
# Copyright 2021 Google LLC All rights reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import time
16+
17+
import pytest
18+
19+
from google.cloud import spanner_v1
20+
from . import _helpers
21+
22+
23+
@pytest.fixture(scope="function")
24+
def if_create_instance():
25+
if not _helpers.CREATE_INSTANCE:
26+
pytest.skip(f"{_helpers.CREATE_INSTANCE_ENVVAR} not set in environment.")
27+
28+
29+
@pytest.fixture(scope="function")
30+
def no_create_instance():
31+
if _helpers.CREATE_INSTANCE:
32+
pytest.skip(f"{_helpers.CREATE_INSTANCE_ENVVAR} set in environment.")
33+
34+
35+
@pytest.fixture(scope="function")
36+
def if_backup_tests():
37+
if _helpers.SKIP_BACKUP_TESTS:
38+
pytest.skip(f"{_helpers.SKIP_BACKUP_TESTS_ENVVAR} set in environment.")
39+
40+
41+
@pytest.fixture(scope="function")
42+
def not_emulator():
43+
if _helpers.USE_EMULATOR:
44+
pytest.skip(f"{_helpers.USE_EMULATOR_ENVVAR} set in environment.")
45+
46+
47+
@pytest.fixture(scope="session")
48+
def spanner_client():
49+
if _helpers.USE_EMULATOR:
50+
from google.auth.credentials import AnonymousCredentials
51+
52+
credentials = AnonymousCredentials()
53+
return spanner_v1.Client(
54+
project=_helpers.EMULATOR_PROJECT, credentials=credentials,
55+
)
56+
else:
57+
return spanner_v1.Client() # use google.auth.default credentials
58+
59+
60+
@pytest.fixture(scope="session")
61+
def operation_timeout():
62+
return _helpers.SPANNER_OPERATION_TIMEOUT_IN_SECONDS
63+
64+
65+
@pytest.fixture(scope="session")
66+
def shared_instance_id():
67+
if _helpers.CREATE_INSTANCE:
68+
return f"{_helpers.unique_id('google-cloud')}"
69+
70+
return _helpers.INSTANCE_ID
71+
72+
73+
@pytest.fixture(scope="session")
74+
def instance_configs(spanner_client):
75+
configs = list(_helpers.retry_503(spanner_client.list_instance_configs)())
76+
77+
if not _helpers.USE_EMULATOR:
78+
79+
# Defend against back-end returning configs for regions we aren't
80+
# actually allowed to use.
81+
configs = [config for config in configs if "-us-" in config.name]
82+
83+
yield configs
84+
85+
86+
@pytest.fixture(scope="session")
87+
def instance_config(instance_configs):
88+
if not instance_configs:
89+
raise ValueError("No instance configs found.")
90+
91+
yield instance_configs[0]
92+
93+
94+
@pytest.fixture(scope="session")
95+
def existing_instances(spanner_client):
96+
instances = list(_helpers.retry_503(spanner_client.list_instances)())
97+
98+
yield instances
99+
100+
101+
@pytest.fixture(scope="session")
102+
def shared_instance(
103+
spanner_client,
104+
operation_timeout,
105+
shared_instance_id,
106+
instance_config,
107+
existing_instances, # evalutate before creating one
108+
):
109+
_helpers.cleanup_old_instances(spanner_client)
110+
111+
if _helpers.CREATE_INSTANCE:
112+
create_time = str(int(time.time()))
113+
labels = {"python-spanner-systests": "true", "created": create_time}
114+
115+
instance = spanner_client.instance(
116+
shared_instance_id, instance_config.name, labels=labels
117+
)
118+
created_op = _helpers.retry_429_503(instance.create)()
119+
created_op.result(operation_timeout) # block until completion
120+
121+
else: # reuse existing instance
122+
instance = spanner_client.instance(shared_instance_id)
123+
instance.reload()
124+
125+
yield instance
126+
127+
if _helpers.CREATE_INSTANCE:
128+
_helpers.retry_429_503(instance.delete)()
129+
130+
131+
@pytest.fixture(scope="session")
132+
def shared_database(shared_instance, operation_timeout):
133+
database_name = _helpers.unique_id("test_database")
134+
pool = spanner_v1.BurstyPool(labels={"testcase": "database_api"})
135+
database = shared_instance.database(
136+
database_name, ddl_statements=_helpers.DDL_STATEMENTS, pool=pool
137+
)
138+
operation = database.create()
139+
operation.result(operation_timeout) # raises on failure / timeout.
140+
141+
yield database
142+
143+
database.drop()
144+
145+
146+
@pytest.fixture(scope="function")
147+
def databases_to_delete():
148+
to_delete = []
149+
150+
yield to_delete
151+
152+
for database in to_delete:
153+
database.drop()

0 commit comments

Comments
 (0)