Python, OOP, Unit Tests and Documentation

Lab 2
Author

Rémi Pépin, Ludovic Deneuville

1 Todo

Before you start

This tutorial combines explanations and code phases.

The explanations in this tutorial should not take precedence over those of your teacher. Take them as a knowledge base for later, but always prefer oral explanations.

Concepts covered
  • OOP reminder
  • Unit tests
  • Business objects
  • Strategy design pattern

2 What you will be coding

Our game will be inspired by Pokémon Unite (no knowledge of the game or Pokémon is necessary).

In a nutshell, there will be Pokemons competing in Battles by throwing Attacks.

2.1 Business objects

You’re going to create business objects for a Pokémon game.

Business object

A business object is a representation of a specific entity in the context of a business application. It encapsulates the data and behaviour associated with that entity and is often used to model real-world concepts in the application domain.

This leads to objects containing mainly attributes and very few methods other than getter and setter.

To take another example, here are the business objects for a simple application that manages bike hire:

  • Bike: Represents a bike (id, type, model…)
  • Customer: Represents a person (id, surname, first name, etc.)
  • Rental: Represents a rental transaction (id, Client, Bike, start, end, amount, etc.)

2.2 The application’s business objects

  • Pokemon: which have various characteristics and statistics
    • a name
    • a level
    • a type
      • here the types are : Attacker, Defender, Versatile, Helper and Fast (and not: Fire, Water, Plant…)
    • statistics contained in a Statistic object defined below
  • Statistic: to avoid overloading the Pokemon class, their stats are stored in an object of the Statistic class.
    • hp: health points
    • attack, defense, speed… which are used to determine the strength of its attacks
  • Attack: different types of attack available to Pokemons (part 3)
  • Battle: used to pit 2 Pokemons against each other to determine the outcome of the battle (part 4)

2.3 Class diagram

Here is a very simplified class diagram of the main classes to be coded during these practical sessions.

classDiagram
  class Pokemon {
    # _name : str
    # _level : int
    # _type : str
    # _current_stat : Statistic
    + get_pokemon_attack_coef() float
  }
 
  class Statistic {
    - __hp : int
    - __attack : int
    - __defense : int
    - __sp_atk : int
    - __sp_def : int
    - __speed : int
  }

  class Attack{
    # _name : str
    # _power : int
    # _description : str
    + compute_damage(Pokemon, Pokemon) int
  }

  class Battle{
    - first_monstie : Pokemon
    - second_monstie : Pokemon
    - winner : Pokemon
  }

  Pokemon o-- Statistic : possesses
  Pokemon o-- Attack : uses
  Battle o.."2" Pokemon : call

3 Get the code

Important

At the end of the course, don’t forget to:

  • Save your work using Git
  • Delete or pause your VSCode service

3.1 Launch VScode

3.2 Fork and Clone

The TP code can be found on this GitHub repository: ENSAI-2A-complement-info-TP

Fork a repository

A fork is a personal copy of another user’s repository.

This allows you to freely experiment with changes without affecting the original project, and then propose your modifications back to the original repository via a pull request if desired.

In VSCode:

    • File > Open Folder > /home/onyxia/work/<repo_name> > OK

4 Modelling and implementation

Initially, we’re going to work only on the Pokemons (the Statistic class has already been coded).

Before writing any code, we’re going to think about the best possible design for our Pokémons. Our design will try as far as possible to respect the following rule: low coupling, high cohesion.

In other words, we will try to make :

  • classes that are as disjoint as possible (low coupling) so that a modification to one class does not require us to modify the others
  • while trying to have in each class tasks that are as linked as possible (strong cohesion).

4.1 First approach: if/elif/else

We’re interested in the get_pokemon_attack_coef() method, which will be used to determine the power of the attack according to the type of Pokemon.

    • Imagine if there were several similar blocks of code in our application, and we had to add a new type

4.2 The power of OOP

Instead of externalising the behaviour of our Pokemons, we’re going to put all their specific behaviour into child classes of a Pokemon superclass. This is made possible by two properties of OOP objects:

  • inheritance: it’s possible to specialise an existing class by modifying its behaviour, or by adding new ones.
  • polymorphism: two functions can have the same name but different behaviours

Furthermore, as each of our Pokemons will necessarily be of a type, none of them will simply be of the Pokemon class, which allows us to make this class abstract. By clearly defining our abstract class, we’ll have :

  • A blueprint for all the classes that inherit from it. This has the following advantages
    • Provides information on how the code is structured
    • Automatically generate the methods to be defined
    • Limits bugs. If a method is forgotten, the code crashes on start-up, which prevents unexpected behaviour that is difficult to detect.
  • A single interface for all Pokemon types. Whatever the type of Pokemon, it will be considered as an AbstractPokemon everywhere in the code.

    • rename the file to abstract_pokemon.py as well

