Created
June 1, 2012 13:24
-
-
Save flub/2852134 to your computer and use it in GitHub Desktop.
pytest-django experiment
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"""pytest plugin for testing django projects | |
This plugin handlers creating and destroying the test environment and | |
database. It is only enabled when DJANGO_SETTINGS_MODULES is set in | |
either the environment, the ini file or a conftest.py file. In case | |
of conftest.py files the django modules are only imported at the last | |
possible moment and it is also possible to test multiple sites | |
(multiple django settings modules) in one py.test process. | |
Similar to Django's TestCase, a transaction is started and rolled back | |
for each test. Additionally, the settings are copied before each test | |
and restored at the end of the test, so it is safe to modify settings | |
within tests. | |
""" | |
import os | |
import sys | |
import py | |
import pytest | |
################ Main plugin ################ | |
def pytest_addoption(parser): | |
group = parser.getgroup("general") | |
group._addoption('--no-db', | |
action='store_true', dest='no_db', default=False, | |
help='Run tests without a django database') | |
parser.addini('DJANGO_SETTINGS_MODULE', | |
'Django settings module to use by pytest-django') | |
def pytest_namespace(): | |
return {'load_fixture': load_fixture, 'urls': urls} | |
def pytest_sessionstart(session): | |
"""Prepare the global django test environment | |
This is only done if either DJANGO_SETTINGS_MODULE is in the | |
environment or is set in the ini file. | |
""" | |
djs = os.environ.get('DJANGO_SETTINGS_MODULE') | |
if not djs: | |
djs = session.config.getini('DJANGO_SETTINGS_MODULE') | |
if djs: | |
runner = setup_django_env(djs, not session.config.getvalue('no_db')) | |
session.config.pytest_django_runner = runner | |
session.config.pytest_django_settings = djs | |
def pytest_sessionfinish(session, exitstatus): | |
"""Teardown django test environment""" | |
runner = getattr(session.config, 'pytest_django_runner', None) | |
if runner: | |
del session.config.pytest_django_runner | |
del session.config.pytest_django_settings | |
capture = py.io.StdCapture() | |
runner.teardown_test_environment() | |
runner.teardown_databases(runner.pytest_django_old_db_config) | |
stdout, stderr = capture.reset() | |
sys.stderr.write(stderr) # XXX | |
def pytest_runtest_setup(item): | |
"""Test-specific django test environment setup | |
This looks up DJANGO_SETTINGS_MODULE in the local conftest and if | |
present will setup django's test environment for this settings | |
module, if it is not already setup. | |
Additionally this will modify the ROOT_URLCONF if the item has the | |
``@pytest.urls()`` decorator applied to it as well as ensure the | |
database is in a clean state. | |
""" | |
if not hasattr(item.config, 'pytest_django_runner'): | |
return | |
if hasattr(item.obj, 'urls'): | |
item.pytest_django_urlconf = py.std.django.conf.settings.ROOT_URLCONF | |
py.std.django.conf.settings.ROOT_URLCONF = item.obj.urls | |
py.std.django.core.urlresolvers.clear_url_caches() | |
# Setup db if not disabled, django unittests do this themself | |
if (not item.config.option.no_db and | |
not is_django_unittest(item) and | |
'transaction_test_case' not in item.keywords): | |
item.pytest_django_testcase = make_django_testcase(item) | |
item.pytest_django_testcase._pre_setup() | |
def pytest_runtest_teardown(item): | |
"""Restore the django database and settings""" | |
if hasattr(item, 'pytest_django_testcase'): | |
item.pytest_django_testcase._post_teardown() | |
elif 'transaction_test_case' in item.keywords: | |
for db in py.std.django.db.connections: | |
# Flush the database adn close database connections. | |
# Django does this by default *before* each test instead | |
# of after. | |
py.std.django.core.management.call_command( | |
'flush', verbosity=0, interactive=False, database=db) | |
for conn in py.std.django.db.connections.all(): | |
conn.close() | |
if hasattr(item, 'pytest_django_urlconf'): | |
py.std.django.conf.settings.ROOT_URLCONF = item.pytest_django_urlconf | |
py.std.django.core.urlresolvers.clear_url_caches() | |
################ Funcargs ################ | |
def pytest_funcarg__client(request): | |
"""Return a Django test client instance""" | |
check_enabled(request.config) | |
return py.std.django.test.client.Client() | |
def pytest_funcarg__admin_client(request): | |
"""Return a Django test client logged in as an admin user""" | |
check_enabled(request.config) | |
try: | |
py.std.django.contrib.auth.models.User.objects.get(username='admin') | |
except py.std.django.contrib.auth.models.User.DoesNotExist: | |
user = py.std.django.contrib.auth.models.User.objects.create_user( | |
'admin', 'admin@example.com', 'password') | |
user.is_staff = True | |
user.is_superuser = True | |
user.save() | |
client = py.std.django.test.client.Client() | |
client.login(username='admin', password='password') | |
return client | |
def pytest_funcarg__rf(request): | |
"""Return a RequestFactory instance""" | |
check_enabled(request.config) | |
return py.std.django.test.client.RequestFactory() | |
def pytest_funcarg__settings(request): | |
"""Return a Django settings object | |
Any changes will be restored after the test has been run. | |
""" | |
check_enabled(request.config) | |
old_settings = py.std.copy.deepcopy(py.std.django.conf.settings) | |
def restore_settings(): | |
for setting in dir(old_settings): | |
if setting == setting.upper(): | |
setattr(py.std.django.conf.settings, | |
setting, getattr(old_settings, setting)) | |
request.addfinalizer(restore_settings) | |
return py.std.django.conf.settings | |
def pytest_funcarg__live_server(request): | |
"""See Django's LiveServerTestCase""" | |
check_enabled(request.config) | |
if not hasattr(py.std.django.test.testcases, 'LiveServerThread'): | |
pytest.fail('live_server not supported in django < 1.4') | |
def setup_live_server(): | |
return LiveServer(*live_server_ports()) | |
def teardown_live_server(live_server): | |
live_server.thread.join() | |
return request.cached_setup(setup=setup_live_server, | |
teardown=teardown_live_server, | |
scope='session') | |
################ Helper functions ################ | |
transaction_test_case = pytest.mark.transaction_test_case | |
def do_django_imports(): | |
"""Required django imports for the plugin | |
Since django is properly messed up with it's global state and code | |
execution at import it is almost impossible to import any part of | |
django without having DJANGO_SETTINGS_MODULE set. Since this | |
plugin wants to work without this environment variable set we need | |
to delay all django imports. | |
For this we access django using py.std.django which does a lazy | |
import. The limitation of py.std is that only the top level | |
package is imported, but by importing the sub-packages explicitly | |
we let the import machinery create all the sub-module references | |
and py.std.django will have all required sub-modules available. | |
""" | |
import django.conf | |
import django.contrib.auth.models | |
import django.core.management | |
import django.core.urlresolvers | |
import django.db.backends.util | |
import django.test | |
import django.test.client | |
import django.test.simple | |
import django.test.testcases | |
def load_fixture(fixture): | |
"""Loads a fixture, useful for loading fixtures in funcargs. | |
Example: | |
def pytest_funcarg__articles(request): | |
pytest.load_fixture('test_articles') | |
return Article.objects.all() | |
""" | |
if 'DJANGO_SETTINGS_MODULE' not in os.environ: | |
pytest.fail('pytest-django not enabled, check DJANGO_SETTINGS_MODULE') | |
py.std.django.core.management.call_command('loaddata', | |
fixture, verbosity=1) | |
def urls(urlconf): | |
"""Decorator to change the URLconf for a particular test | |
This is similar to the `urls` attribute on Django's `TestCase`. | |
Example:: | |
@pytest.urls('myapp.test_urls') | |
def test_something(client): | |
assert 'Success!' in client.get('/some_path/') | |
""" | |
if 'DJANGO_SETTINGS_MODULE' not in os.environ: | |
pytest.fail('pytest-django not enabled, check DJANGO_SETTINGS_MODULE') | |
def wrapper(function): | |
function.urls = urlconf | |
return function | |
return wrapper | |
def setup_django_env(settings_name, db=True): | |
"""Setup the global django test environment | |
This will disable south, setup the django test environment and | |
setup the django database. If django was already loaded it will | |
fail. It will also respect the --no_db option. | |
This does many django imports behind your back. | |
Return the django test suite runner | |
""" | |
for name in sys.modules.keys(): | |
if name.startswith('django.'): | |
raise RuntimeError('Django already loaded before env setup') | |
os.environ['DJANGO_SETTINGS_MODULE'] = settings_name | |
do_django_imports() | |
# Disable south migrations | |
commands = py.std.django.core.management.get_commands() | |
commands['syncdb'] = 'django.core' | |
# Setup django test env and db | |
runner = py.std.django.test.simple.DjangoTestSuiteRunner(interactive=False) | |
if not db: | |
def cursor_wrapper(*args, **kwargs): | |
pytest.fail('Database access blocked by --no-db') | |
def fake_setup(): | |
py.std.django.db.backends.util.CursorWrapper = cursor_wrapper | |
runner.setup_databases = fake_setup | |
runner.teardown_databases = lambda x: None | |
runner.setup_test_environment() | |
runner.pytest_django_old_db_config = runner.setup_databases() | |
py.std.django.conf.settings.DEBUG_PROPAGATE_EXCEPTIONS = True | |
return runner | |
def make_django_testcase(item): | |
"""Retuns a django unittest instance for a test item""" | |
if 'transaction_test_case' in item.keywords: | |
cls = py.std.django.test.TransactionTestCase | |
elif item.config.option.no_db: | |
cls = py.std.django.test.TestCase | |
cls._fixture_setup = lambda self: None | |
else: | |
cls = py.std.django.test.TestCase | |
return cls(methodName='__init__') | |
def is_django_unittest(item): | |
"""Returns True if the item is a Django test case, otherwise False""" | |
TestCase = getattr(py.std.django.test, | |
'SimpleTestCase', py.std.django.test.TestCase) | |
return (hasattr(item.obj, 'im_class') and | |
issubclass(item.obj.im_class, TestCase)) | |
def check_enabled(config): | |
if not hasattr(config, 'pytest_django_runner'): | |
pytest.fail('pytest-django not enabled, check DJANGO_SETTINGS_MODULE') | |
class LiveServer(object): | |
"""Helper class for the live_server funcarg""" | |
def __init__(self, host, possible_ports): | |
connections_override = {} | |
for conn in py.std.django.db.connections.all(): | |
# SQLite in-memory databases need the connection in the | |
# server thread | |
if (conn.settings_dict['ENGINE'] == 'django.db.backends.sqlite3' | |
and conn.settings_dict['NAME'] == ':memory:'): | |
conn.allow_thread_sharing = True | |
connections_override[conn.alias] = conn | |
self.thread = py.std.django.test.testcases.LiveServerThread( | |
host, possible_ports, connections_override) | |
self.thread.daemon = True | |
self.thread.start() | |
self.thread.is_ready.wait() | |
if self.thread.error: | |
raise self.thread.error | |
def __unicode__(self): | |
return 'http://%s:%s' % (self.thread.host, self.thread.port) | |
def __repr__(self): | |
return '<LiveServer listenting at %s>' % unicode(self) | |
def __add__(self, other): | |
# Support string concatenation | |
return unicode(self) + other | |
def live_server_ports(): | |
# This code is copy-pasted from django/test/testcases.py | |
specified_address = os.environ.get('DJANGO_LIVE_TEST_SERVER_ADDRESS', | |
'localhost:8081') | |
# The specified ports may be of the form '8000-8010,8080,9200-9300' | |
# i.e. a comma-separated list of ports or ranges of ports, so we break | |
# it down into a detailed list of all possible ports. | |
possible_ports = [] | |
try: | |
host, port_ranges = specified_address.split(':') | |
for port_range in port_ranges.split(','): | |
extremes = map(int, port_range.split('-')) | |
assert len(extremes) in [1, 2] | |
if len(extremes) == 1: # Port range of the form '8000' | |
possible_ports.append(extremes[0]) | |
else: # Port range of the form '8000-8010' | |
for port in range(extremes[0], extremes[1] + 1): | |
possible_ports.append(port) | |
except Exception: | |
raise Exception('Invalid address ("%s") for live server.' % | |
specified_address) | |
return (host, possible_ports) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment