angle-uparrow-clockwisearrow-counterclockwisearrow-down-uparrow-leftatcalendarcard-listchatcheckenvelopefolderhouseinfo-circlepencilpeoplepersonperson-fillperson-plusphoneplusquestion-circlesearchtagtrashx

Test fonctionnel d'un site web multilingue Flask avec Pytest

En utilisant Pytest et hooks et Flask test_client , nous pouvons effectuer des tests et approuver manuellement les textes.

25 juillet 2020
Dans Testing
post main image
https://unsplash.com/@marco_blackboulevard

L'essai est un travail difficile. C'est totalement différent de créer ou de modifier une fonctionnalité. Je sais, j'ai aussi développé du matériel informatique, des circuits intégrés, des systèmes de test de matériel. J'ai écrit des tests pour CPUs, des produits informatiques, j'ai développé des systèmes de test. Avec le matériel informatique, vous ne pouvez pas faire d'erreurs. Les erreurs peuvent entraîner la fin de votre entreprise.

Bienvenue dans le monde merveilleux du test de logiciels

Avec les logiciels, beaucoup de choses sont différentes. Les erreurs sont tolérées. Je ne blâme pas les petites entreprises. Mais il faut blâmer les géants de la technologie comme Google, Apple, Facebook, Microsoft. La plupart des logiciels sont complexes ou, du moins, utilisent des bibliothèques tierces ou se connectent à des systèmes distants. Tester une application est devenu une tâche complexe.

Pourquoi j'ai choisi d'écrire des tests fonctionnels (premièrement)

Il existe de nombreux types de tests différents pour les logiciels, pour ne citer que les plus connus des développeurs :

  • Test unitaire
  • Test d'intégration
  • Test fonctionnel
  • Test de bout en bout

Les tests unitaires sont faciles à écrire mais même s'ils réussissent tous, cela ne garantit pas que mon application Flask fonctionne. Les tests d'intégration ne sont pas non plus très difficiles. Si vous écrivez des bibliothèques, ces tests sont tout à fait logiques. Il suffit d'intégrer les classes et d'écrire quelques tests. Mais il n'y a aucune garantie que mon application fonctionnera si je ne fais que ces tests.

Avec un temps limité, comment puis-je m'assurer que mon application CMS/Blog Flask fonctionne réellement ? J'ai décidé de passer les tests fonctionnels et les tests end-to-end . C'est beaucoup plus de travail, mais au moins je sais que le visiteur du site web voit les bonnes choses.

Outils pour les tests

J'ai ajouté une option "test" à ma configuration de docker. C'est comme mon option "développement", avec du code hors du conteneur, mais avec quelques paquets supplémentaires,

  • Flake8
  • Pytest
  • Pytest-cov
  • Pytest-flake8

Pytest est un bon test framework si vous voulez tester votre application Flask . Lorsque vous installez Pytest-flake8 , vous pouvez également exécuter Flake8 à partir de Pytest :

python3 -m pytest  app_frontend/blueprints/demo  --flake8  -v

Mais je préfère exécuter flake8 tout seul, en ignorant dans ce cas les longues lignes :

flake8  --ignore=E501  app_frontend/blueprints/demo

Configuration du test

Commencer avec Pytest n'est pas très difficile. J'ai créé un répertoire tests_app_frontend dans le répertoire du projet :

