Confidently Writing Code with Test-Driven Development

What is Test-Driven Development? Test-Driven Development (TDD) is a process where we make requirements into test cases and write the implementation after that. Here are steps in TDD:

  1. Write failing tests -> We called it, RED stage
  2. Make the tests pass -> GREEN stage
  3. Refactor the code
  4. Go to no. 1

Why do we need to do TDD? Based on my experience, here are benefits of implementing TDD:

  1. All the requirements will be fulfilled. You'll write tests based on the requirements (e.g: what should we expect when we call this?). Then, you are going to write code that make the tests pass. If you do TDD correctly, you won't miss these requirements.
  2. Avoid unnecessary code. If you code first before test, you might code something that is unnecessary, that might not be necessary for your use cases. Or, you might do [premature abstraction] (medium.com/@thisdotmedia/the-cost-of-premat..), which is very costly. By having test, you will only need to code that makes the test pass, so you are trying to code as simple as possible.
  3. When you want to refactor, you don't need to worry if the code breaks. You feel some of your code need to be refactored, but you worry if you change it, the code will break. Well, if you don't write tests, most likely you decide not to do it, since there is a high chance it will break. But, if you write test, you will be more confident in refactoring. If you refactored correctly, your tests shouldn't be fail.

Example of TDD

Here is example of TDD with Python in my project:

image.png

image.png

  • RED

Write test for wrong password login

class TestLogin(TestCase):
    def setUp(self):
        self.url = APIClient()
        self.email = "sigendud@email.com"
        self.phone_number = "8123456789"
        self.reverse_path = "account:login"

        self.response_create_user = self.url.post(
            reverse("account:register"),
            {
                "email": "sigendud@email.com",
                "phone_number": "8123456789",
                "password": "sebuahPassYangValid22",
            },
        )

    def test_password_is_wrong(self):
        response = self.url.post(
            reverse(self.reverse_path),
            {
                "email": self.email,
                "password": "passSalah1",
            },
        )
        self.assertEqual(response.status_code, 400)
        self.assertDictContainsSubset(
            {"non_field_errors": ["Password yang Anda masukkan salah."]},
            response.json(),
        )

This test tries to do:

  1. We are trying to POST to login
  2. POST with body of password that is not matched
  3. Assert if response status is 400
  4. Assert if there is message of Password yang Anda masukkan salah.
  • GREEN
class AccountSerializer(serializers.ModelSerializer):
    def validate(self, attrs):
        email = attrs.get("email")

        user = authenticate(
            request=self.context.get("request"), email=email.lower(), password=password
        )

        if not user:
            msg = _("Password is wrong.")
            raise serializers.ValidationError(msg, code="authorization")

        return attrs

This implementation tries to do:

  1. Get email value
  2. Try to authenticate with email and password
  3. If user is None, raise error with message Password is wrong
  • REFACTOR

If you read more about Clean Code, there is a tip to replace conditional with polymorphism (or class). I made like this:

class CheckPasswordLoginValidator:
    def __init__(self, request, email, password):
        self.request = request
        self.email = email
        self.password = password

    def validate(self):
        user = authenticate(
            request=self.request, email=self.email.lower(), password=self.password
        )

        if not user:
            message = _("Password is wrong.")
            raise ValidationError(message, code="authorization")
class AccountSerializer(serializers.ModelSerializer):
    def validate(self, attrs):
        CheckPasswordLoginValidator(
            request=self.context.get("request"), email=email.lower(), password=password
        ).validate()

        return attrs

You can see that the code is more readable but it doesn't make the test fail.

Code coverage

To check how many of your implementation is covered by the tests, you can run these following commands:

coverage run --include="./*/*" --omit="env/*" manage.py test
coverage report -m

Code coverage only tells you how many line of your code that are covered by tests. So if there are lines that are not covered by the tests, it means that you write code that is not necessary.

REMEMBER: High code coverage doesn't mean your code is bug-free! High code coverage means that most of your code is called by the tests, but does it mean the user/system will do something like the tests?

My experience with TDD

  • I notice writing unit tests with high code coverage doesn't mean my code is bug-free. I always wonder, why don't we write integration/end-to-end tests that will cover user interaction based on real cases so that it ensures our code bug-free?
  • Apparently, writing integration/end-to-end tests are not cheap. It is expensive! Since we run tests automated using browser, it means that there is huge cost on performance. Writing unit tests are much cheaper. To be honest, I prefer integration/end-to-end test but I will try to cover about that later :D