Québec-Python


Les tests unitaires en Python

Par Bernard Chhun le lun 10 février 2014

Petite histoire

J'aime profondément les jeux de stratégie et les puzzles tout simplement parce que c'est plaisant de résoudre des problèmes.

Vous comprendrez donc mon engouement sans borne lorsque j'ai découvert Python Challenge il y a de ça quelques années déjà. Je n'ai pas encore passé au travers des 33 défis proposés, mais j'y travaille.

Or, le 24 janvier dernier, Libéo a publié un petit « défi recrutement » que je n'ai pas pu m'empêcher de relever. Ne vous inquiètez pas, j'ai pris le temps de leur demander si c'était éthique de publier la solution içi...

Pourquoi ?

L'idée est de démontrer comment utiliser les tests unitaires en python pour régler ce puzzle.

Je l'ai entendu dire plusieurs fois, mais avec l'expérience, je crois qu'il est faux de dire que notre métier nous apprends à régler des problèmes de façon rectiligne; trouver une solution à un problème qui possède des variables non-connus ressemble beaucoup plus à un mélange de zig-zag et de retour sur nos pas.

Voici donc les étapes pour résoudre le puzzle de Libéo avec les informations que l'on peut extraire de leur annonce de défi ainsi que de la page web proposée dans ce dernier.

À vos terminaux messieurs/dames !

1. La cueillette d'information

Pièce à conviction #1: L'annonce sur facebook

Chez Libéo, les places de programmeur Web sont chères.

Prouve-nous que tu mérites ton entrevue d’embauche en trouvant le code secret et
l'adresse courriel qui se cachent dans cette page.

http://recrutement.libeo.com/programmeur-web/

Obaar punapr!

Ce petit mot nous dirigeait tout simplement vers le site de recrutement lui-même avec un petit indice à la toute fin: Obaar punapr!.

Indice recueilli:

  • Obaar punapr! = ?

Pìèce à conviction #2: Le code source du site web

Mon premier réflexe, lorsqu'on me parle d'un puzzle ou d'un easter egg sur un site web, est de consulter le code source.

<!--
Super, vous avez trouvé le courriel. Malheureusement, vous devrez le décrypter pour que ce dernier soit utilisable.
Faites attention de ne pas y passer plus de 13 minutes car César pourrait se retourner dans sa tombe!

nheryvr.qvba-tnhgvre@yvorb.pbz

Une fois le courriel décrypté, il vous manquera seulement le code caché à envoyer dans votre courriel.
-->

Bingo. Nous avons donc commentaire ci-dessous qui possède plusieurs indices ainsi qu'un courriel à décoder.

Indices recueillis:

  • « vous devrez le décrypter pour que ce dernier soit utilisable »
  • « Faites attention de ne pas y passer plus de 13 minutes car César pourrait se retourner dans sa tombe »
  • « nheryvr.qvba-tnhgvre@yvorb.pbz »

2. Faire des suppositions pour débuter les travaux

Maintenant que nous avons ces indices en main, comment peut-on en extraire du sens ?

En faisant des suppositions, pardi:

Le chiffre 13 a une signification particulière

La personne qui a rédigé le texte peut nous avoir mener à une erreur en mentionnant ce chiffre, mais prenons pour acquis que c'est significatif.

Un courriel ayant un suffixe de 3 lettres se termine habituellement par .com et non pas .pbz

Ici, nous pouvons déduire que com et pdz sont équivalents, mais nous ne pouvons pas encore en être totalement sûr. Voyons voir l'indice suivant...

Obaar punapr ?

Si vous écoutez Fort Boyard, qu'est-ce qu'on souhaite à un participant avant de l'envoyer au trou à tarantules ?

« Bonne chance » n'est-ce pas ?

Obaar punapr et Bonne chance ont justement le même nombre de lettre.

  • Obaar = Bonne
  • punapr = chance

