Python · Тестирование с pytest · Средний

Тестирование HTTP API

5 задач

Пишите автоматизированные тесты для HTTP API с помощью requests и фикстур pytest.

Тестирование HTTP API с pytest

#
## От ручного к автоматизированному тестированию API Если вы тестировали API через REST-клиент, вы знаете этот цикл: отправить запрос, просмотреть ответ, двигаться дальше. Автоматизированные тесты API делают ровно то же самое -- но повторяемо, за миллисекунды, и сигнализируют в момент, когда что-то регрессировало. С pytest и библиотекой `requests` API-тест выглядит почти идентично ручной проверке: ```python import requests def test_products_list(): resp = requests.get('https://apilearn.tukas.dev/api/products/') assert resp.status_code == 200 ``` Разница: это запускается автоматически при каждом пуше, без чьего-либо участия. ## Что проверять в API-тесте Проверяйте послойно -- каждый слой строится на предыдущем: 1. **Код статуса** -- сервер вообще отвечает, и с правильным кодом? 2. **Структура JSON** -- есть ли в ответе ожидаемые ключи? 3. **Значения** -- верны ли конкретные значения для этого входа? ```python def test_products_list(): resp = requests.get('https://apilearn.tukas.dev/api/products/') assert resp.status_code == 200 # слой 1: статус data = resp.json() assert 'results' in data # слой 2: структура assert 'count' in data assert isinstance(data['results'], list) # слой 2: тип assert len(data['results']) > 0 # слой 3: значение ``` Начинайте с кода статуса. Если он неверный, остальное -- шум: тело ответа может оказаться страницей ошибки, а не JSON вообще. ## BASE_URL: одно место для изменения Определите базовый URL API один раз и ссылайтесь на него везде. Это делает переключение между окружениями (staging, local, production) однострочным изменением: ```python BASE_URL = 'https://apilearn.tukas.dev' def test_products(): resp = requests.get(f'{BASE_URL}/api/products/') assert resp.status_code == 200 ``` Ещё лучше -- поместите в `conftest.py` как фикстуру: все тестовые файлы разделяют её, и можно переопределить для конкретной директории. ## Аутентификация: вынести в фикстуру Повторять логин в каждом тесте хрупко. Вынесите в фикстуру: ```python # conftest.py import pytest import requests BASE_URL = 'https://apilearn.tukas.dev' @pytest.fixture(scope='session') def auth_token(base_url): resp = requests.post(f'{base_url}/api/auth/token/', json={ 'username': 'testuser', 'password': 'TestUser2024!', }) resp.raise_for_status() return resp.json()['token'] @pytest.fixture def auth_headers(auth_token): return {'Authorization': f'Token {auth_token}'} ``` Любой тест, требующий аутентификации, принимает `auth_headers` как параметр -- pytest внедряет его: ```python def test_profile(base_url, auth_headers): resp = requests.get(f'{base_url}/api/users/profile/', headers=auth_headers) assert resp.status_code == 200 ``` Session scope для `auth_token` означает, что вызов логина происходит один раз за прогон тестов, а не один раз за тест. ## Проектирование изолированных, повторяемых тестов Хороший API-тест: - **Не зависит от порядка выполнения тестов** -- либо читает публичные/стабильные данные, либо настраивает собственное состояние - **Не оставляет побочных эффектов**, нарушающих другие тесты -- используйте yield-фикстуры для очистки созданного - **Проверяет конкретно** -- проверяйте конкретные значения, а не просто что ответ не пуст Для read-only эндпоинтов (GET) тесты естественно изолированы. Для операций записи очищайте через yield-фикстуру: ```python @pytest.fixture def created_cart_item(base_url, auth_headers): resp = requests.post( f'{base_url}/api/cart/items/', json={'product_id': 1, 'quantity': 1}, headers=auth_headers, ) item_id = resp.json()['id'] yield item_id requests.delete(f'{base_url}/api/cart/items/{item_id}/', headers=auth_headers) ``` ## requests.Response -- что получаете в ответе ```python resp = requests.get(url, headers={}, params={}) resp.status_code # int: 200, 201, 404, 500 и т.д. resp.json() # dict или list -- распарсенное тело JSON resp.text # str -- сырое тело ответа resp.headers # dict-подобный -- заголовки ответа (ключи без учёта регистра) resp.url # str -- финальный URL после редиректов resp.history # list -- промежуточные ответы в цепочке редиректов resp.raise_for_status() # выбрасывает requests.HTTPError если status_code >= 400 ```

