Рубрики
Без рубрики

Портирование в Pтойду: практический пример

Первоначально опубликовано в моем блоге. ВВЕДЕНИЕ В другой день я следил за Джанго … Помечено Python, Django, тестирование.

Первоначально опубликовано мой блог .

На днях я следил за Учебник Django Отказ

Если вы никогда не читаете учебное пособие или не хотите, вот что вам нужно знать:

У нас есть проект Django, содержащий приложение под названием Опросы Отказ

У нас есть две модели, представляющие вопросы и выбор.

Каждый вопрос имеет дату публикации, текст и список вариантов.

Каждый выбор имеет ссылку на существующий вопрос (через внешний ключ), текст и ряд голосов.

Есть представление, который показывает список вопросов в качестве ссылок. Каждая ссылка при щелчке отображается варианты и имеет форму, чтобы позволить пользователю голосовать.

Код довольно простой:

# polls/models.py
class Question(models.Model):
    question_text = models.CharField(max_length=200)
    pub_date = models.DateTimeField('date published')

    def was_published_recently(self):
        now = timezone.now()
        two_days_ago = now - datetime.timedelta(days=2)
        return two_days_ago < self.pub_date <= now


class Choice(models.Model):
    question = models.ForeignKey(Question, on_delete=models.CASCADE)
    choice_text = models.CharField(max_length=200)

Все прошло гладко, пока я не прибыл на Часть 5 , о автоматическом тестировании, где я прочитал следующее:

Иногда может показаться хоре, чтобы оторвать себя от вашего продуктивного, творческого программирования, чтобы противостоять негламую и неоправданному бизнесу написания тестов, особенно если вы знаете, что ваш код работает должным образом.

Ну, позволь мне рециркулировать!

Вот что похоже на тесты при извлечении из документации Django:

import datetime

from django.utils import timezone
from django.test import TestCase

from .models import Question


class QuestionModelTests(TestCase):
    def test_was_published_recently_with_future_question(self):
        """
        was_published_recently() returns False for questions whose pub_date
        is in the future.
        """
        time = timezone.now() + datetime.timedelta(days=30)
        future_question = Question(pub_date=time)
        self.assertIs(future_question.was_published_recently(), False)

    def test_was_published_recently_with_old_question(self);
        """
        was_published_recently() returns False for questions whose pub_date
        is older than 1 day.
        """
        time = timezone.now() - datetime.timedelta(days=1, seconds=1)
        old_question = Question(pub_date=time)
        self.assertIs(old_question.was_published_recently(), False)


    def test_was_published_recently_with_recent_question(self):
        """
        was_published_recently() returns True for questions whose pub_date
        is within the last day.
        """
        time = timezone.now() - datetime.timedelta(hours=23, minutes=59, seconds=59)
        recent_question = Question(pub_date=time)
        self.assertIs(recent_question.was_published_recently(), True)


def create_question(question_text, days):
    """
    Create a question with the given `question_text` and published the
    given number of `days` offset to now (negative for questions published
    in the past, positive for questions that have yet to be published).
    """
    time = timezone.now() + datetime.timedelta(days=days)
    return Question.objects.create(question_text=question_text, pub_date=time)


class QuestionIndexViewTests(TestCase):
    def test_no_questions(self):
        """
        If no questions exist, an appropriate message is displayed.
        """
        response = self.client.get(reverse('polls:index'))
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "No polls are available.")
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            []
        )

    def test_past_question(self):
        """
        Questions with a pub_date in the past are displayed on the
        index page.
        """
        create_question(question_text="Past question.", days=-30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['']
        )

    def test_future_question(self):
        """
        Questions with a pub_date in the future aren't displayed on
        the index page.
        """
        create_question(question_text="Future question.", days=30)
        response = self.client.get(reverse('polls:index'))
        self.assertContains(response, "No polls are available.")
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            []
        )

    def test_future_question_and_past_question(self):
        """
        Even if both past and future questions exist, only past questions
        are displayed.
        """
        create_question(question_text="Past question.", days=-30)
        create_question(question_text="Future question.", days=30)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            ['']
        )

    def test_two_past_questions(self):
        """
        The questions index page may display multiple questions.
        """
        create_question(question_text="Past question 1.", days=-30)
        create_question(question_text="Past question 2.", days=-5)
        response = self.client.get(reverse('polls:index'))
        self.assertQuerysetEqual(
            response.context['latest_question_list'],
            [
                '',
                ''
            ]
        )

