Оптимизация тестов

на примере Django и PostgreSQL

Автор:Гриша aka naspeh
Команда:ostrovok.ru

Люблю три вещи: python, тесты и postgresql, про них и поговорим

Links:
rst2s5:
Какими должны быть тесты?

Если тесты медленные...

Для непрерывной интеграции у нас используется TeamCity от JetBrains

Оптимизация тестов - важная задача для практик TDD и Continuous integration.

Нужны быстрые тесты

Задача

``stat.start_transaction``: 592
``stat.start_unique_db``  : 353

У нас используются специфические возможности PostgreSQL: типы, расширения.

Треть использует ручное управление транзакциями (в частности ``commit_on_success``),
почти все эти тесты связаны с бронированием.

Давайте немного позамеряем

Мерить мы будем стандартное
поведение django в тестах
База нашего реального приложения
с кучей таблиц: 217 штук %)

Параметры системы

Железо: Intel Core i5-3210M, SSD, Mem~4GB
Linux: Kernel~3.7.6-1-ARCH


PostgreSQL 9.2.3; fsync=off
Python 2.7.3
Django 1.4.2

fsync:

> Смысл параметра: Данный параметр отвечает за сброс данных
> из кэша на диск при завершении транзакций. Если
> установить его значение fsync = off то данные не будут
> записываться на дисковые накопители сразу после завершения
> операций. Это может существенно повысить скорость
> операций insert и update, но есть риск повредить базу,
> если произойдет сбой (неожиданное отключение питания,
> сбой ОС, сбой дисковой подсистемы).

Используемый TestCase

class TestV1(TestCase):
    def test_v0(self):
        res = self.client.get('/admin/orders/order/')
        self.assertContains(res, 'this_is_the_login_form')

    def test_v1(self):
        self.go_to_admin()

    def test_v2(self):
        self.go_to_admin()
        self.go_to_admin('admin2')

    def go_to_admin(self, name='admin', password='password'):
        User.objects.create_superuser(name, None, password)
        self.client.login(username=name, password=password)
        res = self.client.get('/admin/orders/order/')
        self.assertNotContains(res, 'this_is_the_login_form')
- v0: мы вообще ничего не меняем в базе (есть несколько select-ов)
- v1:
  - мы создаем пользователя
  - логируем
  - заходим на страницу в админке
- в остальных тестах чем больше номер, тем больше повторений шагов из v1

Тесты в транзакции

./manage.py test tt/tests/test_v1.py
1.34s call     tt/tests/test_v1.py::TestV2::test_v4
1.29s call     tt/tests/test_v1.py::TestV1::test_v4
0.96s call     tt/tests/test_v1.py::TestV1::test_v3
0.96s call     tt/tests/test_v1.py::TestV2::test_v3
0.89s call     tt/tests/test_v1.py::TestV1::test_v0
0.66s call     tt/tests/test_v1.py::TestV1::test_v2
0.66s call     tt/tests/test_v1.py::TestV2::test_v2
0.35s call     tt/tests/test_v1.py::TestV1::test_v1
0.34s call     tt/tests/test_v1.py::TestV2::test_v1
0.04s call     tt/tests/test_v1.py::TestV2::test_v0
============ 10 passed in 7.74 seconds =============
7.82s user 0.36s system 86% cpu 9.492 total

Итого: 9.5 секунд, минимум: ~моментально

Все хорошо, если бы нам не нужен был commit_on_success, т.е. ручное управление транзакциями

На выполнение теста тоже нужно время. Чем больше операций в тесте - тем он дольше.

Тесты с commit_on_success

Django подход: очистка базы (flush) перед каждым тестом

$ ./manage.py test tt/tests/test_v1.py
5.10s call     tt/tests/test_v1.py::TestV2::test_v4
4.97s call     tt/tests/test_v1.py::TestV1::test_v4
4.77s call     tt/tests/test_v1.py::TestV2::test_v3
4.72s call     tt/tests/test_v1.py::TestV1::test_v3
4.67s call     tt/tests/test_v1.py::TestV1::test_v0
4.44s call     tt/tests/test_v1.py::TestV2::test_v2
4.35s call     tt/tests/test_v1.py::TestV1::test_v2
4.15s call     tt/tests/test_v1.py::TestV1::test_v1
4.13s call     tt/tests/test_v1.py::TestV2::test_v1
3.86s call     tt/tests/test_v1.py::TestV2::test_v0
============ 10 passed in 45.41 seconds ============
32.33s user 1.00s system 70% cpu 47.142 total

Итого: 47 секунд, минимум: ~4 секунд

Что это значит?

Уникальная база из шаблона

CREATE DATABASE "t_uniq" WITH TEMPLATE "t_base";
-- запуск теста
DROP DATABASE "t_uniq";

Ограничение: к шаблону не должно быть подключений

Использовать специфичные возможности вашего движка базы дынных - это выход.

Уникальная база, замеряем

./manage.py test tt/tests/test_v1.py
1.76s call     tt/tests/test_v1.py::TestV2::test_v4
1.73s call     tt/tests/test_v1.py::TestV1::test_v4
1.39s call     tt/tests/test_v1.py::TestV2::test_v3
1.35s call     tt/tests/test_v1.py::TestV1::test_v3
1.30s call     tt/tests/test_v1.py::TestV1::test_v0
1.17s call     tt/tests/test_v1.py::TestV2::test_v2
1.08s call     tt/tests/test_v1.py::TestV1::test_v2
0.97s call     tt/tests/test_v1.py::TestV1::test_v1
0.76s call     tt/tests/test_v1.py::TestV2::test_v1
0.45s call     tt/tests/test_v1.py::TestV2::test_v0
============ 10 passed in 12.20 seconds ============
7.88s user 0.31s system 58% cpu 13.945 total