Ce que nous pouvons en conclure est:

  • qu'il y a 2 "a" dans « Obaar » et 2 "n" dans « Bonne »
  • qu'il y a 2 "p" dans « punapr » et 2 "c" dans « chance »
  • que le "o" de « Bonne » semble être équivalents au "b" de « Obaar ». C'est la même chose pour le "o" de « .com » avec le "b" de « .pbz »

Conclusion

Si nous étalons l'aphabet devant nos yeux et que nous comptons le décalage entre les lettres communes mentionnées ci-haut, nous arrivons toujours au chiffre 13.

alphabet = "abcdefghijklmnopqrstuvwxyz"

# Il y a 13 lettres entre les lettres "c" et "p"
# Idem pour "m" et "z" et "o" et "b"

L'encodage du courriel semble utiliser un décalage de l'index des lettres de l'alphabet.

La régle est donc:

  • {L'index de la lettre encodée} - 13 = {l'index de la lettre réelle}

3. Les tests unitaires en python

L'utilisation des tests unitaires en python est, somme toute, assez simple:

# Nous devons importer le module unittest
import unittest

# et créer une classe qui hérite de TestCase
class LibeoCode(unittest.TestCase):
    def setUp(self):
        """
        Fonction appelé avant chacun des tests de la classe
        courante. Elle nous permet de mettre en place des variables
        à tester par exemple.
        """
        self.encoded_email = "nheryvr.qvba-tnhgvre@yvorb.pbz"
        pass

    def tearDown(self):
        """
        Fonction appelé à après chacun des tests de la classe
        courante
        """
        pass

    def test_un_test(self):
        """
        Toutes les fonctions commencant par test_ seront roulés !
        """
        print("un test !")

        # différents types de tests peuvent être utilisés:
        self.assertEqual(self.encoded_email, "une-valeur")
        self.assertTrue("@" in self.encoded_email)

if __name__ == '__main__':
    # Roulons les tests lorsqu'on éxécute le fichier courant !
    unittest.main()

La liste complète des fonctions assertWhatever peut être consultés dans la documentation du module unittest.

4. Les premiers tests unitaires pour le décodage

Nous devons donc rédiger un premier test de vérification de l'encodage du fameux courriel mystère:

class LibeoCode(unittest.TestCase):
    # ...
    def test_p_to_c(self):
        self.assertEqual(libeo_decoder("p"), "c")

La fonction assertEqual vérifie que le premier et le deuxième paramètre sont bel et bien égal.

Si vous éxécuter maintenant le code du fichier, vous devriez voir un message similaire à ceci:

E
======================================================================
ERROR: test_p_to_c (__main__.LibeoCode)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/bchhun/Documents/projets-autres/quebecpython/app/content/libeo.py", line 25, in test_p_to_c
    self.assertEqual(libeo_decoder("p"), "c")
NameError: global name 'libeo_decoder' is not defined

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (errors=1)
[Finished in 0.0s with exit code 1]

Ce message nous indique que la fonction libeo_decoder n'existe pas encore.

Créons-la dans le même fichier, juste après la classe LibeoCode:

def libeo_decoder(encoded):
    """
    Commençons tout d'abord par retourner la chaine de
    caractère en paramètre.
    """
    return encoded

Et roulons le script à nouveau. Un autre message similaire à ci-dessous s'affichera à présent:

F
======================================================================
FAIL: test_p_to_c (__main__.LibeoCode)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/bchhun/Documents/projets-autres/quebecpython/app/content/libeo.py", line 27, in test_p_to_c
    , "c"
AssertionError: 'p' != 'c'

----------------------------------------------------------------------
Ran 1 test in 0.000s

FAILED (failures=1)[Finished in 0.0s with exit code 1]

Félicitations. Vous avez maintenant un test qui échoue !

5. Évolution du décodeur

Version pichu

Rappelons-nous la régle de décodage:

  • {L'index de la lettre encodée} - 13 = {l'index de la lettre réelle}

Voici donc comment cela s'exprime en python:

# notre alphabet
alphabet = list("abcdefghijklmnopqrstuvwxyz")

