sideara-image

Lorem ipsum dolor sit . Proin gravida nibh vel vealiquete sollicitudin, lorem quis bibendum auctonisilin sequat. Nam nec tellus a odio tincidunt auctor ornare.

Stay Connected & Follow us

What are you looking for?

Simply enter your keyword and we will help you find what you need.

Blog

Banner

Refactoring and Unit Test – Demystifying Gilded Rose

The Gilded Rose is a very popular refactoring exercise that is intended for practicing your test case writing and refactoring skills. It represents the dreaded situation we have to face as software engineers when we have to work on a legacy codebase and add a new feature to it. Soon after implementing the feature, we discover that some part of our system starts to malfunction even though we have not touched it. Refactoring becomes a necessity then. And refactoring without writing unit tests is a pain in the neck because you do not know what side effects you are going to introduce that may eventually make the whole system grind to a halt.

What is the story?

Gilded Rose sells some finest goods belonging to the following categories:

  • Normal Items
  • Aged Bries
  • Sulfuras
  • Backstage passes

You are given the task of implementing a new feature to an existing legacy system so that they can sell a new category of Conjured items. But the system is nothing short of a complete mess of unmaintainable spaghetti code with a bunch of if-else statements. Furthermore, there are no test cases!

Please take your time and read the Gilded Rose Requirements Specification here.

How to do the refactoring?

Before you refactor a codebase, you must make sure:

  • You do not alter the behavior of the system in any way. The system must continue to function the same way it used to function i.e. the correct behaviors must be retained.
  • You do not make any change that forces the consumer code to change as well.

When you refactor your codebase, you must make sure:

  • Your code is SOLID-compliant. You can read this beautifully crafted article on SOLID principles- SOLID: The First 5 Principles of Object-Oriented Design
  • Avoid nested conditionals, instead use guard clauses.
  • Avoid comments. Make your code as readable and expressive as possible. Comments only indicate your lack of ability to express your code correctly. Instead document your code. In Python, we use docstrings for code documentation. Docstrings are not separate from the actual code, they are a part of the code itself.
  • Avoid duplicating code. If you find yourself repeating something, better wrap that logic in a helper function and reuse it.
  • Write unit tests, and write them a lot.
  • Aim for high cohesion and low-coupling.

Let’s start solving the kata now.

The Unit Tests

Guess what? You are given a system that does not have test cases and is very tightly coupled. First thing first, let’s write some unit tests for all the categories of items that Gilded Rose will sell. (I am assuming that you have already read the requirement specification of Gilded Rose)

There are several libraries in Python for writing unit tests. The most popular ones are being unittest and pytest. In this tutorial, I will be using the built-in unittest library.

We want to update the quality of an item multiple times. So it’s better to have a bulk updater. Let’s implement a static method for that. Here is the complete test case suite for the items sold by Gilded Rose.

import unittest
from gilded_rose import GildedRose, Item


class GildedRoseTest(unittest.TestCase):

    @staticmethod
    def bulk_updater(obj: GildedRose, times: int = 1) -> None:
        for _ in range(times):
            obj.update_quality()

    def test_normal_item(self) -> None:
        item = Item("Mountain Dew", 20, 40)
        gilded_rose = GildedRose([item])
        gilded_rose.update_quality()
        self.assertEqual("Mountain Dew", item.name)
        self.assertEqual(item.sell_in, 19)
        self.assertEqual(item.quality, 39)
        GildedRoseTest.bulk_updater(gilded_rose, item.sell_in + 1)

        # sell by date passed
        self.assertEqual(item.sell_in, -1)

        # Quality degrades twice as fast now
        self.assertEqual(item.quality, 18)
        GildedRoseTest.bulk_updater(gilded_rose, 10)
        self.assertEqual(item.quality, 0)  # never be less than 0
        self.assertEqual(item.sell_in, -11)

    def test_aged_brie(self) -> None:
        item = Item("Aged Brie", 20, 20)
        gilded_rose = GildedRose([item])
        gilded_rose.update_quality()
        self.assertEqual("Aged Brie", item.name)
        self.assertEqual(item.sell_in, 19)
        self.assertEqual(item.quality, 21)
        GildedRoseTest.bulk_updater(gilded_rose, item.sell_in + 1)

        # sell by date passed
        self.assertEqual(item.sell_in, -1)

        # Quality increases twice as fast now
        self.assertEqual(item.quality, 42)
        GildedRoseTest.bulk_updater(gilded_rose, 10)
        self.assertEqual(item.quality, 50)  # never be more than 50
        self.assertEqual(item.sell_in, -11)

    def test_sulfuras(self) -> None:
        item = Item("Sulfuras, Hand of Ragnaros", 20, 80)
        gilded_rose = GildedRose([item])
        gilded_rose.update_quality()
        self.assertEqual("Sulfuras, Hand of Ragnaros", item.name)
        self.assertNotEqual(item.sell_in, 19)
        self.assertEqual(item.sell_in, 20)  # does not change
        self.assertEqual(item.quality, 80)  # never alters
        GildedRoseTest.bulk_updater(gilded_rose, item.sell_in + 1)

        # sell by date passed
        self.assertEqual(item.sell_in, 20)  # still same
        self.assertNotEqual(item.sell_in, -1)  # it is not equal so passes

    def test_backstage_passes(self) -> None:
        item = Item("Backstage passes to a TAFKAL80ETC concert", 20, 20)
        gilded_rose = GildedRose([item])
        gilded_rose.update_quality()
        self.assertEqual("Backstage passes to a TAFKAL80ETC concert", item.name)
        self.assertEqual(item.sell_in, 19)
        self.assertEqual(item.quality, 21)
        GildedRoseTest.bulk_updater(gilded_rose, 9)

        # sellin approaches to 10 or less
        self.assertEqual(item.sell_in, 10)
        self.assertEqual(item.quality, 30)
        GildedRoseTest.bulk_updater(gilded_rose)
        self.assertEqual(item.sell_in, 9)
        self.assertEqual(item.quality, 32)

        # sellin approaches to 5 or less
        GildedRoseTest.bulk_updater(gilded_rose, 4)
        self.assertEqual(item.sell_in, 5)
        self.assertEqual(item.quality, 40)
        GildedRoseTest.bulk_updater(gilded_rose)
        self.assertEqual(item.sell_in, 4)
        self.assertEqual(item.quality, 43)
        GildedRoseTest.bulk_updater(gilded_rose, 2)
        self.assertEqual(item.sell_in, 2)
        self.assertEqual(item.quality, 49)

        # corner cases: never gets beyond 50 and drops to 0 once sellin passed
        GildedRoseTest.bulk_updater(gilded_rose)
        self.assertEqual(item.sell_in, 1)
        self.assertEqual(item.quality, 50)

        GildedRoseTest.bulk_updater(gilded_rose, 2)
        self.assertEqual(item.sell_in, -1)
        self.assertEqual(item.quality, 0)

    def test_conjured(self) -> None:
        item = Item("Conjured Mana Cake", 20, 50)
        gilded_rose = GildedRose([item])
        gilded_rose.update_quality()
        self.assertEqual("Conjured Mana Cake", item.name)
        self.assertEqual(item.sell_in, 19)
        self.assertEqual(item.quality, 48)
        GildedRoseTest.bulk_updater(gilded_rose, item.sell_in + 1)

        # sell by date passed -- will decrease twice as fast as normal items (-4)
        self.assertEqual(item.sell_in, -1)
        self.assertEqual(item.quality, 6)

        GildedRoseTest.bulk_updater(gilded_rose)
        self.assertEqual(item.sell_in, -2)
        self.assertEqual(item.quality, 2)

        GildedRoseTest.bulk_updater(gilded_rose)
        self.assertEqual(item.sell_in, -3)
        self.assertEqual(item.quality, 0)   # never less than 0


if __name__ == '__main__':
    unittest.main()

Refactoring the codebase

If you look at the current state of Gilded Rose’s codebase, especially the update_quality method of the GildedRose class, you’ll see how messy it is.

You cannot implement a new feature to this codebase without refactoring it first.

So, how to refactor this chaos without affecting the behavior of the system?

Don’t do this

You may be tempted to make the Item class a subclass of ABC – the abstract base class in Python and have a update_quality abstract method. Any class that inherits from the Item class will require to implement the update_quality method of their own.

from abc import ABC, abstractmethod


class Item(ABC):
    MAX_QUALITY = 50
    MIN_QUALITY = 0

    def __init__(self, name, sell_in, quality):
        self.name = name
        self.sell_in = sell_in
        self.quality = quality

    def __repr__(self):
        return f"{self.name}, {self.sell_in}, {self.quality}"

    @abstractmethod
    def update_quality(self):
        pass


class NormalItem(Item):

    def update_quality(self) -> None:
        cls = self.__class__
        if cls.MIN_QUALITY <= self.quality  0 else self.quality - 2
            self.quality = max(quality, cls.MIN_QUALITY)
        self.sell_in -= 1

        
# so on
...
...
...

But there is something terribly wrong. Although you have successfully separated items and retained their behavior, you have failed to comply with the stated requirements not to alter the Item class:

However, do not alter the Item class or Items property as those belong to the goblin in the corner who will insta-rage and one-shot you as he doesn't believe in shared code ownership (you can make the UpdateQuality method and Items property static if you like, we'll cover for you).

You were not given access to the consumer code, which only instantiates objects of type Item, so the system will fail altogether.

Do it the right way

Let’s implement some free functions and let them update the items. First, refactor the update_quality method of the GildedRose class.

class GildedRose:

    def __init__(self, items):
        self.items = items

    def update_quality(self):
        for item in self.items:

            if item.name == "Aged Brie":
                aged_brie_updater(item)
            elif item.name == "Backstage passes to a TAFKAL80ETC concert":
                backstage_passes_updater(item)
            elif item.name == "Sulfuras, Hand of Ragnaros":
                continue
            elif item.name == "Conjured Mana Cake":
                conjured_updater(item)
            else:
                normal_item_updater(item)

This is cleaner compared to what we have started with. We have removed all the nested `if conditionals.

Let’s define constants for MAX and MIN quality.

MAX_QUALITY = 50
MIN_QUALITY = 0

Normal Item Updater

In general, all items have the following properties as specified in the requirement specification:

  • All items have a SellIn value which denotes the number of days we have to sell the item
  • All items have a Quality value which denotes how valuable the item is
  • At the end of each day our system lowers both values for every item

But there is a special condition:

  • Once the sell by date has passed, Quality degrades twice as fast

Let’s convert all these requirements into code.

def normal_item_updater(normal_item: Item):

    if MIN_QUALITY <= normal_item.quality  0 else normal_item.quality - 2
        
        normal_item.quality = max(quality, MIN_QUALITY)
        
    normal_item.sell_in -= 1

Aged Brie Updater

There is an interesting fact about the Aged Brie items.

  • "Aged Brie" actually increases in Quality the older it gets
def aged_brie_updater(aged_brie: Item):

    if MIN_QUALITY <= aged_brie.quality  0 else aged_brie.quality + 2
        aged_brie.quality = min(MAX_QUALITY, quality)
    aged_brie.sell_in -= 1

Sulfuras Updater

If we read the requirement specification, we will see that we don’t even need an updater for the Sulfuras items.

  • "Sulfuras", being a legendary item, never has to be sold or decreases in Quality

Ahh! Good riddance!

Backstage Passes Updater

This is a little bit tricky to implement.

Let’s revisit the special condition of Backstage passes items.

  • "Backstage passes", like aged brie, increases in Quality as its SellIn value approaches; Quality increases by 2 when there are 10 days or less and by 3 when there are 5 days or less but Quality drops to 0 after the concert

Now, let’s convert this into code:

def backstage_passes_updater(backstage: Item):

    if MIN_QUALITY <= backstage.quality <= MAX_QUALITY:
        if backstage.sell_in <= 0:
            backstage.quality = 0
        else:
            quality = backstage.quality + 3 if 0 <= backstage.sell_in <= 5 else \
                backstage.quality + 2 if 5 < backstage.sell_in <= 10 else backstage.quality + 1
            backstage.quality = min(MAX_QUALITY, quality)
    backstage.sell_in -= 1

Conjured Updater

We are done refactoring the codebase. It’s time to implement a new feature for the Conjured items.

  • "Conjured" items degrade in Quality twice as fast as normal items
def conjured_updater(conjured: Item):
    if MIN_QUALITY <= conjured.quality  0 else conjured.quality - 4
        conjured.quality = max(quality, MIN_QUALITY)
    conjured.sell_in -= 1

Verification

We have added unit tests, refactored the legacy codebase and also implemented the new feature. Let’s run the tests to verify if we have been able to retain the behavior of the systems.

Hooray! All the five test cases passed!

author avatar
Saimon
No Comments

Sorry, the comment form is closed at this time.