Testowanie
Mam nadzieję, że nie muszę mówić, że testowanie oprogramowania jest kluczowym elementem jakiegokolwiek procesu tworzenia aplikacji. Dzięki temu, możemy się upewnić, że kod działa poprawnie, jest odporny na błędy i spełnia wymagania funkcjonalne. Do tego przy rozwijaniu aplikacji mamy pewność, że zachowujemy wszystkie działające poprzednio elementy.
W Pythonie zwykle do testowania używa się pakietu pytest
- zaawansowane funkcje, parametryzacja, wspiera testy w stylu funkcjonalnym i obiektowym, ale funkcjonują także inne: unittest
- wbudowany w Pythona, podstawowe testowanie, mock
- pozwala na tworzenie obiektów zastępczych, tox
- umożliwia testowanie w wielu środowiskach.
Rodzaje testów
Testy można podzielić na kilka kategorii w zależności od celu, który mają spełniać, oraz poziomu aplikacji, na którym działają.
Testy jednostkowe (unit tests)
Cel - sprawdzanie działania najmniejszych jednostek kodu, takich jak funkcje, metody czy klasy, w izolacji od reszty aplikacji.
Charakterystyka:
- Skupiają się na jednej funkcjonalności w oderwaniu od innych części systemu.
- Nie wymagają dostępu do baz danych, API, czy zewnętrznych zasobów.
- Są szybkie w uruchamianiu.
# Funkcja do testowania
def add(a, b):
return a + b
# Test jednostkowy
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0
assert add(0, 0) == 0
Testy integracyjne (integration tests)
Cel - weryfikacja, czy różne moduły aplikacji współpracują ze sobą poprawnie.
Charakterystyka:
- Obejmują interakcje między komponentami, np. komunikację z bazą danych czy integrację z API.
- Mogą być wolniejsze niż testy jednostkowe, ponieważ wymagają dostępu do zasobów zewnętrznych.
# Funkcja zapisująca dane do bazy
def save_to_database(data, db_connection):
cursor = db_connection.cursor()
cursor.execute("INSERT INTO table_name (column) VALUES (?)", (data,))
db_connection.commit()
# Test integracyjny
def test_save_to_database():
db_connection = sqlite3.connect(":memory:") # Tworzenie testowej bazy danych
save_to_database("test_data", db_connection)
cursor = db_connection.cursor()
cursor.execute("SELECT column FROM table_name")
result = cursor.fetchone()
assert result == ("test_data",)
Testy e2e (end-to-end tests)
Cel - sprawdzenie całego procesu użytkownika w aplikacji, od wejścia do wyjścia, wraz z interakcją między różnymi komponentami, takimi jak bazy danych, API, czy frontend i backend.
Charakterystyka:
- Testują aplikację jako całość, w pełnym środowisku, jak użytkownik końcowy.
- Obejmują wszystkie warstwy systemu (UI, backend, bazy danych, integracje zewnętrzne).
- Wymagają skonfigurowanego środowiska produkcyjnego lub stagingowego.
- Są czasochłonne, ponieważ uruchamiają całą aplikację.
- Wymagają częstych aktualizacji w miarę zmieniających się funkcjonalności systemu.
# Przykład z użyciem Selenium do automatyzacji przeglądarki
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
import time
def test_purchase_workflow():
# Uruchomienie przeglądarki
driver = webdriver.Chrome()
try:
# 1. Otwórz stronę główną
driver.get("https://example.com")
# 2. Wyszukaj product
search_box = driver.find_element(By.NAME, "search")
search_box.send_keys("Laptop")
search_box.send_keys(Keys.RETURN)
time.sleep(2) # Poczekaj na załadowanie wyników
# 3. Dodaj product do koszyka
add_to_cart_button = driver.find_element(By.CSS_SELECTOR, ".add-to-cart")
add_to_cart_button.click()
time.sleep(2)
# 4. Przejdź do koszyka i dokonaj zakupu
driver.get("https://example.com/cart")
checkout_button = driver.find_element(By.ID, "checkout")
checkout_button.click()
# 5. Sprawdź, czy zakup zakończył się sukcesem
success_message = driver.find_element(By.CLASS_NAME, "success")
assert "Zakup zakończony pomyślnie" in success_message.text
finally:
# Zamknięcie przeglądarki
driver.quit()
Inne
Testy funkcjonalne (functional tests)
Cel - testowanie aplikacji z punktu widzenia użytkownika końcowego.
Charakterystyka:
- Sprawdzają pełne scenariusze działania aplikacji.
- Mogą obejmować interfejs użytkownika (np. testowanie przeglądarki) lub operacje backendowe.
- Wymagają uruchomienia pełnego środowiska aplikacji.
def test_user_registration(client):
response = client.post("/register", data={"username": "test", "password": "pass"})
assert response.status_code == 200
assert b"Registration successful" in response.data
Testy systemowe (system tests)
Cel - testowanie aplikacji jako całości w środowisku jak najbardziej zbliżonym do produkcyjnego.
Charakterystyka:
- Obejmują wszystkie komponenty systemu, takie jak serwery, bazy danych i API.
- Są najdroższe w utrzymaniu i najwolniejsze, ale dają pełen obraz działania systemu.
Testy regresjyjne (regression tests)
Cel - upewnienie się, że now zmiany w kodzie nie spowodowały błędów w działających wcześniej funkcjonalnościach.
Charakterystyka:
- Oparte na istniejących testach jednostkowych, integracyjnych i funkcjonalnych.
- Automatyzowane w ramach Continuous Integration (CI).
Testy akceptacyjne (acceptance tests)
Cel - sprawdzanie, czy aplikacja spełnia wymagania biznesowe i jest gotowa do użycia.
Charakterystyka:
- Prowadzone na podstawie scenariuszy dostarczonych przez klienta lub zespół produktowy.
- Mogą być przeprowadzane ręcznie lub automatycznie.
def test_shopping_cart_workflow(client):
client.post("/add_to_cart", data={"product_id": 1})
client.post("/add_to_cart", data={"product_id": 2})
response = client.get("/cart")
assert "product_id: 1" in response.data
assert "product_id: 2" in response.data
Testy wydajnościowe (performance tests)
Cel - ocena, jak szybko działa aplikacja przy określonym obciążeniu.
Charakterystyka:
- Sprawdzają czas odpowiedzi, zużycie zasobów i zdolność do obsługi dużej liczby równoczesnych użytkowników.
Testy bezpieczeństwa (security tests)
Cel - znalezienie potencjalnych luk w zabezpieczeniach aplikacji.
Charakterystyka:
- Sprawdzają, czy aplikacja jest odporna na ataki typu SQL Injection, Cross-Site Scripting (XSS) itp.
def test_sql_injection(client):
response = client.get("/search", query_string={"q": "' OR 1=1; --"})
assert b"Unexpected error" not in response.data
Testy eksploracyjne (exploratory tests)
Cel - ręczne testowanie aplikacji w celu znalezienia nieoczekiwanych błędów.
Charakterystyka:
- Wykonywane przez doświadczonych testerów bez szczegółowych scenariuszy.
- Koncentrują się na eksploracji aplikacji i szukaniu niestandardowych scenariuszy.
Testy smoke (smoke tests)
Cel - upewnienie się, że najważniejsze funkcjonalności działają po wdrożeniu lub aktualizacji aplikacji.
Charakterystyka:
- Szybkie, podstawowe testy wykonywane przed szczegółowymi testami.
def test_app_up(client):
response = client.get("/")
assert response.status_code == 200
Organizacja testów w repozytorium
Jest to kluczowe dla utrzymania czytelności, łatwości utrzymania oraz szybkiego znajdowania odpowiednich przypadków testowych.
- Dedykowany folder dla testów
Testy powinny znajdować się w oddzielnym folderze, zwykle nazwanym tests
, znajdującym się w głównym katalogu projektu.
- Podfoldery odzwierciedlające rodzaje testów
Podfoldery tworzone są zwykle według typu testów, czyli np. unit
– dla testów jednostkowych, integration
– dla testów integracyjnych i e2e
– dla testów end-to-end.
- Struktura testów odzwierciedlająca strukturę pakietów
Kolejne podfoldery powinny odzwierciedlać strukturę pakietów, które testujemy.
moj_projekt/tests
├── unit/
├── zajecia06/
├── test_module1.py
├── test_module2.py
├── moj_subpakiet/
├── test_submodule.py
- Nazwy plików i testów
Wszystkie pliki testowe powinny zaczynać się od test_
lub kończyć na _test.py
(przykład powyżej).
Nazwy funkcji testowych powinny zaczynać się od test_
.
def test_add_function():
assert add(2, 3) == 5
Organizacja zależności i konfiguracja testów
Plik conftest.py
W frameworku pytest
pozwala centralizować konfigurację i współdzielone zależności testów. Jest to miejsce, gdzie można definiować:
- Fixtures – funkcje tworzące dane testowe lub konfiguracje.
- Funkcje pomocnicze – wspólne dla wielu testów.
- Konfiguracje specyficzne dla pytest.
Zwykle plik znajduje się w ./tests/conftest.py
.
# Definiowanie fixtures w conftest.py
import pytest
@pytest.fixture
def sample_data():
"""Fixture zwracająca dane testowe."""
return {"key": "value"}
@pytest.fixture
def database_connection():
"""Fixture symulująca połączenie z bazą danych."""
class FakeDatabase:
def query(self, query):
return {"result": "fake_data"}
return FakeDatabase()
# Użycie fixture w testach
import pytest
@pytest.fixture
def sample_data():
"""Fixture zwracająca dane testowe."""
return {"key": "value"}
@pytest.fixture
def database_connection():
"""Fixture symulująca połączenie z bazą danych."""
class FakeDatabase:
def query(self, query):
return {"result": "fake_data"}
return FakeDatabase()
Mockowanie
Mockowanie to technika zastępowania rzeczywistych zależności (np. połączeń z bazą danych, API) sztucznymi obiektami podczas testów. Dzięki temu:
- Możemy izolować testy od zewnętrznych zależności.
- Testy są szybsze i bardziej niezawodne.
- Możemy testować zachowanie kodu w trudnych do odtworzenia warunkach.
# Mockowanie funkcji zewnętrznej
from unittest.mock import patch
@patch("my_package.module1.requests.get")
def test_get_data_from_api(mock_get):
# Konfiguracja mocka
mock_get.return_value.json.return_value = {"key": "mocked_value"}
# Wywołanie funkcji
from my_package.module1 import get_data_from_api
result = get_data_from_api("http://example.com/api")
# Sprawdzenie wyniku
assert result == {"key": "mocked_value"}
mock_get.assert_called_once_with("http://example.com/api")
# Mockowanie klasy
from unittest.mock import MagicMock
def test_database_fetch_data():
# Tworzenie mocka
mock_database = MagicMock()
mock_database.fetch_data.return_value = {"result": "mocked_data"}
# Testowanie funkcji z mockiem
result = mock_database.fetch_data("SELECT * FROM table")
assert result == {"result": "mocked_data"}
# Mockowanie z użyciem pytest-mock
def test_get_data_with_mocker(mocker):
# Mockowanie requests.get
mock_get = mocker.patch("my_package.module1.requests.get")
mock_get.return_value.json.return_value = {"key": "mocked_value"}
from my_package.module1 import get_data_from_api
result = get_data_from_api("http://example.com/api")
assert result == {"key": "mocked_value"}
mock_get.assert_called_once_with("http://example.com/api")
Zadania
- Stwórz testy jednostkowe dla programu dla karetek oraz dla rezerwacji w kinie.
- Stwórz testy e2e (w naszym przypadku będą to po prostu całe "symulacje" zaistniałych sytuacji w programie).
- Uruchomy testy wykorzystując polecenie z
Makefile
. - Uruchom testy poprzez pre-commit (odkomentuj najpierw hooka w
.pre-commit-config.yaml
).