API-тесты против apilearn.tukas.dev

#
## Общая настройка в conftest.py ```python # conftest.py import pytest import requests BASE_URL = 'https://apilearn.tukas.dev' @pytest.fixture(scope='session') def base_url(): return BASE_URL @pytest.fixture(scope='session') def auth_token(base_url): resp = requests.post(f'{base_url}/api/auth/token/', json={ 'username': 'testuser', 'password': 'TestUser2024!', }) resp.raise_for_status() return resp.json()['token'] @pytest.fixture def auth_headers(auth_token): return {'Authorization': f'Token {auth_token}'} ``` ## test_products.py -- статус, структура, значения ```python # test_products.py import requests BASE_URL = 'https://apilearn.tukas.dev' def test_products_list(): resp = requests.get(f'{BASE_URL}/api/products/') assert resp.status_code == 200 data = resp.json() assert 'results' in data assert 'count' in data assert isinstance(data['results'], list) def test_products_pagination(): resp = requests.get( f'{BASE_URL}/api/products/', params={'page': 1, 'page_size': 3}, ) assert resp.status_code == 200 assert len(resp.json()['results']) == 3 ``` ## test_auth.py -- логин и защищённые эндпоинты ```python # test_auth.py import requests BASE_URL = 'https://apilearn.tukas.dev' def test_token_login(): resp = requests.post(f'{BASE_URL}/api/auth/token/', json={ 'username': 'testuser', 'password': 'TestUser2024!', }) assert resp.status_code == 200 data = resp.json() assert 'token' in data assert isinstance(data['token'], str) assert len(data['token']) > 10 def test_profile_requires_auth(): resp = requests.get(f'{BASE_URL}/api/users/profile/') assert resp.status_code == 401 def test_profile_with_token(auth_headers): resp = requests.get( f'{BASE_URL}/api/users/profile/', headers=auth_headers, ) assert resp.status_code == 200 data = resp.json() assert 'username' in data assert 'email' in data ``` ## test_misc.py -- эхо и редиректы ```python # test_misc.py import requests BASE_URL = 'https://apilearn.tukas.dev' def test_echo_header(): custom_headers = {'X-Custom-Header': 'pytest-test-123'} resp = requests.get(f'{BASE_URL}/api/echo/', headers=custom_headers) assert resp.status_code == 200 echoed = resp.json()['headers'] assert echoed.get('X-Custom-Header') == 'pytest-test-123' def test_redirect_followed(): # requests автоматически следует редиректам по умолчанию resp = requests.get(f'{BASE_URL}/api/redirect/') assert resp.status_code == 200 assert len(resp.history) > 0 # произошёл хотя бы один редирект assert 'apilearn.tukas.dev' in resp.url def test_redirect_not_followed(): resp = requests.get(f'{BASE_URL}/api/redirect/', allow_redirects=False) assert resp.status_code in (301, 302, 307, 308) assert 'Location' in resp.headers ``` ## Точная проверка содержимого ответа ```python def test_product_fields(base_url): resp = requests.get(f'{base_url}/api/products/') products = resp.json()['results'] # Проверить структуру первого элемента first = products[0] assert 'id' in first assert 'name' in first assert 'price' in first assert isinstance(first['price'], (int, float)) assert first['price'] > 0 ```

Краткий справочник по API-тестированию