def libeo_decoder(encoded):
    # obtenir l'index d'un élément dans une liste
    encoded_index = alphabet.index(encoded)
    # Appliquer la régle
    decoded_index = encoded_index - 13
    # retourner la lettre décodé
    return alphabet[decoded_index]

Roulez à nouveau le script pour voir votre test s'éxécuter avec brio:

.
----------------------------------------------------------------------
Ran 1 test in 0.000s

OK
[Finished in 0.0s]

Version pikachu

Qu'arriverai-t-il si nous tentions maintenant de faire la conversion de pbz à com ?

Écrivons tout d'abord le test:

class LibeoCode(unittest.TestCase):
    # ...
    def test_pbz_to_com(self):
        self.assertEqual(libeo_decoder("pbz"), "com")

Puis roulons le:

.E
======================================================================
ERROR: test_pbz_to_com (__main__.LibeoCode)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/bchhun/Documents/projets-autres/quebecpython/app/content/libeo.py", line 28, in test_pbz_to_com
    self.assertEqual(libeo_decoder("pbz"), "com")
  File "/home/bchhun/Documents/projets-autres/quebecpython/app/content/libeo.py", line 33, in libeo_decoder
    encoded_index = alphabet.index(encoded)
ValueError: 'pbz' is not in list

----------------------------------------------------------------------
Ran 2 tests in 0.000s

FAILED (errors=1)
[Finished in 0.0s with exit code 1]

Le message d'erreur nous indique qu'il tente de trouver la chaîne de caractère pbz dans la liste. Nous désirons en fait faire une recherche sur chacune des lettres. Modifions donc notre fonction:

def libeo_decoder(encoded):
    # pour recueillir le résultat du décodage
    decoded = []

    # parcourons maintenant chacune des lettres du paramètre
    # encoded
    for letter in list(encoded):
        encoded_index = alphabet.index(letter)
        decoded_index = encoded_index - 13
        decoded.append(
            alphabet[decoded_index]
        )

    return "".join(decoded)

Voici le message lorsque nous l'éxécutons:

..
----------------------------------------------------------------------
Ran 2 tests in 0.000s

OK
[Finished in 0.0s]

Essayons le décodage de « Obaar punapr »:

class LibeoCode(unittest.TestCase):
    # ...
    def test_obaar_punapr_to_bonne_chance(self):
        self.assertEqual(
            libeo_decoder("Obaar punapr"),
            "Bonne chance"
        )

Et son message est:

E.
======================================================================
ERROR: test_obaar_punapr_to_bonne_chance (__main__.LibeoCode)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/bchhun/Documents/projets-autres/quebecpython/app/content/libeo.py", line 28, in test_obaar_punapr_to_bonne_chance
    self.assertEqual(libeo_decoder("Obaar punapr"), "Bonne chance")
  File "/home/bchhun/Documents/projets-autres/quebecpython/app/content/libeo.py", line 42, in libeo_decoder
    encoded_index = alphabet.index(letter)
ValueError: 'O' is not in list

----------------------------------------------------------------------
Ran 2 tests in 0.000s

FAILED (errors=1)
[Finished in 0.1s with exit code 1]

Version raichu

Oups. La lettre « o » majuscule n'existe pas dans notre alphabet. Effectuons la transformation lorsque nécessaire:

def libeo_decoder(encoded):
    # pour recueillir le résultat du décodage
    decoded = []

    # parcourons maintenant chacune des lettres du paramètre
    # encoded
    for letter in list(encoded):
        is_uppercased = False

        # vérifions si la lettre courante est
        # en majuscule
        if letter == letter.upper():
            is_uppercased = True
            # Remettons la lettre en miniscule.
            # Ce qui nous intéresse à partir de ce moment est
            # son index
            letter = letter.lower()

        encoded_index = alphabet.index(letter)
        decoded_index = encoded_index - 13
        decoded_letter = alphabet[decoded_index]

        # Si la lettre encodé est en majuscule, nous
        # l'appliquons également à la lettre décodé
        if is_uppercased:
            decoded_letter = decoded_letter.upper()

        decoded.append(decoded_letter)

    # transformons la list decoded en chaîne
    # de caractère
    return "".join(decoded)

