Skip to content

Commit 0f99f8d

Browse files
authored
test: add system tests (#420)
* test: add system tests * test: run system tests on prod * build: allow any Python version for sys tests * build: keep instance and create new databases instead * chore: format code * fix: do not use static fallback config * fix: cleanup job * fix: search until end of string * build: only run system tests on the emulator for presubmits * build: skip system tests when skipping conformance tests * test: run tests with Python 3.8 * test: try this * test: no tests * build: run system tests on real Spanner * chore: cleanup test database after system test run
1 parent 538c640 commit 0f99f8d

File tree

8 files changed

+229
-49
lines changed

8 files changed

+229
-49
lines changed

.github/workflows/test_suite.yml

+24
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,30 @@ jobs:
126126
SPANNER_EMULATOR_HOST: localhost:9010
127127
GOOGLE_CLOUD_PROJECT: appdev-soda-spanner-staging
128128

129+
system:
130+
runs-on: ubuntu-latest
131+
132+
services:
133+
emulator-0:
134+
image: gcr.io/cloud-spanner-emulator/emulator:latest
135+
ports:
136+
- 9010:9010
137+
138+
steps:
139+
- name: Checkout code
140+
uses: actions/checkout@v2
141+
- name: Setup Python
142+
uses: actions/setup-python@v4
143+
with:
144+
python-version: 3.12
145+
- name: Install nox
146+
run: python -m pip install nox
147+
- name: Run System Tests
148+
run: nox -s system
149+
env:
150+
SPANNER_EMULATOR_HOST: localhost:9010
151+
GOOGLE_CLOUD_PROJECT: appdev-soda-spanner-staging
152+
129153
migration_tests:
130154
runs-on: ubuntu-latest
131155

.kokoro/build.sh

+1
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,5 @@ if [[ -n "${NOX_SESSION:-}" ]]; then
4545
python3 -m nox -s ${NOX_SESSION:-}
4646
else
4747
python3 -m nox -s unit
48+
python3 -m nox -s system
4849
fi

create_test_config.py

+10-9
Original file line numberDiff line numberDiff line change
@@ -18,18 +18,18 @@
1818
import sys
1919

2020

21-
def set_test_config(project, instance, user=None, password=None, host=None, port=None):
21+
def set_test_config(project, instance, database, user=None, password=None, host=None, port=None):
2222
config = configparser.ConfigParser()
2323
if user is not None and password is not None and host is not None and port is not None:
2424
url = (
2525
f"spanner+spanner://{user}:{password}@{host}:{port}"
2626
f"/projects/{project}/instances/{instance}/"
27-
"databases/compliance-test"
27+
f"databases/{database}"
2828
)
2929
else:
3030
url = (
3131
f"spanner+spanner:///projects/{project}/instances/{instance}/"
32-
"databases/compliance-test"
32+
f"databases/{database}"
3333
)
3434
config.add_section("db")
3535
config["db"]["default"] = url
@@ -41,17 +41,18 @@ def set_test_config(project, instance, user=None, password=None, host=None, port
4141
def main(argv):
4242
project = argv[0]
4343
instance = argv[1]
44-
if len(argv) == 6:
45-
user = argv[2]
46-
password = argv[3]
47-
host = argv[4]
48-
port = argv[5]
44+
database = argv[2]
45+
if len(argv) == 7:
46+
user = argv[3]
47+
password = argv[4]
48+
host = argv[5]
49+
port = argv[6]
4950
else:
5051
user = None
5152
password = None
5253
host = None
5354
port = None
54-
set_test_config(project, instance, user, password, host, port)
55+
set_test_config(project, instance, database, user, password, host, port)
5556

5657

5758
if __name__ == "__main__":

create_test_database.py

+48-28
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,15 @@
1414
# See the License for the specific language governing permissions and
1515
# limitations under the License.
1616

17-
import configparser
1817
import os
1918
import time
2019

2120
from create_test_config import set_test_config
21+
from google.api_core import datetime_helpers
2222
from google.api_core.exceptions import AlreadyExists, ResourceExhausted
2323
from google.cloud.spanner_v1 import Client
2424
from google.cloud.spanner_v1.instance import Instance
25+
from google.cloud.spanner_v1.database import Database
2526

2627

2728
USE_EMULATOR = os.getenv("SPANNER_EMULATOR_HOST") is not None
@@ -66,43 +67,62 @@ def delete_stale_test_instances():
6667
)
6768

6869

69-
def create_test_instance():
70-
configs = list(CLIENT.list_instance_configs())
71-
if not USE_EMULATOR:
72-
# Filter out non "us" locations
73-
configs = [config for config in configs if "asia-southeast1" in config.name]
70+
def delete_stale_test_databases():
71+
"""Delete test databases that are older than four hours."""
72+
cutoff = (int(time.time()) - 4 * 60 * 60) * 1000
73+
instance = CLIENT.instance("sqlalchemy-dialect-test")
74+
if not instance.exists():
75+
return
76+
database_pbs = instance.list_databases()
77+
for database_pb in database_pbs:
78+
database = Database.from_pb(database_pb, instance)
79+
# The emulator does not return a create_time for databases.
80+
if database.create_time is None:
81+
continue
82+
create_time = datetime_helpers.to_milliseconds(database_pb.create_time)
83+
if create_time > cutoff:
84+
continue
85+
try:
86+
database.drop()
87+
except ResourceExhausted:
88+
print(
89+
"Unable to drop stale database '{}'. May need manual delete.".format(
90+
database.database_id
91+
)
92+
)
7493

75-
instance_config = configs[0].name
76-
create_time = str(int(time.time()))
77-
unique_resource_id = "%s%d" % ("-", 1000 * time.time())
78-
instance_id = (
79-
"sqlalchemy-dialect-test"
80-
if USE_EMULATOR
81-
else "sqlalchemy-test" + unique_resource_id
82-
)
83-
labels = {"python-spanner-sqlalchemy-systest": "true", "created": create_time}
8494

85-
instance = CLIENT.instance(instance_id, instance_config, labels=labels)
95+
def create_test_instance():
96+
instance_id = "sqlalchemy-dialect-test"
97+
instance = CLIENT.instance(instance_id)
98+
if not instance.exists():
99+
instance_config = f"projects/{PROJECT}/instanceConfigs/regional-us-east1"
100+
if USE_EMULATOR:
101+
configs = list(CLIENT.list_instance_configs())
102+
instance_config = configs[0].name
103+
create_time = str(int(time.time()))
104+
labels = {"python-spanner-sqlalchemy-systest": "true", "created": create_time}
105+
106+
instance = CLIENT.instance(instance_id, instance_config, labels=labels)
86107

87-
try:
88-
created_op = instance.create()
89-
created_op.result(1800) # block until completion
90-
except AlreadyExists:
91-
pass # instance was already created
108+
try:
109+
created_op = instance.create()
110+
created_op.result(1800) # block until completion
111+
except AlreadyExists:
112+
pass # instance was already created
92113

93-
if USE_EMULATOR:
94-
database = instance.database("compliance-test")
95-
database.drop()
114+
unique_resource_id = "%s%d" % ("-", 1000 * time.time())
115+
database_id = "sqlalchemy-test" + unique_resource_id
96116

97117
try:
98-
database = instance.database("compliance-test")
118+
database = instance.database(database_id)
99119
created_op = database.create()
100120
created_op.result(1800)
101121
except AlreadyExists:
102-
pass # instance was already created
122+
pass # database was already created
103123

104-
set_test_config(PROJECT, instance_id)
124+
set_test_config(PROJECT, instance_id, database_id)
105125

106126

107-
delete_stale_test_instances()
127+
delete_stale_test_databases()
108128
create_test_instance()

drop_test_database.py

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# -*- coding: utf-8 -*-
2+
#
3+
# Copyright 2021 Google LLC
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# https://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
import configparser
18+
import os
19+
import re
20+
import time
21+
22+
from create_test_config import set_test_config
23+
from google.api_core import datetime_helpers
24+
from google.api_core.exceptions import AlreadyExists, ResourceExhausted
25+
from google.cloud.spanner_v1 import Client
26+
from google.cloud.spanner_v1.instance import Instance
27+
from google.cloud.spanner_v1.database import Database
28+
29+
30+
USE_EMULATOR = os.getenv("SPANNER_EMULATOR_HOST") is not None
31+
32+
PROJECT = os.getenv(
33+
"GOOGLE_CLOUD_PROJECT",
34+
os.getenv("PROJECT_ID", "emulator-test-project"),
35+
)
36+
CLIENT = None
37+
38+
if USE_EMULATOR:
39+
from google.auth.credentials import AnonymousCredentials
40+
41+
CLIENT = Client(project=PROJECT, credentials=AnonymousCredentials())
42+
else:
43+
CLIENT = Client(project=PROJECT)
44+
45+
46+
def delete_test_database():
47+
"""Delete the currently configured test database."""
48+
config = configparser.ConfigParser()
49+
if os.path.exists("test.cfg"):
50+
config.read("test.cfg")
51+
else:
52+
config.read("setup.cfg")
53+
db_url = config.get("db", "default")
54+
55+
instance_id = re.findall(r"instances(.*?)databases", db_url)
56+
database_id = re.findall(r"databases(.*?)$", db_url)
57+
58+
instance = CLIENT.instance(
59+
instance_id="".join(instance_id).replace("/", ""))
60+
database = instance.database("".join(database_id).replace("/", ""))
61+
database.drop()
62+
63+
delete_test_database()

migration_test_cleanup.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,11 @@ def main(argv):
2525

2626
project = re.findall(r"projects(.*?)instances", db_url)
2727
instance_id = re.findall(r"instances(.*?)databases", db_url)
28+
database_id = re.findall(r"databases(.*?)$", db_url)
2829

2930
client = spanner.Client(project="".join(project).replace("/", ""))
3031
instance = client.instance(instance_id="".join(instance_id).replace("/", ""))
31-
database = instance.database("compliance-test")
32+
database = instance.database("".join(database_id).replace("/", ""))
3233

3334
database.update_ddl(["DROP TABLE account", "DROP TABLE alembic_version"]).result(120)
3435

noxfile.py

+42-11
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,43 @@ def compliance_test_20(session):
252252
)
253253

