Python, OOP, Unit Tests and Design Patterns

OOP
Design Patterns
Refactoring
Lab 2 - Refactoring a Monolithic Service
Author

Ludovic Deneuville

🚧

    • for GameService.play()
    • or maybe on next lab mocking dao call

Before you start

This tutorial combines explanations and code phases. The explanations should not take precedence over those of your teacher.

NoteConcepts covered
  • Refactoring existing code
  • Design Patterns: Factory Pattern & Strategy Pattern
  • Dependency Injection
  • Data Persistence (History tracking)
  • Advanced Unit Testing

Work on a branch

🚧

Each member of the team works on its own branch

1 The Mission 🎯

You might be thinking: “All this architecture just for a simple coin flip?” … You’re right. And don’t get too comfortable, because we’re about to add a dozen more classes just for fun!” 🙃

Currently, our gaming engine only supports a single game mode: Coin Flip.

Our goal is to refactor the system to easily integrate new games, such as Dice 🎲, and more complex betting mechanics.

1.1 Current State: A “God Service”

Currently, the GameService class is a Monolith.

It is too heavy because it handles too many responsibilities:

  • Game Logic: It decides the outcome (e.g., random coin flip)
  • Scoring Logic: It manages the mathematical Elo calculation
  • Data Management: It directly interacts with PlayerDao to update scores
  • No History: Once a game is played, the result is lost. It lacks any way to track match history

This design violates the Open/Closed Principle: adding a new game (like “Dice”) or a new scoring rule (like “Flat points”) requires modifying the core GameService code, making it fragile and hard to test.

1.2 Target Architecture: A Modular Engine

Our goal is to transform this monolith into a decoupled system where:

  • The Service is an Orchestrator (it tells others what to do, but doesn’t know how they do it)
  • The Rules are interchangeable (Strategy Pattern)
  • The Creation is automated (Factory Pattern)
  • The History is permanent (Persistence)

classDiagram
  class GameService {
    - scoring_strategy : ScoringStrategy
    + play(player_id, opponent_id, game_mode) Game
  }

  class GameModeFactory {
    + get_mode(game_mode) GameMode
  }

  class GameMode {
    <<interface>>
    + play(p1, p2) Game
  }

  class ScoringStrategy {
    + compute(p1, p2, winner) tuple
  }

  class CoinFlipMode {
    + p1_choice : str
    + play(p1, p2) Game
  }

  class DiceMode {
    + play(p1, p2) Game
  }

  class Game {
    <<Business Object>>
    + id_game : int
    + player1 : Player
    + player2 : Player
    + game_mode : str
    + winner : Player
    + str: detail
    + timestamp : datetime
  }

  GameService o-- ScoringStrategy : uses
  GameService --> GameModeFactory : asks for
  GameModeFactory ..> GameMode : creates
  GameMode <|-- CoinFlipMode
  GameMode <|-- DiceMode
  GameService ..> Game : creates

2 Modelling and Implementation

2.1 Game business object

We want to keep a record of each game. Let’s start by creating a Game business object to store the necessary information after each game is played.

classDiagram
  class Game {
    + id_game : int
    + player1 : Player
    + player2 : Player
    + game_mode : str
    + winner : Player
    + str: detail
    + timestamp : datetime
  }

  class Player {
    + id : int
    + username : str
    + elo : int
  }

  Game o-- Player : involves

Important

Whenever you are asked to create a new class, this implicitly means you must also create a new file.

    • Set id = None it will be filled later when the object is persisted in the database
    • winner: Player or None if it is a draw
    • game_mode: The type of game played (“coinflip” or “dice”)
    • detail: To store more details about the game

coinflip between Jacky and Jackie. Winner: Jackie

We now have an object to store the results. We’ll see later how to insert them into the database.

2.2 Polymorphic Game Modes

Let’s focus on the different game modes and their rules.

The Problem: Currently, we have this code below in the play method.

game_service.py
class GameService:
    def play(self, player_id, opponent_id, choice="heads"):
        ...
        result = secrets.choice(["heads", "tails"])
        winner = p1 if result == choice else p2
        ...

If we want to add the ability to roll dice, we’ll end up with something like:

        if game_mode == "coinflip":
            result = secrets.choice(["heads", "tails"])
            winner = p1 if result == choice else p2
        elif game_mode == "dice":
            d1 = secrets.choice(range(1, 7))
            d2 = secrets.choice(range(1, 7))
            if d1 > d2:
              winner = p1
            elif d1 < d2:
              winner = p2
            else:
              winner = None              

This is a violation of the Open/Closed Principle. Every time we want to add a new game (e.g., Blackjack, Poker heads up, Pokemon battle, etc.), we have to modify the core GameService.

NoteGoal

Refactor the system so that the GameService doesn’t care which game is being played.

It should simply ask a “provider” to give it the correct rules.

    • in a folder called game_mode in business_object
    • including method play(p1, p2)
    • both players roll a die, and the one with the highest roll wins
    • The choice (heads/tails) must be passed to the constructor when the mode is created.
    • then use it in method play()

Fine, we’ll also need a way to generate either a CoinFlipMode object or a DiceMode object based on a game_mode (str) parameter. To do this, let’s create a factory class:

    • that takes the game_mode (str) as parameter
    • and returns the corresponding GameModeFactory object
Tip

In Python, **kwargs (short for keyword arguments) allows a function to accept an arbitrary number of keyword arguments (arguments passed with a name, like name=“Alice”).

When you use **kwargs in a function definition, Python collects all the extra named arguments and stores them in a dictionary, where the key is the argument name and the value is the argument’s value.

**kwargs will allow passing specific arguments (like choice) to the modes.

stateDiagram
    direction LR
    [*] --> GameModeFactory : game_mode (str)
    GameModeFactory --> CoinFlipMode : if "game_mode=coinflip"
    GameModeFactory --> DiceMode : if "game_mode=dice"

Important

With this new architecture, you can add a new game mode with very few changes to the existing code (almost exclusively additions):

  • by creating this mode (inheriting from GameMode)
  • and simply modifying the factory class

2.3 Decoupling Scoring

We want to extract the methods used to calculate the Elo rating in GameService.

    • it updates elo of both players

2.4 GameService refactor

Let’s finally put the pieces of the puzzle together in the GameService class

We should return a Game object, but for now, let’s leave it as is.

3 Unit tests

🚧

  • Explain mocking
  • Few easy tests

4 Conclusion

In this lab, we formalized the business logic and created an object to store the games.

However, without database storage, all the games created will be lost when the application is closed.

Next time, we’ll focus on storing these games in a database.

End of the Lab

Important

When you have finished coding, don’t forget to:

    • If your service is terminated, all unpushed code is lost…
    • to free up reserved resources