diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..f9833f761 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +.pytest_cache/ +.venv/ +.coverage +htmlcov/ +*.pyc +allure-results/ +allure-report/ diff --git a/README.md b/README.md index 272081708..e6ebc8fe9 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,64 @@ -## Задание 1: Юнит-тесты +# Дипломный проект: Stellar Burgers + +## Задание 1: юнит-тесты ### Автотесты для проверки программы, которая помогает заказать бургер в Stellar Burgers ### Реализованные сценарии -Созданы юнит-тесты, покрывающие классы `Bun`, `Burger`, `Ingredient`, `Database` +Класс `Burger` покрыт unit-тестами с использованием моков и параметризации: + +- создание бургера; +- установка булочки; +- добавление и удаление ингредиента; +- перемещение ингредиента; +- расчёт цены; +- формирование чека. -Процент покрытия 100% (отчет: `htmlcov/index.html`) +Покрытие файла `burger.py` — 100%. ### Структура проекта -- `praktikum` - пакет, содержащий код программы -- `tests` - пакет, содержащий тесты, разделенные по классам. Например, `bun_test.py`, `burger_test.py` и т.д. +- `burger.py` — тестируемый класс `Burger`; +- `bun.py`, `ingredient.py` — модели, используемые классом `Burger`; +- `tests/test_burger.py` — unit-тесты; +- `tests/api/` — API-тесты; +- `api_client.py` — клиент для запросов к API; +- `constants.py` — адреса эндпоинтов и ожидаемые сообщения API; +- `data.py` — генерация уникальных тестовых пользователей; +- `requirements.txt` — зависимости проекта; +- `pytest.ini` — настройки pytest. ### Запуск автотестов **Установка зависимостей** -> `$ pip install -r requirements.txt` +> `$ python -m pip install -r requirements.txt` + +**Запуск автотестов с проверкой обязательного покрытия 100%** + +> `$ pytest tests --cov=burger --cov-report=term-missing --cov-fail-under=100` + +**Создание HTML-отчёта** + +> `$ pytest tests --cov=burger --cov-report=html --cov-fail-under=100` + +## Задание 2: API-тесты + +Проверены эндпоинты создания пользователя, авторизации и создания заказа. +Для обязательных полей и неверных учётных данных используется параметризация. +Созданные тестами пользователи удаляются после выполнения проверок. + +**Запуск API-тестов с формированием данных Allure** + +> `$ pytest tests/api --alluredir=allure-results` + +**Просмотр отчёта** + +Если Allure Commandline установлен: -**Запуск автотестов и создание HTML-отчета о покрытии** +> `$ allure serve allure-results` -> `$ pytest --cov=praktikum --cov-report=html` +По документации заказ без авторизации должен возвращать `401 Unauthorized`. +Если стенд возвращает `200 OK`, соответствующий тест падает и фиксирует дефект API; +ожидаемый результат не заменяется фактическим поведением неисправного стенда. diff --git a/api_client.py b/api_client.py new file mode 100644 index 000000000..e5bbc653b --- /dev/null +++ b/api_client.py @@ -0,0 +1,69 @@ +import allure +import requests + +from constants import ( + BASE_URL, + INGREDIENTS_ENDPOINT, + LOGIN_ENDPOINT, + ORDERS_ENDPOINT, + REGISTER_ENDPOINT, + REQUEST_TIMEOUT, + USER_ENDPOINT, +) + + +class StellarBurgersApi: + def __init__(self): + self.session = requests.Session() + + def close(self): + self.session.close() + + @staticmethod + def _attach_response(response): + allure.attach( + f"HTTP {response.status_code}\n{response.text}", + name="Ответ API", + attachment_type=allure.attachment_type.TEXT, + ) + + def _request(self, method, endpoint, **kwargs): + response = self.session.request( + method, + f"{BASE_URL}{endpoint}", + timeout=REQUEST_TIMEOUT, + **kwargs, + ) + self._attach_response(response) + return response + + @allure.step("Создать пользователя") + def create_user(self, payload): + return self._request("POST", REGISTER_ENDPOINT, json=payload) + + @allure.step("Авторизовать пользователя") + def login_user(self, payload): + return self._request("POST", LOGIN_ENDPOINT, json=payload) + + @allure.step("Удалить тестового пользователя") + def delete_user(self, access_token): + return self._request( + "DELETE", + USER_ENDPOINT, + headers={"Authorization": access_token}, + ) + + @allure.step("Получить список ингредиентов") + def get_ingredients(self): + return self._request("GET", INGREDIENTS_ENDPOINT) + + @allure.step("Создать заказ") + def create_order(self, ingredient_ids, access_token=None): + headers = {"Authorization": access_token} if access_token else None + return self._request( + "POST", + ORDERS_ENDPOINT, + json={"ingredients": ingredient_ids}, + headers=headers, + ) + diff --git a/burger.py b/burger.py index 2b3b6a88b..3f71aaa29 100644 --- a/burger.py +++ b/burger.py @@ -1,7 +1,7 @@ from typing import List -from praktikum.bun import Bun -from praktikum.ingredient import Ingredient +from bun import Bun +from ingredient import Ingredient class Burger: diff --git a/constants.py b/constants.py new file mode 100644 index 000000000..003c7adf6 --- /dev/null +++ b/constants.py @@ -0,0 +1,17 @@ +BASE_URL = "https://stellarburgers.education-services.ru" + +REGISTER_ENDPOINT = "/api/auth/register" +LOGIN_ENDPOINT = "/api/auth/login" +USER_ENDPOINT = "/api/auth/user" +INGREDIENTS_ENDPOINT = "/api/ingredients" +ORDERS_ENDPOINT = "/api/orders" + +REQUEST_TIMEOUT = 30 + +# Это точные значения поля message в ответах API, поэтому они не переведены. +USER_ALREADY_EXISTS_MESSAGE = "User already exists" +REQUIRED_FIELDS_MESSAGE = "Email, password and name are required fields" +INVALID_CREDENTIALS_MESSAGE = "email or password are incorrect" +INGREDIENTS_REQUIRED_MESSAGE = "Ingredient ids must be provided" +UNAUTHORIZED_MESSAGE = "You should be authorised" + diff --git a/data.py b/data.py new file mode 100644 index 000000000..ae2d544e8 --- /dev/null +++ b/data.py @@ -0,0 +1,11 @@ +from uuid import uuid4 + + +def generate_user_data(): + unique_part = uuid4().hex + return { + "email": f"qa_baydashev_{unique_part}@yandex.ru", + "password": f"Pwd_{unique_part[:12]}", + "name": "Dmitry", + } + diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..b9c31b2b5 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -ra diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..6de7058cf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +pytest==9.1.1 +pytest-cov==7.1.0 +requests==2.34.2 +allure-pytest==2.16.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/tests/api/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/api/conftest.py b/tests/api/conftest.py new file mode 100644 index 000000000..16af56fdb --- /dev/null +++ b/tests/api/conftest.py @@ -0,0 +1,64 @@ +import warnings + +import pytest +import requests + +from api_client import StellarBurgersApi +from data import generate_user_data + + +@pytest.fixture(scope="session") +def api_client(): + client = StellarBurgersApi() + yield client + client.close() + + +@pytest.fixture +def user_factory(api_client): + created_tokens = [] + + def create_user(payload=None): + user_data = payload or generate_user_data() + response = api_client.create_user(user_data) + + if response.status_code == 200: + token = response.json().get("accessToken") + if token: + created_tokens.append(token) + + return user_data, response + + yield create_user + + for token in created_tokens: + try: + api_client.delete_user(token) + except requests.RequestException as error: + warnings.warn( + f"Не удалось удалить тестового пользователя: {error}", + RuntimeWarning, + ) + + +@pytest.fixture +def registered_user(user_factory): + payload, response = user_factory() + assert response.status_code == 200, response.text + + response_body = response.json() + return { + "payload": payload, + "access_token": response_body["accessToken"], + } + + +@pytest.fixture(scope="session") +def ingredient_ids(api_client): + response = api_client.get_ingredients() + assert response.status_code == 200, response.text + + response_body = response.json() + assert response_body["success"] is True + assert len(response_body["data"]) >= 2 + return [ingredient["_id"] for ingredient in response_body["data"][:2]] diff --git a/tests/api/test_create_order.py b/tests/api/test_create_order.py new file mode 100644 index 000000000..cffcaed34 --- /dev/null +++ b/tests/api/test_create_order.py @@ -0,0 +1,67 @@ +import allure + +from constants import INGREDIENTS_REQUIRED_MESSAGE, UNAUTHORIZED_MESSAGE + + +@allure.feature("Создание заказа") +class TestCreateOrder: + @allure.title("Авторизованный пользователь может создать заказ") + def test_create_order_with_authorization_success( + self, api_client, registered_user, ingredient_ids + ): + response = api_client.create_order( + ingredient_ids[:1], registered_user["access_token"] + ) + response_body = response.json() + + assert response.status_code == 200 + assert response_body["success"] is True + assert isinstance(response_body["order"]["number"], int) + + @allure.title("Неавторизованный пользователь не может создать заказ") + def test_create_order_without_authorization_returns_error( + self, api_client, ingredient_ids + ): + response = api_client.create_order(ingredient_ids) + + assert response.status_code == 401 + assert response.json() == { + "success": False, + "message": UNAUTHORIZED_MESSAGE, + } + + @allure.title("Можно создать заказ с несколькими ингредиентами") + def test_create_order_with_ingredients_success( + self, api_client, registered_user, ingredient_ids + ): + response = api_client.create_order( + ingredient_ids, registered_user["access_token"] + ) + response_body = response.json() + + assert response.status_code == 200 + assert response_body["success"] is True + assert response_body["name"] + assert isinstance(response_body["order"]["number"], int) + + @allure.title("Нельзя создать заказ без ингредиентов") + def test_create_order_without_ingredients_returns_error( + self, api_client, registered_user + ): + response = api_client.create_order([], registered_user["access_token"]) + + assert response.status_code == 400 + assert response.json() == { + "success": False, + "message": INGREDIENTS_REQUIRED_MESSAGE, + } + + @allure.title("Нельзя создать заказ с неверным хешем ингредиента") + def test_create_order_with_invalid_ingredient_hash_returns_error( + self, api_client, registered_user + ): + response = api_client.create_order( + ["invalid-ingredient-hash"], registered_user["access_token"] + ) + + assert response.status_code == 500 diff --git a/tests/api/test_create_user.py b/tests/api/test_create_user.py new file mode 100644 index 000000000..4df3305b5 --- /dev/null +++ b/tests/api/test_create_user.py @@ -0,0 +1,54 @@ +import allure +import pytest + +from constants import REQUIRED_FIELDS_MESSAGE, USER_ALREADY_EXISTS_MESSAGE +from data import generate_user_data + + +@allure.feature("Создание пользователя") +class TestCreateUser: + @allure.title("Можно создать уникального пользователя") + def test_create_unique_user_success(self, user_factory): + payload, response = user_factory() + response_body = response.json() + + assert response.status_code == 200 + assert response_body["success"] is True + assert response_body["user"]["email"] == payload["email"] + assert response_body["user"]["name"] == payload["name"] + assert response_body["accessToken"].startswith("Bearer ") + assert response_body["refreshToken"] + + @allure.title("Нельзя повторно зарегистрировать существующего пользователя") + def test_create_duplicate_user_returns_error(self, user_factory): + payload, first_response = user_factory() + assert first_response.status_code == 200 + + second_response = user_factory(payload)[1] + + assert second_response.status_code == 403 + assert second_response.json() == { + "success": False, + "message": USER_ALREADY_EXISTS_MESSAGE, + } + + @allure.title("Нельзя создать пользователя без обязательного поля: {missing_field}") + @pytest.mark.parametrize( + "missing_field", + ["email", "password", "name"], + ids=["without_email", "without_password", "without_name"], + ) + def test_create_user_without_required_field_returns_error( + self, api_client, missing_field + ): + payload = generate_user_data() + payload.pop(missing_field) + + response = api_client.create_user(payload) + + assert response.status_code == 403 + assert response.json() == { + "success": False, + "message": REQUIRED_FIELDS_MESSAGE, + } + diff --git a/tests/api/test_login_user.py b/tests/api/test_login_user.py new file mode 100644 index 000000000..9e60ed706 --- /dev/null +++ b/tests/api/test_login_user.py @@ -0,0 +1,49 @@ +import allure +import pytest + +from constants import INVALID_CREDENTIALS_MESSAGE +from data import generate_user_data + + +@allure.feature("Логин пользователя") +class TestLoginUser: + @allure.title("Существующий пользователь может войти") + def test_login_existing_user_success(self, api_client, registered_user): + payload = registered_user["payload"] + + response = api_client.login_user( + {"email": payload["email"], "password": payload["password"]} + ) + response_body = response.json() + + assert response.status_code == 200 + assert response_body["success"] is True + assert response_body["user"]["email"] == payload["email"] + assert response_body["user"]["name"] == payload["name"] + assert response_body["accessToken"].startswith("Bearer ") + assert response_body["refreshToken"] + + @allure.title("Нельзя войти с неверным значением поля: {invalid_field}") + @pytest.mark.parametrize( + "invalid_field", + ["email", "password"], + ids=["invalid_email", "invalid_password"], + ) + def test_login_with_invalid_credentials_returns_error( + self, api_client, registered_user, invalid_field + ): + payload = registered_user["payload"] + credentials = { + "email": payload["email"], + "password": payload["password"], + } + credentials[invalid_field] = generate_user_data()[invalid_field] + + response = api_client.login_user(credentials) + + assert response.status_code == 401 + assert response.json() == { + "success": False, + "message": INVALID_CREDENTIALS_MESSAGE, + } + diff --git a/tests/test_burger.py b/tests/test_burger.py new file mode 100644 index 000000000..dc36e96e2 --- /dev/null +++ b/tests/test_burger.py @@ -0,0 +1,94 @@ +from unittest.mock import Mock + +import pytest + +from bun import Bun +from burger import Burger +from ingredient import Ingredient + + +@pytest.fixture +def burger(): + return Burger() + + +class TestBurger: + def test_init_creates_burger_without_bun_and_ingredients(self): + burger = Burger() + + assert burger.bun is None and burger.ingredients == [] + + def test_set_buns_sets_bun(self, burger): + bun = Mock(spec=Bun) + + burger.set_buns(bun) + + assert burger.bun is bun + + def test_add_ingredient_appends_ingredient(self, burger): + ingredient = Mock(spec=Ingredient) + + burger.add_ingredient(ingredient) + + assert burger.ingredients == [ingredient] + + @pytest.mark.parametrize("index, expected_indexes", + [(0, [1, 2]),(1, [0, 2]),(2, [0, 1]),],) + def test_remove_ingredient_removes_item_by_index( + self, burger, index, expected_indexes): + ingredients = [Mock(spec=Ingredient) for _ in range(3)] + burger.ingredients = ingredients.copy() + + burger.remove_ingredient(index) + + assert burger.ingredients == [ingredients[i] for i in expected_indexes] + + @pytest.mark.parametrize("index, new_index, expected_indexes", + [(0, 2, [1, 2, 0]),(2, 0, [2, 0, 1]),(1, 1, [0, 1, 2]),],) + def test_move_ingredient_moves_item_to_new_index( + self, burger, index, new_index, expected_indexes): + ingredients = [Mock(spec=Ingredient) for _ in range(3)] + burger.ingredients = ingredients.copy() + + burger.move_ingredient(index, new_index) + + assert burger.ingredients == [ingredients[i] for i in expected_indexes] + + @pytest.mark.parametrize("bun_price, ingredient_prices, expected_price", + [(100, [], 200),(125.5, [10, 20.5], 281.5),],) + def test_get_price_returns_price_of_two_buns_and_all_ingredients( + self, burger, bun_price, ingredient_prices, expected_price): + bun = Mock(spec=Bun) + bun.get_price.return_value = bun_price + ingredients = [Mock(spec=Ingredient) for _ in ingredient_prices] + for ingredient, price in zip(ingredients, ingredient_prices): + ingredient.get_price.return_value = price + burger.set_buns(bun) + burger.ingredients = ingredients + + result = burger.get_price() + + assert result == expected_price + + @pytest.mark.parametrize( + "ingredient_type, expected_type", + [("SAUCE", "sauce"),("FILLING", "filling"),],) + def test_get_receipt_returns_formatted_receipt( + self, burger, ingredient_type, expected_type): + bun = Mock(spec=Bun) + bun.get_name.return_value = "space bun" + bun.get_price.return_value = 100 + ingredient = Mock(spec=Ingredient) + ingredient.get_type.return_value = ingredient_type + ingredient.get_name.return_value = "meteor" + ingredient.get_price.return_value = 25 + burger.set_buns(bun) + burger.add_ingredient(ingredient) + + result = burger.get_receipt() + + assert result == ( + "(==== space bun ====)\n" + f"= {expected_type} meteor =\n" + "(==== space bun ====)\n\n" + "Price: 225")