Solving a Tennis Refactoring Challenge in Python using SOLID

Author:Murphy  |  View: 28269  |  Time: 2025-03-22 22:57:02
Photo by Lucas Davies on Unsplash

Introduction

Code refactor challenges are well-known by software engineers, but less so by data scientists, though data scientists can also highly benefit from practising such challenges. By practising these, especially when applying the SOLID principles, you learn how to write much better code; code that's modular, of high quality and object-oriented. Mastering the SOLID principles as a data scientist can substantially elevate the quality and manageability of Data Science projects. This is particularly crucial in teams in which most data scientists are statisticians and mathematicians by origin, who may have less familiarity with programming fundamentals than software engineers.

There are many refactoring challenges available online. Perhaps the most famous one is the Gilded Rose Kata. Another fun refactoring kata is the Tennis Refactoring Kata, which we'll tackle in this article.

Often these challenges are called katas, In the context of a "refactoring kata," the word "kata" is borrowed from martial arts, where it refers to a structured practice routine. In martial arts, a kata is a sequence of movements and techniques that are practiced repeatedly to improve skill and fluency.

https://github.com/emilybache/Tennis-Refactoring-Kata (MIT license)

Go to https://github.com/emilybache/Tennis-Refactoring-Kata and select "Use As Template" in the green top right button.

Clone the template and enter the repository in your terminal. Then, cd into the Python directory, create a virtual environment and install the dependencies. To test if everything works run pytest. You can copy-paste the commands below into your terminal.

cd python
python -m venv .venv
source .venv/bin/activate # on mac or linux
# .venvScriptsactivate # on windows
pip install -r requirements.txt
pytest
[Screenshot by Author]

The output of your terminal should look similar to the screenshot provided above.

Interested in the terminal shown above? Take a look at the article below!

How to Setup Your Macbook for Data Science in 2024

At the start, there are 6 tennis.py files. Each represents a different solution to display the scores of a tennis game. Tennis has a somewhat unusual scoring representation. A tennis match consists of sets, which consist of __ games, and in games, you can score points. This challenge is only about representing scores within a single _gam_e.

  • Without a point, your score is called Love.
  • After scoring one point, your score is Fifteen
  • After scoring two points, your score is Thirty
  • After scoring three points, your score is Forty, if your opponent has __ scored three points as well, you call it deuce.
  • After scoring four or more points, with a difference of two, you have won the game.
  • After scoring four or more points, with a difference of one, you have advantage.
  • After scoring four or more points and you have an equal amount of points as your opponent, you call it deuce.

There are also two files, _tennistest.py and _tennisunittest.py, which provide tests to check whether the logic in the tennis.py files is valid. At the start, all tests should pass (as seen previously when running pytest).

Normally, the goal is to refactor each tennis.py file, one by one. However, tackling the refactoring of six Python files in one article is too extensive, so we'll cover one solution which adheres to the Solid principles, which are at the foundation of a proper coding structure.

Unfamilliar with the SOLID principles? Check out this great article by Ugonna Telma:

The S.O.L.I.D Principles in Pictures

Defining a Tennis Game

Let's start with a high abstraction of a tennis game. A tennis game is a form of a two-team game. We use two-team instead of two-player here because tennis can also be played with doubles (and e.g. padel is by default played with two teams rather than two players).

We can define a two-team game as follows:

from abc import ABC, abstractmethod

class TwoTeamGame(ABC):
    def __init__(
        self,
        team1_name: str,
        team2_name: str,
        team1_points: float = 0,
        team2_points: float = 0,
    ):
        self.team1_name = team1_name
        self.team2_name = team2_name
        self.team1_points = team1_points
        self.team2_points = team2_points

Next, let's define a tennis game:

class TennisGame1(TwoTeamGame):
    def __init__(self, team1_name: str, team2_name: str):
        super().__init__(team1_name, team2_name)

From _tennisunittest.py we can see that a TennisGame should have two methods: won_point(team_name: str)and score():

class TennisGame1(TwoTeamGame):
    def __init__(self, team1_name: str, team2_name: str):
        super().__init__(team1_name, team2_name)

    def won_point(self, team_name: str):
        ...

    def score(self) -> str:
        ...

Implementing the scoring strategy

Let's start with won_points(team_name). The method is quite simple. You pass a team's name to team_name and that player's score should increase by one. However, we'll have to zoom out and think about a higher abstraction to apply the SOLID principles.

We can think of won_points as using some kind of scoring strategy.

from abc import ABC, abstractmethod

class TwoTeamScoringStrategy(ABC):
    @abstractmethod
    def update_score(
        self, game: TwoTeamGame, team_name: str,
    ):
        pass

The reason we extract the scoring strategy away from the won_points method is to adhere to the open/closed principle of SOLID. By extracting the scoring strategy away from won_points, we can easily swap or change the scoring strategy without having to modify won_points itself.

Let's create a StandardTennisScoring:

class StandardTennisScoring(TwoTeamScoringStrategy):
    def update_score(
        self,
        game: TwoTeamGame,
        team_name: str,
    ):
        if game.team1_name == team_name:
            game.team1_points += 1
        elif game.team2_name == team_name:
            game.team2_points += 1
        else:
            raise ValueError("Invalid team name")

To implement this into the won_point method, we should pass the scoring strategy to the TennisGame class and call the update_score method of StandardTennisScoring in won_point:

# DISCLAIMER:
# you should never initiate a class instance as the default argument
# we only do this is the article to pass the given tests without modyifing them

class TennisGame1(TwoTeamGame):
    def __init__(
        self,
        team1_name: str,
        team2_name: str,
        score_strategy: TwoTeamScoringStrategy = StandardTennisScoring(),
        score_reperesentation: TwoTeamScoreRepresentation = TennisScoreRepresentation(),
    ):
        super().__init__(team1_name, team2_name)
        self.score_strategy = score_strategy
        self.score_representation = score_representation

    def won_point(
        self,
        team_name: str,
    ):
        if team_name == self.team1_name:
            self.score_strategy.update_score(game=self, team_name=team_name)
        elif team_name == self.team2_name:
            self.score_strategy.update_score(game=self, team_name=team_name)
        else:
            raise ValueError("Invalid team name")

     def score(self):
        ...

Implementing the score representation

The only thing we're left with now is to implement the score() method. Let's first think about an abstract class to represent scores for two-team games. To represent a score, we need to be able to access the score of a game, so let's make sure game is a parameter of the represent_score method.

class TwoTeamScoreRepresentation(ABC):
    @abstractmethod
    def represent_score(self, game: TwoTeamGame) -> str:
        pass

First, a quick recap of which words relate to which points:

  • 0: Love
  • 1: Fifteen
  • 2: Thirty
  • 3: Forty or Deuce
  • 4: Deuce, Advantage or win (as we only express the score for the player with the most points)

