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.
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.
Thank you for such a thorough setup. I'll be copying things from here for sure!
Recent
- Database UUID primaire sleutels van je webapplicatie verbergen
- Don't Repeat Yourself (DRY) met Jinja2
- SQLAlchemy, PostgreSQL, maximum aantal rijen per user
- Toon de waarden in SQLAlchemy dynamische filters
- Veilige gegevensoverdracht met Public Key versleuteling en pyNaCl
- rqlite: een alternatief voor SQLite met hoge beschikbaarheid en distributed
Meest bekeken
- Met behulp van Python's pyOpenSSL om SSL-certificaten die van een host zijn gedownload te controleren
- Gebruik van UUIDs in plaats van Integer Autoincrement Primary Keys met SQLAlchemy en MariaDb
- PyInstaller en Cython gebruiken om een Python executable te maken
- Maak verbinding met een dienst op een Docker host vanaf een Docker container
- SQLAlchemy: Gebruik van Cascade Deletes om verwante objecten te verwijderen
- Flask RESTful API verzoekparametervalidatie met Marshmallow-schema's