#
**Стандартный паттерн проверок:** ```python resp = requests.get(url, headers=headers, params=params) assert resp.status_code == 200 data = resp.json() assert 'key' in data assert data['key'] == expected_value ``` **Свойства requests.Response:** | Свойство | Тип | Описание | |----------|------|-------------| | `.status_code` | int | HTTP-статус (200, 201, 401, 404...) | | `.json()` | dict/list | Распарсенное тело JSON | | `.text` | str | Сырое тело ответа | | `.headers` | dict-подобный | Заголовки ответа | | `.url` | str | Финальный URL после редиректов | | `.history` | list | Цепочка редиректов | | `.raise_for_status()` | -- | Выбрасывает `HTTPError` при статусе >= 400 | **HTTP-методы:** ```python requests.get(url, params={}, headers={}) requests.post(url, json={}, headers={}) # тело JSON requests.post(url, data={}, headers={}) # form-encoded тело requests.put(url, json={}, headers={}) requests.delete(url, headers={}) ``` **Фикстура auth-заголовка (conftest.py):** ```python @pytest.fixture(scope='session') def auth_token(base_url): resp = requests.post(f'{base_url}/api/auth/token/', json={...}) return resp.json()['token'] @pytest.fixture def auth_headers(auth_token): return {'Authorization': f'Token {auth_token}'} ``` **Управление редиректами:** ```python requests.get(url) # следует редиректам (по умолчанию) requests.get(url, allow_redirects=False) # останавливается на первом редиректе ```
01

Тест GET /api/products/ -- статус и структура

#

Напишите тест, который вызывает `GET https://apilearn.tukas.dev/api/products/` и проверяет: (1) код статуса равен 200, (2) JSON-ответ содержит ключ `"results"`, (3) `"results"` является списком, (4) список не пустой. Запустите `pytest -v` и убедитесь, что тест проходит.

import requests

BASE_URL = 'https://apilearn.tukas.dev'


def test_products_list():
    resp = requests.get(f'{BASE_URL}/api/products/')

    # проверить код статуса 200
    # проверить 'results' в JSON-ответе
    # проверить что results является списком
    # проверить что results не пустой
    pass
Решение
import requests

BASE_URL = 'https://apilearn.tukas.dev'


def test_products_list():
    resp = requests.get(f'{BASE_URL}/api/products/')

    assert resp.status_code == 200
    data = resp.json()
    assert 'results' in data
    assert isinstance(data['results'], list)
    assert len(data['results']) > 0
02

Тест пагинации -- точное количество элементов

#

Напишите тест, вызывающий GET `https://apilearn.tukas.dev/api/products/` с параметрами запроса `page=1` и `page_size=3`. Проверьте, что статус ответа 200 и `len(data["results"]) == 3`. Используйте именованный аргумент `params` в `requests.get` для передачи параметров запроса -- не стройте строку запроса вручную.

import requests

BASE_URL = 'https://apilearn.tukas.dev'


def test_products_pagination():
    resp = requests.get(
        f'{BASE_URL}/api/products/',
        # передать params как словарь: {'page': 1, 'page_size': 3}
    )

    # проверить статус 200
    # проверить ровно 3 результата
    pass
Решение
import requests

BASE_URL = 'https://apilearn.tukas.dev'


def test_products_pagination():
    resp = requests.get(
        f'{BASE_URL}/api/products/',
        params={'page': 1, 'page_size': 3},
    )

    assert resp.status_code == 200
    data = resp.json()
    assert len(data['results']) == 3
03

Фикстура auth-заголовка и защищённый эндпоинт

#

Напишите `conftest.py` с: (1) фикстурой `auth_token` с session scope, которая делает POST на `https://apilearn.tukas.dev/api/auth/token/` с `username="testuser"` и `password="TestUser2024!"` и возвращает строку токена; (2) фикстурой `auth_headers` с function scope, возвращающей `{"Authorization": "Token <token>"}`. Затем напишите тест, использующий `auth_headers` для вызова `GET https://apilearn.tukas.dev/api/users/profile/` и проверяющий статус 200 и наличие `"username"` в ответе.

# conftest.py
import pytest
import requests

BASE_URL = 'https://apilearn.tukas.dev'

@pytest.fixture(scope='session')
def auth_token():
    # POST на BASE_URL/api/auth/token/ со словарём учётных данных
    # вернуть resp.json()['token']
    pass