Итого: 14 секунд, минимум: 0.45 секунды

350 * 0.45 = 2.5 мин (против 23 мин для flush)

Сводная таблица

метод всего, сек минимум, сек
в транзакции 9.5 ~0.04
уникальная база 14 ~0.45
очистка базы (flush) 47 ~4.00

Полгода назад

Полгода назад, во время выхода статьи на хабре (26 июня 2012), у нас было ~250 тестов, проходили они за 3-4 минуты и мы радовались - это довольно быстро.

Тогда мы уже использовали уникальную базу вместо flush.

Время шло, кол-во тестов росло, в приоритете были другие задачи.

3 месяца назад

3 месяца назад тестов было 800 и ходили они минут 15. Долго.

Сборки пакете на CI сервере были по 30-40 минут. Из них почти половина времени - тесты.

Оптимизации в однопоточном режиме не давали большого прироста.

Нужно было что-то делать. Нужен был координальный подход.

Решение очевидное - распараллелить тесты.

Правда есть пара сдерживающих моментов... С таким количеством тестов задача оптимизации тоже усложняется. Дольше ходят тесты - дольше разрабатывать тестовую среду.

nose(1.x) и плагины

Посмотрим на реализацию того времени.

Первая реализация тестовой среды:

nose умеет несколько процессов из коробки. В django nose multiprocess работает только для SQLite в памяти.

В последствии оказалось, что множество плагинов не совместимы с multiprocess.

Даже Xunit отчет, который нужен для CI.

Если вы только начинаете проект обратите лучше внимание на nose2 и pytest, в них совместимость между multiprocess и плагинами - лучше.

Но nose2 и pytest до конца несовместимы с nose1.

Рефакторинг тестовой среды

Время нашлось нужно было делать рефакторинг.

1.
  Проверяем, что тест наследуется от нашего TestCase.

  Логика на уровне TestCase нам позволила абстрагироваться от раннера.

  У нас появилась возможность опробовать nose2 и pytest на наших тестах.
  Но пока мы остаемся на nose1.

  pytest в однопоточном режиме работает отлично, а вот
  в несколько потоков выходит дольше чем один, на порядок дольше %)...

2.
  Пулы создаются на уровне раннера.

  Базы для пула создаются тем же методом что и уникальная база.
  "По шаблону" (``create db ... with template``).

3.
  Переда запуском блокируем, после - отпускаем.

Запуск тестов в одном процессе

$ ./manage.py test
---------------------------------------------------
Ran 945 tests in 1049.055s

OK (SKIP=14)
(714.72s user 17.35s system 69% cpu 17:31.64 total)

17-18 минут, это много...

Запуск тестов в несколько процессов

$ ./manage.py test --processes=4
---------------------------------------------------
Ran 945 tests in 341.634s

OK (SKIP=14)
(917.19s user 15.47s system 267% cpu 5:48.75 total)

... то что надо!

На CI сервере немного дольше, но можно добавить больше processes.

Для любителей ультрабуков с их низкочастотными процессорами будет дольше. Например: для любителей 11-х MacBook Air. Но выбор железа остается за разработчиком. Производительность рабочего ноутбука - это важно.

Тесты мы ускорили

1000 тестов за 6 минут:
  • можно подождать
  • или посмотреть на свои изменения еще раз
Заметки:
  • дальнейшая оптимизация скорее всего будет не такой показательной
  • привязываться к nose1 - сейчас плохая идея

Дальнейшая оптимизация нужна, т.к. кол-во тестов обычно только растет. У нас есть отдельная команда экстранета, которая занимается админкой для отелей. В их проекте ~3200 тестов %).

Еще пара приемов

Если хватит времени.

Расскажу несколько приемов из нашей практики.

Свой раннер

Так мы переопределили раннер:

# testing/management/commands/test.py
from testing.runner import run

class Command(object):
    def run_from_argv(self, argv):
        run(argv)

Наш процесс запуска тестов очень изминился, нам понадобился свой раннер.

Добавляем необходимые параметры, а также параметры для удобства.

Мы не используем django параметры - они не нужны.

Отдельные настройки для тестов

При запуске ./manage.py test подхватываются тестовые настройки:

# settings/__init__.py
if 'test' in sys.argv:
    from .testing import *

Фиксируем разные настройки для тестов

Изоляция кэшей и redis

Кэши:
  • используем django...LocMemCache
  • в конце каждого теста - очищаем
Redis:
  • пул для многопроцессорного режима
  • в начале каждого теста блокируем
  • в конце - зачищаем и разблокируем

Отдельный шаг создания базы

Для быстрого запуска отдельных тестов

С созданием базы: 1 тест за ~1 секунду, а ждем ~15 секунд

$ time ./manage.py test tt/tests/test_v1.py:TestV1.test_v0 --create-db
----------------------------------------------------------------------
Ran 1 test in 0.719s
OK (14.616 total)

Без создания базы: 1 тест за ~1 секунду, a ждем всего ~3 секунд

$ time ./manage.py test tt/tests/test_v1.py:TestV1.test_v0
----------------------------------------------------------------------
Ran 1 test in 0.810s
OK (2.641 total)

У нас есть один тест, который мы только что починили и хотим проверить, что он проходит.

Кажется это мелочь 15 секунд, но отзывчивость тестов очень важно для частых операций. При обычном харде (HDD), это время может быть больше.

Есть неблольшой минус, что база может изменится, и нужно пересаздать базу, но плюсов больше.

Подытожим

Спасибо за внимание

Вопросы?

текст читал:Гриша Костюк
email:naspeh@gmail.com
хомяк:pusto.org
github:github.com/naspeh