Prérequis /
TDD : Développement dirigé par les tests

Écrire du code robuste, de qualité et maintenable.


Avant-propos

Dans ce tuto nous allons utiliser le TDD.

TDD = Test Driven Development
  1. Créer Installer les tests :
    • Comme dit précédemment, vous n'aurez pas à créer les tests, ils sont fournis ...
    • ... mais quand vous développerez vos propres applications, vous devrez les créer.
    • Ici, vous devez simplement copier-coller du code.
  2. Tester :
    • A vous de coder la fonctionnalité, puis de relancer les tests.
    • Tant que le résultat de chaque test ne satisfait pas aux attentes, vous devrez modifier cotre code.
       
  3. Refactoring :
    • Toutes ces itérations ont forcément altéré votre code, provoqué des répétitions, mis certaines lignes en commentaire, etc.
    • Il s'agit ici :
      • De nettoyer tout cela,
      • De relancer les tests pour vérifier.
      • D'écrire vos docstrings.

Persistance des données

Par ailleurs, nous aurons besoin pour cette fonctionnalité, de savoir mémoriser des paramètres : ici, la géométrie de la fenêtre (position à l'écran et taille)

Cherchez les informations avec les mots-clés : PyQt5 QSettings


Description

La première fonctionnalité à coder et la suivante :

Mémorisation de l'état de la fenêtre.

Au lancement du programme, la fenêtre qui apparaît doit avoir la même taille et la même position à l'écran que lors de sa dernière fermeture.

Vous n'aurez pas à coder cette fonctionnalité, nous allons la traiter ensemble, à titre d'exemple.
A vous de jouer pour les suivantes.

A faire :

  1. Mise en place du TDD dans PyCharm :
    • Settings (Ctrl + Alt + S) > Tools > Python Integrated Tools
    • Dans Testing > Default test runner, choisir pytest
    • Si le module pytest n'est pas installé, vous aurez un message No pytest runner found... 
      • Il suffira de l'installer (Clic sur le bouton "Fix", ou installation classique par Pip)
    • Valider.
  2. Fichier de configuration de pytest :
    • Créez le fichier pytest.ini dans le dossier tests.
    • Collez-y le code suivant.
      /tests/pytest.ini :
      ;https://docs.pytest.org/en/stable/usage.html
      ;https://docs.pytest.org/en/stable/example/parametrize.html
      [pytest]
      addopts = -p no:faulthandler
    • Cela évite dans certains cas d'avoir des messages superflus qui pollueraint les résultats de nos tests.
  3. Ajouter des boutons de fermeture (code provisoire) dans la fenêtre. Le code de main.py devient.
    /pc/main.py :
    from PyQt5.QtWidgets import QApplication, QWidget, QMainWindow, QPushButton, QVBoxLayout
    import sys
    
    
    class UiMain(QMainWindow):
        def __init__(self):
            super().__init__()
    
            # Code provisoire ************************************************
            self.centralwidget = QWidget(self)
            self.setCentralWidget(self.centralwidget)
            self.button = QPushButton("Fermeture normale")
            self.button2 = QPushButton("Fermeture sauvage\nVeuillez relancer le test")
            self.layout = QVBoxLayout(self.centralwidget)
            self.layout.addWidget(self.button)
            self.layout.addWidget(self.button2)
            self.setLayout(self.layout)
            self.button.clicked.connect(self.close)
            self.button2.clicked.connect(lambda: sys.exit(-1))
            # Fin de code provisoire *****************************************
    
    
    if __name__ == '__main__':
        def start():
            app = QApplication(sys.argv)
            fen = UiMain()
            fen.show()
            sys.exit(app.exec())
    
        """ Affichage de la fenêtre. """
        start()
    

     

  4. Tester : Déplacer, modifier puis fermer la fenêtre avec le bouton Fermeture normale.
    Ouvrez-la à nouveau : Vous voyez le problème ? Elle ne s'affiche pas au même endroit que lors de sa fermeture.
    Il en est de même pour sa taille.

  5. Nous voyons dans ce code que la classe utilisée est UiMain et qu'elle hérite de la classe QMainWindow

    • QMainWindow est une classe du module QtWidgets, contenu dans le package PyQt5.

    • Nous verrons ses propriétés et ses méthodes au fur et à mesure des besoins.

    • Ici nous utiliserons geometry()setGeometry(), closeEvent(), moveEvent() et resizeEvent()

  6. Créez le fichier de test comme ceci :

    • Clic-droit dans le code de la classe UiMain() > Go To > Test > Create New Test

    • La fenêtre Create test s'ouvre, laisser toutes les valeurs par défaut, valider.

    • Le fichier test_main.py a été créé dans le dossier tests.

  7. Voici le code du test. Ouvrez ce fichier test_main.py, remplacez son code par celui-ci.
    /tests/test_main.py :

    from PyQt5.QtWidgets import QApplication
    from PyQt5.QtCore import QSettings, QRect, QTimer
    from pc.main import UiMain
    import sys
    import os
    from random import randint
    g_appli = QApplication(sys.argv)
    
    
    class Main(UiMain):
        """ Classe dérivée de la fenêtre à tester. """
        def __init__(self, normal):
            super().__init__()
            self.settings_tests = QSettings(f"{os.getcwd()}{os.sep}tests.conf", QSettings.IniFormat)
            self.geometrie_ante = self.settings_tests.value('Geometrie', QRect(200, 200, 400, 200))
            self.geometrie_now = self.geometry()    # Lecture de la géométrie à l'ouverture.
            self.normal = normal
    
        def closeEvent(self, ev):
            self.settings_tests.setValue('Geometrie', self.geometry())
            if self.normal is not False:
                super().closeEvent(ev)
    
    
    def fenetre(normal=None):
        def open_window(normal):
            win = Main(normal)
            win.show()
            return win
    
        def geometrie(qrect):
            return f"x = {qrect.x()}, y = {qrect.y()}, largeur = {qrect.width()}, hauteur = {qrect.height()}"
    
        def move_window():
            nb[0] += 1
            if nb[0] > 2 or normal is None:
                g_t.stop()
                fen.close()
            else:
                fen.setGeometry(QRect(randint(50, 800), randint(50, 800), randint(300, 800), randint(300, 800), ))
                g_t.start(200)
    
        fen = open_window(normal)
        g_t = QTimer()
        g_t.setSingleShot(True)
        g_t.timeout.connect(move_window)
        nb = [0]
        g_t.start(200)
    
        """ Boucle d'exécution. """
        g_appli.exec()
    
        """ Fermeture fenêtre => Sortie de boucle d'exécution. """
        if normal is None:
            print(
                "\n- La fenêtre doit apparaître avec la même géométrie (taille et position) "
                "que lors de sa dernière fermeture."
                f"\n- Géométrie à la dernière fermeture :   {geometrie(fen.geometrie_ante)}."
                f"\n- Géométrie à l'ouverture actuelle :    {geometrie(fen.geometrie_now)}.")
            assert fen.geometrie_now == fen.geometrie_ante
    
    
    def test_ferme_normal():
        """
        Affichage de la fenêtre, plusieurs déplacements et changements de taille, aléatoires.
        Fermeture de la fenêtre, mémorisation de sa géométrie.
        """
        fenetre(normal=True)  # Fermeture normale.
    
        """ Réouverture de la fenêtre, lecture de sa géométrie, comparaison. """
        fenetre()
    
    
    def test_ferme_sauvage():
        fenetre(normal=False)  # Fermeture sauvage.
        fenetre()
    
  8. Chaque fonction contenant test dans son nom est un test.

    • Il y en a deux : test_ferme_normal et test_ferme_sauvage.

    • La fermeture 'sauvage' intervient lors de situations anormales : crash, bug, plantage système, fermeture par un élément extérieur, etc.

  9. Lancer les tests : Clic-droit > Run...

    • Pour chacun des tests, le programme procède en 2 temps :

      • Il affiche la fenêtre à l'écran, modifie aléatoirement sa géométrie plusieurs fois, puis la ferme. La géométrie est mémorisée dans un fichier.

      • Il l'ouvre à nouveau, lit sa géométrie réelle et la compare avec celle mémorisée.

    • Erreur : Le test échoue, c'est normal car le code qui va permettre de corriger cela n'est pas encore écrit.
       

  10. Comment corriger cela ? L'idée est la suivante :

    • Au moment de la fermeture de la fenêtre, on mémorise sa géométrie dans un fichier.

    • Lors de la prochaine ouverture, on lira ce fichier et on affectera la géométrie à la fenêtre.

    • Test à la fermeture :

      • Utilisons une méthode appelée automatiquement par l'événement de 'fermeture-fenêtre' : closeEvent(). Copier-coller ce code dans la classe UiMain().
        UiMain.closeEvent() :

        def closeEvent(self, ev):
            """ Mémorisation de la géométrie. """
            self.settings.setValue('Geometrie', self.geometry())
        
      • Oui mais ... on utilise l'attribut self.settings qui n'existe pas ! Créons-le, dans la méthode UiMain.__init__() :
        self.settings = QSettings(f"{os.getcwd()}{os.sep}params.conf", QSettings.IniFormat)
        
      • Oui mais ... 2 classes sont nécessaires pour valider cette ligne de code : QSettings et os. Ajoutons-les au début du fichier, dans les imports.
        Au début de main.py :
        from PyQt5.QtCore import QSettings
        import os
        
      • Avant de relancer les tests, lancez manuellement la fenêtre : run main.py
        • On remarque l'apparition d'un nouveau fichier dans le dossier pc : params.conf
        • Il contient la géométrie :
          [General]
          Geometrie=@Rect(1062 133 510 417)
          
    • Test à l'ouverture :
      • Il nous reste à lire ce fichier params.conf et à affecter la géométrie à la fenêtre.
      • Ajoutez ce code dans __init__() :
        # """ Lecture et application de la géométrie. """
        self.setGeometry(self.settings.value('Geometrie', QRect(200, 200, 400, 200)))
        
      • Oui mais ... la classe QRect est nécessaire pour valider cette ligne de code. Elle appartient au module QtCore. Modifions les imports de QtCore :
        from PyQt5.QtCore import QSettings, QRect
      • Tester : run main-py

      • Modifiez la taille et la position à la souris , fermez la fenêtre ... ouvrez-la à nouveau ... plusieurs fois.
      • Tout semble fonctionner !
         
  11. Relancez les tests :
    • Clic-droit dans test_main.py > Run 'pytest in test_main.py'
    • test_main.py::test_ferme_normal PASSED                                   [ 50%]
      - La fenêtre doit apparaître avec la même géométrie (taille et position) que lors de sa dernière fermeture.
      - Géométrie à la dernière fermeture :   x = 221, y = 431, largeur = 408, hauteur = 657.
      - Géométrie à l'ouverture actuelle :      x = 221, y = 431, largeur = 408, hauteur = 657.
      test_main.py::test_ferme_sauvage FAILED                                  [100%]
      - La fenêtre doit apparaître avec la même géométrie (taille et position) que lors de sa dernière fermeture.
      - Géométrie à la dernière fermeture :   x = 159, y = 510, largeur = 372, hauteur = 350.
      - Géométrie à l'ouverture actuelle :      x = 221, y = 431, largeur = 408, hauteur = 657.
    • Le premier test passe, le deuxième échoue. Pourquoi ?
    • Lors d'une fermeture sauvage, l'événement 'close' n'existe pas, la fermeture est brusque.
    • Par conséquent, votre code dans la méthode closeEvent() n'est pas exécuté !
    • Autrement dit, la géométrie n'est pas mémorisée.
       
  12. Comment corriger cela ?
    • Au lieu de faire l'enregistrement de la géométrie à la fermeture de la fenêtre sur closeEvent(), nous ferons un enregistrement chaque fois que la fenêtre aura bougé ou aura été redimensionnée : respectivement les méthodes moveEvent() et resizeEvent().
      Méthodes dans UiMain :
      def moveEvent(self, ev):
          """ Mémorisation de la géométrie. """
          self.settings.setValue('Geometrie', self.geometry())
      
      def resizeEvent(self, ev):
          """ Mémorisation de la géométrie. """
          self.settings.setValue('Geometrie', self.geometry())
      

      On supprime closeEvent() qui devient inutile.
       

  13. Relancez les tests :

    • Tout fonctionne !

    • test_main.py::test_ferme_normal PASSED                                   [ 50%]
      - La fenêtre doit apparaître avec la même géométrie (taille et position) que lors de sa dernière fermeture.
      - Géométrie à la dernière fermeture :   x = 251, y = 778, largeur = 676, hauteur = 786.
      - Géométrie à l'ouverture actuelle :      x = 251, y = 778, largeur = 676, hauteur = 786.
      test_main.py::test_ferme_sauvage PASSED                                  [100%]
      - La fenêtre doit apparaître avec la même géométrie (taille et position) que lors de sa dernière fermeture.
      - Géométrie à la dernière fermeture :   x = 437, y = 171, largeur = 556, hauteur = 653.
      - Géométrie à l'ouverture actuelle :      x = 437, y = 171, largeur = 556, hauteur = 653.

Refactoring :

Code de main.py :

from PyQt5.QtWidgets import QApplication, QMainWindow
from PyQt5.QtCore import QSettings, QRect
import os
import sys


class UiMain(QMainWindow):
    def __init__(self):
        super().__init__()
        self.settings = QSettings(f"{os.getcwd()}{os.sep}params.conf", QSettings.IniFormat)
        self.setGeometry(self.settings.value('Geometrie', QRect(200, 200, 400, 200)))

    def moveEvent(self, ev):
        """ Mémorisation de la géométrie. """
        self.settings.setValue('Geometrie', self.geometry())

    def resizeEvent(self, ev):
        """ Mémorisation de la géométrie. """
        self.settings.setValue('Geometrie', self.geometry())


if __name__ == '__main__':
    def start():
        app = QApplication(sys.argv)
        fen = UiMain()
        fen.show()
        sys.exit(app.exec())

    """ Affichage de la fenêtre. """
    start()

 


Bonjour les codeurs !