Мы можем запустить их, используя Manage.py Сценарий и проверьте все они проходят:

$ python manage.py test polls
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
........
---------------------------------------------------------------------------
Ran 8 tests in 0.017s

OK
Destroying test database for alias 'default'...

Хорошо, тесты проходят. Давайте попробуем их улучшить.

Я настроил репозиторий GitHub Где вы можете выполнить следующие шаги, совершайте, совершите, если хотите.

Я уже говорил тебе сколько я люблю питисты Так что давайте попробуем преобразовать в pteest Отказ

Первый шаг должен установить pytest-django и настроить это:

$ pip install pytest pytest-django
# in pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE=mysite.settings
python_files = tests.py test_*.py

Теперь мы можем запустить тесты, используя pteest напрямую:

$ pytest
========== test session starts ========
platform linux -- Python 3.5.3, pytest-3.3.1, py-1.5.2, pluggy-0.6.0
Django settings: mysite.settings (from ini file)
rootdir: /home/dmerej/src/dmerej/django-polls, inifile: pytest.ini
plugins: django-3.1.2
collected 8 items

polls/tests.py ........   [100%]

======== 8 passed in 0.18 seconds =======

Теперь мы можем использовать pteest Магия, чтобы переписать все «легкие» утверждения, такие как assertfalse. или Assertequals :

- self.assertFalse(future_question.was_published_recently())
+ assert not future_question.was_published_recently()

Уже мы можем увидеть несколько улучшений:

  • Код более читабелен и следует PEP8
  • Сообщения об ошибках более подробны:
# Before, with unittest
$ python manage.py test
    def test_was_published_recently_with_future_question(self):
        ...
>       self.assertFalse(question.was_published_recently())
E       AssertionError: True is not false

# After, with pytest
$ pytest
>       assert not question.was_published_recently()
E       AssertionError: assert not True
E        +  where True = 

Тогда мы должны иметь дело с Assertcontains и AssertQuerySetequal который выглядит немного Django-специфическим.

Для Assertcontains Мне быстро удалось найти, что смогу использовать Ответ .Rendered_content вместо:

- self.assertContains(response, "No polls are available.")
+ assert "No polls are available." in response.rendered_content

Для AssertQuerySetequal Это было немного сложнее.

def test_past_question(self):
    create_question(question_text="Past question.", days=-30)
    response = self.client.get(reverse('polls:index'))
    self.assertQuerysetEqual(
        response.context['latest_question_list'],
        ['']
    )

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

Но это так, проверяя Строковое представление из Вопрос:| объект.

Таким образом, он сломается, как только Вопрос .__ str__ Изменения, которые не идеальны.

Таким образом, вместо этого мы можем написать что-то вроде этого и проверять содержание Вопрос_text Атрибут напрямую:

def test_past_question(self):
    create_question(question_text="Past question.", days=-30)
    response = self.client.get(reverse('polls:index'))
    actual_questions = response.context['latest_question_list']
    assert len(actual_questions) == 1
    actual_question = actual_questions[0]
    assert actual_question.question_text == "Past question"

Пока мы на этом, мы можем ввести небольшие функции помощника, чтобы просмотреть тесты проживанию:

Например, строка Опроса не доступны жестко закодирован дважды в тестах. Давайте представим assert_no_polls помощник:

def assert_no_polls(text):
    assert "No polls are available" in text
- assert "No polls are available." in response.rendered_content
+ assert_no_polls(response.rendered_content)

Другая жестко закодированная строка Опросы: индекс Так что давайте представим get_latest_list. :

def get_latest_list(client):
    response = client.get(reverse('polls:index'))
    assert response.status_code == 200
    return response.context['latest_question_list']

