Skip to content

The basics of writing quality Unit Tests with Code Katas6 min read

It’s a Sunday evening before the deadline for the team’s next release. You are having a nice cup of ginger tea watching your favourite TV show. Suddenly, you get a Slack notification from your manager asking you to finish the ticket for the new guy who called in sick on Friday. “Pretty short notice but it should be alright.”, you think to yourself, and accept to cover for the poor guy.

You finish your tea and have a look at the file you have to modify slightly. To your horror, you witness this messy code noodle…

def update_quality(self):
    for item in self.items:
        if item.name == "Conjured Mana Cake":
            if item.sell_in > 0:
                item.quality = item.quality - 1
            else:
                item.quality = item.quality - 2
        if item.name != "Aged Brie" and item.name != "Backstage passes to a TAFKAL80ETC concert":
            if item.quality > 0:
                if item.name != "Sulfuras, Hand of Ragnaros":
                    item.quality = item.quality - 1
        else:
            if item.quality < 50:
                item.quality = item.quality + 1
                if item.name == "Backstage passes to a TAFKAL80ETC concert":
                    if item.sell_in < 11:
                        if item.quality < 50:
                            item.quality = item.quality + 1
                    if item.sell_in < 6:
                        if item.quality < 50:
                            item.quality = item.quality + 1
        if item.name != "Sulfuras, Hand of Ragnaros":
            item.sell_in = item.sell_in - 1
        if item.sell_in < 0:
            if item.name != "Aged Brie":
                if item.name != "Backstage passes to a TAFKAL80ETC concert":
                    if item.quality > 0:
                        if item.name != "Sulfuras, Hand of Ragnaros":
                            item.quality = item.quality - 1
                else:
                    item.quality = item.quality - item.quality
            else:
                if item.quality < 50:
                    item.quality = item.quality + 1

Luckily, you are working in Python and the bad practices we talked about in this post are not so critical. The code is somewhat readable but there is urgent need of fixing this “logical mess” before making any changes to it. So let’s get to work…

Introduction

Tasks like this one are sadly not a rarity even to this day so you want to prepare to clean up someone else’s code. Code katas are exercises to get better at dealing with such problematic code. Today’s challenge is the Gilded Rose by Emily Bache. You can find the Python exercise files in my GitHub repository along with the solution.

The first step when tackling such a challenge is writing tests for the code we already have so we know that in the process of adding a new feature we didn’t break the original functionalities. One such type of test is a unit test.

In this post, I’ll walk you through what a unit test is, the dos and don’ts of unit testing and even help you get started with a practical example.

Unit testing best practices

Unit tests help to isolate and check the output of only a certain chunk of your code (a unit). Their purpose is to validate each individual part of the program with a set of test cases.

1. One assert per test function

An assertion is usually the final line of code in a test function that compares the output of the function tested and the expected output.

In the Python package we are going to use for testing this is done like this:

self.assertEquals([the correct answer], [the function's output])

It is best to keep one assertion per function because it makes it easy to find exactly what part of the code failed. With multiple assertions, you are left to again narrow down which one of the tests failed. 10 assertions result in one failure so better keep it to one.

2. Avoid interdependent tests

Don’t count on your test functions to be run in a certain order. Each test has to have its own setup before executing. They might even be all run in parallel.

Don’t be fooled into a false sense of security when your tests depend on each other but work fine. If you have a couple of tests it can work but if you add a test, the order might change and break your test suite. Rule of thumb: Avoid dependencies at all costs.

3. Tests shouldn’t break before the assertion

If the setup of your test is cumbersome and fails even before testing the part you are interested in, it is time to go back and lower the scope of your test. Get the small tests out of the way before your start setting up more complicated examples.

Tests have to also be maintained as the requirements may change. A complicated test is hard to modify for the new requirements, wasting your time. If your tests are “noodling” it is time to break them down into smaller pieces.

4. Keep it simple and to the point

If your test setup takes up more time than writing the code to be tested you should rethink your code design. Unlike the code, tests have to be to the point without any generalizations.

When a test fails, you want to know at a glance what went wrong. If you have another logic tree to untangle in your tests then something is wrong. Tests are supposed to eliminate the confusion so make it easy for yourself.

5. Take the tests you write seriously

If you decided to write tests then under no circumstances deliver code that doesn’t pass the test you have written. Neglecting test failures is a habit that may not affect your team performance today but will eventually come back to bite you. An overlooked bug today doesn’t magically disappear the next day.

Unit testing in practice

Knowing the theory is great but how do you go about applying it to solve a real code kata?

Code katas are mainly intended to practice refactoring (a topic we will discuss in a later post) but, like Gilded Rose, they don’t come with pre-written unit tests. Our task is to make sense of the requirements (found in requirements.txt) and add the tests to the test_gilded_rose.py

Step 1. Understanding the requirements

In requirements.txt you will find a long text describing the setting in much detail but like a lot of clients, most of the points of our interest are found in the bulleted lists.

The whole text can be summarized in a list of situations that we are going to test:

  • Sellin and Quality get lowered by one for standard items.
  • Standard item once Sellin passed, Quality lowers by two.
  • No negative Quality.
  • “Aged Brie” Sellin decrease by one and Quality increase by one.
  • “Aged Brie” once Sellin passed, Quality increase by two.
  • Item Quality no more than 50.
  • “Sulfuras” Quality is 80 and both Sellin and Quality don’t change.
  • “Backstage passes” Quality increase by one when Sellin greater than 10.
  • “Backstage passes” Quality increase by two when Sellin between 10 and 5.
  • “Backstage passes” Quality increase by three when Sellin less than 5.
  • “Backstage passes” Quality is zero when Sellin less than zero.

And we will add these two tests for the new type of items:

  • Conjured item Quality decrease by two.
  • Conjured item when Sellin passed, Quality decrease by two.

This list is flexible and can change as we discover new edge cases while implementing the tests.

Step 2. Testing setup

Check that everything works by running texttest_fixure.py (skip the 3 if you are using python 2).

python3 texttest_gilded_rose.py

The output will be a test that shows the outputs of the update function over 2 days.

Next, we need to install a couple of Python packages to be able to easily analyze and come up with tests. The test file is using the unittest package but we will also install pytest and coverage to check that our tests go over all our if statements in gilded_rose.py

In your terminal type

pip3 install pytest

and then

pip3 install coverage

And finally add this line to the top of gilded_rose.py and test_gilded_rose.py:

import pytest

Step 3. Testing procedure

The GildedRoseTest has initially only one function to verify the testing setup.

def test_foo(self):
    items = [Item("foo", 0, 0)]
    gilded_rose = GildedRose(items)
    gilded_rose.update_quality()
    self.assertEquals("bar", items[0].name)

This test is supposed to fail because it is going to compare the “foo” in Item and “bar”.

To test this run these commands in your terminal:

python3 test_gilded_rose.py

This will run the test and show a failure. Change the “self.assertEquals” to “self.assertEqual” so pytest can read it and run (weirdly it doesn’t update without running the line above first):

coverage run -m pytest test_gilded_rose.py gilded_rose.py

Then

coverage report -m --include=gilded_rose.py

We are looking for coverage of like 51% at this point. Also, in the Missing column, you can see the lines that are not executed in gilded_rose.py

Step 4. Making changes and adding tests

As a practice round, before getting rid of the test_foo function, change the “bar” to a “foo” to make the test pass and do Step 3 again. Remember to change “Equal” back to “Equals” before that!

Now, you can add the actual tests. I’ll provide the first one on the list as a template:

def test_StandardItemStandardBehaviourQualityDecreaseBy1(self):
        items = [Item("Elixir of the Mongoose", 6, 7)]
        gilded_rose = GildedRose(items)
        gilded_rose.update_quality()
        self.assertEquals(6, items[0].quality)

unittest requires you to have “test” at the start of your function’s name to execute properly. Also, notice that I didn’t comment the code because the name explains it all. If the test fails I can see by its name what went wrong.

Do Step 3 after each time you add a new test. The coverage should gradually increase as you add more tests and you can see which cases are not tested. Repeat until all the update_quality function lines are run by the tests.

Note that the item is in the form Item([name], [sellin value], [quality value]) when making the tests.

Step 5. Testing new functionality

You can implement the last two tests in the lists knowing that they will fail. Then just add a separate if-condition for the “conjured” items and doesn’t interfere with the rest of the code in the update function.

I added this at the start of the for-loop

if item.name == "Conjured Mana Cake":
    if item.sell_in > 0:
        item.quality = item.quality - 1
    else:
        item.quality = item.quality - 2

There are cleaner ways to implement this but for now, the goal is to make the tests pass before moving on to refactoring.

Conclusion

Testing exercises like Gilded Rose are a great way to begin working on your skills at identifying and writing tests before refactoring the code. With enough practice, it becomes an indispensable part of all your projects to make sure you deliver bug-free code.

So far, we have learned how to setup a test suite and convert requirements into test cases.

In the next post we will talk about refactoring and actually solving the issues in this code kata.

Happy coding and a good day!

Share online:

Leave a Reply

Your email address will not be published. Required fields are marked *