Когда я работал в Яндексе, мы писали тесты вручную, запускали их перед каждым коммитом и половину времени забывали. Результат? Боги знают сколько багов прошло в production. Потом мы автоматизировали процесс — и всё изменилось. Сейчас автоматизация тестирования на Python это не роскошь, а базовый стандарт. Если ты ещё не внедрил это в свой workflow, то честно — отстаёшь.
В этой статье я расскажу, как организовать полный цикл автоматизированного тестирования, какие инструменты использовать, и как интегрировать всё в CI/CD pipeline. Не будет общих фраз — только то, что работает на боевых проектах.
Почему автоматизация тестирования — это не опционально
Короче, давайте от простого. Если твоя команда:
- Запускает тесты вручную перед каждым деплоем
- Надеется, что QA поймёт все проблемы
- Ждёт недели перед релизом, пока что-то проверится
...то вы теряете недели времени на рутину. На одном проекте мы считали: ручной запуск тестов и их анализ занимал 3-4 часа в день на каждого разработчика. Это просто деньги на ветер.
Автоматизация означает:
- Каждый коммит проверяется автоматически. Баг найдётся за минуты, а не дни.
- Разработчик узнает о проблеме сразу, пока код ещё в голове.
- Ты можешь деплоить в production по нажатию кнопки, не боясь.
- Меньше человеческих ошибок — меньше ночных звонков.
По статистике, автоматизация тестирования сокращает время на релиз в 5-10 раз. И это не маркетинг — это реальные цифры из наших проектов.
Три уровня тестирования и инструменты
Прежде чем писать pipeline, разберёмся с типами тестов. Классическая пирамида:
Unit-тесты — тестируем отдельные функции. Быстро. Пишет разработчик.
import pytest
def add(a, b):
return a + b
def test_add():
assert add(2, 3) == 5
assert add(-1, 1) == 0
Инструмент: pytest. Это король среди фреймворков для Python. Почему? Потому что он просто работает. Нет боли, нет конфигурации, нет магии.
Integration-тесты — проверяем, как компоненты работают вместе. Медленнее unit-тестов, но всё ещё быстро.
import pytest
from fastapi.testclient import TestClient
from app import app
client = TestClient(app)
def test_api_create_user():
response = client.post("/users/", json={"name": "John", "email": "john@example.com"})
assert response.status_code == 201
assert response.json()["name"] == "John"
Для API используем requests или встроенный TestClient из FastAPI. Для БД — SQLAlchemy с тестовой базой в памяти.
E2E-тесты — проверяем весь пользовательский сценарий в боевом окружении. Медленно, но критично важно.
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
def test_user_registration_flow():
driver = webdriver.Chrome()
driver.get("https://app.example.com")
# Кликаем на "Зарегистрироваться"
register_btn = driver.find_element(By.ID, "register-btn")
register_btn.click()
# Заполняем форму
email_field = WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.NAME, "email"))
)
email_field.send_keys("test@example.com")
password_field = driver.find_element(By.NAME, "password")
password_field.send_keys("SecurePass123!")
driver.find_element(By.ID, "submit-btn").click()
# Проверяем, что попали на страницу профиля
assert "profile" in driver.current_url
driver.quit()
Инструмент: Selenium для браузерной автоматизации. Или Playwright — он быстрее и стабильнее, если честно.
По-хорошему, должно быть примерно так:
- 70% unit-тестов (быстро, дешево, легко поддерживать)
- 20% integration-тестов
- 10% E2E-тестов (они медленные, поэтому их меньше)
Настройка CI/CD pipeline с GitHub Actions
Теперь переходим к интересному. Как встроить все эти тесты в процесс разработки?
Начнём с самого популярного варианта — GitHub Actions. Это бесплатно, встроено в GitHub, и работает из коробки.
Создаём файл .github/workflows/tests.yml:
name: Tests & Code Quality
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11"]
services:
postgres:
image: postgres:14
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test_db
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Cache pip packages
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov pytest-xdist
- name: Run unit tests
run: |
pytest tests/unit/ -v --cov=src --cov-report=xml
- name: Run integration tests
env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/test_db
run: |
pytest tests/integration/ -v
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
files: ./coverage.xml
fail_ci_if_error: true
Что здесь происходит:
- Триггер: запускается на каждый push в main/develop и на каждый pull request
- Матрица: тестируем на Python 3.9, 3.10, 3.11 — ловим проблемы совместимости
- Сервис Postgres: для интеграционных тестов нужна настоящая база. GitHub Actions поднимает её автоматически
- Кэширование: pip-пакеты кэшируются между запусками — экономим 2-3 минуты
- Параллельный запуск:
pytest-xdistраспределяет тесты между ядрами — быстрее в 4 раза - Coverage: отслеживаем покрытие кода тестами
Запустится — и за 5-10 минут ты узнаешь, сломал ли что-то. Если тесты упали, PR не сможет смержиться. Работает.
GitLab CI — для более сложных сценариев
Если ты на GitLab (например, на своих серверах в РФ), то там ещё более гибко.
.gitlab-ci.yml:
stages:
- test
- build
- deploy
variables:
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"
cache:
paths:
- .cache/pip
- venv/
before_script:
- python -V
- pip install virtualenv
- virtualenv venv
- source venv/bin/activate
- pip install -r requirements.txt
unit_tests:
stage: test
image: python:3.11
script:
- pytest tests/unit/ -v --cov=src --cov-report=term --cov-report=html
coverage: '/TOTAL.*\s+(\d+%)$/'
artifacts:
paths:
- htmlcov/
reports:
coverage_report:
coverage_format: cobertura
path: coverage.xml
integration_tests:
stage: test
image: python:3.11
services:
- postgres:14
variables:
POSTGRES_DB: test_db
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
DATABASE_URL: postgresql://postgres:postgres@postgres:5432/test_db
script:
- pytest tests/integration/ -v --tb=short
e2e_tests:
stage: test
image: python:3.11
services:
- selenium/standalone-chrome:latest
script:
- pip install selenium
- pytest tests/e2e/ -v --tb=short
only:
- main
- tags
lint:
stage: test
image: python:3.11
script:
- pip install flake8 black isort mypy
- black --check .
- isort --check-only .
- flake8 . --max-line-length=120
- mypy src/
GitLab позволяет:
- Запускать разные тесты параллельно (unit, integration, E2E одновременно)
- Кэшировать артефакты между пайплайнами
- Хранить результаты coverage в самом GitLab
- Легче интегрировать с локальными Kubernetes-кластерами
Автоматизация тестирования API с помощью Python
Отдельно про API-тестирование, потому что это большой класс задач.
Если у тебя REST API, то вот простой, но эффективный подход:
import pytest
import requests
from typing import Dict, Any
BASE_URL = "http://localhost:8000"
class TestUserAPI:
@pytest.fixture(autouse=True)
def setup(self):
# Очищаем БД перед каждым тестом
requests.delete(f"{BASE_URL}/admin/reset")
yield
def test_create_user(self):
payload = {
"username": "alice",
"email": "alice@example.com",
"password": "SecurePass123!"
}
response = requests.post(f"{BASE_URL}/users", json=payload)
assert response.status_code == 201
data = response.json()
assert data["username"] == "alice"
assert data["id"] > 0
def test_get_user(self):
# Сначала создаём
requests.post(f"{BASE_URL}/users", json={
"username": "bob",
"email": "bob@example.com",
"password": "Pass123!"
})
# Потом получаем
response = requests.get(f"{BASE_URL}/users/bob")
assert response.status_code == 200
assert response.json()["email"] == "bob@example.com"
def test_invalid_email_returns_400(self):
response = requests.post(f"{BASE_URL}/users", json={
"username": "charlie",
"email": "not-an-email",
"password": "Pass123!"
})
assert response.status_code == 400
assert "email" in response.json()["errors"]
@pytest.mark.parametrize("password", ["123", "pass", "p@ss"])
def test_weak_password_validation(self, password):
response = requests.post(f"{BASE_URL}/users", json={
"username": "david",
"email": "david@example.com",
"password": password
})
assert response.status_code == 400
Ключ здесь — pytest.mark.parametrize. Вместо того, чтобы писать 3 отдельных теста, пишешь один и параметризуешь его. Код меньше, читается лучше, поддерживается проще.
Для более сложных сценариев можно использовать pytest-asyncio (если API асинхронное) или httpx вместо requests (асинхронные клиент).
Selenium и Playwright: браузерная автоматизация
Selenium — классика, но медленная. На одном проекте мы перешли с Selenium на Playwright и выиграли в скорости в 2-3 раза.
Вот сравнение:
# Selenium (медленно)
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
driver = webdriver.Chrome()
driver.get("https://example.com")
element = WebDriverWait(driver, 10).until(
EC.presence_of_element_located((By.ID, "submit"))
)
element.click()
# Playwright (быстро и удобно)
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page()
page.goto("https://example.com")
page.click("#submit")
browser.close()
Playwright проще, быстрее, и лучше обрабатывает асинхронность. Но Selenium всё ещё стандарт в индустрии, если ты на собеседовании упомянешь Selenium — это плюс.
Для CI/CD обычно E2E-тесты запускаются только на main-ветке или перед релизом. Они медленные, и запускать их на каждый коммит — это трата ресурсов.