Обратите внимание, как мы встроили код состояния прямо в нашем помощник, поэтому нам не нужно повторять чек в каждом тесте.

Также обратите внимание, что если имя маршрута ( Опросы: index ) или имя контекстно-клавиши, используемого в шаблоне ( Neighle_Question_List ), когда-либо изменения, нам просто нужно будет обновить тестовый код в одном месте.

Тогда мы можем еще больше упростить наши утверждения:

def assert_question_list_equals(actual_questions, expected_texts):
    assert len(actual_questions) == len(expected_texts)
    for actual_question, expected_text in zip(actual_questions, expected_texts):
        assert actual_question.question_text == expected_text

def test_past_question(self):
    ...
    create_question(question_text="Past question.", days=-30)
    latest_list = get_latest_list(self.client)
    assert_question_list_equals(latest_list, ["Past question."])

Хорошая вещь о pteest Это то, что вам не нужно ставить свои тесты как методы класса, вы можете просто написать Тестовые функции напрямую.

Итак, мы просто удаляем Я Параметр, отступ всех кода, и мы (почти) приятно идти.

Мы уже избавились от всех Self.assert * Методы, поэтому последнее, что нужно сделать, это передавать тестовый клиент Django в качестве параметра вместо использования Self.client Отказ (Вот как Pytest Светильники Работа):

-    def test_two_past_questions(self):
-        ...
-        latest_list = get_latest_list(self.client)

+ def test_no_questions(client):
+    latest_list = get_latest_list(client)

Но тогда мы сталкиваемся о неожиданном неудаче:

Polls/tests.py:34: in create_question
    return Question.objects.create(question_text=question_text, pub_date=time)

    ...

>       self.ensure_connection()
E       Failed: Database access not allowed, use the "django_db" mark, or the "db" or "transactional_db" fixtures to enable it.

Назад, когда мы использовали Python Manage.py Test , Джанго Manage.py Сценарий был неявно создает для нас тестовую базу данных.

Когда мы используем pteest Мы должны быть четко об этом и добавить специальный маркер:

import pytest

# No change here, no need for a DB
def test_was_published_recently_with_old_question():
    ...

# We use create_question, which in turn calls Question.objects.create(),
# so we need a database here:
@pytest.mark.django_db
def test_no_questions(client):
    ...

Правда, это немного раздражает, но обратите внимание, что если мы только хотим проверить самими моделями (например, FAL_PUBLIFE_RECTY () METHOLD), мы можем просто использовать:

$ pytest -k was_published_recently

И база данных не будет создана Вообще Отказ

Я не люблю строки дока, кроме Когда я реализую очень публичную API. Там я сказал это.

Я очень предпочитаю, когда код «самодоступность», Особенно когда это тестовый код.

Как Дядя Боб Сказанные, «Тесты должны читать как хорошо написанные спецификации». Итак, давайте попробуем некоторые рефакторинг.

Мы можем начать с более значимых имен переменной и веселиться с примерами:

def test_was_published_recently_with_old_question():
-   time = timezone.now() - datetime.timedelta(days=1, seconds=1)
-   old_question = Question(pub_date=time)
+   last_year = timezone.now() - datetime.timedelta(days=365)
+   old_question = Question('Why is there something instead of nothing?',
+                            pub_date=last_year)
    assert not old_question.was_published_recently()

def test_was_published_recently_with_recent_question():
-   time = timezone.now() - datetime.timedelta(days=1, seconds=1)
-   recent_question = Question(pub_date=time)
+   last_night = timezone.now() - datetime.timedelta(hours=10)
+   recent_question = Question('Dude, where is my car?', pub_date=last_night)

Кодекс времени и даты всегда хитрый, а негативное количество дней не имеет смысла, поэтому давайте облегшим рассуждать о:

def n_days_ago(n):
    return timezone.now() - datetime.timedelta(days=n)


def n_days_later(n):
    return timezone.now() + datetime.timedelta(days=n)

Также create_question связан с Вопрос:| Модель, так что давайте будем использовать те же имена для имен параметров и атрибутов модели.

И поскольку мы можем захотеть создать вопрос, не заботясь о дате публикации, давайте сделаем его необязательным параметром:

def create_question(question_text, *, pub_date=None):
    if not pub_date:
        pub_date = timezone.now()
    ...

Код становится:

-    create_question(question_text="Past question.", days=-30)
+    create_question(question_text="Past question.", pub_date=n_days_ago(30))

Наконец, давайте добавим новый тест, чтобы увидеть, действительно ли наши помощники работают:

@pytest.mark.django_db
def test_latest_five(client):
    for i in range(0, 10):
        pub_date = n_days_ago(i)
        create_question("Question #%s" % i, pub_date=pub_date)
    latest_list = get_latest_list(client)
    assert len(actual_list) == 5

Вы все еще думаете, что этот тест нуждается в DocString?

Основы селена

Селен Снимается с автоматией браузера.

Здесь мы собираемся использовать привязки Python, которые позволяют нам начать Firefox или Хром и контролировать их с кодом.

(В обоих случаях вам нужно установить отдельный двоичный файл: Geckodriver или хромаривер соответственно)

Вот как вы можете использовать Селен Посетите веб-страницу и нажмите на первую ссылку:

import selenium.webdriver

driver = selenium.webdriver.Firefox()
# or
driver = selenium.webdriver.Chrome()
driver.get("http://example.com")
link = driver.find_element_by_tag_name('a')
link.click()

Тестовый случай Live Server

Джанго разоблачает Liveservertestcase , но нет Liveserver объект или аналогичный.

Код немного сложно, потому что ему необходимо создавать «реальный» сервер в отдельном потоке, убедитесь, что он использует бесплатный порт и сообщите драйверу Selenium, чтобы использовать URL, как http://localhost: 32456

Не бойся, pteest Также работает нормально в этом случае. Мы просто должны быть осторожны, чтобы использовать Super () в настройках и отрывать методы, так что код из Liveservertestcase выполняется правильно:

import urllib.parse


class TestPolls(LiveServerTestCase):
    serialized_rollback = True

    def setUp(self):
        super().setUp()
        self.driver = selenium.webdriver.Firefox()

    def tearDown(self):
        self.driver.close()
        super().tearDown()

    def test_home_no_polls(self):
        url = urllib.parse.urljoin(self.live_server_url, "/polls")
        self.driver.get(url)
        assert_no_polls(self.browser.page_source)

Если вам интересно, почему нам нужно srialized_rollback = Правда Ответ в Документация Отказ Без этого у нас может быть странные ошибки базы данных во время тестов.

Наш первый тест довольно базовый: мы просим браузера посетить Опросы/ URL-адрес и проверьте опросы, повторно используйте наши assert_no_polls Функция помощника от раньше.

Давайте также проверим, мы показаны ссылки на вопросы, если они некоторые, и могут нажать на них:

class TestPolls(LiveServerTestCase):
    ...
    def test_home_list_polls(self):
        create_question("One?")
        create_question("Two?")
        create_question("Three?")
        url = urllib.parse.urljoin(self.live_server_url, "polls/")
        self.driver.get(url)
        first_link = self.driver.find_element_by_tag_name("a")
        first_link.click()
        assert "Three?" in self.driver.page_source

Давайте построим фасад

find_element_by_ * Методы API селена немного утомительны для использования: Theery называются find_element_by_tag_name. , find_element_by_class_name , find_element_by_id и так на

Так что давайте напишем Браузер Класс, чтобы скрыть те, кто за более «Pythonic» API:

# old
link = driver.find_element_by_tag_name("link")
form = driver.find_element_by_id("form-id")

# new
link = driver.find_element(tag_name="link")
form = driver.find_element(id="form-id")

(Это известно как «фасад» шаблон дизайна)

class Browser:
    """ A nice facade on top of selenium stuff """
    def __init__(self, driver):
        self.driver = driver

    def find_element(self, **kwargs):
        assert len(kwargs) == 1   # we want exactly one named parameter here
        name, value = list(kwargs.items())[0]
        func_name = "find_element_by_" + name
        func = getattr(self.driver, func_name)
        return func(value)

