Blog

Drop Shipping Online Store on Django (Part 5)

5. Creation of Database Functionality Using TDD (Test Driven Development)

Attention! If you're having trouble following the previous step, you can visit relevant lesson , download the archive of the previous step, install it, and start this lesson exactly where the previous one ended!

In this course, we will go through all the stages of creating a new project together:

Why do programmers write tests?

Most programmers don't like to write tests.

It is understandable - few people like to do double work: first write and debug code, and then spend the same amount of time (if not more!) on writing tests.

Well, if the project will be supported further. Then the time spent on writing tests is guaranteed to pay off: it will be possible to make sure that creating a new one did not break the old one in seconds.

However, if the project is closed, then the cost of tests will be added to the loss of time for writing the main code.

So writing tests is a bit like investing - in principle, a good thing, but risky. Therefore, to write tests or not to write - everyone chooses for himself, depending on each specific situation.

Nevertheless, there is an approach in programming that allows you, if not to win, then at least almost not to lose when writing tests. And this approach is called "Test Driven Development" (TDD) ("Development through testing"). The idea is very simple: we FIRST write tests, and only then we write the code itself, which must pass these tests.

And what is the advantage here? And the plus is that in this case, the time spent on checking the code is completely eliminated. After all, even the very first run of a freshly written code will be done "not by hand", but by tests!

And an additional bonus of this approach is that the check itself becomes more systematic, and therefore more reliable. Because what and how to test is thought out in advance, and not done impromptu. Which drastically reduces the chances of missing something important.

Actually, this is why Django has a well-developed code testing toolkit. And that is why already in the first training example on the website of this framework, a whole lesson out of eight is devoted to the consideration of an example of the implementation of this principle: "Development through testing" or "Test Driven Development" (TDD).

In our training mini-course, we will consider the test-driven development approach when creating a database, since the main computational load or the main business logic will be located in the shop/models.py module, which is responsible for the interaction between database tables data.

So, our tasks at this stage:

  • Formulate the basic requirements for the structure of the database
  • Create a database design (draw tables and relationships between them)
  • Formulate requirements for the business logic of the database
  • Implement these requirements into test code
  • Write code that passes these tests

Requirements for the structure of the database

To simplify the task (after all, this is a mini-course!) we will stipulate three assumptions:

  • Our products are flat (no categorization)
  • We have dropshipping, and therefore there is no warehouse and there can be no stock leftovers (an infinite stock of goods for each item is assumed)
  • Our cart is an order with the status cart. Just after clicking the “Proceed to payment” button, this status changes to waiting_for_payment

Creating a database structure

This block diagram satisfies all the above requirements:

Therefore, in the shop/models.py module, we create the following structure:

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}'

The final step in creating a database is to create migrations and apply them:

python manage.py makemigrations
python manage.py migrate

And the final touch is to add control and display of the database to the admin panel:

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)

Database business logic requirements

  1. When a user selects a product, the product should be added in the selected quantity to the Cart, which should be created automatically (unless, of course, it has not already been created by this moment)

  2. A cart that has not changed to an Order status within 7 days should be automatically deleted on the first call to the get_cart(user) method

  3. At each new addition (deletion, change) of the quantity (or price) of goods, the total amount of the order should be automatically recalculated

  1. After completing the set/change of the Cart and proceeding to payment, the Cart should change its status (if it is not empty!) and become an Order awaiting payment (waiting_for_payment)

  2. The get_unpaid_orders(user) method is required, which will allow you to get the total amount of unpaid orders (status=waiting_for_payment) for the specified user

  3. The get_balance(user) method is required, which will allow you to get the balance on the account of the specified user

  1. Changing the status to waiting_for_payment automatically starts checking the balance of the current user. If the balance amount >= the amount of the order, then the Order changes its status to paid. This simultaneously creates a payment equal to (minus) the amount of the order (which immediately after payment reduces the balance of the client's account by the amount of the order)

  2. Making a payment automatically triggers a mechanism to check all outstanding orders, starting with the oldest one. If the payment amount entered is sufficient to pay for several orders awaiting payment, then all these orders change their status to paid. At the same time, payments equal to (minus) the amount of each order are formed (which immediately after payment reduces the balance of the client's account by the amount of orders)

As you can see, the list of checks is quite impressive. Even a single manual execution of all the listed tests is "very hard work", which can take 15 minutes, no less.

But we know well from our own experience that a rare code starts working without errors the first time. Consequently, manual testing would take hours of our time. And each new refactoring of the code is again hours for verification. Not to mention the fact that during manual testing, you can forget to check something, or forget to delete test values ​​from the database.

In general, as we will see for ourselves further, writing tests will take less time from the first time, which means that it will pay off its costs already on the first test run!

Write tests (transfer the requirements for the database to the test code)

When writing tests, keep the following in mind:

  • Application-level tests already have a ready-made module tests.py
  • Django tests are based on unittests
  • By default, before running EACH test, its own test database is created, which is immediately deleted after the test is completed.
  • It follows from this that a “just created” test database simply cannot physically have test values. This means that these database test values must be created each time with each new test.

  • It is most convenient to use fixtures for these purposes - special files (usually in json format) containing values ​​for the test database. The “just created” test database automatically loads these values ​​into itself before each test.
  • If you are not yet familiar with the concept of fixtures, then the easiest way to get acquainted with them is to manually populate the database and then dump these values ​​with the command: python manage.py dumpdata -o mydata.json.gz (more on this here: https://docs.djangoproject.com /en/4.0/ref/django-admin/#dumpdata)
  • Fixtures that contain data to populate the test database must be added to the application in the shop/fixtures/data.json
  • directory
  • You must specify
  • as an attribute of the test case class

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

  • You need to remember that a superuser must be created in the test database. Therefore, if the superuser is not in the fixtures, then it must be created in the setUp(self)
  • method

An example of the contents of the test file shop/tests.py is shown below:

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'))

Writing code that passes these tests

An example of a code snippet used in the lecture is shown below:

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)

You can learn more about all the details of this stage from this video (RU voice):




To the next stage of the project