Skip to content

Commit

Permalink
Simplify AiidaTestCase implementation (#4779)
Browse files Browse the repository at this point in the history
This simplifies the `AiidaTestCase` implementation - not yet replacing it with pytest fixtures, 
but hopefully getting one step closer to doing so eventually.

In particular
 * only truly backend-specific code is left in the backend-specific test classes
 * introduces `refurbish_db()` which includes the combination of cleaning the db and repopulating it with a user (which is a common combination)
 *  move creation of default computer from `setUpClass` to "on demand" (not needed by many tests)
 * merges `reset_database` and `clean_db` function that basically did the same
 * factors out the `get_default_user` function so that it can be reused outside the AiidaTestCase (`verdi setup`, pytest fixtures, ...) in a follow-up PR
 * add `orm.Computer.objects.get_or_create` (in analogy to similar methods for user, group, ...)

Note: While this change gets rid of unnecessary complexity, it does *not* switch to a mode where the database is cleaned between *every* test.
While some subclasses of `AiidaTestCase` do this, the `AiidaTestCase` itself only cleans the database in `setupClass`.
Some subclasses do significant test setup at the class level, which might slow things down if they had to be done for every test.
  • Loading branch information
ltalirz authored Feb 25, 2021
1 parent 31cbd59 commit c07e3ef
Show file tree
Hide file tree
Showing 29 changed files with 194 additions and 343 deletions.
18 changes: 0 additions & 18 deletions aiida/backends/djsite/db/testbase.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,6 @@
"""

from aiida.backends.testimplbase import AiidaTestImplementation
from aiida.orm.implementation.django.backend import DjangoBackend

# Add a new entry here if you add a file with tests under aiida.backends.djsite.db.subtests
# The key is the name to use in the 'verdi test' command (e.g., a key 'generic'
# can be run using 'verdi test db.generic')
# The value must be the module name containing the subclasses of unittest.TestCase


# This contains the codebase for the setUpClass and tearDown methods used internally by the AiidaTestCase
Expand All @@ -29,13 +23,6 @@ class DjangoTests(AiidaTestImplementation):
Automatically takes care of the setUpClass and TearDownClass, when needed.
"""

# pylint: disable=attribute-defined-outside-init

# Note this is has to be a normal method, not a class method
def setUpClass_method(self):
self.clean_db()
self.backend = DjangoBackend()

def clean_db(self):
from aiida.backends.djsite.db import models

Expand All @@ -49,8 +36,3 @@ def clean_db(self):
models.DbUser.objects.all().delete() # pylint: disable=no-member
models.DbComputer.objects.all().delete()
models.DbGroup.objects.all().delete()

def tearDownClass_method(self):
"""
Backend-specific tasks for tearing down the test environment.
"""
16 changes: 0 additions & 16 deletions aiida/backends/sqlalchemy/testbase.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,22 +20,6 @@
class SqlAlchemyTests(AiidaTestImplementation):
"""Base class to test SQLA-related functionalities."""
connection = None
_backend = None

def setUpClass_method(self):
self.clean_db()

def tearDownClass_method(self):
"""Backend-specific tasks for tearing down the test environment."""

@property
def backend(self):
"""Get the backend."""
if self._backend is None:
from aiida.manage.manager import get_manager
self._backend = get_manager().get_backend()

return self._backend

def clean_db(self):
from sqlalchemy.sql import table
Expand Down
128 changes: 85 additions & 43 deletions aiida/backends/testbase.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@
import traceback

from aiida.common.exceptions import ConfigurationError, TestsNotAllowedError, InternalError
from aiida.common.lang import classproperty
from aiida.manage import configuration
from aiida.manage.manager import get_manager, reset_manager
from aiida import orm
from aiida.common.lang import classproperty

TEST_KEYWORD = 'test_'

Expand All @@ -31,6 +32,8 @@ class AiidaTestCase(unittest.TestCase):
"""This is the base class for AiiDA tests, independent of the backend.
Internally it loads the AiidaTestImplementation subclass according to the current backend."""
_computer = None # type: aiida.orm.Computer
_user = None # type: aiida.orm.User
_class_was_setup = False
__backend_instance = None
backend = None # type: aiida.orm.implementation.Backend
Expand Down Expand Up @@ -63,35 +66,39 @@ def get_backend_class(cls):
return cls.__impl_class

@classmethod
def setUpClass(cls, *args, **kwargs): # pylint: disable=arguments-differ
def setUpClass(cls):
"""Set up test class."""
# Note: this will raise an exception, that will be seen as a test
# failure. To be safe, you should do the same check also in the tearDownClass
# to avoid that it is run
check_if_tests_can_run()

# Force the loading of the backend which will load the required database environment
get_manager().get_backend()

cls.backend = get_manager().get_backend()
cls.__backend_instance = cls.get_backend_class()()
cls.__backend_instance.setUpClass_method(*args, **kwargs)
cls.backend = cls.__backend_instance.backend

cls._class_was_setup = True

cls.refurbish_db()

@classmethod
def tearDownClass(cls):
"""Tear down test class.
Note: Also cleans file repository.
"""
# Double check for double security to avoid to run the tearDown
# if this is not a test profile

check_if_tests_can_run()
if orm.autogroup.CURRENT_AUTOGROUP is not None:
orm.autogroup.CURRENT_AUTOGROUP.clear_group_cache()
cls.clean_db()
cls.insert_data()
cls.clean_repository()

def tearDown(self):
reset_manager()

def reset_database(self):
"""Reset the database to the default state deleting any content currently stored"""
from aiida.orm import autogroup

self.clean_db()
if autogroup.CURRENT_AUTOGROUP is not None:
autogroup.CURRENT_AUTOGROUP.clear_group_cache()
self.insert_data()
### Database/repository-related methods

@classmethod
def insert_data(cls):
Expand All @@ -100,19 +107,9 @@ def insert_data(cls):
inserts default data into the database (which is for the moment a
default computer).
"""
from aiida.orm import User

cls.create_user()
User.objects.reset()
cls.create_computer()

@classmethod
def create_user(cls):
cls.__backend_instance.create_user()

@classmethod
def create_computer(cls):
cls.__backend_instance.create_computer()
orm.User.objects.reset() # clear Aiida's cache of the default user
# populate user cache of test clases
cls.user # pylint: disable=pointless-statement

@classmethod
def clean_db(cls):
Expand All @@ -131,9 +128,23 @@ def clean_db(cls):
raise InvalidOperation('You cannot call clean_db before running the setUpClass')

cls.__backend_instance.clean_db()
cls._computer = None
cls._user = None

if orm.autogroup.CURRENT_AUTOGROUP is not None:
orm.autogroup.CURRENT_AUTOGROUP.clear_group_cache()

reset_manager()

@classmethod
def refurbish_db(cls):
"""Clean up database and repopulate with initial data.
Combines clean_db and insert_data.
"""
cls.clean_db()
cls.insert_data()

@classmethod
def clean_repository(cls):
"""
Expand Down Expand Up @@ -164,24 +175,31 @@ def computer(cls): # pylint: disable=no-self-argument
:return: the test computer
:rtype: :class:`aiida.orm.Computer`"""
return cls.__backend_instance.get_computer()
if cls._computer is None:
created, computer = orm.Computer.objects.get_or_create(
label='localhost',
hostname='localhost',
transport_type='local',
scheduler_type='direct',
workdir='/tmp/aiida',
)
if created:
computer.store()
cls._computer = computer

return cls._computer

@classproperty
def user_email(cls): # pylint: disable=no-self-argument
return cls.__backend_instance.get_user_email()
def user(cls): # pylint: disable=no-self-argument
if cls._user is None:
cls._user = get_default_user()
return cls._user

@classmethod
def tearDownClass(cls, *args, **kwargs): # pylint: disable=arguments-differ
# Double check for double security to avoid to run the tearDown
# if this is not a test profile
from aiida.orm import autogroup
@classproperty
def user_email(cls): # pylint: disable=no-self-argument
return cls.user.email # pylint: disable=no-member

check_if_tests_can_run()
if autogroup.CURRENT_AUTOGROUP is not None:
autogroup.CURRENT_AUTOGROUP.clear_group_cache()
cls.clean_db()
cls.clean_repository()
cls.__backend_instance.tearDownClass_method(*args, **kwargs)
### Usability methods

def assertClickSuccess(self, cli_result): # pylint: disable=invalid-name
self.assertEqual(cli_result.exit_code, 0, cli_result.output)
Expand All @@ -206,3 +224,27 @@ def tearDownClass(cls, *args, **kwargs):
"""Close the PGTest postgres test cluster."""
super().tearDownClass(*args, **kwargs)
cls.pg_test.close()


def get_default_user(**kwargs):
"""Creates and stores the default user in the database.
Default user email is taken from current profile.
No-op if user already exists.
The same is done in `verdi setup`.
:param kwargs: Additional information to use for new user, i.e. 'first_name', 'last_name' or 'institution'.
:returns: the :py:class:`~aiida.orm.User`
"""
from aiida.manage.configuration import get_config
email = get_config().current_profile.default_user

if kwargs.pop('email', None):
raise ValueError('Do not specify the user email (must coincide with default user email of profile).')

# Create the AiiDA user if it does not yet exist
created, user = orm.User.objects.get_or_create(email=email, **kwargs)
if created:
user.store()

return user
87 changes: 10 additions & 77 deletions aiida/backends/testimplbase.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,87 +10,20 @@
"""Implementation-dependednt base tests"""
from abc import ABC, abstractmethod

from aiida import orm
from aiida.common import exceptions


class AiidaTestImplementation(ABC):
"""For each implementation, define what to do at setUp and tearDown.
Each subclass must reimplement two *standard* methods (i.e., *not* classmethods), called
respectively ``setUpClass_method`` and ``tearDownClass_method``.
It is also required to implement setUp_method and tearDown_method to be run for each single test
They can set local properties (e.g. ``self.xxx = yyy``) but remember that ``xxx``
is not visible to the upper (calling) Test class.
Moreover, it is required that they define in the setUpClass_method the two properties:
- ``self.computer`` that must be a Computer object
- ``self.user_email`` that must be a string
"""Backend-specific test implementations."""
_backend = None

These two are then exposed by the ``self.get_computer()`` and ``self.get_user_email()``
methods."""
# This should be set by the implementing class in setUpClass_method()
backend = None # type: aiida.orm.implementation.Backend
computer = None # type: aiida.orm.Computer
user = None # type: aiida.orm.User
user_email = None # type: str
@property
def backend(self):
"""Get the backend."""
if self._backend is None:
from aiida.manage.manager import get_manager
self._backend = get_manager().get_backend()

@abstractmethod
def setUpClass_method(self): # pylint: disable=invalid-name
"""This class prepares the database (cleans it up and installs some basic entries).
You have also to set a self.computer and a self.user_email as explained in the docstring of the
AiidaTestImplemention docstring."""

@abstractmethod
def tearDownClass_method(self): # pylint: disable=invalid-name
"""Backend-specific tasks for tearing down the test environment."""
return self._backend

@abstractmethod
def clean_db(self):
"""This method implements the logic to fully clean the DB."""

def insert_data(self):
pass

def create_user(self):
"""This method creates and stores the default user. It has the same effect
as the verdi setup."""
from aiida.manage.configuration import get_config
self.user_email = get_config().current_profile.default_user

# Since the default user is needed for many operations in AiiDA, it is not deleted by clean_db.
# In principle, it should therefore always exist - if not we create it anyhow.
try:
self.user = orm.User.objects.get(email=self.user_email)
except exceptions.NotExistent:
self.user = orm.User(email=self.user_email).store()

def create_computer(self):
"""This method creates and stores a computer."""
self.computer = orm.Computer(
label='localhost',
hostname='localhost',
transport_type='local',
scheduler_type='direct',
workdir='/tmp/aiida',
backend=self.backend
).store()

def get_computer(self):
"""An ORM Computer object present in the DB."""
try:
return self.computer
except AttributeError:
raise exceptions.InternalError(
'The AiiDA Test implementation should define a self.computer in the setUpClass_method'
)

def get_user_email(self):
"""A string with the email of the User."""
try:
return self.user_email
except AttributeError:
raise exceptions.InternalError(
'The AiiDA Test implementation should define a self.user_email in the setUpClass_method'
)
"""This method fully cleans the DB."""
32 changes: 21 additions & 11 deletions aiida/orm/computers.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,7 @@

class Computer(entities.Entity):
"""
Base class to map a node in the DB + its permanent repository counterpart.
Stores attributes starting with an underscore.
Caches files and attributes before the first save, and saves everything only on store().
After the call to store(), attributes cannot be changed.
Only after storing (or upon loading from uuid) metadata can be modified
and in this case they are directly set on the db.
In the plugin, also set the _plugin_type_string, to be set in the DB in the 'type' field.
Computer entity.
"""
# pylint: disable=too-many-public-methods

Expand Down Expand Up @@ -68,6 +58,26 @@ def get(self, **filters):

return super().get(**filters)

def get_or_create(self, label=None, **kwargs):
"""
Try to retrieve a Computer from the DB with the given arguments;
create (and store) a new Computer if such a Computer was not present yet.
:param label: computer label
:type label: str
:return: (computer, created) where computer is the computer (new or existing,
in any case already stored) and created is a boolean saying
:rtype: (:class:`aiida.orm.Computer`, bool)
"""
if not label:
raise ValueError('Computer label must be provided')

try:
return False, self.get(label=label)
except exceptions.NotExistent:
return True, Computer(backend=self.backend, label=label, **kwargs)

def list_names(self):
"""Return a list with all the names of the computers in the DB.
Expand Down
Loading

0 comments on commit c07e3ef

Please sign in to comment.