Cette version donne maintenant le message suivant:

E.
======================================================================
ERROR: test_obaar_punapr_to_bonne_chance (__main__.LibeoCode)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/bchhun/Documents/projets-autres/quebecpython/app/content/libeo.py", line 28, in test_obaar_punapr_to_bonne_chance
    self.assertEqual(libeo_decoder("Obaar punapr"), "Bonne chance")
  File "/home/bchhun/Documents/projets-autres/quebecpython/app/content/libeo.py", line 48, in libeo_decoder
    encoded_index = alphabet.index(letter)
ValueError: ' ' is not in list

----------------------------------------------------------------------
Ran 2 tests in 0.000s

FAILED (errors=1)
[Finished in 0.1s with exit code 1]

Il semblerait donc que si une lettre n'existe pas dans notre alphabet, nous n'avons pas besoin de le décoder. Ajoutons une petite vérification de l'existence de la lettre courante:

def libeo_decoder(encoded):
    # pour recueillir le résultat du décodage
    decoded = []

    # parcourons maintenant chacune des lettres du paramètre
    # encoded
    for letter in list(encoded):
        is_uppercased = False

        if letter == letter.upper():
            is_uppercased = True
            letter = letter.lower()

        if letter in alphabet:
            encoded_index = alphabet.index(letter)
            decoded_index = encoded_index - 13
            decoded_letter = alphabet[decoded_index]

            if is_uppercased:
                decoded_letter = decoded_letter.upper()

            decoded.append(decoded_letter)
        else:
            decoded.append(letter)

    # transformons la list decoded en chaîne
    # de caractère
    return "".join(decoded)

Cela donne maintenant 3 décodages réussis:

...
----------------------------------------------------------------------
Ran 3 tests in 0.000s

OK
[Finished in 0.1s]

Le test ultime est celui du courriel encodé ! Qui est-ce ?

libeo_decoder("nheryvr.qvba-tnhgvre@yvorb.pbz")
# aurelie.dion-gautier@libeo.com !

Rajoutons le test du courriel et allons de l'avant avec l'encodage:

class LibeoCode(unittest.TestCase):
    # ...
    def test_decoded_email(self):
        self.assertEqual(
            libeo_decoder("nheryvr.qvba-tnhgvre@yvorb.pbz"),
            "aurelie.dion-gautier@libeo.com"
        )

6. Les tests unitaires pour l'encodage

Faisons maintenant le processus inverse avec une fonction d'encodage.

Nous devons implémenter la régle suivante:

  • {L'index de la lettre réelle} + 13 = {l'index de la lettre encodée}

Rajoutons un nouveau test:

class LibeoCode(unittest.TestCase):
    # ...
    def test_encoded_email(self):
        self.assertEqual(
            libeo_encoder("aurelie.dion-gautier@libeo.com"),
            "nheryvr.qvba-tnhgvre@yvorb.pbz"
        )

Puis la fonction elle-même qui est une copie inverse de libeo_decoder:

def libeo_encoder(decoded):
    encoded = []

    for letter in list(decoded):
        is_uppercased = False

        if letter == letter.upper():
            is_uppercased = True
            letter = letter.lower()

        if letter in alphabet:
            decoded_index = alphabet.index(letter)
            encoded_index = decoded_index + 13

            encoded_letter = alphabet[encoded_index]

            if is_uppercased:
                encoded_letter = encoded_letter.upper()

            encoded.append(encoded_letter)
        else:
            encoded.append(letter)

    return "".join(encoded)

Si nous roulons le test à ce moment, cela nous donnera l'erreur suivante:

.E...
======================================================================
ERROR: test_encoded_email (__main__.LibeoCode)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/home/bchhun/Documents/projets-autres/quebecpython/app/content/libeo.py", line 41, in test_encoded_email
    libeo_encoder("aurelie.dion-gautier@libeo.com"),
  File "/home/bchhun/Documents/projets-autres/quebecpython/app/content/libeo.py", line 92, in libeo_encoder
    encoded_letter = alphabet[encoded_index]
