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

Functioneel testen van een meertalige Flask website met Pytest

Met behulp van Pytest fixtures en hooks en Flask test_client kunnen we testen uitvoeren en handmatig teksten goedkeuren.

25 juli 2020
In Testing
post main image
https://unsplash.com/@marco_blackboulevard

Testen is hard werken. Het is totaal anders dan het creëren of aanpassen van functionaliteit. Ik weet het, ik heb ook computerhardware, geïntegreerde schakelingen, hardware-testsystemen ontwikkeld. Ik heb tests geschreven voor CPUs, computerproducten, ontwikkelde testsystemen. Met hardware kun je geen fouten maken. Fouten kunnen het einde van je bedrijf betekenen.

Welkom in de wondere wereld van het testen van software

Met software zijn veel dingen anders. Fouten worden getolereerd. Ik geef kleine bedrijven niet de schuld. Maar grote tech reuzen zoals Google, Apple, Facebook, Microsoft moet de schuld krijgen. De meeste software is complex of maakt in ieder geval gebruik van bibliotheken van derden of maakt verbinding met systemen op afstand. Het testen van een applicatie is een complexe taak geworden.

Waarom ik kies voor het schrijven van functionele testen (eerst)

Er zijn veel verschillende soorten testen voor software, om de meest bekende bij ontwikkelaars te noemen:

  • Eenheidstest
  • Integratietest
  • Functionele test
  • End-to-end test

Eenheidstests zijn gemakkelijk te schrijven, maar zelfs als ze allemaal slagen, garandeert dit niet dat mijn Flask applicatie werkt. Integratietesten zijn ook niet zo moeilijk. Als u bibliotheken schrijft, zijn deze tests perfect zinvol. Trek gewoon de klassen in en schrijf een aantal tests. Maar er is geen garantie dat mijn applicatie werkt als je alleen deze testen doet.

Hoe kan ik er met beperkte tijd voor zorgen dat mijn Flask CMS/Blog applicatie daadwerkelijk werkt? Ik heb besloten om te gaan voor de functionele tests en end-to-end -tests. Dit is veel meer werk, maar ik weet in ieder geval dat de bezoeker van de website de juiste dingen ziet.

Hulpmiddelen voor het testen

Ik heb een 'test' optie toegevoegd aan mijn docker setup. Dit is net als mijn 'ontwikkeling' optie, met code buiten de container, maar met een paar extra pakketten,

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

Pytest is mooie test framework als u uw Flask applicatie wilt testen. Wanneer u Pytest-flake8 installeert, kunt u ook Flake8 uitvoeren vanaf Pytest:

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

Maar ik geef er de voorkeur aan om flake8 op zichzelf te draaien, waarbij ik lange lijnen negeer:

flake8  --ignore=E501  app_frontend/blueprints/demo

Testopstelling

Beginnen met Pytest is niet erg moeilijk. Ik heb een tests_app_frontend directory gemaakt in de project directory:

.
|-- 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
|   `-- ...

Het pytest.ini bestand:

[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

De helpersmap bevat functies die je leven veel gemakkelijker kunnen maken, in feite was ik na het schrijven van een paar tests op zoek naar het minimaliseren van mijn testcode, en besteedde ik veel tijd aan het schrijven van deze helpersfuncties.

Schoonmaakfuncties in helpers_cleanup.py maken deel uit van de test en worden voor de eigenlijke test uitgevoerd, voorbeelden:

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):
    ...

Sommige van de globalen die in helpers.globals zijn opgeslagen:

# 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'

Enkele van de helper functies in 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):
    ...

Ik heb een client_get() en client_post() functie toegevoegd die zich bijna identiek gedragen als client.get() en client.post() maar ook de locatie van de pagina teruggeven na omleidingen. De functie client_post() heeft ook een optioneel formulier_prefix parameter die gebruikt kan worden wanneer het formulier prefixed is, zie WTForms documentatie.

De functie my_test_user_login() kan gebruikt worden om in te loggen in de test user wanneer deze niet geauthenticeerd is. Alle helperbestanden hebben een optioneel dbg (debug) argument. Bij het ontwikkelen van tests wil je vaak zien wat er gebeurt, door dbg=True in te stellen krijg ik alle details, ook in het logbestand.

Testbestandsnamen, functienamen en authenticatie

In Pytest kun je 'mark' gebruiken om tests te groeperen, maar dat heb ik nog niet gedaan. In plaats daarvan heb ik twee hoofdcategorieën. Tests wanneer de user niet geauthenticeerd is, an = niet geauthenticeerd, en tests wanneer de user geauthenticeerd is, ay = geauthenticeerd. Testbestanden en tests beginnen met dezelfde string. Testbestand:

test_an_auth_login.py

heeft testen zoals:

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

En het testbestand:

test_ay_account.py

heeft testen als:

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

De parameters 'client_lang_code_an' en 'client_lang_code_ay' zijn Tuples die de (test)client en de taalcode bevatten.
Ze worden uitgepakt in de testfunctie. Er is een pakket Pytest-cases dat het uitpakken vermijdt, maar ik wilde dit niet gebruiken.

Testen starten met fixtures

Ik wil dat de tests één voor één worden uitgevoerd voor alle beschikbare talen, of slechts één of meer talen die ik op de opdrachtregel specificeer. Dit betekent dat de fixture scope 'functie' moet zijn. Voorbeeld van ouput uit 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

U zou kunnen beargumenteren dat het uitvoeren van alle tests voor taal X eerst, dan het uitvoeren van alle tests voor taal Y, sneller is. U hebt gelijk, zie ook hieronder 'het wijzigen van de Pytest scope', maar ik wilde de optie om dingen bij elkaar te houden als er meerdere nieuwe testen worden uitgevoerd. Hier is het belangrijkste deel van 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)

Niet echt veel bijzonders hier. Tests die met een geauthenticeerde user moeten worden uitgevoerd, hebben de parameter 'client_lang_code_ay'. Dit verwijst naar de fixture met deze naam. In dit geval wordt de klant ingelogd voordat de test wordt aangeroepen.

Als ik de '--lc' op de opdrachtregel niet specificeer, worden alle talen gebruikt voor een test. Ik kan één of meerdere talen selecteren door bijvoorbeeld '--lc de' op de opdrachtregel te specificeren. In feite heb ik later ook een aantal toegevoegd waarmee ik '--lc=fr,en,de' kan typen.

Database

Omdat dit een functionele test is van een CMS/Blog heb ik wat gegevens in de database nodig. Voorafgaand aan een test moet ik wat data opschonen om te voorkomen dat de test wordt verstoord. Ik moet deze opschooncode zelf schrijven. Dit is extra werk, maar niet zo erg omdat ik delen van deze code ook later in de applicatie kan gebruiken. Zoals bij het verwijderen van een user.

Enkele testen

Wanneer niet geverifieerd is de situatie niet erg complex. Hier is een test die de foutmelding controleert met een te kort wachtwoord.

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)

Voor de eigenlijke test worden enkele gegevens uit de database verwijderd, indien aanwezig, om interferentie te voorkomen. Ik krijg het formulier prefix, minimale lengte en foutmelding door enkele klassen in te trekken.

Het wordt ingewikkelder als het wordt geverifieerd. Dit heeft te maken met het feit dat een geregistreerde user de voorkeurstaal in het account kan instellen. Bijvoorbeeld, de user met Duits (Deutsch) als voorkeurstaal, staat op de Engelse versie van de site, en logt dan in. Na het inloggen wordt de taal van de site automatisch omgezet naar Duits (Deutsch).

Omdat ik berichten in alle talen wil controleren, heb ik wat code toegevoegd aan de my_test_user_login() functie om mijn leven gemakkelijker te maken. Als de taal, lang_code, is opgegeven, dan wordt na een login de user verplaatst naar de opgegeven taalversie.

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)

Testtijd

Om een test te timen kunt u eenvoudigweg het tijdcommando voor bijvoorbeeld Pytest plaatsen:

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

afdrukken aan het einde van de terminal:

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

Dit is het resultaat voor een enkele test met zes talen, gemiddeld 2,5 seconden per taal.

Een gemakkelijkere manier om de uitvoeringstijden van de tests in detail te bekijken is door de --durations=N parameter toe te voegen aan de opdrachtregel. Als N=0 krijgt u de uitvoeringstijden voor elke test. Met N=1 krijgt u de uitvoeringstijd van de langzaamste test. Met N=2 krijgt u de uitvoeringstijd van de twee langzaamste testen, etc. Bijvoorbeeld, om de langzaamste 4 contact formulier testen te krijgen is het commando:

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

Het resultaat is inclusief:

========== 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 ==========

Timings in de fixture

Om te zien waar al die tijd heen gaat heb ik een tijdtest gedaan door in de geauthenticeerde fixture, client_lang_code_ay() de tijden te verzamelen.

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)

De tijd om in te loggen is langer dan u mag verwachten, dit wordt veroorzaakt door een opzettelijke vertraging bij het inloggen. Tests met de niet geauthenticeerde fixture, client_lang_code_an(), nemen gemiddeld 1,5 seconde in beslag, wat verwacht wordt zonder in te loggen.

Het verkorten van de testtijd door het veranderen van de Pytest scope

Omdat de Pytest scope 'functie' is, krijgen we voor elke test een nieuwe test_client . Ik heb de scope='functie' gebruikt om tests te zien uitvoeren voor alle talen. De uitvoer van 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

Dit is erg leuk, maar kost ook veel tijd.

Om de testtijd te verkorten verander ik de fixture scope naar 'sessie'. In dit geval wordt alleen voor elke taal een nieuwe test_client aangemaakt. Eerst worden alle testen uitgevoerd voor taal 'de', daarna worden alle testen uitgevoerd voor taal 'en', enz. De uitvoer van 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

De testtijd wordt op deze manier drastisch verkort. Maar alleen als je veel, alle, tests selecteert natuurlijk. Over het algemeen zullen de meeste tests zeer snel worden uitgevoerd en slechts enkele zijn verantwoordelijk voor 80% van de testtijd.

De testtijd voor 14 (contact_form) tests met de scope='functie' was 00:06:11. Na het wijzigen van de scope naar 'sessie' was de tijd 00:04:56. In dit geval is de reductie van de testtijd 20%. In een ander geval was de reductie meer dan 50%.

Ontwerp voor test

Hoe meer gegevens we uit onze applicatie kunnen halen, hoe meer we het testen kunnen automatiseren. Wat is bijvoorbeeld de prefix van het inlogformulier? Wat zijn de foutmeldingen als u probeert in te loggen als u al bent ingelogd? Wat zijn het minimum en maximum aantal karakters voor een wachtwoord? Deze gegevens zijn aanwezig in de applicatie, hoe krijgen we ze eruit?

Voor een aantal klassen heb ik een 'Info' klasse toegevoegd die de ouder is van de eigenlijke klasse.

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())
        ...

Ik weet nog steeds niet of dit beter kan, maar het is werkbaar.

Automatisering van de taalcontrole

In de bovenstaande tests weten we niet of de berichten daadwerkelijk correct zijn en voor elke taal vertaald zijn. We moeten de teksten handmatig controleren, maar kunnen proberen ons werk te minimaliseren.

Ik heb een functie check_text() gemaakt die drie dingen doet:

  • Sla de tekst op in een woordenboek
  • Controleer of dezelfde tekst al in een woordenboek staat.
  • Sla de tekst op in een taallogboekbestand

De namen van deze taallogbestanden zijn de testnamen. Er wordt een test uitgevoerd voor verschillende talen. Als de tekst al in het woordenboek staat dan is er waarschijnlijk iets mis, alsof de tekst niet vertaald is. In dit geval roep ik 'assert' op in de functie check_text(). Dit laat een fout zien zodat we kunnen onderzoeken wat er mis is.

Als alles goed is, controleer ik het taallogbestand om de teksten te bekijken. De tab-gescheiden velden van regels in dit bestand zijn test_naam, tekst_id, lang_code, tekst:

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	Вы уже вошли в систему.

Ik ken geen Russisch en alleen een beetje Spaans en Frans, maar ik denk dat dit er goed uitziet.

Meer over de functie check_text()

Ik kan een text_id opgeven als ik meer dan één tekst in een testfunctie wil controleren. Als we dit niet specificeren dan wordt 'standaard' gebruikt.
Ik heb ook een vlag toegevoegd voor teksten die we niet (automatisch) willen controleren. De naam van de test, de beller, wordt uit de stapel gehaald. De check_text() functie eerste regels:

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

We hebben veel gedaan, maar we moeten nog steeds elke keer na een testrit handmatig teksten controleren. Dit is nog steeds veel te veel werk, we hebben de teksten, vertalingen, in het taallogbestand geverifieerd en willen dit niet nog een keer doen!

Om dit te voorkomen heb ik een directory 'language_logs_ok' gemaakt en de taallogbestanden die we hier hebben goedgekeurd, gekopieerd. Maar nog steeds te veel werk, we willen de bestanden niet handmatig kopiëren!

Met wat extra code kunnen we aan het einde van een test controleren of dit ok_bestand bestaat. Als het niet bestaat, kunnen we vragen om de teksten goed te keuren en als het antwoord Ja is, kopiëren we het taallogbestand ook naar de directory 'language_logs_ok'. Als het bestaat, kunnen we de teksten van de huidige test vergelijken met de goedgekeurde teksten en een foutmelding geven als ze niet overeenkomen.

Pytest heeft hooks die gebruikt kan worden om functionaliteit toe te voegen voor het begin van de tests en na afloop ervan. Hier gebruik ik alleen de laatste:

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

Ik heb de code toegevoegd en nu is het resultaat:

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]

Als ik deze teksten goedkeur, wordt het ok_file aangemaakt. Vervolgens wordt bij de volgende run, na de Pytest testresultaten, de volgende melding getoond:

test_an_auth_login_password_too_long: language match with ok_file PASSED

Dit neemt niet alleen veel stress weg, maar ook veel tijd!

Nog een test met meerdere teksten

Ik gebruik het WTForms pakket. Ik kan het formulier in de test trekken, de veldlabels van het formulier uitpakken en controleren of ze op de pagina staan. Door het automatiseren van de controles van de veldlabels van het formulier elimineren we gemiste vertalingen, of verkeerde vertalingen.

Het aanmeldingsformulier:

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'))

De 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)

En het resultaat na een succesvolle test:

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] 

Test dekking met Pytest-cov

Ik heb ook Pytest-cov geïnstalleerd om details te krijgen over de hoeveelheid code die tijdens een test is gebruikt. Nogmaals, dit is zeer de moeite waard bij het testen van een bibliotheek, pakket. Je krijgt de regelnummers van de code die niet werd gebruikt in de tests. Je zou 100% dekking moeten krijgen!

Bij het uitvoeren van functionele testen is het in eerste instantie erg moeilijk om deze 100% te bereiken, omdat we delen van de applicatie testen. Als we bijvoorbeeld een inputvalidatieklasse hebben, kunnen we bij het starten van onze functionele testen veel inputvalidatiemethoden overslaan.

Soms voeg ik extra code toe om opnieuw te controleren of een waarde correct is. Dit komt omdat ik er zeker van wil zijn dat ik niet ergens een fout heb gemaakt, en ook vertrouw ik soms een pakket van een derde partij dat ik gebruik niet. Deze code blijft ongetest. Ik moet hooks toevoegen om deze code testbaar te maken ... op een dag.

Parallelle testen

Wanneer ik de tests parallel kan uitvoeren, is de batch sneller klaar. Ik heb het al gehad over opzettelijke vertragingen die bij het inloggen worden ingevoerd. Wat een verspilling van testtijd. Maar ik kan niet alle testen tegelijk uitvoeren. Sommige tests wijzigen de test_user, andere kunnen de beperkingen van het verzenden van e-mail verstoren, enz. Ik moet zorgvuldig selecteren welke testen onafhankelijk van elkaar zijn. Het Pytest-xdist pakket kan helpen. Ik zal hier een dezer dagen naar kijken.

Problemen

Toen ik begon met mijn eerste Pytest fixture met de Flask test_client kreeg ik de volgende fout:

AssertionError: a  localeselector  function is already registered

Flask-Babel met Pytest geeft AssertionError: een localeselector functie is al geregistreerd. Bij de eerste parameter ('de') geen problemen maar bij de volgende ('en', 'es', ...) wordt bovenstaande foutmelding getoond. Dit gebeurt wanneer een nieuwe test_client wordt aangemaakt.

Mijn workaround, die prima lijkt te werken, gebruikt een globale variabele die aangeeft of de localeselector al gedecoreerd was. Zo niet, dan versier ik het zelf.

    # 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

Samenvatting

Dit is mijn eerste implementatie van voornamelijk functionele tests voor deze meertalige Flask CMS/Blog website. Het kostte veel tijd om de helperfuncties in te stellen en te schrijven. Maar deze zijn essentieel voor het schrijven van compacte tests. En ik ben er zeker van dat ik nieuwe helperfuncties zal moeten schrijven voor nieuwe testcategorieën.

Ik wil geen hardcode teksten en parameters in de tests opnemen. Teksten en parameters kunnen tijdens de levensduur van een product veranderen. Ik heb wat code veranderd en 'Info'-klassen toegevoegd zodat ik de teksten en parameters die ik wil testen kan binnenhalen.

Tests met teksten vereisen interactie en moeten handmatig worden goedgekeurd. Na goedkeuring kan ik dezelfde testen uitvoeren en worden de teksten automatisch getoetst aan de goedgekeurde teksten. Als de teksten veranderen, wordt er een ERROR gepresenteerd en worden we opnieuw gevraagd om deze wijzigingen goed te keuren.

Ik heb ook een aantal 30+ testen uitgevoerd met Selenium. Maar ik heb besloten me eerst te richten op de Pytest + Flask .

Er is nog veel te wensen over, maar laat me eerst wat meer code en tests schrijven ...

Links / credits

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/

Laat een reactie achter

Reageer anoniem of log in om commentaar te geven.

Opmerkingen (1)

Laat een antwoord achter

Antwoord anoniem of log in om te antwoorden.

avatar

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