Улучшение кода с помощью разработки через тестирование

Как и многие разработчики, когда я впервые познакомился с Разработкой, основанной на тестировании (TDD), я вообще ничего не понял. У меня не было ни малейшего представления (как и терпения) о том, как начать писать тесты в первую очередь. Поэтому я не стал прилагать к этому особых усилий и занялся своим обычным процессом написания кода, прежде чем добавить тесты для его покрытия. Это продолжалось в течение ряда лет.

В 2017 году я стал соучредителем typless, где в настоящее время возглавляю инженерное подразделение. Вначале нам пришлось действовать быстро, поэтому мы накопили немалый технический долг. В то время сама платформа поддерживалась большим монолитным приложением Django. Отладка была очень сложной задачей. Код было трудно читать и еще труднее изменять. Мы устранили одну ошибку, а на ее место пришли еще три. После этого мне пришло время попробовать TDD еще раз. Все, что я читал об этом, указывало на то, что это поможет - и это помогло. Наконец-то я увидел один из основных побочных эффектов TDD: он значительно упрощает внесение изменений в код.

Содержимое

Программное обеспечение - это живое существо

Одним из важнейших факторов качества любого программного обеспечения является простота его изменения.

"Хороший дизайн означает, что когда я вношу изменения, создается впечатление, что вся программа была создана с учетом этого. Я могу решить задачу с помощью всего лишь нескольких вызовов функций выбора, которые идеально сочетаются друг с другом, не оставляя ни малейшей ряби на спокойной поверхности кода". источник

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

Гораздо проще изменить чистый модульный код, обработанный тестами, а это именно тот тип кода, который обычно создает TDD.

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

Требования

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

Следует ли вам создать список чисел вместе с несколькими вспомогательными функциями для добавления, сортировки и печати? Или вам следует создать класс? Конечно, на данный момент это, вероятно, не имеет большого значения. Вы могли бы начать писать код, соответствующий текущим требованиям. Это кажется наиболее естественным, не так ли? Однако, что, если эти требования изменятся и вам придется включить поиск или удаление? Код может быстро запутаться, если вы с самого начала не выберете разумную стратегию.

Так что сделайте шаг назад и сначала напишите несколько тестов.

Сначала пишем тесты

Сначала создайте (и активируйте) виртуальную среду и установите pytest:

(venv)$ pip install pytest

Создайте новый файл для хранения ваших тестов с именем test_phone_book.py.

Кажется разумным начать с класса с двумя методами, add и all, не так ли?

  • ЗАДАН PhoneBook класс со свойством records
  • ПРИ вызове метода all
  • ВСЕ числа должны быть возвращены в порядке возрастания

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

class TestPhoneBook:

    def test_all(self):

        phone_book = PhoneBook(
            records=[
                ('John Doe', '03 234 567 890'),
                ('Marry Doe', '01 234 567 890'),
                ('Donald Doe', '02 234 567 890'),
            ]
        )

        previous = ''

        for record in phone_book.all():
            assert record[0] > previous
            previous = record[0]

Здесь мы проверяем, что предыдущий элемент всегда находится в алфавитном порядке ниже текущего элемента.

Запустите его:

(venv)$ pytest

Конечно, тест не пройден.

Для реализации добавьте новый файл с именем phone_book.py:

class PhoneBook:

    def __init__(self, records=None):
        self.records = records or []

    def all(self):
        return sorted(self.records)

Импортируйте его в тестовый файл:

from phone_book import PhoneBook


class TestPhoneBook:

    def test_all(self):

        phone_book = PhoneBook(
            records=[
                ('John Doe', '03 234 567 890'),
                ('Marry Doe', '01 234 567 890'),
                ('Donald Doe', '02 234 567 890'),
            ]
        )

        previous = ''

        for record in phone_book.all():
            assert record[0] > previous
            previous = record[0]

Запустите его еще раз:

(venv)$ pytest