@pytest.fixture
def auth_headers(auth_token):
    # вернуть {'Authorization': f'Token {auth_token}'}
    pass


# test_profile.py
def test_profile_authenticated(auth_headers):
    # GET BASE_URL/api/users/profile/ с auth_headers
    # проверить статус 200
    # проверить 'username' в JSON-ответе
    pass
Решение
# conftest.py
import pytest
import requests

BASE_URL = 'https://apilearn.tukas.dev'

@pytest.fixture(scope='session')
def auth_token():
    resp = requests.post(f'{BASE_URL}/api/auth/token/', json={
        'username': 'testuser',
        'password': 'TestUser2024!',
    })
    resp.raise_for_status()
    return resp.json()['token']

@pytest.fixture
def auth_headers(auth_token):
    return {'Authorization': f'Token {auth_token}'}


# test_profile.py
import requests

BASE_URL = 'https://apilearn.tukas.dev'

def test_profile_authenticated(auth_headers):
    resp = requests.get(f'{BASE_URL}/api/users/profile/', headers=auth_headers)
    assert resp.status_code == 200
    data = resp.json()
    assert 'username' in data
04

Отправить пользовательский заголовок и проверить эхо

#

Напишите тест, отправляющий `GET https://apilearn.tukas.dev/api/echo/` с пользовательским заголовком `X-Test-Id: pytest-exercise-4`. Проверьте, что статус ответа 200 и что тело JSON отражает ваш заголовок обратно -- эхо-эндпоинт возвращает заголовки запроса в ключе `"headers"` JSON-ответа.

import requests

BASE_URL = 'https://apilearn.tukas.dev'


def test_echo_custom_header():
    custom_headers = {'X-Test-Id': 'pytest-exercise-4'}
    resp = requests.get(f'{BASE_URL}/api/echo/', headers=custom_headers)

    # проверить статус 200
    # проверить resp.json()['headers']['X-Test-Id'] == 'pytest-exercise-4'
    pass
Решение
import requests

BASE_URL = 'https://apilearn.tukas.dev'


def test_echo_custom_header():
    custom_headers = {'X-Test-Id': 'pytest-exercise-4'}
    resp = requests.get(f'{BASE_URL}/api/echo/', headers=custom_headers)

    assert resp.status_code == 200
    echoed_headers = resp.json()['headers']
    assert echoed_headers.get('X-Test-Id') == 'pytest-exercise-4'
05

Следовать редиректу и проверить финальный URL

#

Напишите два теста для `GET https://apilearn.tukas.dev/api/redirect/`. Первый: позвольте requests следовать редиректу (по умолчанию) -- проверьте статус 200, что `resp.history` не пуст, и что финальный `resp.url` содержит `"apilearn.tukas.dev"`. Второй: передайте `allow_redirects=False` -- проверьте, что статус является 3xx-кодом (301, 302, 307 или 308) и что в ответе присутствует заголовок `"Location"`.

import requests

BASE_URL = 'https://apilearn.tukas.dev'


def test_redirect_followed():
    resp = requests.get(f'{BASE_URL}/api/redirect/')
    # проверить статус 200
    # проверить resp.history не пустой
    # проверить 'apilearn.tukas.dev' в resp.url
    pass


def test_redirect_not_followed():
    resp = requests.get(f'{BASE_URL}/api/redirect/', allow_redirects=False)
    # проверить статус 3xx
    # проверить 'Location' в resp.headers
    pass
Решение
import requests

BASE_URL = 'https://apilearn.tukas.dev'


def test_redirect_followed():
    resp = requests.get(f'{BASE_URL}/api/redirect/')

    assert resp.status_code == 200
    assert len(resp.history) > 0
    assert 'apilearn.tukas.dev' in resp.url


def test_redirect_not_followed():
    resp = requests.get(f'{BASE_URL}/api/redirect/', allow_redirects=False)

    assert resp.status_code in (301, 302, 307, 308)
    assert 'Location' in resp.headers