Блог

Дропшиппинг интернет-магазин на Django (часть 5)

5. Создание функционала базы данных методом TDD (Test Driven Development) или пример Разработки через тестирование

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

Содержание курса:

Зачем программисты пишут тесты?

Большинство программистов не любит писать тесты.

Оно и понятно - мало кто любит делать двойную работу: сначала написать и отладить код, а потом ещё столько же времени (если не больше!) затратить на написание тестов.

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

Однако, если проект будет закрыт, то к потерям времени на написание основного кода добавятся ещё и затраты на тесты.

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

Тем не менее, есть подход в программировании, который позволяет если не выиграть, то, как минимум, почти не проиграть при написании тестов. И называется это подход "Test Driven Development" (TDD) ("Разработка через тестирование"). Идея очень проста: мы СНАЧАЛА пишем тесты, и только потом пишем сам код, который должен пройти эти тесты.

И в чём же здесь плюс? А плюс в том, что в этом случае полностью исключается затраты времени на проверку кода. Ведь даже самый первый прогон свеже-написанного кода будет делаться уже "не руками", а тестами!

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

Собственно, именно поэтому в Django достаточно хорошо разработан инструментарий для тестирования кода. И именно поэтому уже в первом учебном примере на сайте этого фреймворка целый урок из восьми отводится на рассмотрение примера реализации этого принципа: "Разработка через тестирование" или "Test Driven Development" (TDD).

В нашем учебном мини-курсе мы рассмотрим подход разработки через тестирование при создании базы данных, поскольку основная вычислительная нагрузка или основная бизнес-логика будет находится именно в модуле shop/models.py, отвечающем за взаимодействие между таблицами базы данных.

Итак, наши задачи на этом этапе:

  • Сформулировать основные требования к структуре БД
  • Составить дизайн БД (нарисовать таблицы и связи между ними)
  • Сформулировать требования к бизнес-логике БД
  • Перенести эти требования в код тестов
  • Написать код, который пройдёт эти тесты

Формулируем требования к структуре БД

Для упрощения задачи (всё-таки, это мини-курс!) оговорим три допущения:

  • Наши товары имеют плоскую структуру (нет разбивки на категории)
  • У нас дропшиппинг, а следовательно нет склада и не может быть складских остатков (предполагается бесконечный остаток товара по каждой позиции)
  • Наша корзина - это заказ со статусом cart. Просто после нажатия кнопки “Перейти к оплате” этот статус меняется на waiting_for_payment

Таким образом, структура данных будет состоять из 4-х таблиц:

  • Таблицы Заказа Order
  • Таблицы Элементов заказа OrderItems (или элементов, из которых состоит каждый заказ)
  • Таблицы Товаров Product (ассортимента)
  • И таблицы Оплат Payment

Ещё нам потребуется таблица Клиентов User, но на наше счастье она уже присутствует в Django по умолчанию, хотя и в самой простой базовой версии. Однако, на этапе учебного проекта этого будет более, чем достаточно.

Создаём структуру БД

Всем вышеперечисленным требованиям вполне удовлетворяет эта блок-схема:

Поэтому в модуле shop/models.py создаём следующую структуру:

class Product(models.Model):
    name = models.CharField(max_length=255, verbose_name='product_name')
    code = models.CharField(max_length=255, verbose_name='product_code')
    price = models.DecimalField(max_digits=20, decimal_places=2)
    unit = models.CharField(max_length=255, blank=True, null=True)
    image_url = models.URLField(blank=True, null=True)
    note = models.TextField(blank=True, null=True)

