From db0ab70cbc812d699b3c3d6d4a9b4c94fc738d0a Mon Sep 17 00:00:00 2001 From: Vikash Singh <3116482+vi3k6i5@users.noreply.github.com> Date: Thu, 20 May 2021 13:56:38 +0530 Subject: [PATCH] ci: add system tests (#623) * fix: lint_setup_py was failing in Kokoro is not fixed * perf: removed nox run on all kokoro workers and moved it to github actions * feat: add decimal/numeric support * fix: Update links in comments to use googleapis repo (#622) * refactor: common settings for unit tests and system tests Co-authored-by: Chris Kleinknecht --- noxfile.py | 55 +++++++- tests/{unit => }/conftest.py | 8 +- tests/{unit => }/settings.py | 26 ++-- tests/system/django_spanner/__init__.py | 0 tests/system/django_spanner/models.py | 16 +++ tests/system/django_spanner/test_queries.py | 49 +++++++ tests/system/django_spanner/utils.py | 135 ++++++++++++++++++++ 7 files changed, 275 insertions(+), 14 deletions(-) rename tests/{unit => }/conftest.py (56%) rename tests/{unit => }/settings.py (58%) create mode 100644 tests/system/django_spanner/__init__.py create mode 100644 tests/system/django_spanner/models.py create mode 100644 tests/system/django_spanner/test_queries.py create mode 100644 tests/system/django_spanner/utils.py diff --git a/noxfile.py b/noxfile.py index 5d65eb6153..569650a335 100644 --- a/noxfile.py +++ b/noxfile.py @@ -10,6 +10,7 @@ from __future__ import absolute_import import os +import pathlib import shutil import nox @@ -27,6 +28,8 @@ SYSTEM_TEST_PYTHON_VERSIONS = ["3.8"] UNIT_TEST_PYTHON_VERSIONS = ["3.6", "3.7", "3.8", "3.9"] +CURRENT_DIRECTORY = pathlib.Path(__file__).parent.absolute() + @nox.session(python=DEFAULT_PYTHON_VERSION) def lint(session): @@ -86,7 +89,7 @@ def default(session): "--cov-report=", "--cov-fail-under=65", os.path.join("tests", "unit"), - *session.posargs + *session.posargs, ) @@ -96,6 +99,56 @@ def unit(session): default(session) +@nox.session(python=SYSTEM_TEST_PYTHON_VERSIONS) +def system(session): + """Run the system test suite.""" + constraints_path = str( + CURRENT_DIRECTORY / "testing" / f"constraints-{session.python}.txt" + ) + system_test_path = os.path.join("tests", "system.py") + system_test_folder_path = os.path.join("tests", "system") + + # Check the value of `RUN_SYSTEM_TESTS` env var. It defaults to true. + if os.environ.get("RUN_SYSTEM_TESTS", "true") == "false": + session.skip("RUN_SYSTEM_TESTS is set to false, skipping") + # Sanity check: Only run tests if the environment variable is set. + if not os.environ.get( + "GOOGLE_APPLICATION_CREDENTIALS", "" + ) and not os.environ.get("SPANNER_EMULATOR_HOST", ""): + session.skip( + "Credentials or emulator host must be set via environment variable" + ) + + system_test_exists = os.path.exists(system_test_path) + system_test_folder_exists = os.path.exists(system_test_folder_path) + # Sanity check: only run tests if found. + if not system_test_exists and not system_test_folder_exists: + session.skip("System tests were not found") + + # Use pre-release gRPC for system tests. + session.install("--pre", "grpcio") + + # Install all test dependencies, then install this package into the + # virtualenv's dist-packages. + session.install( + "django~=2.2", + "mock", + "pytest", + "google-cloud-testutils", + "-c", + constraints_path, + ) + session.install("-e", ".[tracing]", "-c", constraints_path) + + # Run py.test against the system tests. + if system_test_exists: + session.run("py.test", "--quiet", system_test_path, *session.posargs) + if system_test_folder_exists: + session.run( + "py.test", "--quiet", system_test_folder_path, *session.posargs + ) + + @nox.session(python=DEFAULT_PYTHON_VERSION) def cover(session): """Run the final coverage report. diff --git a/tests/unit/conftest.py b/tests/conftest.py similarity index 56% rename from tests/unit/conftest.py rename to tests/conftest.py index f4be037821..4be4564725 100644 --- a/tests/unit/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,16 @@ +# Copyright 2021 Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd + import os import django from django.conf import settings # We manually designate which settings we will be using in an environment # variable. This is similar to what occurs in the `manage.py` file. -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.unit.settings") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") # `pytest` automatically calls this function once when tests are run. diff --git a/tests/unit/settings.py b/tests/settings.py similarity index 58% rename from tests/unit/settings.py rename to tests/settings.py index 1e44e6e11f..dd6778463f 100644 --- a/tests/unit/settings.py +++ b/tests/settings.py @@ -4,7 +4,6 @@ # license that can be found in the LICENSE file or at # https://developers.google.com/open-source/licenses/bsd -import time import os DEBUG = True @@ -23,23 +22,26 @@ TIME_ZONE = "UTC" -ENGINE = "django_spanner" -PROJECT = os.getenv( - "GOOGLE_CLOUD_PROJECT", os.getenv("PROJECT_ID", "emulator-test-project"), +INSTANCE_ID = os.environ.get( + "GOOGLE_CLOUD_TESTS_SPANNER_INSTANCE", "spanner-django-python-systest" ) -INSTANCE = "django-test-instance" -NAME = "spanner-django-test-{}".format(str(int(time.time()))) +PROJECT_ID = os.getenv("GOOGLE_CLOUD_PROJECT", "emulator-test-project") + +# _get_test_db_name method in creation.py addes prefix of 'test_' to db name. +DATABASE_NAME = os.getenv("DJANGO_SPANNER_DB", "django_test_db") DATABASES = { "default": { - "ENGINE": ENGINE, - "PROJECT": PROJECT, - "INSTANCE": INSTANCE, - "NAME": NAME, + "ENGINE": "django_spanner", + "PROJECT": PROJECT_ID, + "INSTANCE": INSTANCE_ID, + "NAME": DATABASE_NAME, + "TEST": {"NAME": DATABASE_NAME}, } } -SECRET_KEY = "spanner emulator secret key" + +SECRET_KEY = "spanner env secret key" PASSWORD_HASHERS = [ "django.contrib.auth.hashers.MD5PasswordHasher", @@ -52,6 +54,6 @@ ENGINE = "django_spanner" PROJECT = "emulator-local" INSTANCE = "django-test-instance" -NAME = "django-test-db" +NAME = "django_test_db" OPTIONS = {} AUTOCOMMIT = True diff --git a/tests/system/django_spanner/__init__.py b/tests/system/django_spanner/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/system/django_spanner/models.py b/tests/system/django_spanner/models.py new file mode 100644 index 0000000000..b7a4fdcd39 --- /dev/null +++ b/tests/system/django_spanner/models.py @@ -0,0 +1,16 @@ +# Copyright 2021 Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd + +""" +Different models used by system tests in django-spanner code. +""" +from django.db import models + + +class Author(models.Model): + first_name = models.CharField(max_length=20) + last_name = models.CharField(max_length=20) + rating = models.DecimalField() diff --git a/tests/system/django_spanner/test_queries.py b/tests/system/django_spanner/test_queries.py new file mode 100644 index 0000000000..4d9218b309 --- /dev/null +++ b/tests/system/django_spanner/test_queries.py @@ -0,0 +1,49 @@ +# Copyright 2021 Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd + +from .models import Author +from django.test import TransactionTestCase +from django.db import connection +from decimal import Decimal +from .utils import ( + setup_instance, + teardown_instance, + setup_database, + teardown_database, +) + + +class TestQueries(TransactionTestCase): + @classmethod + def setUpClass(cls): + setup_instance() + setup_database() + with connection.schema_editor() as editor: + # Create the tables + editor.create_model(Author) + + @classmethod + def tearDownClass(cls): + with connection.schema_editor() as editor: + # delete the table + editor.delete_model(Author) + teardown_database() + teardown_instance() + + def test_insert_and_fetch_value(self): + """ + Tests model object creation with Author model. + Inserting data into the model and retrieving it. + """ + author_kent = Author( + first_name="Arthur", last_name="Kent", rating=Decimal("4.1"), + ) + author_kent.save() + qs1 = Author.objects.all().values("first_name", "last_name") + self.assertEqual(qs1[0]["first_name"], "Arthur") + self.assertEqual(qs1[0]["last_name"], "Kent") + # Delete data from Author table. + Author.objects.all().delete() diff --git a/tests/system/django_spanner/utils.py b/tests/system/django_spanner/utils.py new file mode 100644 index 0000000000..7fac5166e0 --- /dev/null +++ b/tests/system/django_spanner/utils.py @@ -0,0 +1,135 @@ +# Copyright 2021 Google LLC +# +# Use of this source code is governed by a BSD-style +# license that can be found in the LICENSE file or at +# https://developers.google.com/open-source/licenses/bsd + +import os +import time + +from django.core.management import call_command +from django.db import connection +from google.api_core import exceptions +from google.cloud.spanner_v1 import Client +from google.cloud.spanner_v1.instance import Instance, Backup +from test_utils.retry import RetryErrors + +from django_spanner.creation import DatabaseCreation + +CREATE_INSTANCE = ( + os.getenv("GOOGLE_CLOUD_TESTS_CREATE_SPANNER_INSTANCE") is not None +) +USE_EMULATOR = os.getenv("SPANNER_EMULATOR_HOST") is not None +SPANNER_OPERATION_TIMEOUT_IN_SECONDS = int( + os.getenv("SPANNER_OPERATION_TIMEOUT_IN_SECONDS", 60) +) +EXISTING_INSTANCES = [] +INSTANCE_ID = os.environ.get( + "GOOGLE_CLOUD_TESTS_SPANNER_INSTANCE", "spanner-django-python-systest" +) +PROJECT_ID = os.getenv("GOOGLE_CLOUD_PROJECT", "emulator-test-project") +DATABASE_NAME = os.getenv("DJANGO_SPANNER_DB", "django_test_db") + + +class Config(object): + """Run-time configuration to be modified at set-up. + + This is a mutable stand-in to allow test set-up to modify + global state. + """ + + CLIENT = None + INSTANCE_CONFIG = None + INSTANCE = None + DATABASE = None + + +def _list_instances(): + return list(Config.CLIENT.list_instances()) + + +def setup_instance(): + if USE_EMULATOR: + from google.auth.credentials import AnonymousCredentials + + Config.CLIENT = Client( + project=PROJECT_ID, credentials=AnonymousCredentials() + ) + else: + Config.CLIENT = Client() + + retry = RetryErrors(exceptions.ServiceUnavailable) + + configs = list(retry(Config.CLIENT.list_instance_configs)()) + + instances = retry(_list_instances)() + EXISTING_INSTANCES[:] = instances + + # Delete test instances that are older than an hour. + cutoff = int(time.time()) - 1 * 60 * 60 + instance_pbs = Config.CLIENT.list_instances( + "labels.python-spanner-systests:true" + ) + for instance_pb in instance_pbs: + instance = Instance.from_pb(instance_pb, Config.CLIENT) + if "created" not in instance.labels: + continue + create_time = int(instance.labels["created"]) + if create_time > cutoff: + continue + if not USE_EMULATOR: + # Instance cannot be deleted while backups exist. + for backup_pb in instance.list_backups(): + backup = Backup.from_pb(backup_pb, instance) + backup.delete() + instance.delete() + + if CREATE_INSTANCE: + if not USE_EMULATOR: + # Defend against back-end returning configs for regions we aren't + # actually allowed to use. + configs = [config for config in configs if "-us-" in config.name] + + if not configs: + raise ValueError("List instance configs failed in module set up.") + + Config.INSTANCE_CONFIG = configs[0] + config_name = configs[0].name + create_time = str(int(time.time())) + labels = {"django-spanner-systests": "true", "created": create_time} + + Config.INSTANCE = Config.CLIENT.instance( + INSTANCE_ID, config_name, labels=labels + ) + if not Config.INSTANCE.exists(): + created_op = Config.INSTANCE.create() + created_op.result( + SPANNER_OPERATION_TIMEOUT_IN_SECONDS + ) # block until completion + else: + Config.INSTANCE.reload() + + else: + Config.INSTANCE = Config.CLIENT.instance(INSTANCE_ID) + Config.INSTANCE.reload() + + +def teardown_instance(): + if CREATE_INSTANCE: + Config.INSTANCE.delete() + + +def setup_database(): + Config.DATABASE = Config.INSTANCE.database(DATABASE_NAME) + if not Config.DATABASE.exists(): + creation = DatabaseCreation(connection) + creation._create_test_db(verbosity=0, autoclobber=False, keepdb=True) + + # Running migrations on the db. + call_command("migrate", interactive=False) + + +def teardown_database(): + Config.DATABASE = Config.INSTANCE.database(DATABASE_NAME) + if Config.DATABASE.exists(): + Config.DATABASE.drop()