When we think about a tennis game, there are three kinds of situations to represent a score:

  • Both players have the same amount of points.
  • Both players have the same amount of points, or one player has more points, but at least one player has more than three points (then, there's eligibility to win, and, advantages and disadvantages are possible).
  • One player has more points than the other, but neither has more than three points
class TennisScoreRepresentation(TwoTeamScoreRepresentation):
    def represent_score(self, game: TwoTeamGame) -> str:
        if game.team1_points == game.team2_points:
            return ...
        if max(game.team1_points, game.team2_points) >= 4:
            return ...
        return ...

Let's create classes for the three situations we've specified. To see how a score should be represented, we have to look at how the tests are specified in _tennisunittest.py.

#(team1_score, team2_score, score_representation, team1_name, team2_name)
(2, 2, "Thirty-All", 'player1', 'player2'),
(3, 3, "Deuce", 'player1', 'player2'),
(4, 4, "Deuce", 'player1', 'player2'),
(1, 0, "Fifteen-Love", 'player1', 'player2'),
(4, 2, "Win for player1", 'player1', 'player2')
(4, 3, "Advantage player1", 'player1', 'player2'),
(14, 15, "Advantage player2", 'player1', 'player2'),
(14, 16, "Win for player2", 'player1', 'player2'),
class TennisTieRepresentation(TwoTeamScoreRepresentation):
    def __init__(self, score_names: set[str] = ("Love", "Fifteen", "Thirty")):
        super().__init__()
        self.score_names = score_names

    def represent_score(self, game: TwoTeamGame) -> str:
        return {
            0: f"{self.score_names[0]}-All",
            1: f"{self.score_names[1]}-All",
            2: f"{self.score_names[2]}-All",
        }.get(game.team1_points, "Deuce")

class TennisEndGameRepresentation(TwoTeamScoreRepresentation):
    def represent_score(self, game: TwoTeamGame) -> str:
        point_difference = game.team1_points - game.team2_points
        leader = game.team1_name if point_difference > 0 else game.team2_name
        if abs(point_difference) == 1:
            return f"Advantage {leader}"
        else:
            return f"Win for {leader}"

class TennisNormalRepresentation(TwoTeamScoreRepresentation):
    def __init__(self, score_names: set[str] = ("Love", "Fifteen", "Thirty", "Forty")):
        super().__init__()
        self.score_names = score_names

    def represent_score(self, game: TwoTeamGame) -> str:
        return f"{self.score_names[game.team1_points]}-{self.score_names[game.team2_points]}"

If we implement these classes in the TennisScoreRepresentation we'll get the following code:

class TennisScoreRepresentation(TwoTeamScoreRepresentation):
    def __init__(
        self,
        tie_score: TwoTeamScoreRepresentation = TennisTieRepresentation(),
        end_game_score: TwoTeamScoreRepresentation = TennisEndGameRepresentation(),
        normal_score: TwoTeamScoreRepresentation = TennisNormalRepresentation(),
    ):
        self.tie_score = tie_score
        self.end_game_score = end_game_score
        self.normal_score = normal_score

    def represent_score(self, game: TwoTeamGame) -> str:
        if game.team1_points == game.team2_points:
            return self.tie_score.represent_score(game)
        if max(game.team1_points, game.team2_points) >= 4:
            return self.end_game_score.represent_score(game)
        return self.normal_score.represent_score(game)

Putting everything together

Now we're done! Let's take a look at all the code combined. If you have this code in tennis1.py, and you run pytest in the terminal, all tests should still pass.

# tennis1.py
# of course, normally you might want to split this code into multiple
# files/modules

from abc import ABC, abstractmethod

class TwoTeamGame(ABC):
    def __init__(
        self,
        team1_name: str,
        team2_name: str,
        team1_points: float = 0,
        team2_points: float = 0,
    ):
        self.team1_name = team1_name
        self.team2_name = team2_name
        self.team1_points = team1_points
        self.team2_points = team2_points

class TwoTeamScoringStrategy(ABC):
    @abstractmethod
    def update_score(self, game: TwoTeamGame, team_name: str):
        pass

class StandardTennisScoring(TwoTeamScoringStrategy):
    def update_score(
        self,
        game: TwoTeamGame,
        team_name: str,
    ):
        if game.team1_name == team_name:
            game.team1_points += 1
        elif game.team2_name == team_name:
            game.team2_points += 1
        else:
            raise ValueError("Invalid team name")

class TwoTeamScoreRepresentation(ABC):
    @abstractmethod
    def represent_score(self, game: TwoTeamGame) -> str:
        pass

class TennisTieRepresentation(TwoTeamScoreRepresentation):
    def __init__(self, score_names: set[str] = ("Love", "Fifteen", "Thirty")):
        super().__init__()
        self.score_names = score_names

    def represent_score(self, game: TwoTeamGame) -> str:
        return {
            0: f"{self.score_names[0]}-All",
            1: f"{self.score_names[1]}-All",
            2: f"{self.score_names[2]}-All",
        }.get(game.team1_points, "Deuce")

class TennisEndGameRepresentation(TwoTeamScoreRepresentation):
    def represent_score(self, game: TwoTeamGame) -> str:
        point_difference = game.team1_points - game.team2_points
        leader = game.team1_name if point_difference > 0 else game.team2_name
        if abs(point_difference) == 1:
            return f"Advantage {leader}"
        else:
            return f"Win for {leader}"

class TennisNormalRepresentation(TwoTeamScoreRepresentation):
    def __init__(self, score_names: set[str] = ("Love", "Fifteen", "Thirty", "Forty")):
        super().__init__()
        self.score_names = score_names

    def represent_score(self, game: TwoTeamGame) -> str:
        return f"{self.score_names[game.team1_points]}-{self.score_names[game.team2_points]}"

class TennisScoreRepresentation(TwoTeamScoreRepresentation):
    def __init__(
        self,
        tie_score: TwoTeamScoreRepresentation = TennisTieRepresentation(),
        end_game_score: TwoTeamScoreRepresentation = TennisEndGameRepresentation(),
        normal_score: TwoTeamScoreRepresentation = TennisNormalRepresentation(),
    ):
        self.tie_score = tie_score
        self.end_game_score = end_game_score
        self.normal_score = normal_score

    def represent_score(self, game: TwoTeamGame) -> str:
        if game.team1_points == game.team2_points:
            return self.tie_score.represent_score(game)
        if max(game.team1_points, game.team2_points) >= 4:
            return self.end_game_score.represent_score(game)
        return self.normal_score.represent_score(game)

class TennisGame1(TwoTeamGame):
    def __init__(
        self,
        team1_name: str,
        team2_name: str,
        score_strategy: TwoTeamScoringStrategy = StandardTennisScoring(),
        score_representation: TwoTeamScoreRepresentation = TennisScoreRepresentation(),
    ):
        super().__init__(team1_name, team2_name)
        self.score_strategy = score_strategy
        self.score_representation = score_representation

    def won_point(
        self,
        team_name: str,
    ):
        if team_name == self.team1_name:
            self.score_strategy.update_score(game=self, team_name=team_name)
        elif team_name == self.team2_name:
            self.score_strategy.update_score(game=self, team_name=team_name)
        else:
            raise ValueError("Invalid team name")

    def score(self):
        return self.score_representation.represent_score(self)

Maybe you wonder, what's the use of all these classes when you could also write one class that implements all functionality and rules to adhere to the tests written in _tennisunittest.py. The reason is, that by adhering to the SOLID principles, we can easily swap any part of the whole implementation without changing or breaking existing code. We only need to write a bit of new code for the part we'd like to change. This applies to almost every part of the code, as each class is swappable.

# New score representation
class FrenchScoreRepresentation(TwoTeamScoreRepresentation):
    # Implementation for French score representation
    ...

# Injecting new strategies into the game
french_game = TennisGame1(
                    team1_name="T1", 
                    team2_name="T2", 
                    score_representation=FrenchScoreRepresentation()
)

Conclusion

By applying the SOLID principles, we have solved this refactoring kata using a production-level code structure. Hopefully, you can grasp better now how to apply SOLID principles; by starting with defining a higher abstraction of a class and making each subclass of that class interchangeable so that it's easy to adjust and apply new functionalities by using dependency injection.

Eager to learn more about high-quality Python code? Make sure to check out these other articles!

How to Level Up Your Python Skills by Learning From These Professionals

6 Python Best Practices that Distinguish Senior Developers from Juniors

Tags: Data Science Python Software Engineering Solid Tips And Tricks

Comment