Отладка SQL в django тестах
В django тестах можно замерять количество сделанных SQL запросов:
def test_home(self):
with self.assertNumQueries(1):
response = self.client.get('/')
self.assertEqual(response.status_code, 200)
Если код в контексте assertNumQueries
сделает иное количество обращений к БД, чем ожидается (здесь 1), то тест выдает ошибку. Но когда такой тест не проходит, бывает трудно определить лишний отправленный запрос. Для отладки такого случая удобно вывести в консоль отправленные SQL запросы. Далее опишу, как этого добиться.
Кстати, если вы используете Django 1.7+, то вместе с ошибкой будут показаны все SQL выражения, т.е. если этого достаточно - то делать вообще ничего не надо. Ура! Если же вы используете более старые версии, то данная статья будет полезна.
Настройка логов для вывода SQL запросов в консоль
Django будет логировать SQL запрос, если соблюдается одного из следующих условий:
settings.DEBUG = True
или
connection.use_debug_cursor = True
По умолчанию в тестах всегда DEBUG = False вне зависимости от того, что стоит в вашем settings.DEBUG. Это правильно, тестировать лучше с боевыми настройками.
Т.о. остается connection.use_debug_cursor, которое по умолчанию всегда None или False (в разных версиях по разному). Но контекстный менеджер assertNumQueries сам выставляет это значение в True на время работы соответствующего блока! Нам остается лишь правильно задать настройки логов.
Создадим отдельный файл настроек для тестов и назовем его settings_test.py. Тесты гонять будем с ним. Рекомендую всегда делать так - это удобно. Структура проекта примерно такая:
project
├── project
│ ├── __init__.py
│ ├── settings.py
│ ├── settings_test.py
│ ├── urls.py
│ └── wsgi.py
│
├── spam # some app
│ ├── __init__.py
│ ├── views.py
│ ├── tests.py
│ └── models.py
│
└── manage.py
Файл tests.py содержит тесты приложения spam. Так организовывать тесты не самый хороший выбор, лучше выделить отдельную папку и держать все тесты там. Но для простоты нашего примера годится.
Содержимое settings_test.py:
from settings import *
try:
from settings import LOGGING
except ImportError:
LOGGING = dict(version=1, disable_existing_loggers=False,
handlers={}, loggers={})
# use database in memory to not lose your data!
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': ':memory:',
'USER': '',
'PASSWORD': '',
'TEST_CHARSET': 'utf8',
}}
LOGGING['handlers']['console'] = {
'level': 'DEBUG',
'class': 'logging.StreamHandler',
}
LOGGING['loggers']['django.db.backends'] = {
'handlers': ['console'],
'level': 'DEBUG',
'propagate': False,
}
LOGGING['loggers']['django.db.backends.schema'] = {
'propagate': False, # don't log schema queries, django >= 1.7
}
Тест (tests.py) выглядит так:
from django.test import TestCase
from spam.models import Foo
class SpamTestCase(TestCase):
def setUp(self):
Foo.objects.create(title="Foo")
def test_home(self):
with self.assertNumQueries(1):
response = self.client.get('/')
self.assertEqual(response.status_code, 200)
Давайте посмотрим, что будет при прогоне теста используя разные runner’ы.
Django 1.4+
$ python manage.py test
(нет SQL):
Creating test database for alias 'default'...
.
----------------------------------------------------------------------
Ran 1 test in 0.009s
OK
Destroying test database for alias 'default'...
$ python manage.py test --settings=project.settings_test
(SQL в консоле)
Creating test database for alias 'default'...
(0.000) SELECT "spam_foo"."id", "spam_foo"."title" FROM "spam_foo" LIMIT 21; args=()
.
----------------------------------------------------------------------
Ran 1 test in 0.009s
OK
Destroying test database for alias 'default'...
Django 1.4+ и django-nose
- pip install django-nose
- в settings.py выставляем TEST_RUNNER = ‘django_nose.NoseTestSuiteRunner’
$ python manage.py test
(нет SQL):
...
$ python manage.py test --settings=project.settings_test
(SQL в консоле)
(0.000) SELECT "spam_foo"."id", "spam_foo"."title" FROM "spam_foo" LIMIT 21; args=()
...
Django 1.4+ и pytest-django
- pip install pytest-django
- создаем файл pytest.ini рядом с manage.py и задаем файл настроек:
[pytest]
DJANGO_SETTINGS_MODULE = project.settings
- переименуем tests.py в test_spam.py (стандартные имена для py.test)
$ py.test
(нет SQL):
...
$ py.test --ds=project.settings_test
(нет SQL, py.test перехватывает весь вывод)
...
$ py.test --ds=project.settings_test -s
(SQL в консоле)
(0.000) SELECT "spam_foo"."id", "spam_foo"."title" FROM "spam_foo" LIMIT 21; args=()
...
Итого
Как видно из предыдущих примеров, при использовании нашего settings_test в консоль выводятся все запросы к БД, сделанные в блоке контекстного менеджера assertNumQueries.
Если мы хотим гонять тесты всегда с этими настройками, а не только когда хотим что-то отладить, то очень скоро устанем от обилия SQL. Можно сделать так: закомментировать строчку 'handlers': ['console'],
, т.о. ничего выводится не будет. А когда нужно их посмотреть - просто убираем комментарий.
Вывод ошибки assertNumQueries и Django 1.7+
Если assertNumQueries регистрирует отличное от ожидаемого количество обращений к БД, то выводится traceback и ошибка:
Traceback (most recent call last):
...
AssertionError: 1 queries executed, 2 expected
Но в django 1.7+ так же выводятся и сделанные запросы:
Captured queries were:
QUERY = u'SELECT "spam_foo"."id", "spam_foo"."title" FROM "spam_foo" LIMIT 21' - PARAMS = ()
Как вы понимаете, настройки логов на это не влияют, очень удобно!
Вывод всех SQL запросов в консоль не используя assertNumQueries
До сих пор речь шла об использовании assertNumQueries. Но что если нужно посмотреть запросы вне этого менеджера?
Для этого нужно самому выставить connection.use_debug_cursor = True
перед прогоном тестов. Это можно сделать в test runner’е или используя специальный hook в py.test.
SQL во всех тестах: Django 1.4+
Создаем файл test_runner.py, кладем его рядом с settings.py и вставляем такой код:
try:
from django.test.runner import DiscoverRunner as DjangoTestSuiteRunner
except ImportError:
# django < 1.6
from django.test.simple import DjangoTestSuiteRunner
from django.db import connections, DEFAULT_DB_ALIAS
class SqlDebugTestSuiteRunner(DjangoTestSuiteRunner):
def setup_test_environment(self, **kwargs):
super(SqlDebugTestSuiteRunner, self).setup_test_environment(**kwargs)
connections[DEFAULT_DB_ALIAS].use_debug_cursor = True
Теперь либо в settings (или settings_test, если используем его для тестов) указываем наш runner:
TEST_RUNNER = 'project.test_runner.SqlDebugTestSuiteRunner'
Запускаем python manage.py test --settings=project.settings_test
и видим все запросы, созданные за время тестов.
Либо можно не задавать TEST_RUNNER, а использовать аргумент –testrunner:
python manage.py test --settings=project.settings_test --testrunner=project.test_runner.SqlDebugTestSuiteRunner
Результат будет тем же.
SQL во всех тестах: Django 1.4+ и django-nose
Для nose почти все так же, только runner нужно унаследовать от NoseTestSuiteRunner.
test_runner.py:
from django_nose import NoseTestSuiteRunner
from django.db import connections, DEFAULT_DB_ALIAS
class SqlDebugTestSuiteRunner(NoseTestSuiteRunner):
def setup_test_environment(self, **kwargs):
super(SqlDebugTestSuiteRunner, self).setup_test_environment(**kwargs)
connections[DEFAULT_DB_ALIAS].use_debug_cursor = True
Да, на момент написания статьи версия django-nose==1.3. В этой версии не поддерживается аргумент –testrunner. Но я отправил пул реквест, возможно, он уже попал в релиз.
SQL во всех тестах: Django 1.4+ и pytest-django
В py.test все немного по другому, ведь там не используется стандартный runner из django. Вместо этого для установления тестового окружения можно использовать py.test хуки.
Создаем файл plugin_debug_sql.py, кладем рядом с settings.py, вставляем код:
def pytest_runtest_setup(item):
from django.db import connections, DEFAULT_DB_ALIAS
connections[DEFAULT_DB_ALIAS].use_debug_cursor = True
Запускать так:
PYTHONPATH=`pwd`:$PYTHONPATH py.test -s --ds=sql.settings_test -p project.plugin_debug_sql
Тут я явно добавляю текущую папку в PYTHONPATH, потому что иначе py.test не сможет найти наш плагин.