254254

255+
@nox.session()
256+
def system(session):
257+
"""Run SQLAlchemy dialect system test suite."""
258+
259+
# Sanity check: Only run tests if the environment variable is set.
260+
if not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS", "") and not os.environ.get(
261+
"SPANNER_EMULATOR_HOST", ""
262+
):
263+
session.skip(
264+
"Credentials or emulator host must be set via environment variable"
265+
)
266+
267+
if os.environ.get("RUN_COMPLIANCE_TESTS", "true") == "false" and not os.environ.get(
268+
"SPANNER_EMULATOR_HOST", ""
269+
):
270+
session.skip("RUN_COMPLIANCE_TESTS is set to false, skipping")
271+
272+
session.install(
273+
"pytest",
274+
"pytest-cov",
275+
"pytest-asyncio",
276+
)
277+
278+
session.install("mock")
279+
session.install(".[tracing]")
280+
session.install("opentelemetry-api==1.27.0")
281+
session.install("opentelemetry-sdk==1.27.0")
282+
session.install("opentelemetry-instrumentation==0.48b0")
283+
session.run("python", "create_test_database.py")
284+
285+
session.install("sqlalchemy>=2.0")
286+
287+
session.run("py.test", "--quiet", os.path.join("test", "system"), *session.posargs)
288+
289+
session.run("python", "drop_test_database.py")
290+
291+
255292
@nox.session(python=DEFAULT_PYTHON_VERSION)
256293
def unit(session):
257294
"""Run unit tests."""
@@ -263,7 +300,9 @@ def unit(session):
263300
session.install("opentelemetry-api==1.27.0")
264301
session.install("opentelemetry-sdk==1.27.0")
265302
session.install("opentelemetry-instrumentation==0.48b0")
266-
session.run("python", "create_test_config.py", "my-project", "my-instance")
303+
session.run(
304+
"python", "create_test_config.py", "my-project", "my-instance", "my-database"
305+
)
267306
session.run("py.test", "--quiet", os.path.join("test/unit"), *session.posargs)
268307

