Test driven interview

Success is not final, failure is not fatal: it is the courage to continue that counts.

Introduction:

Test Driven Development TDD is a method of implementing software that rely on software requirements being converted to test cases before software is fully developed.

Red, Green and Refactor is the 3 phases of TDD. When followed, this order of steps helps ensure that you have tests for the code you are writing and you are writing only the code that you have to test for.

Context:

Let's suppose we should implement some requirements. Let's suppose all the requirements and specifications are written using unit tests. See this repository. Our mission is to write enough code to make all the tests pass. Let's dive in.

TOC:

Setup

>> git create-branch feat-1  # create a new branch for feature work
Basculement sur la nouvelle branche 'feat-1'
(.env) ~/GIT/interview  (feat-1) 

>> python3.10 -m venv .env  # create a virtual environment
~/GIT/interview  (feat-1) 

>> source .env/bin/activate  # activate virtual environment
(.env) ~/GIT/interview  (feat-1) 

>> pip install -r requirements.txt  # install requirements
Collecting django<3.2,>=3.1
  Using cached Django-3.1.14-py3-none-any.whl (7.8 MB)
Collecting djangorestframework<3.13,>=3.12
  Using cached djangorestframework-3.12.4-py3-none-any.whl (957 kB)
Collecting sqlparse>=0.2.2
  Using cached sqlparse-0.4.3-py3-none-any.whl (42 kB)
Collecting asgiref<4,>=3.2.10
  Using cached asgiref-3.5.2-py3-none-any.whl (22 kB)
Collecting pytz
  Using cached pytz-2022.4-py2.py3-none-any.whl (500 kB)
Installing collected packages: pytz, sqlparse, asgiref, django, djangorestframework
Successfully installed asgiref-3.5.2 django-3.1.14 djangorestframework-3.12.4 pytz-2022.4 sqlparse-0.4.3
WARNING: You are using pip version 22.0.4; however, version 22.3 is available.
You should consider upgrading via the '/home/nsukami/GIT/interview/.env/bin/python3.10 -m pip install --upgrade pip' command.
(.env) ~/GIT/interview  (feat-1) 

How to launch tests ?

According to the documentation, we can run tests using the test command of your project's manage.py utility. Let's run that command when any of the Python files changes. We also tell Django to stop running the test suite after first failed test. In a different terminal, let's type one of the following commands:

>> # you can ask git ls-files command to watch for all the python files
>> git ls-files -- '*.py' ':!:*__.py' | entr sh -c "python src/manage.py test --failfast --force-color -v 2 src"
>> # you can ask find command to watch for all the python files
>> find src -type f -name "*.py" -not -name "__*" | entr sh -c "python src/manage.py test --failfast --force-color -v 2 src"
>> # the ack command seems more readable
>> ack -f --python | entr sh -c "python src/manage.py test --failfast --force-color -v 2 src"

Here is our first error:

django.db.utils.OperationalError: no such table: inventory_product

1. First requirements: we need a Product model.

We need to create a model named Product inside an application named inventory. Somewhere within the tests file, we can read:

    def create_coke(self):
        return Product.objects.create(
            description='Coca-Cola',
            unit_price=500,
            stock=10
        )

Let's open the models.py file and write the following:

from django.db import models

class Product(models.Model):
    description = models.CharField(max_length=200)
    unit_price = models.FloatField()
    stock = models.IntegerField()

After running the migration scripts:

>> python src/manage.py makemigrations && python src/manage.py migrate

The tests tells us to return an http status 201 after Product creation. Right now, we have a 404. We need to:

  • create a REST API.
  • we should be able to create a Product via the API.
  • and the returned http status should be 201 _created.