Тест завершен. Вы выполнили одно из первых требований.

Теперь напишите тест для метода add, чтобы проверить, что новое число находится в records.

  • ЗАДАН PhoneBook с помощью add метода
  • КОГДА добавляется число и вызывается метод all
  • ТО новое число является частью возвращаемых чисел
from phone_book import PhoneBook


class TestPhoneBook:

    def test_all(self):

        phone_book = PhoneBook(
            records=[
                ('John Doe', '03 234 567 890'),
                ('Marry Doe', '01 234 567 890'),
                ('Donald Doe', '02 234 567 890'),
            ]
        )

        previous = ''

        for record in phone_book.all():
            assert record[0] > previous
            previous = record[0]

    def test_add(self):

        record = ('John Doe', '01 234 567 890')
        phone_book = PhoneBook(
            records=[
                ('Marry Doe', '01 234 567 890'),
                ('Donald Doe', '02 234 567 890'),
            ]
        )
        phone_book.add(record)

        assert record in phone_book.all()

Тест должен завершиться неудачей, поскольку метод add еще не реализован.

class PhoneBook:

    def __init__(self, records=None):
        self.records = records or []

    def all(self):
        return sorted(self.records)

    def add(self, record):
        self.records.append(record)

Класс PhoneBook теперь соответствует всем вышеуказанным требованиям. Номера могут быть добавлены, и все они могут быть возвращены отсортированными в алфавитном порядке. Клиент в восторге. Упакуйте и доставьте код.

Новые требования

Давайте поразмыслим над этой первой реализацией.

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

Проходит несколько недель, а от клиента ничего не слышно. Им, должно быть, нравится добавлять и просматривать телефонные номера. Отличная работа. Похлопайте себя по плечу и отправьте клиенту вежливое напоминание о неоплаченном счете. Не прошло и тридцати секунд после того, как вы нажали кнопку отправить, как вы получили в ответ разочарованное электронное письмо, в котором говорилось, что поиск номеров происходит довольно медленно.

Что происходит? Итак, вы сортируете записи каждый раз, когда вызывается метод all, что со временем замедляет работу. Итак, давайте изменим код для сортировки при инициализации списка и при добавлении нового номера.

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

class PhoneBook:

    def __init__(self, records=None):
        self.records = sorted(records or [])

    def add(self, record):
        self.records.append(record)
        self.records = sorted(self.records)

    def all(self):
        return self.records

Тесты все равно должны пройти.

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

class PhoneBook:

    def __init__(self, records=None):
        self.records = sorted(records or [], key=lambda rec: rec[0])

    def add(self, record):

        index = len(self.records)
        for i in range(len(self.records)):
            if record[0] < self.records[i][0]:
                index = i
                break

        self.records.insert(index, record)

    def all(self):
        return self.records

Здесь мы вставляем новое число по порядку и убираем сортировку.

Несмотря на то, что мы изменили реализацию в соответствии с новыми требованиями, мы по-прежнему отвечаем нашим первоначальным требованиям. Откуда мы знаем? Запустите тесты.

Можем ли мы добиться большего успеха?

Мы выполнили все наши требования. Это здорово. Наш клиент оплачивает счет-фактуру. Все хорошо. Время идет. Вы забываете о проекте. Затем, ни с того ни с сего, вы видите в своем почтовом ящике электронное письмо от них с жалобой на то, что приложение теперь работает медленно при добавлении нового номера.

Вы открываете свой текстовый редактор и начинаете исследовать. Забыв о проекте, вы начинаете с тестов, а затем погружаетесь в код. Рассматривая метод add, вы видите, что вам нужно найти точное место для вставки числа перед вставкой, чтобы сохранить порядок. Оба этих метода - вставка и поиск индекса вставки - имеют временную сложность O(n).

Итак, как вы повышаете производительность в этой области?