You should end up with a tree structure similar to this one:

ENSAI-2A-COMPLEMENTS-INFO-TP
    └── src
        └── business_object
            ├── pokemon
            │   ├── abstract_pokemon.py
            │   ├── attacker_pokemon.py
            │   ├── defender_pokemon.py
            │   └── all_rounder_pokemon.py
            └── statistic.py

To make an abstract class, use the abc package.

For inspiration, here’s an example of what’s expected:

abstract_personnage.py
from abc import ABC, abstractmethod

class AbstractPersonnage(ABC):
    def __init__(self, phrase_attaque:str, phrase_defense:str):
        self._phrase_attaque = phrase_attaque
        self._phrase_defense = phrase_defense
       
    @abstractmethod  # decorator that defines a method as abstract
    def damages(self) -> int:
        pass
magicien.py
from abstract_personnage import AbstractPersonnage

class Magicien(AbstractPersonnage):
    def __init__(self):
        super().__init__("Throw a fireball", "Use a magic barrier")
       
    def damages(self) -> int:
        return 10

To help you, here is the class diagram:

classDiagram
  class AbstractPokemon {
    <<abstract>>
    # _name : str
    # _level : int
    # _type : str
    # _current_stat : Statistic
    + get_pokemon_attack_coef() float
  }
 
  class Statistic {
    - __hp : int
    - __attack : int
    - __defense : int
    - __sp_atk : int
    - __sp_def : int
    - __speed : int
  }
 
  AbstractPokemon <|-- AttackerPokemon
  AbstractPokemon <|-- DefenderPokemon
  AbstractPokemon <|-- AllRounderPokemon
  AbstractPokemon o-- Statistic

4.3 Test your code

    • pytest -v

To do this, you will use the pytest package in python.

An example of a test is given in the TestDefenderPokemon class. As a reminder, a test breaks down into 3 parts:

  • GIVEN: create the objects needed to run the test
  • WHEN: call the method to be tested
  • THEN: check the result

The test classes will be organised as follows, reproducing the architecture of your application:

ENSAI-2A-COMPLEMENTS-INFO-TP
    └── src
        ├── business_object
        │   ├── pokemon
        │   │   ├── abstract_pokemon.py
        │   │   ├── attacker_pokemon.py
        │   │   ├── defender_pokemon.py
        │   │   └── all_rounder_pokemon.py
        │   └── statistic.py
        └── test
            └── test_business_object
                └── test_pokemon
                    ├── test_attacker_pokemon.py
                    ├── test_defender_pokemon.py
                    └── test_all_rounder_pokemon.py
If you’re having import problems
  • Check that the parent folder in the VSCode explorer is : ENSAI-2A-complement-info-TP
  • Use full import paths rather than relative paths
    • the path root is set in the src folder
    • example: from business_object.pokemon.abstract_pokemon import AbstractPokemon
  • Create __init__.py files (empty)
    • in ALL the folders you create
    • it’s a bit of a pain but it can unblock the situation

5 Aggregation, the other way of adding flexibility to code

Now that our Pokémons are done, we’re going to add the attacks.

Our system will have to respect certain constraints:

  • Several types of attack will co-exist, each with a different way of calculating damage:
    • Variable damage attacks divided into 2 types:
      • Physical attacks, which use the Pokémon’s attack and defence
      • Special attacks, which use the Pokémon’s special attack and special defence
    • Fixed damage attacks, which do a fixed amount of damage.
  • A pokémon can have several attacks, and the type of attack must be transparent to the pokémon.

5.1 Fixed damage attacks

We’ll start with fixed damage attacks. As there will be another type of attack, all attacks will inherit from the AbstractAttack class. This class has the abstract method compute_damage() which will need to be implemented in the child classes.

classDiagram

  class AbstractAttack{
    <<abstract>>
    # _power : int
    # _name : str
    # _description : str
    + compute_damage(APkm, APkm)$  int
  }

   class FixedDamageAttack{
    + compute_damage(APkm,APkm)  int
   }
 
   AbstractAttack <|-- FixedDamageAttack

    • class and its compute_damage() method, which will simply return the power of the attack.

5.2 Variable damage attacks

Next we’re going to code the variable damage attacks. They use the following formula:

\[Damage = \big ( \frac{(\frac{2 \times Level}{5}+2) \times Power \times Att}{Def \times 50} +2\big) \times random \times other\_multipliers\]

with:

  • \(Att\): equal to either the attack or special attack of the attacking Pokemon
  • \(Def\): equal to either the defence or special defence of the defending Pokemon
  • \(Power\): the attack’s power value
  • \(random\) : a value in the range [0.85; 1]
  • \(other\_ multipliers\): Other possible multipliers, such as the pokemon’s attack coefficient
Important

The only difference between physical and special attacks is the coefficients \(Att\) and \(Def\).