======================================================================
FAIL: test_create_product (modules.inventory.tests.ProductTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/nsukami/GIT/interview/src/modules/inventory/tests.py", line 45, in test_create_product
    self.assertEqual(response.status_code, 201)
AssertionError: 404 != 201

----------------------------------------------------------------------
Ran 1 test in 0.010s

FAILED (failures=1)

We need a REST API for managing a Product.

Another part of the tests is telling us we need an url named api/products/. Let's create a serializer, a viewset and a router. Here is the code:

# /src/modules/inventory/views.py

from rest_framework import serializers  # type:ignore
from rest_framework import viewsets
from .models import Product

class ProductSerializer(serializers.ModelSerializer):
    class Meta:
        model = Product
        fields = "__all__"

class ProductsViewSet(viewsets.ModelViewSet):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer

# /src/pos/urls.py

from django.urls import path, include
from rest_framework import routers
from modules.inventory.views import ProductsViewSet

router = routers.DefaultRouter()
router.register(r"products", ProductsViewSet)

urlpatterns = [
    path("api/", include(router.urls)),
]


After the changes, we notice some progress. The test tells us that if we attempt to delete a Product, we should receive an http status 405 instead of 204. Litteraly, we have been able to delete a Product while it shouldn't be allowed:

======================================================================
FAIL: test_delete_product (modules.inventory.tests.ProductTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/nsukami/GIT/interview/src/modules/inventory/tests.py", line 142, in test_delete_product
    self.assertEqual(response.status_code, 405)
AssertionError: 204 != 405

----------------------------------------------------------------------
Ran 2 tests in 0.011s

FAILED (failures=1)

A Product cannot be deleted.

I should have take the time to carefully read all the tests. Indeed, one of them clearly states that we can't delete a Product. Obviously, the ModelViewSet is not a good choice. We should update our viewset in a way that forbids product deletion. The operations: creating, listing, updating, getting one product should still be permitted. To achieve that, we'll use some mixins offered by the framework. The product viewset becomes:

from rest_framework import viewsets, mixins

class ProductsViewSet(
    mixins.CreateModelMixin,
    mixins.ListModelMixin,
    mixins.UpdateModelMixin,
    mixins.RetrieveModelMixin,
    viewsets.GenericViewSet,
):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer

Let's commit what have been done so far.

All the tests related to Product are passing. We can commit and push what we've done so far.

>> git commit -m "Add Product API"
[feat-1 a11ffeb] Add Product API
 5 files changed, 76 insertions(+), 6 deletions(-)
 create mode 100644 src/modules/inventory/migrations/0001_initial.py
 rewrite src/modules/inventory/views.py (98%)
 create mode 100644 src/modules/orders/migrations/0001_initial.py
>> git push origin feat-1 
Énumération des objets: 25, fait.
Décompte des objets: 100% (24/24), fait.
Compression par delta en utilisant jusqu'à 4 fils d'exécution
Compression des objets: 100% (14/14), fait.
Écriture des objets: 100% (14/14), 1.80 Kio | 1.80 Mio/s, fait.
Total 14 (delta 6), réutilisés 0 (delta 0), réutilisés du pack 0
remote: 
remote: Create a new pull request for 'feat-1':
remote:   https://git.disroot.org/nsukami/interview/compare/master...feat-1
remote: 
remote: . Processing 1 references
remote: Processed 1 references in total
To https://git.disroot.org/nsukami/interview.git
 * [new branch]      feat-1 -> feat-1
Of course

We need another model named Order.

All the tests located in the inventory application are passing. This is why we decided to commit and push. There is one test currently failing, it it located inside the orders application. The test tells us we need to create a model named Order:

======================================================================
FAIL: test_create_order (modules.orders.tests.OrdersTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/nsukami/GIT/interview/src/modules/orders/tests.py", line 87, in test_create_order
    self.assertEqual(response.status_code, 201)
AssertionError: 404 != 201

----------------------------------------------------------------------
Ran 7 tests in 0.054s

FAILED (failures=1)

Let's continue reading the tests. Line 90, we read that an item should have 4 attributes: description, quantity, unit_price, total. We also read that an Order has 2 attributes: items and total:

order = Order.objects.get()
expected = {
    'id': order.id,
    'items': [
        {
            'description': 'Coca-Cola',
            'quantity': 1,
            'unit_price': 500,
            'total': 500,
        },
        {
            'description': 'Potato Chips',
            'quantity': 2,
            'unit_price': 1000,
            'total': 2000,
        }
    ],
    'total': 2500,
}

So, our models may look like the following:

class Order(models.Model):
    @property
    def total(self):
        # this field is just a sum, depending on all the items in the current Order
        return sum([x.total for x in self.items.all()])

class OrderItem(models.Model):
    order = models.ForeignKey(Order, related_name="items", on_delete=models.CASCADE)
    # For being able to retrieve the description and the unit_price fields ?
    # Should I add Product as a foreign key ? 
    # Should I duplicate the fields ?

    @property
    def total(self):
        # the total is obviouly a property that will be calculated 
        # based on the number of items in the Order & the unit price for each item 
        return float(self.unit_price * self.quantity)

We should be able to update the stock depending on the number of items ordered:

The Line 114 of the tests tells us, a Product should have a method to refresh its stock:

self.chips.refresh_from_db()
self.assertEqual(self.chips.stock, 8)

Let's update our Product model and add the new method. This method will substract the number of Product ordered from the current stock:

from django.db import models

class Product(models.Model):
    description = models.CharField(max_length=200)
    unit_price = models.FloatField()
    stock = models.IntegerField()

    def refresh_from_db(self):
        from modules.orders.models import OrderItem

        self.stock = self.stock - sum(
            oi.quantity for oi in OrderItem.objects.filter(id=self.id)
        )
        self.save()

We should be able to update a Product without modifiying already made Orders:

After reading the Line 141, we know that the fields description and unit_price will be duplicated inside the OrderItem model. During the OrderItem creation, we should copy those fields from the Product model. This task will be achieved using our serializers:

class ItemSerializer(serializers.ModelSerializer):
    quantity = serializers.IntegerField(required=False)
    unit_price = serializers.FloatField(required=False)
    description = serializers.CharField(required=False)

    class Meta:
        model = OrderItem
        fields = ["description", "quantity", "unit_price", "total"]


class OrderSerializer(serializers.ModelSerializer):
    items = ItemSerializer(many=True)

    def create(self, validated_data):
        order = Order.objects.create()
        # I want to be able to access the id, the id represent the Product id
        # So, I'm using initial_data instead of validated_data
        # I'll need to find a proper way
        for item_data in self.initial_data["items"]:
            product = Product.objects.get(id=item_data["id"])
            OrderItem.objects.create(
                id=product.id,
                order=order,
                description=product.description,
                unit_price=product.unit_price,
                quantity=item_data["quantity"],
            )
        return order

    class Meta:
        model = Order
        fields = ["id", "items", "total"]


class OrdersViewSet(
    mixins.CreateModelMixin,
    mixins.ListModelMixin,
    mixins.RetrieveModelMixin,
    viewsets.GenericViewSet,
):

    queryset = Order.objects.all()
    serializer_class = OrderSerializer

Conclusion:

It's been a long time since I've written Django and DRF code. I really enjoyed the exercise. The final result can be found here.

>> python src/manage.py test src --failfast
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
............
----------------------------------------------------------------------
Ran 12 tests in 0.093s

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

While I still have your attention:

A conversation with Adam Hill about writing software, the perfect settings file set up, and more. Adam is a software engineer at The Motley Fool and the author of multiple open source packages including Django Unicorn.

The videos for the DjangoCon Europe 2022 are available. Can someone tell me please when the DjangoCon Africa event will be held ? So I can prepare myself ?


More on the topic:

I really hope you have learned something. To dive even more deeper in the topic, let me please recommend the following links: