Object-Oriented Programming and the magic of Test-Driven Development

Object-Oriented Programming and the magic of Test-Driven Development

Python is one of the most popularly used programming languages in Data Science. For some, it is about the language’s flexibility and readability, for others it’s about its relatively low complexity, and for most, it is about its multifaceted nature.

We call Python a multifaceted language because it allows you to code in four different styles: FunctionalImperativeObject-Oriented, and Procedural. These coding styles are known formally as Programming Paradigms and represent a way to classify languages based on their features.

Ever since I began my adventure towards Data Science, I’ve been wanting to delve more into Object-Oriented Programming (OOP), so I used this week’s blogpost to jump in with both feet.

What Is Object-Oriented Programming (OOP)?

As I said earlier, OOP is a programming paradigm built upon the concept of objects. In computer science, the word object can refer to different concepts, but at the most basic level, it is a value in memory referenced by an identifier.

In the context of OOP, an object is a combination of states (variables) and behavior (methods). The Object-Oriented approach’s goal is creating reusable software that is easier to maintain by way of four principlesEncapsulationAbstractionInheritance, and Polymorphism.

We can also distinguish further within Object-Oriented languages, for example, class-based and prototype-based.

In class-based OOP, an object is an instance of a class. A class is a blueprint of how something should be defined, but it doesn’t activate the content itself — it just provides the structure.

The Learning Strategy

There are more ways than I can think of to learn and practice OOP, but I had to choose one, so I decided to create a simple text-based game using the storyline of Shakespeare’s Romeo and Juliet. My process was the following:

  1. Write the story
  2. Draw the problem
  3. Identify the problem’s entities — these will be your classes
  4. Create an entity hierarchy
  5. Identify the entities responsibilities
  6. Write your tests
  7. Check if the test fails — they will initially since you haven’t written any code!
  8. Write code
  9. Repeat! Refactor! Refine!

The process isn’t set in stone, nor are we meant to be slaves to it. It’s just a series of steps that helped me get started. Object-Oriented is more than just a programming paradigm, it is a problem-solving approach, and although it is not without criticism, it is a great option when building complex systems.

The Rabbit Hole

Please note Step 6: Write your tests. Now, this wasn’t part of my original process. I was planning on coding my classes, and that’s it! But while I was researching OOP, I came across the concept of Test-Driven Development(TDD).

TDD is a programming practice that starts with the design and development of tests for every function of a program. This way you are forced to think about the specifications, requirements or design before you start writing code. In other words, you write code to test your code before you write any code.

Confused? So was I. But doing this exercise was 100% worth it.

Test-Driven Development & Unit Testing

The process of TDD is quite straightforward:

Image from Kanchan Kulkarni’s TDD Tutorial.
  1. Write a test
  2. Run the tests
  3. Write some code
  4. Run the tests
  5. Refactor code
  6. Repeat

In my case, I was doing TDD using Unit Testing. Unit Testing is the first level of software testing, where the purpose is to validate that each unit in the program performs as designed. There are different frameworks to perform unit testing, I used unittest.

You will find people on both sides of the fence when it comes to TDD. Some of the benefits — which I experienced personally, are the following:

  • Forces you to think about the problem you’re trying to solve before you start typing code with no direction.
  • In the particular case of class-based OOP, it helps you to understand the contract each class has. What were their responsibilities? What did they have to know? — This becomes even more relevant when you’re aiming for low coupling and high cohesion.
  • Even though it might slow you down at first, in the long-run it saves you time by minimizing the time spent debugging.
  • It encourages better design, making code easier to maintain, less redundant (keep it DRY!), and safe to refactor when needed.
  • It serves as living documentation — just by looking at the tests, you can understand what each unit should do, making code self-explanatory.

Romeo & Juliet — Code and Tests

After thinking about the story for the game, I decided to have two different storylines classic and alternative. The first one is the story of Romeo and Juliet as we know it, and the second one is, well, not.

The entities in the story (which I will use as a reference to create the different classes) are the following:

  • Scenes: The Masked Ball, The Balcony, The Duel, The Arrangement, The Apothecary, The Capulet Tomb, and The Alternative Ending. Scenes have two main responsibilities, describing the scene for the player, and then prompting the player with a yes or no question, to get their input.
  • Map: the Map works as a finite-state machine. It has a finite number of states (the scenes), a transition function (to move from one scene to another), and a start state (the first scene).
  • Storyline: defining the two unique, constant values for the storylines.

As you can see from the definition of scenes, all scenes have the same responsibilities, and only their content changes (the description of the scene and the prompt). Which is why we will make use of the concept of inheritance. This concept allows us to define a class that inherits all methods and properties from another class; this is paramount in this case to keep the code DRY.

class Storyline(Enum):
    CLASSIC = "classic"
    ALTERNATIVE = "alternative"

For the Storyline class, I used Python’s enumeration type or enum . From the documentation, they are defined as “a set of symbolic names (members) bound to unique, constant values. Within an enumeration, the members can be compared by identity, and the enumeration itself can be iterated over.

Next, we have the Scene class and TestScene class. Two noteworthy characteristics in the test code: 1. The use of a MockMap class; 2. The creation of a TestScene class to test the Scene class. At the unit testing level, you would create a Test class to write the tests for each of your classes.

class Scene(object):
    a_map = None
    
    def __init__(self, a_map):
        self.a_map = a_map

    def get_message(self):
        return """
            This scene is yet to be initialized
            """
    def get_prompt(self):
        return """
            This scene is yet to be initialized
            """
    
    def enter(self):
        self.print_description()
        self.prompt_user()
    
    def print_description(self):
        print(dedent(self.get_message()))

    def prompt_user(self):
        input_from_user = input(self.get_prompt()).lower()
        if input_from_user == "yes":
            self.a_map.advance_scene(Storyline.CLASSIC)
        elif input_from_user == "no":
            self.a_map.advance_scene(Storyline.ALTERNATIVE)
        self.a_map.play()
import unittest
from unittest.mock import patch, mock_open
import sys
import io
from romeo_and_juliet import *

class MockMap(Map):
    storyline = None
    play_executed = False

    def advance_scene(self, a_storyline):
        self.storyline = a_storyline
    def play(self):
        self.play_executed = True
        

class TestScene(unittest.TestCase):
    def test_print_description(self):
        a_scene = Scene(Map())
        # Capturing the standard output as a test harness.
        capturedOutput = io.StringIO()
        sys.stdout = capturedOutput
        a_scene.print_description()
        self.assertEqual(capturedOutput.getvalue(), dedent("""
            This scene is yet to be initialized\n
            """))
        # Releasing standard output.
        sys.stdout = sys.__stdout__
    
    def test_prompt_user(self):
        a_map = MockMap()
        a_scene = Scene(a_map)
        with patch("builtins.input", return_value = "yes"):
            a_scene.prompt_user()
            self.assertEqual(a_map.storyline, Storyline.CLASSIC)
        with patch("builtins.input", return_value = "no"):
            a_scene.prompt_user()
            self.assertEqual(a_map.storyline, Storyline.ALTERNATIVE)
        self.assertTrue(a_map.play_executed)

Last, but not least, let’s look at the Map class and the TestMap class. Just as before, we are creating a mock but for the Scene class in this case, with a MockScene class.

class Map(object):

    scenes = None
    current_scene = None

    def __init__(self):
        self.scenes = {
            "the_masked_ball": TheMaskedBall(self),
            "the_balcony": TheBalcony(self),
            "the_duel": TheDuel(self),
            "the_arrangement": TheArrangement(self),
            "the_apothecary": TheApothecary(self),
            "the_capulet_tomb": TheCapuletTomb(self),
            "the_alternative_ending": TheAlternativeEnding(self)
        }
        self.current_scene = self.scenes["the_masked_ball"]

    def get_current_scene(self):
        return self.current_scene

    def play(self):
        self.current_scene.enter()

    def advance_scene(self, storyline):
        if storyline == Storyline.CLASSIC:
            if self.current_scene == self.scenes["the_masked_ball"]:
                self.current_scene = self.scenes["the_balcony"]
            elif self.current_scene == self.scenes["the_balcony"]:
                self.current_scene = self.scenes["the_duel"]
            elif self.current_scene == self.scenes["the_duel"]:
                self.current_scene = self.scenes["the_arrangement"]
            elif self.current_scene == self.scenes["the_arrangement"]:
                self.current_scene = self.scenes["the_apothecary"]
            elif self.current_scene == self.scenes["the_apothecary"]:
                self.current_scene = self.scenes["the_capulet_tomb"]
            elif self.current_scene == self.scenes["the_capulet_tomb"]:
                raise Exception
        if storyline == Storyline.ALTERNATIVE:
            if self.current_scene == self.scenes["the_masked_ball"]:
                self.current_scene = self.scenes["the_alternative_ending"]
            elif self.current_scene == self.scenes["the_balcony"]:
                self.current_scene = self.scenes["the_alternative_ending"]
            elif self.current_scene == self.scenes["the_duel"]:
                self.current_scene = self.scenes["the_alternative_ending"]
            elif self.current_scene == self.scenes["the_arrangement"]:
                self.current_scene = self.scenes["the_alternative_ending"]
            elif self.current_scene == self.scenes["the_apothecary"]:
                self.current_scene = self.scenes["the_alternative_ending"]
            elif self.current_scene == self.scenes["the_alternative_ending"]:
                raise Exception
class MockScene(Scene):
was_entered = False
def enter(self):
self.was_entered = True
class TestMap(unittest.TestCase):
def test_play(self):
a_map = Map()
mock_scene = MockScene(a_map)
a_map.current_scene = mock_scene
a_map.play()
self.assertTrue(mock_scene.was_entered)      
def test_initial_state(self):
a_map = Map()
self.assertIsInstance(a_map.get_current_scene(), TheMaskedBall)
def test_advance_scene_classic(self):
a_map = Map()
self.assertIsInstance(a_map.get_current_scene(), TheMaskedBall)
a_map.advance_scene(Storyline.CLASSIC)
self.assertIsInstance(a_map.get_current_scene(), TheBalcony)
a_map.advance_scene(Storyline.CLASSIC)
self.assertIsInstance(a_map.get_current_scene(), TheDuel)
a_map.advance_scene(Storyline.CLASSIC)
self.assertIsInstance(a_map.get_current_scene(), TheArrangement)
a_map.advance_scene(Storyline.CLASSIC)
self.assertIsInstance(a_map.get_current_scene(), TheApothecary)
a_map.advance_scene(Storyline.CLASSIC)
self.assertIsInstance(a_map.get_current_scene(), TheCapuletTomb)
with self.assertRaises(Exception):
a_map.advance_scene(Storyline.CLASSIC)
def test_advance_scene_alternative_one(self):
a_map = Map()
self.assertIsInstance(a_map.get_current_scene(), TheMaskedBall)
a_map.advance_scene(Storyline.ALTERNATIVE)
self.assertIsInstance(a_map.get_current_scene(), TheAlternativeEnding)
with self.assertRaises(Exception):
a_map.advance_scene(Storyline.ALTERNATIVE)
def test_advance_scene_alternative_two(self):
a_map = Map()
self.assertIsInstance(a_map.get_current_scene(), TheMaskedBall)
a_map.advance_scene(Storyline.CLASSIC)
self.assertIsInstance(a_map.get_current_scene(), TheBalcony)
a_map.advance_scene(Storyline.ALTERNATIVE)
self.assertIsInstance(a_map.get_current_scene(), TheAlternativeEnding)
with self.assertRaises(Exception):
a_map.advance_scene(Storyline.ALTERNATIVE)
def test_advance_scene_alternative_three(self):
a_map = Map()
self.assertIsInstance(a_map.get_current_scene(), TheMaskedBall)
a_map.advance_scene(Storyline.CLASSIC)
self.assertIsInstance(a_map.get_current_scene(), TheBalcony)
a_map.advance_scene(Storyline.CLASSIC)
self.assertIsInstance(a_map.get_current_scene(), TheDuel)
a_map.advance_scene(Storyline.ALTERNATIVE)
self.assertIsInstance(a_map.get_current_scene(), TheAlternativeEnding)
with self.assertRaises(Exception):
a_map.advance_scene(Storyline.ALTERNATIVE)
def test_advance_scene_alternative_four(self):
a_map = Map()
self.assertIsInstance(a_map.get_current_scene(), TheMaskedBall)
a_map.advance_scene(Storyline.CLASSIC)
self.assertIsInstance(a_map.get_current_scene(), TheBalcony)
a_map.advance_scene(Storyline.CLASSIC)
self.assertIsInstance(a_map.get_current_scene(), TheDuel)
a_map.advance_scene(Storyline.CLASSIC)
self.assertIsInstance(a_map.get_current_scene(), TheArrangement)
a_map.advance_scene(Storyline.ALTERNATIVE)
self.assertIsInstance(a_map.get_current_scene(), TheAlternativeEnding)
with self.assertRaises(Exception):
a_map.advance_scene(Storyline.ALTERNATIVE)               
def test_advance_scene_alternative_five(self):
a_map = Map()
self.assertIsInstance(a_map.get_current_scene(), TheMaskedBall)
a_map.advance_scene(Storyline.CLASSIC)
self.assertIsInstance(a_map.get_current_scene(), TheBalcony)
a_map.advance_scene(Storyline.CLASSIC)
self.assertIsInstance(a_map.get_current_scene(), TheDuel)
a_map.advance_scene(Storyline.CLASSIC)
self.assertIsInstance(a_map.get_current_scene(), TheArrangement)
a_map.advance_scene(Storyline.CLASSIC)
self.assertIsInstance(a_map.get_current_scene(), TheApothecary)
a_map.advance_scene(Storyline.ALTERNATIVE)
self.assertIsInstance(a_map.get_current_scene(), TheAlternativeEnding)
with self.assertRaises(Exception):
a_map.advance_scene(Storyline.ALTERNATIVE)

Conclusion

The exercise of writing the tests first and the code for my program second was not without its difficulties.

I won’t deny there were times I thought about abandoning the whole testing idea, but as I soldiered through the process, I found myself thinking a little more like a programmer, spending more time thinking about design, and in a way, graduating from scripting to coding.

I invite you to test it out (pun intended!). And please, do reach out to talk about it if you feel differently or have had another experience when learning TDD.

Source: towardsdatascience