Repository: googleapis/python-spanner-django

Issue:
ci: Add system tests similar to python spanner.
ci: Add system tests similar to python spanner client.


Discussion:

Resolving patch:
diff --git a/noxfile.py b/noxfile.py
--- 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.


Test patch:
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
--- 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
--- 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
diff --git a/tests/system/django_spanner/models.py b/tests/system/django_spanner/models.py
new file mode 100644
--- /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
--- /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
--- /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()