Обратите внимание, как мы должны преобразовать Предметы () в реальный список, просто чтобы получить первый элемент … (В Python2, kwargs.items () [0] будет работать просто хорошо). Пожалуйста, скажите мне, если вы найдете лучший способ …

Обратите внимание, как мы Не Просто наследую от selenium.webdriver. Firefox Отказ Цель состоит в том, чтобы разоблачить разные API, поэтому с использованием композиции здесь лучше.

Если нам нужен доступ к атрибутам Self.driver Мы можем просто использовать недвижимость, как это:

class Browser

    ...

    @property
    def page_source(self):
        return self.driver.page_source

И если нам нужно позвонить методу непосредственно в базовый объект, мы можем просто пересылать звонок:

    def close(self):
        self.driver.close()

Мы также можем скрыть уродливую Urllib.Parse.urljoin (Self.live_server_url) Деталь внедрения:

class Browser:
    def __init__(self, driver):
        self.driver = driver
        self.live_server_url = None  # will be set during test set up

    def get(self, url):
        full_url = urllib.parse.urljoin(self.live_server_url, url)
        self.driver.get(full_url)


class TestPolls(LiveServerTestCase):

    def setUp(self):
        super().setUp()
        driver = selenium.webdriver.Firefox()
        self.browser = Browser(driver)
        self.browser.live_server_url = self.live_server_url

Теперь тест читает:

    def test_home_no_polls(self):
        self.browser.get("/polls")
        assert_no_polls(self.browser.page_source)

Хорошо и коротко:)

Запуск водителя только один раз

Настройка () Метод называется перед каждым тестом, поэтому, если мы добавим больше тестов, мы собираемся создавать тонны экземпляров драйверов Firefox.

Давайте исправим это, используя setupclass (И не забывать Super () Call)

class TestPolls(LiveServerTestCase):

    @classmethod
    def setUpClass(cls):
        super().setUpClass()
        driver = webdriver.Chrome()
        cls.browser = Browser(driver)

    def setUp(self):
        self.browser.base_url = self.live_server_url

    @classmethod
    def tearDownClass(cls):
        cls.browser.close()
        super().tearDownClass()

Теперь браузер это Атрибут класса вместо того, чтобы быть Атрибут экземпляра . Так что есть только один Браузер Объект для всего тестового набора, который мы хотели.

Остальная часть кода все еще может использовать Self.Browser , хотя.

Отладка тестов

Последняя вещь. Вы можете подумать, что отладка таких испытаний высокого уровня будет болезненным.

Но это на самом деле довольно хороший опыт из-за всего лишь одного: встроенный отладчик Python!

Просто добавьте что-то вроде:

def test_login():
    self.browser.get("/login")
    import pdb; pdb.set_trace()

а затем запустите такие тесты:

$ pytest -k login -s

(Требуется -s необходим для того, чтобы избежать захвата выхода, которые PDP не любит)

А затем, как только тесты достигают линии pdb.set_trace () У вас будет:

  • Новый новый экземпляр Firefox, с доступом ко всем хорошим инструменте отладки (так что вы можете быстро найти такие вещи, как IDS или имена классов CSS)
  • … и хорошая замена, где вы сможете попробовать код, используя Self.Browser

Кстати, рент будет даже приятнее, если вы используете IPDB. или PDBPP И наслаждайтесь автоматическим завершением и синтаксическим расцветом прямо из REFL:)

Я надеюсь, мне удалось показать, что вы на самом деле может Получите творческие тесты на написание, и даже повеселиться.

Увидимся в следующий раз!

Спасибо за чтение этого далеко:)

Я хотел бы услышать, что вы должны сказать, пожалуйста, не стесняйтесь оставить комментарий ниже, или прочитайте Страница обратной связи для большего способа связаться со мной.

Оригинал: “https://dev.to/dmerejkowsky/porting-to-pytest-a-practical-example-12hm”