The rest of the damage formula is identical. So we’re going to use the template method design pattern, the UML model of which is as follows.

classDiagram

  class AbstractAttack{
    <<abstract>>
    # _power : int
    # _name : str
    # _description : str
    + compute_damage(APkm, APkm)$  int
  }

   class FixedDamageAttack{
    + compute_damage(APkm,APkm )  int
   }

  class AbstractFormulaAttack{
    <<abstract>>
    -get_attack_stat(APkm)$  float
    -get_defense_stat(APkm)$  float
    +compute_damage(APkm,APkm)  int
  }
 
  class PhysicalFormulaAttack{
    -get_attack_stat(APkm)  float
    -get_defense_stat(APkm)  float
  }
 
  class SpecialFormulaAttack{
    -get_attack_stat(APkm)  float
    -get_defense_stat(APkm)  float
  }
 
   AbstractAttack <|-- FixedDamageAttack
   AbstractAttack <|-- AbstractFormulaAttack
   AbstractFormulaAttack <|-- SpecialFormulaAttack
   AbstractFormulaAttack <|-- PhysicalFormulaAttack

The AbstractFormulaAttack class will contain the methods :

  • compute_damage()
    • This method will contain the formula for calculating damage
    • It will call the get_attack_stat() and get_defense_stat() methods to find out which statistics to use
  • get_attack_stat() and get_defense_stat() (abstract).
    • These methods will need to be implemented in the child classes to determine which statistics to use

6 Final architecture

Bonus

If you have time!

We are now going to reattach the ends to create our final architecture:

classDiagram
  class AbstractPokemon {
    <<abstract>>
    # _current_stat : Statistique
    # _level : int
    # _name : str
    # _attack_list : List~AbstractAttack~
    +get_pokemon_attack_coef()$  float
    +level_up() None
  }
 
  class Statistique {
    - hp : int
    - attaque : int
    - defense : int
    - spe_atk : int
    - spe_def : int
    - vitesse : int
  }
   
  class BattleService {
    + resolve_battle(APkm, APkm) : Battle
    + get_order(APkm, APkm)
    + choose_attack(APkm) : AAttack
  }  
  
  class Battle{
    - first_monstie : APkm
    - second_monstie : APkm
    - winner : APkm
    - rounds : List<Round>
  }

  class Round{
    attacker: APkm
    defender: APkm
    dealt_damage: int
    attack_description: str
  }
  BattleService ..>"2" AbstractPokemon : use
  AbstractPokemon <|-- Attacker
  AbstractPokemon <|-- Defender
  AbstractPokemon <|-- AllRounder
  Statistique --* AbstractPokemon

  Battle .. BattleService
  Battle .. Round
 
  class AbstractAttack{
    <<abstract>>
    # _power : int
    # _name : str
    # _description : str
    +compute_damage(APkm, APkm)$ int
  }

  class FixedDamageAttack{
    + compute_damage(APkm,APkm )  int
  }

  class AbstractFormulaAttack{
    <<abstract>>
    -get_attack_stat(APkm)$  float
    -get_defense_stat(APkm)$  float
    + compute_damage(APkm,APkm ) int
  }
 
  class PhysicalFormulaAttack{
   -get_attack_stat(APkm)$  float
   -get_defense_stat(APkm)$  float
  }
 
  class SpecialFormulaAttack{
    -get_attack_stat(APkm)  float
    -get_defense_stat(APkm)  float
  }
 
  AbstractAttack <|-- FixedDamageAttack
  AbstractAttack <|-- AbstractFormulaAttack
  AbstractFormulaAttack <|-- SpecialFormulaAttack
  AbstractFormulaAttack <|-- PhysicalFormulaAttack
  BattleService >.. AbstractAttack  : use
  AbstractPokemon o-->"0..*" AbstractAttack

This architecture makes it possible to decorrelate pokemon attacks and specify the behaviour of attacks as they are inherited. The advantages are:

  • For the AbstractPokemon class, all attacks are AbstractAttacks. As long as they expose the compute_damage() method, our program will work. This makes it easy to add new types of attack.
  • A Pokémon can have any type of attack.
  • We can add a status system such as paralysis or poison quite easily. To do this, we need to modify the AbstractAttack class and the classes that inherit from it. This is potentially time-consuming, but doesn’t require any changes to the “Pokémon” part of our architecture.
  • One person could concentrate on creating Pokémons, while another could concentrate on creating attacks without difficulty. The two parts of the code are relatively independent, the only area of coupling being the AbstractPokemon and AbstractAttack classes, which are used above all to define what must be done by the daughter classes and what is accessible to the outside.

Externalizing the behaviour of attacks into specific classes and then linking them to Pokémon via a fairly flexible aggregation relationship that allows a Pokémon’s attacks to be changed dynamically is the strategy design pattern.

End of the Lab

Important
  • Push your code to GitHub
  • Pause or delete your datalab services