Dlaczego warto testować?
Pisanie testów to inwestycja, która:
- zwiększa stabilność aplikacji — testy wykrywają błędy przed trafieniem do produkcji,
- pozwala na bezpieczną refaktoryzację — masz pewność, że zmiany nie psują istniejącej funkcjonalności,
- dokumentuje kod — testy pokazują, jak kod powinien być używany,
- zmniejsza stres przy wdrożeniach — wiedzisz, że aplikacja działa poprawnie przed deploymentem.
Brak testów to nie oszczędność czasu — to przesunięcie kosztów na przyszłość, gdy błędy wyjdą w produkcji i będą wymagały natychmiastowego fixa, często pod presją czasu i obawą o wpływ na użytkowników.
Rodzaje testów
W projektach Pythonowych najczęściej spotkasz trzy typy testów:
| Typ testu | Cel | Zakres |
|---|---|---|
| Unit test | Weryfikuje pojedynczą funkcję lub metodę | Mały (moduł) |
| Integration test | Sprawdza współpracę wielu komponentów | Średni |
| End-to-End / API test | Sprawdza całe zachowanie aplikacji | Duży (pełna aplikacja) |
Dobrze zorganizowany projekt ma testy na wszystkich trzech poziomach. Testy jednostkowe są szybkie i izolowane, testy integracyjne weryfikują współpracę między komponentami, a testy E2E sprawdzają cały flow użytkownika.
Testy jednostkowe (Unit tests)
Testy jednostkowe to najprostszy i najczęstszy rodzaj testów. Sprawdzają, czy konkretna funkcja działa zgodnie z założeniami. Powinny być szybkie, izolowane i testować tylko jedną rzecz naraz.
Podstawowy przykład z pytest
Python1 2 3 4 5 6 7def suma(a, b): return a + b def test_suma_dodaje_dwie_liczby(): assert suma(2, 3) == 5 assert suma(0, 0) == 0 assert suma(-1, 1) == 0
Aby uruchomić testy:
Bash1pytest
pytest automatycznie znajdzie i wykona wszystkie testy w plikach zaczynających się od test_ lub kończących na _test.py. Możesz też uruchomić konkretny plik lub test:
Bash1 2pytest test_math.py pytest test_math.py::test_suma_dodaje_dwie_liczby
Parametryzacja testów
pytest pozwala na parametryzację testów, co eliminuje duplikację kodu:
Python1 2 3 4 5 6 7 8 9 10import pytest @pytest.mark.parametrize("a, b, expected", [ (2, 3, 5), (0, 0, 0), (-1, 1, 0), (-5, -3, -8), ]) def test_suma_parametrized(a, b, expected): assert suma(a, b) == expected
Fixtures – powtarzalne środowisko testowe
Fixtures pozwalają przygotować dane lub konfigurację przed uruchomieniem testu. To eliminuje duplikację kodu i zapewnia spójne środowisko testowe.
Podstawowy fixture
Python1 2 3 4 5 6 7 8 9import pytest @pytest.fixture def sample_data(): return {"username": "admin", "password": "1234"} def test_user_data(sample_data): assert "username" in sample_data assert sample_data["username"] == "admin"
Zaawansowane fixtures
Fixtures mogą inicjalizować bazy danych, logować użytkowników, przygotowywać API lub mockować zewnętrzne serwisy:
Python1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22@pytest.fixture def client(): """Klient testowy Flask""" from app import app app.config['TESTING'] = True with app.test_client() as client: yield client @pytest.fixture def db_session(): """Tymczasowa sesja bazy danych""" from app import db db.create_all() yield db.session db.drop_all() def test_create_user(client, db_session): response = client.post('/api/users', json={ 'username': 'testuser', 'email': 'test@example.com' }) assert response.status_code == 201
Fixtures mogą również mieć zakres (scope) — function (domyślny), class, module, session — co pozwala na optymalizację wydajności testów.
Testy integracyjne
Testy integracyjne sprawdzają, czy różne części aplikacji działają razem poprawnie. Przykład: czy widok w Django poprawnie komunikuje się z modelem i zwraca odpowiedź HTTP.
Przykład w Django
Python1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29from django.test import TestCase from django.urls import reverse from myapp.models import Post class PostViewTests(TestCase): def setUp(self): """Przygotowanie danych przed każdym testem""" self.post = Post.objects.create( title="Test Post", content="Test content" ) def test_post_list_returns_200(self): response = self.client.get(reverse("post_list")) self.assertEqual(response.status_code, 200) self.assertContains(response, "Test Post") def test_post_detail_view(self): response = self.client.get(reverse("post_detail", args=[self.post.id])) self.assertEqual(response.status_code, 200) self.assertEqual(response.context['post'], self.post) def test_create_post(self): response = self.client.post(reverse("post_create"), { 'title': 'New Post', 'content': 'New content' }) self.assertEqual(response.status_code, 302) # Redirect after creation self.assertTrue(Post.objects.filter(title='New Post').exists())
W tym teście Django automatycznie tworzy tymczasową bazę danych, dzięki czemu nie musisz modyfikować danych produkcyjnych. setUp() jest uruchamiane przed każdym testem w klasie, co pozwala na przygotowanie wspólnych danych.
Testy API w FastAPI
FastAPI ma wbudowany TestClient, który ułatwia testowanie endpointów API. TestClient działa bez uruchamiania serwera — wywołuje endpointy bezpośrednio w pamięci, co sprawia, że testy są bardzo szybkie.
Podstawowy test API
Python1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19from fastapi.testclient import TestClient from main import app client = TestClient(app) def test_get_root(): response = client.get("/") assert response.status_code == 200 assert response.json() == {"message": "Hello World"} def test_create_item(): response = client.post("/items/", json={ "name": "Test Item", "price": 10.99 }) assert response.status_code == 201 data = response.json() assert data["name"] == "Test Item" assert "id" in data
Testy z autoryzacją
Python1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28def test_protected_endpoint(): # Najpierw zaloguj się login_response = client.post("/login", json={ "username": "testuser", "password": "testpass" }) token = login_response.json()["access_token"] # Użyj tokena w nagłówku headers = {"Authorization": f"Bearer {token}"} response = client.get("/protected", headers=headers) assert response.status_code == 200 # Alternatywnie z fixture @pytest.fixture def authenticated_client(): client = TestClient(app) login_response = client.post("/login", json={ "username": "testuser", "password": "testpass" }) token = login_response.json()["access_token"] client.headers = {"Authorization": f"Bearer {token}"} return client def test_protected_with_fixture(authenticated_client): response = authenticated_client.get("/protected") assert response.status_code == 200
Testy API w FastAPI są niezwykle szybkie, bo działają bez serwera — wywołują endpointy bezpośrednio w pamięci. To sprawia, że możesz uruchomić setki testów w sekundach.
TDD – Test Driven Development
TDD to podejście, w którym najpierw piszesz testy, a dopiero potem implementację. Proces wygląda tak:
- Red — Napisz test, który na początku nie przejdzie (bo funkcja jeszcze nie istnieje),
- Green — Zaimplementuj minimalny kod, aby test przeszedł,
- Refactor — Refaktoryzuj, zachowując poprawność testów.
Przykład TDD
Najpierw test:
Python1 2 3 4# test_math.py def test_dodawanie(): assert dodaj(2, 2) == 4 assert dodaj(-1, 1) == 0
Test nie przejdzie, bo funkcja jeszcze nie istnieje. Teraz implementacja:
Python1 2 3# math_utils.py def dodaj(a, b): return a + b
Teraz test przejdzie. Jeśli chcesz dodać więcej funkcjonalności (np. obsługę więcej niż dwóch argumentów), najpierw napisz test, a potem zaktualizuj implementację.
To prosty przykład, ale zasada jest kluczowa: najpierw weryfikacja, potem implementacja. TDD zmusza Cię do myślenia o interfejsie funkcji i przypadkach brzegowych przed napisaniem kodu, co prowadzi do lepszego designu.
Mockowanie zależności
W testach często chcesz „odłączyć" zewnętrzne API, bazę danych lub system plików. Do tego służy mocking. Mockowanie pozwala testować logikę bez rzeczywistego wykonywania zewnętrznych operacji, co czyni testy szybkimi i niezależnymi od zewnętrznych serwisów.
Przykład z unittest.mock
Python1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18from unittest.mock import patch, MagicMock import requests @patch("requests.get") def test_fetch_data(mock_get): # Konfiguracja mocka mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"ok": True, "data": "test"} mock_get.return_value = mock_response # Test logiki response = requests.get("https://api.example.com") assert response.status_code == 200 assert response.json()["ok"] is True # Weryfikacja wywołania mock_get.assert_called_once_with("https://api.example.com")
Mockowanie w pytest
pytest oferuje własną funkcjonalność do mockowania:
Python1 2 3 4 5 6 7 8 9 10import pytest from unittest.mock import Mock def test_with_mock(): mock_service = Mock() mock_service.process.return_value = "processed" result = my_function(mock_service) assert result == "processed" mock_service.process.assert_called_once()
Mockowanie pozwala testować logikę bez rzeczywistego wykonywania zewnętrznych operacji, co czyni testy szybkimi i niezależnymi od zewnętrznych serwisów.
Raporty z testów
pytest umożliwia generowanie szczegółowych raportów, co jest kluczowe dla integracji z CI/CD i analizy wyników testów.
Podstawowe opcje
Bash1 2 3 4 5 6 7 8 9 10 11# Tylko pierwszy błąd, bez ostrzeżeń, tryb cichy pytest --maxfail=1 --disable-warnings -q # Szczegółowy output pytest -v # Pokaż print statements pytest -s # Pokrycie kodu (wymaga pytest-cov) pytest --cov=myapp --cov-report=html
Raporty w formacie XML
Bash1pytest --junitxml=report.xml
Dzięki temu łatwo zintegrujesz testy z narzędziami CI/CD (GitHub Actions, GitLab CI), które mogą wyświetlać wyniki testów i pokrycie kodu.
Pokrycie kodu
Pokrycie kodu (code coverage) pokazuje, jaki procent kodu jest testowany:
Bash1 2 3pip install pytest-cov pytest --cov=myapp --cov-report=term-missing
Dążyć należy do wysokiego pokrycia, ale nie kosztem jakości testów — lepiej mieć mniej, ale lepszych testów, niż wiele słabych.
Integracja z CI/CD
Testy powinny być automatycznie uruchamiane przy każdym pushu lub pull requeście. To zapewnia, że każda zmiana jest zweryfikowana przed mergem.
Przykład workflow dla GitHub Actions
YAML1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28name: Python Tests on: [push, pull_request] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: 3.12 - name: Install dependencies run: | pip install -r requirements.txt pip install pytest pytest-cov - name: Run tests run: | pytest --cov=myapp --cov-report=xml - name: Upload coverage uses: codecov/codecov-action@v3 with: file: ./coverage.xml
Dzięki temu każdy commit przechodzi przez testy automatycznie — zanim trafi do produkcji. CI/CD może również blokować mergowanie pull requestów, jeśli testy nie przechodzą.
Dobre praktyki testowania
Przy pisaniu testów warto pamiętać o kilku kluczowych zasadach:
-
Każdy nowy feature = nowy test — jeśli dodajesz funkcjonalność, dodaj też test, który ją weryfikuje.
-
Testuj tylko zachowanie, nie implementację — testy powinny sprawdzać, czy kod robi to, co powinien, a nie jak to robi. To pozwala na refaktoryzację bez łamania testów.
-
Stosuj opisowe nazwy testów — nazwa testu powinna jasno komunikować, co testuje:
Python1 2 3 4 5 6 7# Dobrze def test_user_cannot_login_with_wrong_password(): pass # Źle def test_login(): pass
-
Oddziel testy jednostkowe od integracyjnych — różne typy testów mogą mieć różne wymagania (baza danych, sieć) i różną szybkość wykonania.
-
Używaj fixtures do powtarzalnych danych — unikaj duplikacji kodu i zapewnij spójność danych testowych.
-
Mierz pokrycie kodu testami — użyj
pytest-cov, aby zobaczyć, które części kodu nie są testowane. Dążyć należy do wysokiego pokrycia, ale nie kosztem jakości. -
Nie testuj bibliotek zewnętrznych — testuj swoją logikę, nie kod z innych bibliotek (np. Django, SQLAlchemy). Te biblioteki mają własne testy.
-
Testuj przypadki brzegowe — nie tylko „happy path", ale także błędy, puste wartości, wartości graniczne.
-
Testy powinny być izolowane — każdy test powinien móc być uruchomiony niezależnie, bez zależności od innych testów.
-
Używaj Arrange-Act-Assert (AAA) — struktura testu powinna być czytelna:
Python1 2 3 4 5 6 7 8 9 10def test_example(): # Arrange - przygotowanie danych user = create_user() # Act - wykonanie akcji result = user.activate() # Assert - weryfikacja wyniku assert result is True assert user.is_active is True
Podsumowanie
Testowanie to nawyk, który oddziela amatorów od profesjonalistów. Dobrze zaprojektowany zestaw testów chroni przed regresjami, pozwala na bezpieczne wdrożenia, automatyzuje kontrolę jakości i zwiększa pewność siebie zespołu przy każdej zmianie.
Nieważne, czy tworzysz REST API w FastAPI, aplikację w Django czy mikroserwis w Flasku — testy to Twoja pierwsza linia obrony. Zacznij od prostych testów jednostkowych, stopniowo dodawaj testy integracyjne, a następnie rozbuduj o testy E2E w miarę potrzeb projektu. Pamiętaj, że testy to nie tylko narzędzie do weryfikacji — to także dokumentacja i zabezpieczenie na przyszłość.