class Payment(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    amount = models.DecimalField(max_digits=20, decimal_places=2, blank=True, null=True)
    time = models.DateTimeField(auto_now_add=True)
    comment = models.TextField(blank=True, null=True)

class Order(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)
    # items = models.ManyToManyField(OrderItem, related_name='orders')
    status = models.CharField(max_length=32, choices=STATUS_CHOICES, default=STATUS_CART)
    amount = models.DecimalField(max_digits=20, decimal_places=2, blank=True, null=True)
    creation_time = models.DateTimeField(auto_now_add=True)
    payment = models.ForeignKey(Payment, on_delete=models.PROTECT, blank=True, null=True)
    comment = models.TextField(blank=True, null=True)

class OrderItem(models.Model):
    order = models.ForeignKey(Order, on_delete=models.CASCADE)
    product = models.ForeignKey(Product, on_delete=models.PROTECT)
    quantity = models.PositiveIntegerField(default=1)
    price = models.DecimalField(max_digits=20, decimal_places=2)
    discount = models.DecimalField(max_digits=20, decimal_places=2, default=0)

Чтобы объекты этих классов понятно отображались при отладке и в админке, полезно будет добавить в каждый класс методы __str__, а также упорядочить все записи в таблицах по порядку, например:

class Meta:
        ordering = ['pk']

    def __str__(self):
        return f'{self.user} --- {self.time.ctime()} --- {self.amount}'

Завершающим шагом создания БД будет создание миграция и их применение:

python manage.py makemigrations
python manage.py migrate

И последний штрих - добавить управление и отображение БД в админку:

from django.contrib import admin

from shop.models import Product, Payment, OrderItem, Order

admin.site.register(Product)
admin.site.register(Payment)
admin.site.register(OrderItem)
admin.site.register(Order)

Формулируем требования к бизнес-логике БД

  1. При выборе товара пользователем, товар должен в выбранном количестве добавляться в корзину Cart, которая должна создаваться автоматически (если, конечно, она уже не была создана к этому моменту)

  2. Корзина, не перешедшая в статус Заказа в течении 7 дней, должна автоматически удаляться при первом же вызове метода get_cart(user)

  3. При каждом новом добавлении (удалении, изменении) количества (или цены) товара, общая сумма заказа должна автоматически пересчитываться

  1. После завершения набора/изменения Корзины и перехода к оплате, Корзина должна менять статус (если она не пустая!) и становиться Заказом, ожидающим оплаты (waiting_for_payment)

  2. Необходим метод get_unpaid_orders(user), который позволит получить общую сумму неоплаченных заказов (status=waiting_for_payment) по указанному пользователю

  3. Необходим метод get_balance(user), который позволит получить баланс по счёту указанного пользователя

  1. Смена статуса на waiting_for_payment автоматически запускает проверку баланса текущего пользователя. Если сумма баланса >= сумме заказа, то Заказ изменяет свой статус на оплаченный. При этом параллельно создаётся оплата, равная (минус) сумме заказа (что сразу же после оплаты уменьшает баланс счёта клиента на сумму заказа)

  2. Внесение оплаты автоматически запускает механизм проверки всех неоплаченных заказов, начиная с самого старого. Если внесённой суммы оплаты достаточно для оплаты нескольких заказов, ожидающих оплаты, то все эти заказы изменяют свой статус на оплаченный. При этом параллельно формируются оплаты, равные (минус)сумме каждого заказа (что сразу же после оплаты уменьшает баланс счёта клиента на сумму заказов)

Как видим, список проверок достаточно внушительный. Даже однократное ручное выполнение всех перечисленных тестов - “та ещё работёнка”, которая может занять минут 15, не меньше.

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

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

Пишем тесты (переносим требования к БД в код тестов)

При написании тестов необходимо помнить следующее:

  • Для тестов на уровне приложения уже существуют готовый модуль tests.py
  • В основе Django-тестов лежат unittest’ы
  • По умолчанию, перед запуском КАЖДОГО теста создается собственная тестовая БД, которая после завершения теста немедленно удаляется.
  • Из этого следует, что “свежесозданная” тестовая БД просто физически не может иметь тестовых значений. Значит, эти тестовые значения БД необходимо создавать каждый раз при каждом новом тесте.

  • Удобнее всего для этих целей использовать фикстуры - специальные файлы (обычно json-формата), содержащие в себе значения для тестовой БД. “Свежесозданная” тестовая БД автоматически загружает в себя эти значения перед каждым тестом.
  • Если вы ещё не знакомы с понятием фикстур, то самый простой способ с ними познакомиться - вручную заполнить БД и затем выгрузить эти значения по команде: python manage.py dumpdata -o mydata.json.gz (подробнее об этом здесь: https://docs.djangoproject.com/en/4.0/ref/django-admin/#dumpdata)
  • Фикстуры, которые содержат данные для заполнения тестовой БД, необходимо добавить в приложение в каталог shop/fixtures/data.json
  • В качесте атрибута класса тестового случая необходимо указать

fixtures = [
        "shop/fixtures/data.json"
    ]

  • Необходимо помнить, что в тестовой БД должен быть создан суперпользователь. Поэтому, если суперпользователя нет в фикстурах, то его необходимо создать в методе setUp(self)

Пример содержимого файла тестов shop/tests.py представлен ниже:

from django.test import TestCase,
from shop.models import *

class TestDataBase(TestCase):
    fixtures = [
        "shop/fixtures/data.json"
    ]

    def setUp(self):
        self.user = User.objects.get(username='root')

    def test_user_exists(self):
        users = User.objects.all()
        users_number = users.count()
        user = users.first()
        self.assertEqual(users_number, 1)
        self.assertEqual(user.username, 'root')
        self.assertTrue(user.is_superuser)

    def test_user_check_password(self):
        self.assertTrue(self.user.check_password('123'))

Пишем код, который пройдёт эти тесты

Пример фрагмента кода, используемого в лекции, представлен ниже:

def change_status_after_payment(payment: Payment):
    """
    Calling the method after creating a payment and before saving it.
    First need to find total amount from all payments (previous and current) for the user.
    If total amount >= the order amount, we change status and create negative payment.
    """
    user = payment.user
    while True:
        order = Order.objects.filter(status=Order.STATUS_WAITING_FOR_PAYMENT, user=user) \
            .order_by('creation_time') \
            .first()
        if not order:
            break

        total_payments_amount = get_balance(payment.user)
        if order.amount > total_payments_amount:
            break

        order.payment = payment
        order.status = Order.STATUS_PAID
        order.save()
        Payment.objects.create(user=user, amount=-order.amount)


@receiver(post_save, sender=Payment)
def on_payment_save(sender, instance, **kwargs):
    if instance.amount > 0:
        # pay_for_waiting_orders(instance)
        change_status_after_payment(instance)


@receiver(post_save, sender=Order)
def on_order_save(sender, instance, **kwargs):
    if Order.objects.filter(status='2_waiting_for_payment'):
        pay_for_waiting_orders(instance)

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






Переход на следующий этап проекта