IndexError: list index out of range

----------------------------------------------------------------------
Ran 5 tests in 0.002s

FAILED (errors=1)
[Finished in 0.1s with exit code 1]

Le message nous indique que l'index calculé est plus grand que le nombre total d'élément dans l'alphabet.

Faisons une petite déduction en utilisant une lettre proche de la fin de l'alphabet:

# utilisons le "u" de aurelie
# sa lettre de remplacement est "h"

u_index = alphabet.index("u") # 20
mystery_index = u_index + 13 # 33
h_index = alphabet.index("h") # 7
diff = mystery_index - h_index # 26
mystery_index - diff == h_index # 33 - 26 = 7

Nous devons donc seulement appliquer ce calcul lorsque l'index calculé est plus grand que la longueur de l'alphabet:

def libeo_encoder(decoded):
    encoded = []

    # pour chacune des lettres
    for letter in list(decoded):
        is_uppercased = False

        # vérifier si la lettre courante est en majuscule
        if letter == letter.upper():
            is_uppercased = True
            letter = letter.lower()

        if letter in alphabet:
            decoded_index = alphabet.index(letter)
            encoded_index = decoded_index + 13

            # vérifier si l'index calculé est
            # plus grand que la longueur de l'alphabet
            if encoded_index >= len(alphabet):
                encoded_index = encoded_index - len(alphabet)

            encoded_letter = alphabet[encoded_index]

            if is_uppercased:
                encoded_letter = encoded_letter.upper()

            encoded.append(encoded_letter)
        else:
            encoded.append(letter)

    return "".join(encoded)

Conclusion

Voici le fichier final après tous ces essais.

Comme vous pouvez maintenant le constater, les étapes de résolution d'un problème peuvent être assez chaotique et pointilleux. Les tests unitaires vous permettent de suivre un cheminement logique tout en s'assurant que les tests précédents fonctionnent encore.

Soyez patient avec vous-même lorsque vous tenter de résoudre un problème comme celui-ci.

Merci aux courageux qui ont lu l'article au complet.

Le moment du défi

Essayez maintenant de décoder la chaîne de caractère suivante:

encoded = "So11025Mc6084So10404Nl12321Lo4096So12769Lo13689Lo40401So9604Sm10201So9801Cn12544Lo14641Lo13456Sm10816Lo6241So12100Cn2116Mn2304So12996Sm10609"
decoded = "???"

EDIT:

La chaîne de caractère encodé précédente était la suivante, mais c'était une erreur de ma part...:

encoded = "Lu210Cc156Lu204Lu222Cc128Ll226Ll234Ll402Lu196Lu202Lu198Ll224Ll242Ll232Lu208Cc158Lu220Po92Sk96Ll228Lu206"

Vous pourriez vous en servir pour régler le problème plus rapidement !

Et finalement, un petit mot d'encouragement pour les participants aux défis:

encouragement = "Lo4225Lo11664Lo11664Sm10201Lo14884Lo1936Lu1024Lo13924Nl12321Lo13689So13225Lu1024Lo54756Lo13456Sm10201So13225Lu1024So9801So9409Cn12544So9409So9604Lo11664Sm10201Lu1024So10000Sm10201Lu1024So12996Lo54289Sm10609Lo11664Sm10201So12996Lu1024So9801Sm10201Lu1024Cn12544Lo13689Lo14884Lo14884Lo11664Sm10201Lu1024Ll1089"

Indices

  • Ce qui abreuve Yggdrasil
  • Son ode est unique
  • L'utilisation des regex n'est pas nécessairement conseillé.

Prix

La première personne à me communiquer la solution via les réseaux sociaux obtiendra une copie papier de Two scoops of Django d'une valeur de 34.95 $ US. Ce bouquin donne une panoplie de conseils utiles lorsque l'on bosse avec Django.

Je vous donnerai la solution dans 2 semaines.

Bonne chance aux participants !

L'équipe de Québec-Python