Pisanie testów jednostkowych w Pythonie - dobre praktyki
Pisanie testów tworzonego kodu to obecnie codzienność pracy wielu programistów - utrzymanie wielkich i skomplikowanych projektów jest praktycznie niemożliwe (a przynajmniej bardzo trudne) bez zestawu testów, sprawdzających poprawność działania aplikacji. Testy jednostkowe, a więc takie, które sprawdzają małe elementy (np. pojedyncze funkcje, metody, czy obiekty) to jedna z najpopularniejszych obecnie metod testowania.
Python w swoim ekosystemie posiada rozmaite narzędzia wspomagające tworzenie takich testów. Nawet w standardowej bibliotece znajduje się moduł unittest
, a oprócz tego powstało wiele zewnętrznych narzędzi takich jak pytest, nose czy doctest.
W tym artykule postaram się omówić proces tworzenia testów jednostkowych oraz wskazać kilka typowych problemów, które mogą powodować pogorszenie jakości kodu testującego oraz pokażę jak je obejść lub poprawić.
Czym jest test jednostkowy, a czym test integracyjny?
Na wstępie warto zastanowić się jak wygląda test jednostkowy i czym różni się od testów integracyjnych.
Test jednostkowy (unit test) sprawdza poprawność działania pojedynczego elementu - jednostki, np. funkcji, obiektu, interakcji. W założeniu, test jednostkowy powinien działać na odizolowanym fragmencie kodu, tj. sprawdzić czy kod robi to co trzeba, ale bez uruchamiania jego zależności.
Test integracyjny (integration test) natomiast nie działa w pełnej izolacji - sprawdza poszczególne elementy w grupie - przykładowo, testuje konkretną funkcję razem z wszystkimi (lub częścią) jej zależności.
Testy jednostkowe sprawdzają, czy poszczególne elementy systemu robią to co trzeba, zaś testy integracyjne sprawdzają czy elementy w systemu działają prawidłowo razem ze sobą.
Warto znać tę różnicę, ponieważ bardzo łatwo przez przypadek napisać test integracyjny, myśląc że pisze się test jednostkowy.
Typowe problemy w testach jednostkowych
Wolny zestaw testów
Testy jednostkowe, aby były skuteczne, muszą być uruchamiane. Niestety, jeśli uruchomienie testów wiąże się z długim czasem oczekiwania na ich zakończenie, programiści często zwlekają z ich uruchomieniem. Jest to w pełni zrozumiałe - jeśli uruchomienie pełnego zestawu testów jednostkowych trwa kilkanaście do kilkudziesiąt minut, spora grupa programistów będzie uruchamiała je rzadziej - wolne testy zniechęcają do ich uruchamiania.
Gdyby z drugiej strony uruchomienie kilku tysięcy testów trwało maksymalnie kilkanaście sekund, testy mogłyby na przykład być uruchamiane automatycznie po każdej małej zmianie - taką funkcjonalność oferują niektóre IDE, na przykład PyCharm.
Jeżeli uruchomienie całego kompletu testów trwa zaledwie kilka sekund, zmienia się sposób myślenia o zestawie testów. Przestaje on być smutnym obowiązkiem, na którego wynik trzeba długo czekać a ich uruchamianie przy każdej, nawet najdrobniejszej zmianie staje się rutyną - Noel Llopis napisał bardzo ciekawy artykuł opisujący jak szybkość i łatwość uruchomienia testów jednostkowych zmieniła jego podejście do pracy.
Do najczęstszych problemów spowalniających wykonywanie testów można zaliczyć:
- Nadmierne użycie bazy danych w testach, tj. testowanie z użyciem bazy danych gdy nie jest to konieczne, ładowanie zbyt dużej ilości testowych danych, etc,
- Użycie zewnętrznych API w trakcie testów, albo ogólnie używanie sieci - poza wpływem na wydajność (prędkość testów zależy od prędkości działania zewnętrznych zasobów sieciowych), takie rozwiązanie prowadzi również do niestabilnych testów (jeśli zewnętrzny zasób przestanie działać prawidłowo - nasze testy również),
- Korzystanie z dysku w testach (np. tworzenie dużej ilości plików tymczasowych),
- Robienie “za dużo” w testach, przykładowo: testowanie prostej funkcji w Django z wykorzystaniem testowego klienta.
Bardzo często tego typu błędy sprawiają, że test przestaje być testem jednostkowym a zaczyna być testem integracyjnym - jeśli zmiana w jednej z zależności danej funkcji psuje jej testy, to znaczy że nie był spełniony warunek izolacji.
Niestabilne testy
Testy jednostkowe powinny być spójne, tj. ich każdorazowe uruchomienie powinno skutkować takim samym, jednoznacznym wynikiem. Niestety, bardzo prosto się zapomnieć i stworzyć taki zestaw testów, który losowo nie działa - powoduje to niepotrzebną frustrację, a błąd taki może być niełatwy do znalezienia.
Do najczęstszych błędów powodujących niestabilne działanie testów należą:
- Testy zależne od losowości (np. od biblioteki random),
- Wspomniane wcześniej testy zależne od zewnętrznych zasobów sieciowych,
- Testy zależne od aktualnej daty i godziny,
- Testy zależne od stanu systemu pozostawionego po poprzednich testach.
Przykład - wolny i niestabilny test
Przyjrzyjmy się poniższemu fragmentowi kodu, który korzystając z zewnętrznej usługi wyświetla losową ciekawostkę o kotach:
import random
import unittest
import requests
def random_cat_fact():
response = requests.get('https://cat-fact.herokuapp.com/facts').json()
facts = [fact['text'] for fact in response['all']]
return random.choice(facts)
class RandomCatFactTestCase(unittest.TestCase):
def test_random_cat_fact(self):
fact = random_cat_fact()
self.assertEqual(
"A cat has five toes on his front paws,"
"and four on the back, unless he's a polydactyl.",
fact
)
unittest.main()
Po jego uruchomieniu dostrzeżemy dwa problemy:
======================================================================
FAIL: test_random_cat_fact (__main__.RandomCatFactTestCase)
----------------------------------------------------------------------
Traceback (most recent call last):
File "<string>", line 17, in test_random_cat_fact
AssertionError: "A cat has five toes on his front paws,an[41 chars]tyl." != 'CaTs ussually have 4.5 kg in weight.'
- A cat has five toes on his front paws,and four on the back, unless he's a polydactyl.
+ CaTs ussually have 4.5 kg in weight.
----------------------------------------------------------------------
Ran 1 test in 0.396s
FAILED (failures=1)
- Test jest niestabilny - jest zależny od zewnętrznego serwisu oferującego API przez HTTP, który w razie awarii sprawi, że nasz test przestanie działać (warto zaznaczyć, że tego typu błąd jest bardzo istotny),
- Dodatkowo, wybierana jest losowa ciekawostka, więc nawet jeśli serwis działa, szansa na to że sprawdziliśmy prawidłowy wynik jest bardzo niewielka,
- Na sam koniec - wykonanie tego testu trwa prawie 0.4 sekundy, jest więc on bardzo wolny.
Spróbujmy rozwiązać te problemy.
Na początek, sprawimy, że test będzie szybszy - użyjemy mocka, tj. symulowanego obiektu, służącego do “podstawienia” implementacji danej metody/obiektu do testów. Za jego pomocą “nadpiszemy” implementację requests.get w taki sposób, aby zwracała stałą wartość, która znajduje się już w pamięci, co jednocześnie spowoduje że nie będziemy łączyć się do zewnętrznego serwisu:
import json
import random
import unittest
from unittest import mock
import requests
from requests.models import Response
def random_cat_fact():
response = requests.get('https://cat-fact.herokuapp.com/facts').json()
facts = [fact['text'] for fact in response['all']]
return random.choice(facts)
class RandomCatFactTestCase(unittest.TestCase):
@mock.patch("requests.get")
def test_random_cat_fact(self, mock_get):
facts = [
"Fun fact about cats",
"Another fun fact about cats"
]
content = {
'all': [{"text": fact} for fact in facts]
}
response = Response()
response._content = json.dumps(content).encode('utf-8')
mock_get.return_value = response
fact = random_cat_fact()
self.assertIn(fact, facts)
unittest.main()
Po drobnej refaktoryzacji, przy każdym uruchomieniu testów otrzymujemy dużo bardziej satysfakcjonujący wynik:
----------------------------------------------------------------------
Ran 1 test in 0.001s
OK
Test nie tylko przestał być niestabilny (każde wywołanie testu gwarantuje ten sam wynik), ale też przyspieszył (0.001s zamiast 0.396s). Gdyby takich testów były setki, różnica byłaby bardzo zauważalna.
A to wszystko dzięki kilku prostym zmianom:
- Użyliśmy biblioteki mock, aby podmienić implementację metody
requests.get
na taką, która po prostu zwraca wskazaną przez nas wartość - metoda od tej pory zwraca stworzony przez nas obiekt response, który jest instancją klasy requests.models.Response. Od tej pory,requests.get
nie będzie łączyć się do zewnętrznej usługi w ramach tego testu. - Zamiast sprawdzać, czy zwracana wartość to “ta konkretna”, zebraliśmy wszystkie możliwe wartości do listy i badamy, czy zwrócona wartość zawiera się w tej kolekcji. Można również użyć mock.patch, aby podmienić implementację random.choice i sprawdzić czy została użyta, lub można użyć metody random.seed() aby ustawić “na sztywno” ziarno przed testem.
Powyższy kod można dodatkowo usprawnić używając biblioteki responses, która ułatwia podstawianie własnych odpowiedzi do biblioteki requests:
import json
import random
import unittest
import responses
import requests
def random_cat_fact():
response = requests.get('https://cat-fact.herokuapp.com/facts').json()
facts = [fact['text'] for fact in response['all']]
return random.choice(facts)
class RandomCatFactTestCase(unittest.TestCase):
@responses.activate
def test_random_cat_fact(self):
facts = [
"Fun fact about cats",
"Another fun fact about cats"
]
content = {
'all': [{"text": fact} for fact in facts]
}
responses.add(
responses.GET,
'https://cat-fact.herokuapp.com/facts',
body=json.dumps(content),
content_type='application/json'
)
fact = random_cat_fact()
self.assertIn(fact, facts)
unittest.main()
Przykład - test który robi “za dużo”
Dosyć częstym błędem - szczególnie przy większych projektach - jest pisanie testów do bardzo prostych elementów kodu (na przykład: jednej funkcji czy metody) wykorzystując do tego cały “stack” projektu/frameworku w którym osadzona jest dana funkcjonalność. Dobrym przykładem tego anty-patternu będzie poniższy test, wykorzystujący framework Django.
Załóżmy, że testujemy prostą aplikację typu “pastebin” (kod został maksymalnie uproszczony na potrzeby artykułu).
Posiadamy następujący model:
from django.db import models
from django.http import HttpResponse
from myapp.models import User
class Paste(models.Model):
public = models.BooleanField(default=False)
content = models.TextField()
author = models.ForeignKey(User)
Oraz poniższy widok:
from django.views.generic import View
from django.core.exceptions import PermissionDenied
from pastes.models import Paste
class PasteView(View):
@staticmethod
def paste_can_be_viewed(request, paste):
if paste.author == request.user:
return True
return paste.public
def get(self, request):
paste = Paste.objects.get(self.kwargs['id'])
if not self.paste_can_be_viewed(request, paste):
raise PermissionDenied()
return HttpResponse(paste.content)
Programista chciał przetestować zachowanie metody paste_can_be_viewed
przy pomocy poniższego testu:
from django.test import TestCase, Client
from myapp.models import User
from pastes.models import Paste
class PasteTestCase(TestCase):
def setUp(self):
self.user1 = User.objects.create(username='user1')
self.user2 = User.objects.create(username='user2')
self.paste_private = Paste.objects.create(
public=False, author=self.user1, content='Paste 1 (private)')
self.paste_public = Paste.objects.create(
public=True, author=self.user1, content='Paste 2 (public)')
def test_unauthorized(self):
"""Unauthorized should not be able to view private pastes."""
response1 = self.client.get(f'/pastes/{self.paste_private.id}')
response2 = self.client.get(f'/pastes/{self.paste_public.id}')
self.assertEqual(403, response1.status_code)
self.assertEqual(200, response2.status_code)
def test_authorized_user1(self):
"""User1 should be able to view both his pastes."""
client = Client()
client.force_login(self.user1)
response1 = self.client.get(f'/pastes/{self.paste_private.id}')
response2 = self.client.get(f'/pastes/{self.paste_public.id}')
self.assertEqual(200, response1.status_code)
self.assertEqual(200, response2.status_code)
def test_authorized_user2(self):
"""User2 should not be able to view other users private pastes."""
client = Client()
client.force_login(self.user2)
response1 = self.client.get(f'/pastes/{self.paste_private.id}')
response2 = self.client.get(f'/pastes/{self.paste_public.id}')
self.assertEqual(403, response1.status_code)
self.assertEqual(200, response2.status_code)
Powyższy zestaw testów co prawda testuje prawidłowo zachowanie aplikacji, jednak zdecydowanie nie jest testem jednostkowym - nie tylko testuje aplikacje z wykorzystaniem bazy danych, ale do tego wykorzystuje cały “stos” Django, tj. uruchamia cały mechanizm obsługi zapytań i odpowiedzi oferowany przez Django (a więc url resolver, middleware itp).
Dodatkowo, ponieważ tworzenie testowych danych odbywa się w metodzie setUp, wszystkie dane są tworzone i zapisywane do bazy od nowa przy każdym z trzech testów.
Jeśli przyjrzymy się metodzie paste_can_be_viewed
zauważymy, że jest ona statyczna - tzn. nie polega w ogóle na “stanie” instancji w kontekście której została uruchomiona, a jej zachowanie zależy tylko i wyłącznie od argumentów, które zostały do niej przekazane. Nie uruchamia ona również żadnego dodatkowego kodu (nie ma dodatkowych zależności), tak więc można przetestować ją w pełnej izolacji.
W tym celu możemy wykorzystać stub, tj. prosty obiekt który “imituje” właściwą implementację swojego pierwowzoru. W powyższym przykładzie musimy podstawić dwa stuby - jeden będzie imitował obiekt typu Request, a drugi instancję modelu Paste:
from unittest import TestCase, mock
from pastes.views import PasteView
class PasteTestCase(TestCase):
def test_unauthorized(self):
"""Unauthorized should not be able to view private pastes."""
request = mock.Mock(user=None)
# Private
paste_private = mock.Mock(public=False, author=mock.Mock())
result = PasteView.paste_can_be_viewed(request, paste_private)
self.assertFalse(result)
# Public
paste = mock.Mock(public=True, author=mock.Mock())
result = PasteView.paste_can_be_viewed(request, paste)
self.assertTrue(result)
def test_authorized_user1(self):
"""User should be able to view both his pastes."""
user = mock.Mock()
request = mock.Mock(user=user)
# Private
paste_private = mock.Mock(public=False, author=user)
result = PasteView.paste_can_be_viewed(request, paste_private)
self.assertTrue(result)
# Public
paste_public = mock.Mock(public=True, author=user)
result = PasteView.paste_can_be_viewed(request, paste_public)
self.assertTrue(result)
def test_authorized_user2(self):
"""User2 should not be able to view other users private pastes."""
user1 = mock.Mock()
user2 = mock.Mock()
request = mock.Mock(user=user2)
# Private
paste_private = mock.Mock(public=False, author=user1)
result = PasteView.paste_can_be_viewed(request, paste_private)
self.assertFalse(result)
# Public
paste_public = mock.Mock(public=True, author=user1)
result = PasteView.paste_can_be_viewed(request, paste_public)
self.assertTrue(result)
Co prawda powyższy test wymaga więcej linijek kodu niż jego oryginał, ale w swym działaniu jest zdecydowanie prostszy. Podsumujmy zmiany:
- Nie zapisujemy nic do bazy danych - wszystkie obiekty które otrzymuje testowana metoda znajdują się tylko i wyłącznie w pamięci
- Tworzymy tylko te dane, które są potrzebne dla danego testu (nie zostawiamy żadnych danych zapisanych w instancji klasy
PasteTestCase
) - Wykonujemy tylko metodę, którą chcemy przetestować - testy przestały być zależne od frameworku
Podsumowanie
Podczas tworzenia testów do aplikacji nad którą pracujemy bardzo łatwo przez przypadek zatrzeć granicę między testami jednostkowymi i integracyjnymi, tym samym tworząc testy które uruchamiają się długo lub nie są stabilne.
Testy jednostkowe nie są w stanie całkowicie zastąpić testów integracyjnych - oba wzajemnie się uzupełniają. Pisanie dobrych i odizolowanych testów jest bardzo ważną umiejętnością, która przyda się każdemu programiście.
Warto również pamiętać o tym, że testy powinny być łatwe w uruchomieniu i powinny wykonywać się możliwie szybko, co zdecydowanie sprawia że będą uruchamiane częściej, a praca z testami stanie się znacznie bardziej produktywna.