Zadziwiająco dużo praktycznych unit-testów wymaga realizacji sekwencji operacji. Aby sprawdzić, czy poprawnie działa kasowanie profilu dla którego istnieją zlecone operacje z przyszłą datą, trzeba najpierw ten profil stworzyć, potem zlecić owe operacje i dopiero można przejść do właściwego testu. Aby zweryfikować zachowanie trzeciej strony jakiejś sekwencji formularzy trzeba zasymulować logowanie, zainicjować sekwencję, przejść pierwszą stronę, przejść drugą stronę. I tak dalej…
Różnie można do tego podchodzić.
Technika przyrostowa
Bardziej rozbudowane testy wołają te „krótsze” jako
funkcje pomocnicze. Mamy np. normalną funkcję test_create_profile
,
rozszerzoną o zwracanie profilu (albo jego identyfikatora, albo nazwy, albo...):
def test_create_profile():
# …
# Zakłada profil i testuje jego poprawność
# …
return profile
następnie funkcja test_schedule_operations
zaczyna się od pomocniczego wywołania test_create_profile()
po czym robi swoje:
def test_schedule_operations():
profile = test_create_profile()
# Tu właściwe testy zlecania operacji
return profile
a w końcu test_close_profile_with_ops
zaczyna się od test_schedule_operations()
:
def test_close_profile():
profile = test_schedule_operations()
# Tu właściwe testy zamykania
Jest tu wiele wariantów, zamiast wołać wcześniejsze testy można współdzielić funkcje pomocnicze albo nawet modelować testy jako „przyrastające” klasy w modelu dziedziczenia.
Ta technika działa ale ma dwie wady:
-
przy dłuższych sekwencjach zaczyna wpływać na wydajność (jeśli mamy do testowania osiemdziesiąt operacji na profilu z potwierdzonym emailem, musimy osiemdziesiąt razy założyć profil i potwierdzić mu ten email, a to trwa)
-
jeżeli na którymś z wczesnych etapów pojawia się błąd (np. pojawia się błąd w tworzeniu profilu), zostaje on zaraportowany dla chmary testów i nieraz pewnego wysiłku wymaga dopatrzenie się, co się właściwie dzieje.
Droga na skróty
Zamiast przechodzić pełną sekwencję wprowadzającą, zakładamy bezpośrednio odpowiedni rekord w bazie danych, podstawiamy zamiast prawdziwego obiektu jakiegoś mocka, siłowo przestawiamy takie czy inne atrybuty, odtwarzamy jakąś formę backupu… – tak, by uzyskać „od razu” ten (np.) profil z emailem i zleconymi operacjami.
def test_close_profile():
profile = db_make_profile_with_email_and_operations()
# Tu właściwe testy zamykania profilu
Rozwiązuje to problemy poprzedniej techniki ale … wprowadza gorsze.
Najmniejszym problemem jest, że ten symulujący kod trzeba napisać,
choć też czasem wymaga to trochę wysiłku. Prawdziwy problem widać, gdy
pomyślimy nie tylko o stworzeniu i inicjalnym użyciu testów, ale też o
ich utrzymaniu. Gdzie mamy gwarancję, że kod „skrótu” faktycznie robi
to samo, co kod „prawdziwy”? A nawet jeśli robi to teraz, jak
zapewnić, że w przyszłości nie powstanie rozbieżność (np. że nie
zapomnimy w owej db_make_profile_with_email_and_operations
dodać
zakładania dodatkowego rekordu w nowej tabeli gdy zacznie to robić prawdziwe
tworzenie profilu).
Przy kodzie o dłuższym czasie życia łatwo niestety dojść tu do sytuacji, gdy nasze testy dokładnie testują … zdezaktualizowane obiekty.
Wielkie testy
Piszemy testy w których pojedyncza funkcja testowa przeprowadza kilkanaście albo i kilkadziesiąt kolejnych operacji, testując kolejno efekty każdej z nich.
def test_profile_operations():
# … zakładamy profil
# … testujemy jego poprawność
# … potwierdzamy email
# … testujemy poprawność stanu profilu po tym
# … zlecamy jakąś operację
# … testujemy efekty
# … i jeszcze jedną
# … i znowu testujemy efekty
# … i zamykamy
# … i testujemy co z tego wynikło
To podejście, wbrew pozorom, ma sporo zalet (nie powtarzamy tej samej inicjalizacji dziesiątki razy, błąd dostajemy tam gdzie wystąpił, nie musimy pisać zaślepkowego kodu) ale … bardzo kiepsko wygląda raportowo. Możemy dodawać do takiej funkcji dziesiątki a nawet setki kolejnych akcji i weryfikacji, a przy uruchamianiu dostajemy raport o pojedynczym udanym (lub nie) teście. Nie ma też mowy o śledzeniu przebiegu testu w trakcie jego działania, z punktu widzenia runnera dzieje się jedna rzecz.
Generatory testów
Ze względu na wszystko powyższe, bardzo lubię stosunkowo mało znaną technikę obsługiwaną przez Nose: generowanie testów. Pojedyncza funkcja testowa może być generatorem, który zwraca kolejne funkcje, a te są raportowane jako indywidualne testy. Brzmi to groźnie ale w praktyce wymaga bardzo niewielkich zmian kodu w stosunku do „wielkich testów”.
Powiedzmy, że mamy taką „wielką” funkcję testową.
def test_profile_ops():
# Szykujemy parametry testowe
client_id, client_password = generate_client_id(), generate_password()
# Wołamy pierwszą z testowanych funkcji
profile_id = create_profile(client_id, client_password)
# Sprawdzamy jej efekty, tu załóżmy że mamy funkcję pomocniczą która
# ogląda dany profil (np. w bazie danych) i sprawdza czy jego dane są OK
check_profile_correctness(profile_id, client_id)
# Dalsze parametry testowe
email = generate_email()
# Wołamy drugą z testowanych funkcji
confirm_profile_email(profile_id, email)
# Sprawdzamy efekty, tu np. bezpośrednią asercją
nt.assert_equal(email, db_lookup_profile_email(profile_id))
nt.assert_equal(None, db_lookup_profile_phone(profile_id))
# ... itd
Można ją przerobić następująco:
def test_profile_ops():
client_id, client_password = generate_client_id(), generate_password()
profile_id = create_profile(client_id, client_password)
# Pojawia się słówko yield i znikają nawiasy - generujemy pierwszy test
yield check_profile_correctness, profile_id, client_id
email = generate_email()
confirm_profile_email(profile_id, email)
# Sprawdzenie efektów wkładamy do lokalnej funkcji...
def check_after_email():
nt.assert_equal(email, db_lookup_profile_email(profile_id))
nt.assert_equal(None, db_lookup_profile_phone(profile_id))
# ... którą też yieldujemy
yield check_after_email
# ... itd
Działa to niemal identycznie jak poprzednio ale każdy „moment wystąpienia yield” jest raportowany jako osobny test, z własnym wynikiem.
Pewnego przemyślenia wymaga tylko sprawa jak jest on raportowany. Ale najpierw…
Dygresja o nazwach testów
Istnieją dwie szkoły. W pierwszej piszemy
def test_create_profile():
"""Test tworzenia profilu"""
# ...
i widzimy
$ nosetests -v
Test tworzenia profilu ... ok
a w drugiej piszemy
def test_create_profile():
# Test tworzenie profilu
# ...
i widzimy
$ nosetests -v
test_profile.test_create_profile ... ok
Ta pierwsza ma pewne zalety, zwłaszcza gdy efekty testów chcemy pokazać komuś „nietechnicznemu” ale ja zdecydowanie wolę tę drugą. Zapewnia ona natychmiastową konkretną informację jaki test się udał (lub nie udał), pozwala przemyszować jego nazwę do ewentualnej ponownej próby, ładnie pokazuje powiązanie testów z modułami w których są zdefiniowane, wreszcie ……… nie jest podatna na copy & paste.
Dalsza część artykułu jest poświęcona dopracowaniu drugiej formy dla generowanych testów, zwolennicy pierwszej zechcą niejedno w poniższym kodzie zmienić.
Domyślna prezentacja generowanych testów
Nose domyślnie nazywa generowane testy łącząc nazwę „głównej” funkcji
z parametrami przekazanymi przy yield
. Przykładowo, weźmy następujący
test, w którym wykonuję kolejne zmiany w słowniku i sprawdzam ich
efekty (przykład jest dość głupi ale dał się zapisać krótko):
# -*- coding: utf-8 -*-
from nose.tools import assert_true, assert_equal, assert_raises
def check_dictlike(obj, values):
"""
Funkcja pomocnicza: sprawdza czy podany słownik lub
słownikopodobny obiekt zawiera podane wartości (przy
czym wartość None oznacza że elementu być nie powinno).
"""
for name, value in values.iteritems():
if value is None:
assert_true(name not in obj,
"Key {0} should not be present".format(name))
else:
assert_equal(obj.get(name), value,
"For key {0} value should be {1} but is {2}".format(
name, value, obj.get(name)))
def test_empty():
# Test bez yieldów, dla porównania efektów prezentacyjnych
d = {}
check_dictlike(d, dict(a=None, b=None, ccc=None))
def test_dictops_nodesc():
d = { 'a': 1, 'b': 2 }
yield check_dictlike, d, dict(a=1, b=2, c=None, x=None)
d['c'] = 7
yield check_dictlike, d, dict(a=1, b=2, c=7, x=None)
d['a'] = 4
yield check_dictlike, d, dict(a=4, b=2, c=7, x=None)
del d['a']
yield check_dictlike, d, dict(a=None, b=2, c=7, x=None)
d['b'] = 9
d['x'] = 11
yield check_dictlike, d, dict(a=None, b=9, c=7, x=11)
assert_raises(KeyError, lambda key: d[key], 'z')
yield check_dictlike, d, dict(a=None, b=9, c=7, x=11)
Efekty uruchomienia:
$ nosetests -v test_dic1.py
test_dic1.test_empty ... ok
test_dic1.test_dictops_nodesc({'a': 1, 'b': 2}, {'a': 1, 'x': None, 'c': None, 'b': 2}) ... ok
test_dic1.test_dictops_nodesc({'a': 1, 'c': 7, 'b': 2}, {'a': 1, 'x': None, 'c': 7, 'b': 2}) ... ok
test_dic1.test_dictops_nodesc({'a': 4, 'c': 7, 'b': 2}, {'a': 4, 'x': None, 'c': 7, 'b': 2}) ... ok
test_dic1.test_dictops_nodesc({'c': 7, 'b': 2}, {'a': None, 'x': None, 'c': 7, 'b': 2}) ... ok
test_dic1.test_dictops_nodesc({'x': 11, 'c': 7, 'b': 9}, {'a': None, 'x': 11, 'c': 7, 'b': 9}) ... ok
test_dic1.test_dictops_nodesc({'x': 11, 'c': 7, 'b': 9}, {'a': None, 'x': 11, 'c': 7, 'b': 9}) ... ok
----------------------------------------------------------------------
Ran 7 tests in 0.003s
OK
Niezbyt dobrze. Gdy mamy jeden-dwa parametry o prostej wartości potrafi to wyglądać nieźle, tu zdecydowanie tak nie jest.
Ustawienie description
Nose pozwala ustawiać generowanym funkcjom atrybut .description
i
wykorzystuje go. Zmieńmy powyższy test następująco:
# -*- coding: utf-8 -*-
from nose.tools import assert_true, assert_equal, assert_raises
def check_dictlike(obj, values):
for name, value in values.iteritems():
if value is None:
assert_true(name not in obj,
"Key {0} should not be present".format(name))
else:
assert_equal(obj.get(name), value,
"For key {0} value should be {1} but is {2}".format(
name, value, obj.get(name)))
def test_empty():
# Test bez yieldów, dla porównania efektów prezentacyjnych
d = {}
check_dictlike(d, dict(a=None, b=None, ccc=None))
def test_dictops_desc():
d = { 'a': 1, 'b': 2 }
check_dictlike.description="After simple setup (a and b)"
yield check_dictlike, d, dict(a=1, b=2, c=None, x=None)
d['c'] = 7
check_dictlike.description="After adding new element (c)"
yield check_dictlike, d, dict(a=1, b=2, c=7, x=None)
d['a'] = 4
check_dictlike.description="After modifying element (a)"
yield check_dictlike, d, dict(a=4, b=2, c=7, x=None)
del d['a']
check_dictlike.description="After removing element (a)"
yield check_dictlike, d, dict(a=None, b=2, c=7, x=None)
d['b'] = 9
d['x'] = 11
check_dictlike.description="After modifying one and adding another element (b,x)"
yield check_dictlike, d, dict(a=None, b=9, c=7, x=11)
assert_raises(KeyError, lambda key: d[key], 'z')
check_dictlike.description="After attempt to read non-existant element"
yield check_dictlike, d, dict(a=None, b=9, c=7, x=11, z=None)
Efekt:
test_dic2.test_empty ... ok
After simple setup (a and b) ... ok
After adding new element (c) ... ok
After modifying element (a) ... ok
After removing element (a) ... ok
After modifying one and adding another element (b,x) ... ok
After attempt to read non-existant element ... ok
----------------------------------------------------------------------
Ran 7 tests in 0.003s
OK
Dla zwolenników „tekstowego” nazywania testów może być to optymalna forma. Dla mnie nie jest, bo nie widać, z której funkcji testowej i którego modułu pochodzi dany test.
Niby mógłbym pisać
check_dictlike.description="test_dic2.test_dictops After modifying element (a)"
ale jest to mocno męczące i kłopotliwe w utrzymaniu (np. gdy zmieniamy nazwę pliku testów albo kopiujemy go jako bazę do nieco zmienionych innych testów).
Jest też inny problem: jeśli wykorzystywana funkcja jest reużywana, a ktoś
gdzieś zapomni ustawić to .description
, to dostanie opis ustawiony przez
… jakiś poprzedni test.
Generacja z podpórką
Wszystko co męczące dla człowieka, jest dobrą kandydaturą dla automatyzacji. Stąd urodził mi się następujący pomocniczy moduł:
# -*- coding: utf-8 -*-
import inspect, os, pprint
def yield_step(actual_function, description, *args, **kwargs):
# Ustalenie nazwy testu: po prostu jest to funkcja, która nas woła
caller = inspect.stack()[1]
caller_func_name = caller[3]
caller_file_name = os.path.basename(str(caller[1]))
# Funkcja którą zwrócimy do nose. Potrzebna głównie do tego, by
# mieć co opatrzyć opisem
def func(params):
args, kwargs = params
actual_function(*args, **kwargs)
# Fallback do pokazania parametrów jeśli nie podano opisu
if not description:
description = pprint.pformat( (args, kwargs) )
func.description = caller_file_name + "." + caller_func_name + ": " + description
return func, (args, kwargs)
Przy jego użyciu (zakładam, że został zapisany jako helpers.py
) test
wygląda następująco:
# -*- coding: utf-8 -*-
from nose.tools import assert_true, assert_equal, assert_raises
from helpers import yield_step
def check_dictlike(obj, values):
for name, value in values.iteritems():
if value is None:
assert_true(name not in obj,
"Key {0} should not be present".format(name))
else:
assert_equal(obj.get(name), value,
"For key {0} value should be {1} but is {2}".format(
name, value, obj.get(name)))
def test_empty():
# Test bez yieldów, dla porównania efektów prezentacyjnych
d = {}
check_dictlike(d, dict(a=None, b=None, ccc=None))
def test_dictops_helper():
d = { 'a': 1, 'b': 2 }
yield yield_step(check_dictlike, "After simple setup (a and b)",
d, dict(a=1, b=2, c=None, x=None))
d['c'] = 7
yield yield_step(check_dictlike, "After adding new element (c)",
d, dict(a=1, b=2, c=7, x=None))
d['a'] = 4
yield yield_step(check_dictlike, "After modifying element (a)",
d, dict(a=4, b=2, c=7, x=None))
del d['a']
yield yield_step(check_dictlike, "After removing element (a)",
d, dict(a=None, b=2, c=7, x=None))
d['b'] = 9
d['x'] = 11
yield yield_step(check_dictlike, "After modifying one and adding another element (b,x)",
d, dict(a=None, b=9, c=7, x=11))
assert_raises(KeyError, lambda key: d[key], 'z')
yield yield_step(check_dictlike, "After attempt to read non-existant element",
obj=d, values=dict(a=None, b=9, c=7, x=11, z=None))
a jego efekty:
nosetests -v test_dic3.py
test_dic3.test_empty ... ok
test_dic3.py.test_dictops_helper: After simple setup (a and b) ... ok
test_dic3.py.test_dictops_helper: After adding new element (c) ... ok
test_dic3.py.test_dictops_helper: After modifying element (a) ... ok
test_dic3.py.test_dictops_helper: After removing element (a) ... ok
test_dic3.py.test_dictops_helper: After modifying one and adding another element (b,x) ... ok
test_dic3.py.test_dictops_helper: After attempt to read non-existant element ... ok
----------------------------------------------------------------------
Ran 7 tests in 0.038s
OK
Ta forma mi odpowiada, więc na niej zakończę.
Jako bonus, powyższy zapis pozwala swobodnie stosować nazwane czy opcjonalne parametry (co ilustruje ostatni
yield_step
).