.
|-- alembic
|--  app_admin
|--  app_frontend
|   |--  blueprints
|   |-- templates
|   |-- translations
|   |-- __init__.py
|   `-- ...
|-- shared
|-- tests_frontend
|   |-- helpers
|   |   |-- helpers_cleanup.py
|   |   |-- helpers_globals.py
|   |   `-- helpers_utils.py
|   |-- language_logs
|   |-- language_logs_ok
|   |-- conftest.py
|   |--  pytest.ini
|   |-- ...
|   |-- test_ay_account.py
|   |-- ...
|   |-- test_an_auth_login.py
|   `-- ...

Le fichier pytest.ini :

[pytest]
norecursedirs=tests_frontend/helpers

cache_dir=/home/flask/project/tests_frontend/cache
console_output_style=progress

log_file=/home/flask/project/tests_frontend/testresults_frontend.log
log_file_level=DEBUG
log_file_format=%(asctime)s %(levelname)s %(message)s
log_file_date_format=%Y-%m-%d %H:%M:%S

log_level=DEBUG
python_files=test_*.py

flake8-max-line-length=200

Le répertoire helpers contient des fonctions qui peuvent vous rendre la vie beaucoup plus facile. En fait, après avoir écrit quelques tests, je cherchais à minimiser mon code de test, et j'ai passé beaucoup de temps à écrire ces fonctions helpers.

Les fonctions de nettoyage dans helpers_cleanup.py font partie du test et sont exécutées avant le test réel, exemples :

def delete_test_contact_forms_from_db(client=None,  lang_code=None, dbg=False):
    ...
    
def delete_test_contact_form_emails_from_db(client=None,  lang_code=None, dbg=False):
    ...

Quelques-uns des globals stockés dans helpers.globals :

# all test emails have the same email_host
email_host = 'test_email_host.test'

# test  user  present in the database
my_test_user_email = 'mytestuser@mytestuser.mytestuser'
my_test_user_password = 'nio7FKCYUHTgd765edfglfsSDgn'
my_test_user_new_password = 'LUI76tlfvutjlFLJFgf'

Certaines des fonctions de l'aide dans helper_utils.py :

def get_attr_from_html_page_by_elem_id(rv, elem_id, attr_name, form_prefix=None, dbg=False):
    ...

def get_unique_test_email():
    ...

def page_has_tag_with_text(rv, tag, text, dbg=False):
    ...

def client_get(client, url, follow_redirects=False, dbg=False):
    ...

def client_post(client, url, form_field2values=None, form_prefix=None, follow_redirects=False, dbg=False):
    ...

def my_test_user_restore_password(dbg=False):
    ...

def my_test_user_login(client,  lang_code=None, email=None, password=None, check_message_logged_in=True, dbg=False):
    ...

def my_test_user_logout(client,  lang_code=None, check_message_logged_out=True, dbg=False):
    ...

def get_auth_login_url(client=None,  lang_code=None, dbg=False):
    ...

def get_auth_logout_url(client=None,  lang_code=None, dbg=False):
    ...

J'ai ajouté une fonction client_get() et client_post() qui se comportent de manière presque identique à client.get() et client.post() mais qui renvoient également l'emplacement de la page après les redirections. La fonction client_post() possède également un paramètre optionnel form_prefix qui peut être utilisé lorsque le formulaire est prefixed, voir la documentation WTForms .

La fonction my_test_user_login() peut être utilisée lorsqu'elle n'est pas authentifiée pour se connecter au test user. Tous les fichiers d'aide ont un argument dbg (débogage) optionnel. Lorsque vous développez des tests, vous voulez souvent voir ce qui se passe, le paramètre dbg=True me donne tous les détails, également dans le fichier journal.

Noms des fichiers de test, noms des fonctions et authentification

Dans Pytest , vous pouvez utiliser la "note" pour regrouper les tests, mais je ne l'ai pas encore utilisée. J'ai plutôt deux catégories principales. Tests lorsque le user n'est pas authentifié, an = non authentifié, et tests lorsque le user est authentifié, ay = authentifié. Les fichiers de test et les tests commencent par la même chaîne. Fichier de test :

test_an_auth_login.py

a des tests comme :

def test_an_auth_login_enable_disable(client_lang_code_an):
    client,  lang_code  =  client_lang_code_an
    ...

Et le fichier de test :

test_ay_account.py

a des tests comme :

def test_ay_account_change_password_to_new_password(client_lang_code_ay):
    client,  lang_code  =  client_lang_code_ay
    ...

Les paramètres 'client_lang_code_an' et 'client_lang_code_ay' sont Tuples qui contiennent le client (test) et le code de langue.
Ils sont décompressés dans la fonction de test. Il y a un paquet Pytest-cases qui évite le déballage mais je ne voulais pas l'utiliser.

Commencer les tests avec fixtures

Je veux que les tests soient effectués un par un pour toutes les langues disponibles, ou seulement pour une ou plusieurs langues que je spécifie sur la ligne de commande. Cela signifie que le champ fixture doit être "fonction". Exemple de sortie de Pytest :

tests_frontend/test_ay_account.py::test_ay_account_change_password_logout_login[de] PASSED
tests_frontend/test_ay_account.py::test_ay_account_change_password_logout_login[en] PASSED
tests_frontend/test_ay_account.py::test_ay_account_change_password_logout_login[es] PASSED
tests_frontend/test_ay_account.py::test_ay_account_change_password_logout_login[fr] PASSED
tests_frontend/test_an_auth_login.py::test_an_auth_login_email_too_many_attempts[de] PASSED
tests_frontend/test_an_auth_login.py::test_an_auth_login_email_too_many_attempts[en] PASSED
tests_frontend/test_an_auth_login.py::test_an_auth_login_email_too_many_attempts[es] PASSED
tests_frontend/test_an_auth_login.py::test_an_auth_login_email_too_many_attempts[fr] PASSED

Vous pourriez faire valoir qu'il est plus rapide d'exécuter d'abord tous les tests pour la langue X, puis tous les tests pour la langue Y. Vous avez raison, voir aussi ci-dessous "modification du champ d'application du Pytest ", mais je voulais avoir la possibilité de conserver l'ensemble lors de l'exécution de plusieurs nouveaux tests. Voici la partie principale de conftest.py :

from  app_frontend  import  create_app


def pytest_addoption(parser):
    parser.addoption('--lc', action='append', default=[],
        help='lc (one or more possible)')


def pytest_generate_tests(metafunc):
    default_opts = ['de', 'en', 'es']
	lc_opts = metafunc.config.getoption('lc') or default_opts
    if 'client_lang_code_an' in metafunc.fixturenames:
        metafunc.parametrize('client_lang_code_an', lc_opts, indirect=True)
    if 'client_lang_code_ay' in metafunc.fixturenames:
        metafunc.parametrize('client_lang_code_ay', lc_opts, indirect=True)


@pytest.fixture(scope='function')
def  client_lang_code_an(request):

     lang_code  = request.param
    project_config = os.getenv('PROJECT_CONFIG')
    flask_app =  create_app(project_config)

    with flask_app.test_client() as client:
        with flask_app.app_context():
            rv = client.get(get_language_root_url(lang_code))
            yield (client,  lang_code)


@pytest.fixture(scope='function')
def  client_lang_code_ay(request):

     lang_code  = request.param
    project_config = os.getenv('PROJECT_CONFIG')
    flask_app =  create_app(project_config)

    with flask_app.test_client() as client:
        with flask_app.app_context():
            rv = client.get(get_language_root_url(lang_code))
            my_test_user_restore_password()
            my_test_user_login(client,  lang_code, change_to_language=True)
            yield (client,  lang_code)
            my_test_user_logout(client)

Ce n'est pas vraiment spécial ici. Les tests qui doivent être exécutés avec un user authentifié ont le paramètre "client_lang_code_ay". Il s'agit du fixture portant ce nom. Dans ce cas, le client est connecté avant que le test ne soit appelé.

Si je ne spécifie pas le paramètre "--lc" sur la ligne de commande, toutes les langues sont utilisées pour le test. Je peux sélectionner une ou plusieurs langues en spécifiant par exemple "--lc de" sur la ligne de commande. En fait, j'en ai aussi ajouté par la suite qui me permet de taper "--lc=fr,en,de".

Base de données

Comme il s'agit d'un test fonctionnel d'un CMS/Blog, j'ai besoin de certaines données dans la base de données. Avant un test, je dois nettoyer certaines données pour éviter toute interférence avec le test. Je dois écrire ce code de nettoyage moi-même. C'est un travail supplémentaire mais pas si mal car je peux utiliser des parties de ce code également dans l'application plus tard. Comme pour la suppression d'un user.

Quelques tests

Lorsqu'il n'est pas authentifié, la situation n'est pas très complexe. Voici un test qui vérifie le message d'erreur avec un mot de passe trop court.

def test_an_auth_login_password_too_short(client_lang_code_an):
    client,  lang_code  =  client_lang_code_an
    """
    TEST: submit login_form with too short password
    EXPECT: proper error message
    """

    cleanup_db_auth()

    auth_login_url = get_auth_login_url()

    auth_login_info = AuthLoginInfo()
    login_form_prefix  = auth_login_info.login_form_prefix

    input_validation = InputValidationAuth()
    length_min = input_validation.password.length_min
    error_message = check_text(lang_code, input_validation.password.error_message_length_min)

    url, rv = client_post(
        client,
        auth_login_url,
        form_field2values=get_login_form_form_field2values_for_submit(
            password='x' * (length_min - 1)
        ),
        form_prefix=login_form_prefix,
        follow_redirects=True
    )
    assert page_contains_text(rv, error_message)

Avant le test proprement dit, certaines données sont supprimées de la base de données, le cas échéant, pour éviter toute interférence. J'obtiens le formulaire prefix, longueur minimale et message d'erreur en tirant sur certaines classes.

Il devient plus complexe lorsqu'il est authentifié. Cela est dû au fait qu'un user enregistré peut définir la langue préférée dans le compte. Par exemple, le user avec l'allemand (Deutsch) comme langue préférée, se trouve sur la version anglaise du site, et se connecte ensuite. Après la connexion, la langue du site passe automatiquement à l'allemand (Deutsch).

Comme je veux vérifier les messages dans toutes les langues, j'ai ajouté un code à la fonction my_test_user_login() pour me faciliter la vie. Si la langue, lang_code, est spécifiée, alors après une connexion, la fonction user est déplacée vers la version linguistique spécifiée.

def test_ay_auth_try_to_login_when_loggedin_get(client_lang_code_ay):
    client,  lang_code  =  client_lang_code_ay
    """
    TEST: logged in test  user, try to login
    EXPECT: proper error message
    """

    cleanup_db_auth()

    auth_login_url = get_auth_login_url()

    auth_login_info = AuthLoginInfo()
    error_message = check_text(lang_code, auth_login_info.error_message_you_are_already_logged_in)

    rv = client.get(auth_login_url, follow_redirects=True)
    assert page_contains_text(rv, error_message), 'not found, error_message = {}'.format(error_message)

Temps d'essai

Pour chronométrer un test, vous pouvez simplement placer la commande time devant la commande Pytest , par exemple :

time python3 -m pytest --cov=shared/blueprints/auth --cov-report term-missing  tests_frontend  -k "test_ay_auth_try_to_login_when_loggedin_get" -s -v

à l'extrémité du terminal :

real	0m 16.67s
user	0m 9.65s
sys	0m 0.29s

C'est le résultat d'un seul test avec six langues, soit une moyenne de 2,5 secondes par langue.

Une façon plus facile de voir en détail les temps d'exécution des tests est d'ajouter le paramètre --durations=N à la ligne de commande. Si N=0, vous obtenez les temps d'exécution pour chaque test. Avec N=1, vous obtenez le temps d'exécution du test le plus lent. Avec N=2, vous obtenez le temps d'exécution des deux tests les plus lents, etc. Par exemple, pour obtenir les 4 tests les plus lents du formulaire de contact, la commande est

time python3 -m pytest --cov=shared/blueprints/auth --cov-report term-missing  tests_frontend  -k "test_an_contact" -s -vv  --durations=4 --lc=fr

Le résultat comprend :

========== slowest 4 test durations ==========
14.88s call     test_an_contact_form.py::test_an_contact_form_multiple_same_ip_address[fr]
6.96s call     test_an_contact_form.py::test_an_contact_form_multiple_same_email[fr]
6.36s call     test_an_contact_form.py::test_an_contact_form_missing_form_field[fr]
2.87s call     test_an_contact_form.py::test_an_contact_form_thank_you_logged_in[fr]
========== 15 passed, 24 deselected, 1 warning in 51.92s ==========

Chronométrage à l'intérieur du fixture

Pour voir où va tout ce temps, j'ai fait un test de temps en collectant des temps à différents stades dans le fixture, client_lang_code_ay() authentifié.

0.0  client_lang_code_ay
0.141031 after: flask_app =  create_app(project_config)
0.141062 after: with flask_app.test_client() as client
0.141078 after: with flask_app.app_context()
0.448304 after: rv = client.get(get_language_root_url(lang_code))
0.531201 after: my_test_user_restore_password()
1.154249 after: my_test_user_login(client,  lang_code)
2.553381 after: yield (client,  lang_code)
2.624607 after: my_test_user_logout(client,  lang_code)

Le temps de connexion est plus long que prévu, ce qui est dû à un retard délibéré lors de la connexion. Les tests avec le fixture non authentifié, client_lang_code_an(), prennent en moyenne 1,5 seconde, ce qui est normal sans connexion.

Réduire la durée du test en modifiant la portée de Pytest

Comme le champ d'application du Pytest est "fonction", nous obtenons un nouveau test_client pour chaque test. J'ai utilisé le scope='function' pour voir les tests exécutés pour toutes les langues. La sortie de Pytest :

test1[de] PASSED
test1[en] PASSED
test1[es] PASSED
test1[fr] PASSED
test2[de] PASSED
test2[en] PASSED
test2[es] PASSED
test2[fr] PASSED
test3[de] PASSED
test3[en] PASSED
test3[es] PASSED
test3[fr] PASSED

C'est très bien, mais cela prend aussi beaucoup de temps.

Pour réduire la durée du test, je change le champ d'application de fixture en "session". Dans ce cas, un nouveau test_client sera créé uniquement pour chaque langue. Tout d'abord, tous les tests sont exécutés pour la langue "de", puis tous les tests sont exécutés pour la langue "en", etc. La sortie de Pytest :

test1[de] PASSED
test2[de] PASSED
test3[de] PASSED
test1[en] PASSED
test2[en] PASSED
test3[en] PASSED
test1[es] PASSED
test2[es] PASSED
test3[es] PASSED
test1[fr] PASSED
test2[fr] PASSED
test3[fr] PASSED

La durée du test est ainsi considérablement réduite. Mais seulement si vous sélectionnez un grand nombre de tests, tous, bien sûr. En général, la plupart des tests se déroulent très rapidement et seuls quelques uns sont responsables de 80% du temps de test.

La durée des 14 tests (contact_form) utilisant la fonction scope='function' était de 00:06:11. Après avoir changé le scope en "session", le temps était de 00:04:56. Dans ce cas, la réduction du temps de test est de 20%. Dans un autre cas, la réduction était supérieure à 50 %.

Conception du test

Plus nous pouvons extraire de données de notre application, plus nous pouvons automatiser les tests. Par exemple, quel est le prefix du formulaire de connexion ? Quels sont les messages d'erreur lorsque l'on essaie de se connecter alors que l'on est déjà connecté ? Quel est le nombre minimum et maximum de caractères pour un mot de passe ? Ces données sont présentes dans l'application, comment les faire sortir ?

Pour un certain nombre de classes, j'ai ajouté une classe "Info" qui est le parent de la classe actuelle.

class AuthLoginInfo:

    def __init__(self):

        self.error_message_you_are_already_logged_in = _('You are already logged in.')
        self.error_message_login_is_temporary_suspended = _('Log in is temporary suspended.')
        ...
        self.message_you_are_logged_in = _('You are logged in.')

        self.login_form_prefix  = self.login_form_id


class AuthLogin(AuthLoginInfo):

    def __init__(self):
        super().__init__()

    def login(self):

        if already_logged_in():
            flash(self.error_message_you_are_already_logged_in, 'error')
            return redirect(already_logged_in_redirect_url())
        ...

Je ne sais toujours pas si cela peut être mieux fait, mais c'est faisable.

Automatisation de la vérification des langues

Dans les tests ci-dessus, nous ne savons pas si les messages sont réellement corrects et traduits pour chaque langue. Nous devons vérifier manuellement les textes, mais nous pouvons essayer de minimiser notre travail.

J'ai créé une fonction check_text() qui fait trois choses :

  • Stocker le texte dans un dictionnaire
  • Vérifier si le même texte se trouve déjà dans un dictionnaire
  • Stocker le texte dans un fichier journal des langues

Les noms de ces fichiers journaux de langue sont les noms de test. Un test est effectué pour différentes langues. Si le texte se trouve déjà dans le dictionnaire, il y a probablement quelque chose qui ne va pas, comme le fait que le texte n'est pas traduit. Dans ce cas, j'appelle "assert" dans la fonction check_text(). Cela indique une erreur, ce qui nous permet de déterminer ce qui ne va pas.

Si tout va bien, je vérifie le fichier journal des langues pour examiner les textes. Les champs de lignes séparés par des tabulations dans ce fichier sont test_name, text_id, lang_code, text :

test_ay_auth_try_to_login_when_loggedin_get	default	de	Sie sind bereits eingeloggt.
test_ay_auth_try_to_login_when_loggedin_get	default	en	You are already logged in.
test_ay_auth_try_to_login_when_loggedin_get	default	es	Ya está conectado.
test_ay_auth_try_to_login_when_loggedin_get	default	fr	Vous êtes déjà connecté.
test_ay_auth_try_to_login_when_loggedin_get	default	nl	Je bent al ingelogd.
test_ay_auth_try_to_login_when_loggedin_get	default	ru	Вы уже вошли в систему.

Je ne connais pas le russe et seulement un peu l'espagnol et le français, mais je pense que ça a l'air bien.

En savoir plus sur la fonction check_text()

Je peux spécifier un text_id lorsque je veux vérifier plus d'un texte dans une fonction de test. Si nous ne le spécifions pas, alors "default" est utilisé.
J'ai également ajouté un drapeau pour les textes que nous ne voulons pas vérifier (automatiquement). Le nom du test, l'appelant, est extrait de la pile. Les premières lignes de la fonction check_text() :

def check_text(lang_code, text, text_id=None, do_not_check=False, dbg=False):
    test_name = inspect.stack()[1][3]
    ...

Nous avons fait beaucoup de choses, mais nous devons encore vérifier manuellement les textes à chaque fois après un test. C'est encore beaucoup trop de travail, nous avons vérifié les textes, les traductions, dans le fichier journal des langues et ne voulons pas recommencer !

Pour éviter cela, j'ai créé un répertoire "language_logs_ok" et j'ai copié les fichiers journaux de langue que nous avons approuvés ici. Mais encore trop de travail, nous ne voulons pas copier les fichiers manuellement !

Avec un peu de code supplémentaire, nous pouvons vérifier à la fin d'un test si ce fichier ok_file existe. S'il n'existe pas, nous pouvons demander à approuver les textes et si la réponse est oui, nous copions également le fichier journal de langue dans le répertoire "language_logs_ok". S'il existe, nous pouvons comparer les textes du test en cours aux textes approuvés et signaler une erreur s'ils ne correspondent pas.

Pytest a hooks qui peut être utilisé pour ajouter des fonctionnalités avant le début des tests et après la fin des tests. Ici, je n'utilise que ce dernier :

def pytest_sessionfinish(session, exitstatus):
    ....

J'ai ajouté le code et maintenant le résultat est :

test_an_auth_login_password_too_long: NO ok file yet WARNING
texts:
test_an_auth_login_password_too_long	default	de	Das Passwort muss zwischen 6 und 40 Zeichen lang sein.
test_an_auth_login_password_too_long	default	en	Password must be between 6 and 40 characters long.
test_an_auth_login_password_too_long	default	es	La contraseña debe tener entre 6 y 40 caracteres.
test_an_auth_login_password_too_long	default	fr	Le mot de passe doit comporter entre 6 et 40 caractères.
test_an_auth_login_password_too_long	default	nl	Het wachtwoord moet tussen 6 en 40 tekens lang zijn.
test_an_auth_login_password_too_long	default	ru	Пароль должен иметь длину от 6 до 40 символов.
Do you approve these texts (create an ok file)? [yN]

Si j'approuve ces textes, le fichier ok_file est créé. Ensuite, lors de l'exécution suivante, après les résultats du test Pytest , le message suivant est affiché :

test_an_auth_login_password_too_long: language match with ok_file PASSED

Cela m'enlève non seulement beaucoup de stress mais aussi beaucoup de temps !

Un autre test avec plusieurs textes

J'utilise le paquet WTForms . Je peux tirer le formulaire dans le test, extraire les étiquettes des champs du formulaire et vérifier qu'elles se trouvent sur la page. En automatisant la vérification des étiquettes des champs de formulaire, nous éliminons les traductions manquantes ou erronées.

Le formulaire de connexion :

class AuthLoginForm(FlaskForm):

    email = StringField(
        _l('Email'),
        filters=[strip_whitespace],
        validators=[InputRequired()])

    password = PasswordField(
        _l('Password'),
        render_kw={'autocomplete': 'off'},
        filters=[strip_whitespace],
        validators=[InputRequired()])

    remember = BooleanField(_l('Remember me'))

    submit = SubmitField(_l('Log in'))

Le test :

def test_an_auth_login_form_labels(client_lang_code_an):
    client,  lang_code  =  client_lang_code_an
    """
    TEST: check login form labels
    EXPECT: proper error message
    """
    fname = get_fname()

    cleanup_db_auth()

    auth_login_url = get_auth_login_url()

    auth_login_info = AuthLoginInfo()
    auth_login_form_prefix  = auth_login_info.login_form_prefix

    auth_login_form = AuthLoginForm(prefix=auth_login_form_prefix)

    # select page
    location, rv = client_get(client, auth_login_url, follow_redirects=True)

    assert page_contains_form_field_label(rv, 'email', auth_login_form,  lang_code, form_prefix=auth_login_form_prefix)
    assert page_contains_form_field_label(rv, 'password', auth_login_form,  lang_code, form_prefix=auth_login_form_prefix)
    assert page_contains_form_field_label(rv, 'remember', auth_login_form,  lang_code, form_prefix=auth_login_form_prefix)
    assert page_contains_form_field_label(rv, 'submit', auth_login_form,  lang_code, form_prefix=auth_login_form_prefix)

Et le résultat après un test réussi :

tests_frontend/test_an_auth_login.py::test_an_auth_login_form_labels[de] PASSED
tests_frontend/test_an_auth_login.py::test_an_auth_login_form_labels[en] PASSED
tests_frontend/test_an_auth_login.py::test_an_auth_login_form_labels[es] PASSED
tests_frontend/test_an_auth_login.py::test_an_auth_login_form_labels[fr] PASSED
tests_frontend/test_an_auth_login.py::test_an_auth_login_form_labels[nl] PASSED
tests_frontend/test_an_auth_login.py::test_an_auth_login_form_labels[ru] PASSED
test_an_auth_login_form_labels: language NO ok file yet WARNING
texts:
test_an_auth_login_form_labels	email	de	E-Mail
test_an_auth_login_form_labels	email	en	Email
test_an_auth_login_form_labels	email	es	Correo electrónico
test_an_auth_login_form_labels	email	fr	Courriel
test_an_auth_login_form_labels	email	nl	E-mail
test_an_auth_login_form_labels	email	ru	Электронная почта
test_an_auth_login_form_labels	password	de	Passwort
test_an_auth_login_form_labels	password	en	Password
test_an_auth_login_form_labels	password	es	Contraseña
test_an_auth_login_form_labels	password	fr	Mot de passe
test_an_auth_login_form_labels	password	nl	Wachtwoord
test_an_auth_login_form_labels	password	ru	Пароль
test_an_auth_login_form_labels	remember	de	Erinnern Sie sich an mich
test_an_auth_login_form_labels	remember	en	Remember me
test_an_auth_login_form_labels	remember	es	Recuérdame
test_an_auth_login_form_labels	remember	fr	Se souvenir de moi
test_an_auth_login_form_labels	remember	nl	Herinner mij
test_an_auth_login_form_labels	remember	ru	Помните меня
test_an_auth_login_form_labels	submit	de	Einloggen
test_an_auth_login_form_labels	submit	en	Log in
test_an_auth_login_form_labels	submit	es	Iniciar sesión
test_an_auth_login_form_labels	submit	fr	Ouvrir une session
test_an_auth_login_form_labels	submit	nl	Aanmelden
test_an_auth_login_form_labels	submit	ru	Войти
Do you approve these texts (create an ok file)? [yN] 

Couverture du test avec Pytest-cov

J'ai également installé Pytest-cov pour obtenir des détails sur la quantité de code utilisée pendant un test. Encore une fois, c'est très utile pour tester une bibliothèque, un paquet. Vous obtenez les numéros de ligne du code qui n'a pas été utilisé dans les tests. Vous devriez obtenir une couverture de 100% !

Lors des tests fonctionnels, il est initialement très difficile d'atteindre ces 100 % car nous testons des parties de l'application. Par exemple, si nous avons une classe de validation des entrées, lorsque nous commençons nos tests fonctionnels, nous pouvons sauter de nombreuses méthodes de validation des entrées.

Parfois, j'ajoute un code supplémentaire pour revérifier si une valeur est correcte. C'est parce que je veux m'assurer que je n'ai pas fait d'erreur quelque part, et aussi parfois je ne fais pas confiance à un paquet tiers que j'utilise. Ce code n'est pas testé. Je dois ajouter hooks pour rendre ce code testable ... un jour.

Tests parallèles

Lorsque je peux effectuer des tests en parallèle, le lot se termine plus rapidement. J'ai déjà parlé des retards délibérés qui sont insérés sur la connexion. Quelle perte de temps pour les tests. Mais je ne peux pas exécuter tous les tests en même temps. Certains tests modifient le test_user, d'autres peuvent interférer avec les limitations d'envoi de courrier électronique, etc. Je dois choisir avec soin les tests qui sont indépendants les uns des autres. Le paquet Pytest-xdist peut vous aider. Je me pencherai sur cette question un de ces jours.

Problèmes

Lorsque j'ai commencé avec mon premier Pytest fixture en exécutant le Flask test_client , j'ai obtenu l'erreur suivante :

AssertionError: a  localeselector  function is already registered

Flask-Babel avec Pytest donne AssertionError : une fonction localeselector est déjà enregistrée. Le premier paramètre ("de") ne pose aucun problème, mais le message d'erreur ci-dessus est affiché pour le paramètre suivant ("en", "es", ...). Cela se produit lorsqu'une nouvelle fonction test_client est créée.

Mon contournement, qui semble bien fonctionner, consiste à utiliser une variable globale qui indique si le localeselector a déjà été décoré. Si ce n'est pas le cas, je le décore moi-même.

    # removed @babel.localeselector
    def get_locale():
        do something

    global babel_localeselector_already_registered
    if not babel_localeselector_already_registered:
        get_locale = babel.localeselector(get_locale)
        babel_localeselector_already_registered = True

Résumé

C'est la première fois que je réalise des tests principalement fonctionnels pour ce site multilingue Flask CMS/Blog. Il m'a fallu un certain temps pour mettre en place et écrire les fonctions d'aide. Mais celles-ci sont essentielles pour écrire des tests compacts. Et je suis certain que je devrai écrire de nouvelles fonctions d'aide pour de nouvelles catégories de tests.

Je ne veux pas coder en dur des textes et des paramètres dans les tests. Les textes et les paramètres peuvent changer pendant la durée de vie d'un produit. J'ai modifié certains codes et ajouté des classes "Info" afin de pouvoir intégrer les textes et les paramètres que je veux tester.

Les tests avec des textes nécessitent une interaction et doivent être approuvés manuellement. Après l'approbation, je peux effectuer les mêmes tests et les textes seront automatiquement comparés aux textes approuvés. Si les textes changent, une ERREUR est présentée et il nous sera à nouveau demandé d'approuver ces changements.

J'ai également mis en œuvre certains tests 30+ avec Selenium. Mais j'ai décidé de me concentrer d'abord sur le Pytest + Flask .

Il y a encore beaucoup à faire, mais laissez-moi d'abord écrire un peu plus de code et de tests ...

Liens / crédits

Create and import helper functions in tests without creating packages in test directory using py.test
https://stackoverflow.com/questions/33508060/create-and-import-helper-functions-in-tests-without-creating-packages-in-test-di

how to testing flask-login? #40
https://github.com/pytest-dev/pytest-flask/issues/40

Pytest - How to override fixture parameter list from command line?
https://stackoverflow.com/questions/51992562/pytest-how-to-override-fixture-parameter-list-from-command-line

pytest cheat sheet
https://gist.github.com/kwmiebach/3fd49612ef7a52b5ce3a

Python Pytest assertion error reporting
https://code-maven.com/pytest-assert-error-reporting

Testing Flask Applications
https://flask.palletsprojects.com/en/1.1.x/testing/

En savoir plus...

Flask Pytest Testing

Laissez un commentaire

Commentez anonymement ou connectez-vous pour commenter.

Commentaires (1)

Laissez une réponse

Répondez de manière anonyme ou connectez-vous pour répondre.

avatar

Thank you for such a thorough setup. I'll be copying things from here for sure!