Обратитесь к Google и Stack Overflow. Используйте свои навыки поиска информации. Примерно через час вы обнаружите, что временная сложность вставки в двоичное дерево равна O(log n). «так лучше». Кроме того, элементы могут быть возвращены в отсортированном порядке с помощью обхода по порядку. Поэтому измените свою реализацию, чтобы использовать двоичное дерево вместо списка.

Бинарное дерево

Новичок в бинарных деревьях? Ознакомьтесь с видео Бинарные деревья в Python: введение и алгоритмы обхода, а также с отличной библиотекой binarytree,

Сначала определите узел:

class Node:

    def __init__(self, data):

        self.left = None
        self.right = None
        self.data = data

Во-вторых, добавьте метод вставки:

class Node:

    def __init__(self, data):

        self.left = None
        self.right = None
        self.data = data

    def insert(self, data):
        # Compare the new value with the parent node
        if self.data:
            if data[0] < self.data[0]:
                if self.left is None:
                    self.left = Node(data)
                else:
                    self.left.insert(data)
            elif data[0] > self.data[0]:
                if self.right is None:
                    self.right = Node(data)
                else:
                    self.right.insert(data)
        else:
            self.data = data

Здесь мы проверяем, есть ли набор данных в текущем узле.

Если нет, то данные заданы.

Если заданы данные, программа проверяет, больше или меньше первый элемент данных, которые нам нужно вставить. На основе этого она добавляет левый или правый узел.

Наконец, добавьте метод обхода по порядку:

class Node:

    def __init__(self, data):

        self.left = None
        self.right = None
        self.data = data

    def insert(self, data):
        # Compare the new value with the parent node
        if self.data:
            if data[0] < self.data[0]:
                if self.left is None:
                    self.left = Node(data)
                else:
                    self.left.insert(data)
            elif data[0] > self.data[0]:
                if self.right is None:
                    self.right = Node(data)
                else:
                    self.right.insert(data)
        else:
            self.data = data

    def inorder_traversal(self, root):
        res = []
        if root:
            res = self.inorder_traversal(root.left)
            res.append(root.data)
            res = res + self.inorder_traversal(root.right)
        return res

Благодаря этому мы можем внедрить его в наш PhoneBook:

class Node:

    def __init__(self, data):

        self.left = None
        self.right = None
        self.data = data

    def insert(self, data):
        # Compare the new value with the parent node
        if self.data:
            if data[0] < self.data[0]:
                if self.left is None:
                    self.left = Node(data)
                else:
                    self.left.insert(data)
            elif data[0] > self.data[0]:
                if self.right is None:
                    self.right = Node(data)
                else:
                    self.right.insert(data)
        else:
            self.data = data

    def inorder_traversal(self, root):
        res = []
        if root:
            res = self.inorder_traversal(root.left)
            res.append(root.data)
            res = res + self.inorder_traversal(root.right)
        return res


class PhoneBook:

    def __init__(self, records=None):
        records = records or []

        if len(records) == 1:
            self.records = Node(records[0])
        elif len(records) > 1:
            self.records = Node(records[0])
            for elm in records[1:]:
                self.records.insert(elm)
        else:
            self.records = Node(None)

    def add(self, record):
        self.records.insert(record)

    def all(self):
        return self.records.inorder_traversal(self.records)

Запустите тесты. Они должны пройти.

Заключение

Написание тестов в первую очередь помогает правильно определить проблему, что помогает найти лучшее решение.

Вы можете использовать тесты, которые помогут прояснить проблему, а также область применения запутанной функции.

Затем выполните тесты и проверьте, решает ли ваше решение проблему.

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

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

Тесты дали нам определенную свободу в том, что мы можем изменять эту реализацию, не беспокоясь о нарушении интерфейса. В конце концов, важно не как это работает, а то, что это работает.

TDD может обеспечить уверенность, необходимую для качественного рефакторинга кода. Быстрее, чище, с лучшей структурой - это не имеет значения.

Счастливого кодирования!

Back to Top