Écrire des tests #
La façon de rédiger les tests peut drastiquement changer le confort de l'utilisateur, lorsqu'il essaie de résoudre un problème.
En particulier, si la sortie standard est désactivée durant les tests secrets, il est critique que les assertions donnent bien le niveau d'information souhaité par le rédacteur, sans quoi l'utilisateur peut se retrouver sans aucune information utile, et donc très peu de chances de trouver comment modifier son code.
Stratégies #
De manière générale, les "stratégies" suivantes sont conseillées :
-
Mettre tous les cas particuliers dans les tests publics.
-
Mettre suffisamment de tests publics (section
tests
) pour couvrir au moins une fois tous les types de cas présents dans les tests de la sectionsecrets
. -
Les assertions sans message d'erreur sont à proscrire dans les tests secrets.
Si vous comptez utiliser la fonctionnalité de génération automatique des messages d'erreurs des macrosIDE
(voir l'argumentLOGS
et sa configuration globale), il faut à minima que les valeurs des arguments soient définies dans le code de chaque assertion. -
On peut adapter le niveau de feedback des messages d'erreur selon le but recherché et le contexte de l'exercice.
Typiquement :-
Un message donnant uniquement le ou les arguments peut être considéré comme le strict minimum acceptable.
assert func(arg) == attendu, f"func({arg})"
-
Un message donnant en plus la réponse de l'utilisateur est un confort ajouté certain. Cela ne change rien au niveau d'information accessible à l'utilisateur, mais cela lui évite d'avoir à créer lui-même le test public correspondant pour savoir ce que sa fonction a renvoyé.
val = func(arg) assert val == attendu, f"func({arg}): a renvoyé {val}"
-
Un message présentant en plus la réponse attendue est la "Rolls" du message d'assertion (mais n'est pas toujours souhaitable pour des tests "secrets").
val = func(arg) assert val == attendu, f"func({arg}): {val} devrait être {attendu}"
Ne pas appeler plusieurs fois la fonction de l'utilisateur pour un même test
Il est vivement déconseillé d'appeler plusieurs fois la fonction de l'utilisateur pour le même test. Cela peut rendre le code plus difficile à déboguer.
assert func(arg) == attendu, f"func({arg}): {func(arg)} devrait être {attendu}"
val = func(arg) assert val == attendu, f"func({arg}): {val} devrait être {attendu}"
-
-
Si des tests aléatoires sont implantés, il est INDISPENSABLE de leur associer un niveau de feedback élevé (voir ci-dessus) :
Gardez en tête que des tests aléatoires vont générer des cas particuliers pour les fonctions alambiquées de vos utilisateurs, que vous n'avez aucune chance de prévoir dans les tests publics. Les insuffisances du feedback peuvent alors transformer l'exercice que vous aurez passé des heures à peaufiner en véritable cauchemar pour vos élèves, ce qui serait tout de même dommage...
Aspects techniques #
Problématique :
Gardez en mémoire que l'environnement Pyodide est commun à toute la page, ou jusqu'à un éventuel rechargement de celle-ci. Cela implique que de nombreux effets de bords peuvent se présenter, dont les utilisateurs n'auront pas conscience.
En particulier toute variable ou fonction définie dans les tests est visible depuis la fonction de l'utilisateur. Ceci signifie que l'environnement de la seconde exécution n'est souvent pas le même que celui de la première exécution des tests.
Concrètement, cela peut amener à des erreurs très dures à tracer côté utilisateur, car les comportements ne sont pas toujours reproductibles. On a par exemple vu des codes lever ou pas NameError
, selon l'ordre d'utilisation des boutons de l'IDE après un chargement de page...
Solution :
- Il est vivement conseillé d'écrire les tests dans une fonction afin que leur contenu ne puisse pas "leaker" dans l'environnement de l'utilisateur.
- Ne pas oublier d'appeler la fonction des tests après l'avoir écrite...
- Comme les fonctions restent définies dans l'environnement global, après avoir lancé une première validation, un utilisateur malin pourrait exécuter des fonctions définies dans la section
secrets
(1)- Le nom des fonctions est visible dans les stacktraces des erreurs, ou encore, accessible via
dir()
dans le terminal.
Dans le contexte du thème, cela n'est sans doute pas un problème (il n'y a rien à gagner, après tout... !), mais si vous voulez l'empêcher, il vous faudra supprimer la fonction de l'environnement en utilisantdel
comme montré dans le second exemple ci-dessous. - Le nom des fonctions est visible dans les stacktraces des erreurs, ou encore, accessible via
Le décorateur @auto_run
proposé par le thème facilite tout ceci, en gérant la définition et le lancement des fonctions englobant les tests pour le rédacteur.
Voir plus bas les explications détaillées sur le décorateur @auto_run
.
# Exécute automatiquement la fonction et empêche sa définition dans l'environnement:
@auto_run
def tests():
# Il est conseillé d'utiliser une fonction pour éviter que des
# variables des tests se retrouvent dans l'environnement global.
for n in range(100):
val = est_pair(n)
exp = n%2 == 0
msg = f"est_pair({n})" # Minimum vital
msg = f"est_pair({n}): valeur renvoyée {val}" # Conseillé
msg = f"est_pair({n}): {val} devrait être {exp}" # La totale
assert val == exp, msg
def tests():
for n in range(100):
# Il est conseillé d'utiliser une fonction pour éviter que des
# variables des tests se retrouvent dans l'environnement global.
val = est_pair(n)
exp = n%2 == 0
msg = f"est_pair({n})" # Minimum vital
msg = f"est_pair({n}): valeur renvoyée {val}" # Conseillé
msg = f"est_pair({n}): {val} devrait être {exp}" # La totale
assert val == exp, msg
tests() # Ne pas oublier d'appeler la fonction de tests... ! x)
Voir l'outil @auto_run
, plus bas dans la page
Le thème met à disposition un décorateur faisant exactement tout cela, et gérant également le nettoyage régulier de l'environnement.
Effacer les fonctions de tests peut devenir assez fastidieux, notamment de par le fait que si une erreur est levée, le code suivant la fonction de test n'est pas exécuté, sauf à utiliser des blocs try/except
ou assimilés...
Il existe une solution très rapide qui permet :
- D'être certain de ne pas oublier d'appeler les fonctions de tests.
- D'être certain que la fonction de tests ne sera pas disponible du côté de l'utilisateur, et ce quelle que soit le déroulement des tests (succès/erreur).
- Ne fournit aucun élément exploitable à l'utilisateur pour investiguer le contenu des tests.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
|
test
repose sur la syntaxe des décorateurs, mais n'en est en fait pas vraiment un : test
appelle directement la fonction f
passée en argument, au lieu de renvoyer une fonction comme un décorateur normal.
Ceci fait que toute fonction décorée avec test
est une fonction de test exécutée.
Par ailleurs, la variable du scope global correspondant au nom d'une fonction décorée se voit en fait associée ce que l'appel au décorateur renvoie. Ici, test
renvoie le résultat de f()
, qui est la fonction décorée, mais les fonctions de tests ne renvoient rien, et test
renvoie donc None
, si aucune erreur n'est levée.
Une fonction décorée avec test
n'est donc en fait jamais assignée à la variable correspondant dans le scope globale, et ne sera donc jamais accessible à l'utilisateur.
Après les exécutions :
test
est définie et accessible à l'utilisateur, mais n'a strictement aucun intérêt pour lui.passing
est également définie, mais est en faitNone
au lieu d'être la fonctionpassing
.failing
n'est pas définie car la fonction a levé une erreur avant que le décorateur n'affecte la variable dans le scope global.
Il est possible de constater tout cela avec l'IDE ci-dessous :
Vous pouvez appliquer la même stratégie aux tests publics, mais cela surcharge notablement le code et n'est donc probablement pas souhaitable.
D'autant plus que pour ces tests, si les assertions sont écrites avec toutes les données "en dur", la construction automatique des messages d'erreur donne presque tout le feedback nécessaire à l'utilisateur, tout en faisant gagner du temps au rédacteur (le seul élément non affiché étant alors la valeur renvoyée par la fonction de l'utilisateur).
# Suffisant pour des tests publics, mais pas forcément pour des tests secrets !
assert est_pair(3) is False
assert est_pair(24) is True
...
L'outil @auto_run
#
But#
À partir de la version 2.0, le thème propose un décorateur qui permet d'exécuter automatiquement la fonction qu'il décore, et empêche de la réutiliser par la suite.
Ceci est particulièrement utile pour écrire des fonctions devant exécuter certaines tâches en gardant leur contenu isolé de l'environnement global, comme des fonctions de tests pour les sections secrets
des IDEs, par exemple.
@auto_run
def test():
""" tests dans un scope isolé """
assert func() == 42
- Le code executant la fonction (le décorateur) est écrit au plus proche de la déclaration de fonction, ce qui évite d'oublier l'appel de fonction dans le code, quand la fonction devient très longue.
- La façon de fonctionner du décorateur fait que la fonction décorée n'est en fait jamais affectée dans l'environnement, et l'utilisateur ne pourra pas y accéder lors d'une exécution ultérieure.
def test():
""" tests dans un scope isolé """
assert func() == 42
try:
test()
finally:
test=None
- Obtenir le même comportement sans le décorateur est très laborieux, car il faut garantir l'effacement de la fonction, si
test()
lève une erreur. - Si le code de la fonction est long, on ne voit pas de suite si la fonction est bien appelée ou pas.
Spécifications#
- Le décorateur exécute automatiquement la fonction décorée.
- Le décorateur renvoie
None
, donc la fonction d'origine n'est jamais disponible dans le scope de l'utilisateur après exécution. - Le décorateur est redéfini à chaque exécution lancée par l'utilisateur, de manière à fiabiliser son utilisation.
-
Une fonction
func
décorée va laisser une variablefunc=None
dans l'environnement. Le thème supprime automatiquement ces variables, à différents moments des exécutions :- juste avant d'exécuter le code ou la commande de l'utilisateur,
- à la toute fin d'une exécution, après
post_term
etpost
.
-
Il est possible de déclencher manuellement le nettoyage de l'environnement en appelant la méthode
auto_run.clean()
, si besoin.
Ne pas utiliser le décorateur dans les tests publics
L'utilisateur n'a pas besoin de connaître l'existence du décorateur : il pourrait sinon interagir avec lui et potentiellement changer le comportement des exécutions, s'il sait comment faire.
Voici une mise en évidence du comportement des fonctions décorées, et des variables présentes dans l'environnement après exécutions.
Contenu de auto_run_ex.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
|
# Tests
(insensible à la casse)(Ctrl+I)
(Alt+: ; Ctrl pour inverser les colonnes)
(Esc)
On observe que :
- Les variables
passing
etfailing
ne sont jamais définies lors de l'exécution de la sectioncode
, même après la première validation. - Après l'exécution de la section
secrets
(danspost
) :passing
existe mais vautNone
.failing
a levé une erreur ce qui a empêché son affectation. Elle n'est donc pas définie dans l'environnement.
- Après l'exécution de
post
,passing
est supprimée automatiquement de l'environnement.
@auto_run
vs async
#
À partir de la version 4.2.0
du thème, le décorateur @auto_run
peut être utilisé sur des fonctions asynchrones. Il y a cependant quelques subtilités concernant l'ordre d'exécution des différents codes dont il faut alors être conscient.
Le fonctionnement du décorateur pour les fonctions asynchrones est le suivant :
- La section en cours est exécutée normalement.
- Les fonctions asynchrones décorées ne sont PAS appelées directement mais sont stockées.
- Une fois tout le code de la section exécuté, les fonctions asynchrones sont exécutées dans l'ordre où elles ont été stockées.
Important :
- La première erreur rencontrée stoppe les exécutions (que ce soit durant la phase d'exécution du code de la section elle-même ou durant l'exécution des fonctions asynchrones décorées).
- Les fonctions asynchrones sont toutes exécutées avant que d'éventuelles restrictions ne soient supprimées, donc toutes les restrictions s'appliquent à ces fonctions de la façon habituelle.
Concrètement, il faut donc se méfier des codes contenant des fonctions asynchrones décorées avec d'autres codes synchrones qui modifieraient des états globaux. En effet, les fonctions asynchrones décorées ne verraient que l'état final des différentes variables globales, comme le montre l'exemple ci-dessous :
Contenu de async_auto_run_dec.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
|
# Tests
(insensible à la casse)(Ctrl+I)
(Alt+: ; Ctrl pour inverser les colonnes)
(Esc)
À la lecture du code, on pourrait imaginer que VAL
prenne la valeur 0
dans le message de la fonction delayed
, mais c'est en fait la valeur VAL=4
qui est affichée.
Feedback : terminal_message
#
Si l'option ides.deactivate_stdout_for_secrets
est activée, la fonction print
est désactivée durant les tests de validation.
Si cela permet de sécuriser un peu le contenu de la section secrets
, cela présente un énorme désavantage : il devient impossible de donner un quelconque feedback à l'utilisateur, ou de mettre en évidence un découpage des tests en différentes parties, s'ils sont nombreux.
À partir de la version 2.5.0
, le thème met à disposition dans l'environnement pyodide une fonction terminal_message
, qui permet d'afficher du contenu directement dans le terminal, potentiellement formaté, et donc sans passer par la sortie standard de python ni être impacté par la désactivation de la sortie standard.
Signature#
En dehors du premier argument, @key
, la fonction terminal_message
est très proche de la fonction print
d'origine.
À partir de la version 4.2.3
, la signature est la suivante :
def terminal_message(key, *msg:Any, format:str=None, sep=' ', end=None, new_line=True):
"""
Display the given message directly into the terminal, without using the python stdout.
This allows to give informations to the user even if the stdout is deactivated during
a validation.
@key: Value to pass to allow the use of the function when the stdout is deactivated.
@*msg: Any number of elements to display in the terminal.
@format: One of the predefined formatting options for the terminal:
"error", "warning", "info", "italic", "stress", "success", "none" (default)
@sep=' ': Equivalent to `print(..., sep=...)`
@end=None: Close to `print(..., end=...)`. If not used, the @new_line argument will apply.
Otherwise, @new_line will be False, and @end will control the end of the
displayed message.
@new_line: _Legacy behavior_: this argument is now useless and can be replaced with the
use of `end`. If False, no new line character is added after the @msg content.
@returns: None
"""
Signature pour les versions antérieures de PMT
def terminal_message(key, msg:str, format:str="none", new_line=True):
"""
Affiche @msg directement dans le terminal de l'IDE en cours, sans passer par la sortie
standard de python/pyodide.
Le message est formaté avec le réglage désigné par `@format`.
@key: Valeur autorisant l'utilisation de la fonction, lorsque la sortie standard est
désactivée. La valeur attendue est définie par l'argument `STD_KEY` de la macro IDE.
@msg: Message à afficher. Potentiellement multilignes.
@format: Nom du formatage à appliqué au message.
@new_line: Si False, ne rajoute pas `\n` à la fin de @msg.
@throws: `ValueError` si la sortie standard est désactivée et @key n'est pas la valeur attendue
@returns: None
"""
Contrats & comportements#
-
L'argument
@key
Cet argument, bien qu'ennuyeux à l'usage, est nécessaire pour empêcher un utilisateur de se servir de la fonction
terminal_message
en lieu et place deprint
lorsque la sortie standard est désactivée.Il n'est en fait utilisé que lors des validations, et à condition que la sortie standard soit désactivée (option
ides.deactivate_stdout_for_secrets: true
). Dans les autres cas, il est possible d'utiliser n'importe quelle valeur, et la fonctionterminal_message
se comporte alors plus ou moins commeprint
. -
Définition de la valeur cible pour
@key
C'est l'argument
STD_KEY
des macrosIDE
qui permet de choisir quelle sera la valeur autorisant l'utilisation de la fonctionterminal_message
. Comme toutes les valeurs passées via les macros, cet argument peut également être défini via les fichiers de métadonnées ou les entêtes de pages markdown. -
Validation de l'argument
{{ IDE(..., STD_KEY=...)}}
Lors de la construction du site, une erreur est levée si un IDE est rencontré avec toutes ces conditions réunies :
- Des tests de validation
- La sortie standard est désactivée durant les validations
- Un argument
STD_KEY
qui est "falsy" - La chaîne
"terminal_message("
est trouvée dans l'une des sectionstests
ousecrets
.
Ne pas utiliser
terminal_message
dans les sectionstests
Il est à noter que la fonction
terminal_message
ne devrait normalement jamais être utilisée dans la sectiontests
, car son contenu apparaît dans l'éditeur et est également utilisé dans les tests de validation. Or, pour pouvoir l'utiliser lors d'une validation, la bonne valeur pour l'argument@key
doit être utilisée, ce qui veut dire que cette valeur apparaîtra aussi dans l'éditeur... -
L'argument
@format
Voici les différentes options de formatage disponibles.
Remarques
- La couleur par défaut des terminaux est celle définie par la palette de couleur du site (dans le fichier
mkdocs.yml:theme.palette
). - Les couleurs utilisées dans les exemples ci-contre ne sont pas exactement les mêmes que celles utilisées dans les terminaux, mais permettent d'avoir une bonne idée du rendu qui sera obtenu.
Nom Rendu "error"
Rouge + gras "info"
Gris + italique "italic"
Italique "none"
Défaut "stress"
Gras "success"
Vert + gras + italique "warning"
Orange + gras + italique - La couleur par défaut des terminaux est celle définie par la palette de couleur du site (dans le fichier
Profiter de l'exécution async
de la section secrets
#
À partir de la version 4.2.0
, les sections secrets
sont exécutées en mode async
.
Intérêts d'utiliser des appels asynchrones dans la section secrets
Outre la possibilité de réaliser des appels asynchrones pour faire des requêtes ou autres, l'un des buts de passer cette section en exécution asynchrone est de pouvoir rendre visible à l'utilisateur toute modification de la page réalisée durant les exécutions.
Avec un mode asynchrone, il est en effet possible de mettre l'environnement pyodide "en pause", de manière non bloquante via l'appel asynchrone await js.sleep()
, ceci permettant à l'horloge du navigateur d'avancer et de mettre à jour le contenu de la page. L'environnement pyodide reprend ensuite le cours des exécutions normalement.
L'exécution asynchrone s'avère donc particulièrement intéressante pour pouvoir :
-
Afficher des informations dans un terminal lorsque les tests sont longs, pour signaler à l'utilisateur où il en est (utilisation de
terminal_message
). -
Mettre à jour des graphiques ou des animations p5 dans la page au cours des tests.
Informer l'utilisateur#
L'exemple ci-dessous simule une exécution lente des tests de validation, en montrant comment afficher des informations dans le terminal au fur et à mesure, avec terminal_message
.
Contenu de async_long_secrets.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
|
# Tests
(insensible à la casse)(Ctrl+I)
(Alt+: ; Ctrl pour inverser les colonnes)
(Esc)
Mettre à jour la page#
De la même façon, il est aussi possible de mettre à jour des éléments de la page autres que le terminal, alors même que la validation est toujours en cours.
L'exemple ci-dessous remplace le tracé matplotlib d'une fonction choisie aléatoirement.
Contenu de async_update_plot.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
|
# Tests
(insensible à la casse)(Ctrl+I)
(Alt+: ; Ctrl pour inverser les colonnes)
(Esc)
Votre figure
Tester l'utilisation de print
#
Il est possible d'intercepter facilement le contenu de la sortie standard, pour tester l'utilisation de la fonction print
par l'utilisateur.
Durant les exécutions, sys.stdout
est en fait une instance de io.String
, qui récupère tous les contenus affichés dans la console pour la section en cours. Cet objet n'est cependant pas utilisé par le thème pour afficher les messages dans les terminaux, et un rédacteur peut donc l'utiliser à sa guise pour réaliser des tests.
import sys
txt = sys.stdout.getvalue()
Le code ci-dessus renvoie l'intégralité de tout ce qui a été transmis à la sortie standard via des appels à la fonction print
, depuis le début de la section en cours d'exécution.
Comme cet objet n'est en fait pas utilisé par le thème, il est possible de le modifier pour tester plus facilement les contenus transmis à la sortie standard :
sys.stdout.truncate(0)
sys.stdout.seek(0)
Le contenu de cet objet est également mis à jour lors de l'utilisation de print
alors que la sortie standard dans le terminal est désactivée, ce qui permet de réaliser des tests même lors des validations.
Exemple
# Tests
(insensible à la casse)(Ctrl+I)
(Alt+: ; Ctrl pour inverser les colonnes)
(Esc)
Contenu de stdout_capture.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
Tester la correction et les tests #
-
Durant le développement en local, via
mkdocs serve
, les IDEs disposant d'un contenu pour la sectioncorr
se voient automatiquement ajoutés un nouveau bouton qui permet d'exécuter une validation, mais avec le contenu de la correction en lieu et place du contenu de l'éditeur de code. Cela permet de :- Tester le code de la correction, dans le même contexte que le code de l'utilisateur.
- Tester aussi le bon fonctionnement des tests publics et secrets, puisque les tests des sections
tests
etsecrets
sont tous deux exécutés lors des validations (voir les informations sur le déroulement des exécutions).
Ce bouton n'est jamais présent dans le site construit avec la commande
mkdocs build
.
-
À partir de la version 2.3.0, le thème propose de générer automatiquement une page pour tester tous les IDEs du site, permettant notamment de vérifier que le code présent dans la section
corr
d'un IDE passe bien les tests de validations.
Cette fonctionnalité vient avec diverses options permettant d'affiner la façon de tester un IDE (ne pas faire le test, le faire mais considérer une erreur comme un succès, ...).Ceci permet de tester les codes dans le même environnement que l'utilisateur, de manière semi-automatisée.
Voir les contenus corr
& REM
s #
De manière similaire, et toujours durant le développement en local, via mkdocs serve
, les IDEs disposant d'un contenu corr
ou de fichiers {exo}_REM.md
ou {exo}_VIS_REM.md
se voient automatiquement ajoutés un autre bouton permettant de révéler le contenu caché sans exécuter les tests.
Ce bouton n'est jamais présent dans le site construit avec la commande mkdocs build
.
# Tests
(insensible à la casse)(Ctrl+I)
(Alt+: ; Ctrl pour inverser les colonnes)
(Esc)