269308

@@ -281,6 +320,7 @@ def mockserver(session):
281320
"create_test_config.py",
282321
"my-project",
283322
"my-instance",
323+
"my-database",
284324
"none",
285325
"AnonymousCredentials",
286326
"localhost",
@@ -323,21 +363,12 @@ def _migration_test(session):
323363

324364
session.run("python", "create_test_database.py")
325365

326-
project = os.getenv(
327-
"GOOGLE_CLOUD_PROJECT",
328-
os.getenv("PROJECT_ID", "emulator-test-project"),
329-
)
330-
db_url = (
331-
f"spanner+spanner:///projects/{project}/instances/"
332-
"sqlalchemy-dialect-test/databases/compliance-test"
333-
)
334-
335366
config = configparser.ConfigParser()
336367
if os.path.exists("test.cfg"):
337368
config.read("test.cfg")
338369
else:
339370
config.read("setup.cfg")
340-
db_url = config.get("db", "default", fallback=db_url)
371+
db_url = config.get("db", "default")
341372

342373
session.run("alembic", "init", "test_migration")
343374

test/system/test_basics.py

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Copyright 2024 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+
from sqlalchemy import text, Table, Column, Integer, PrimaryKeyConstraint, String
15+
from sqlalchemy.testing import eq_
16+
from sqlalchemy.testing.plugin.plugin_base import fixtures
17+
18+
19+
class TestBasics(fixtures.TablesTest):
20+
@classmethod
21+
def define_tables(cls, metadata):
22+
Table(
23+
"numbers",
24+
metadata,
25+
Column("number", Integer),
26+
Column("name", String(20)),
27+
PrimaryKeyConstraint("number"),
28+
)
29+
30+
def test_hello_world(self, connection):
31+
greeting = connection.execute(text("select 'Hello World'"))
32+
eq_("Hello World", greeting.fetchone()[0])
33+
34+
def test_insert_number(self, connection):
35+
connection.execute(
36+
text("insert or update into numbers(number, name) values (1, 'One')")
37+
)
38+
name = connection.execute(text("select name from numbers where number=1"))
39+
eq_("One", name.fetchone()[0])

0 commit comments

Comments
 (0)