From fb6da37dd66dc2956cc6b4485e47b00d416a3f50 Mon Sep 17 00:00:00 2001 From: ThomasTDS Date: Sat, 19 Sep 2026 15:40:04 -0300 Subject: [PATCH] =?UTF-8?q?Adiciona=20camada=20de=20testes=20unit=C3=A1rio?= =?UTF-8?q?s=20isolados=20para=20api/=20e=20models/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/unit/ testa AuthApiClient, BookingApiClient e os schemas Pydantic de forma isolada, com requests-mock simulando as respostas HTTP em vez de bater na restful-booker de verdade. Complementa os cenários de steps/, que continuam sendo os testes de integração reais. pyproject.toml passa a incluir tests/ em testpaths. Cobertura de api/ e models/ foi de 92% para 100%: os testes unitários passaram a exercitar caminhos que os cenários de BDD não cobriam (filtros de get_booking_ids, partial_update_booking). --- README.md | 13 +++- pyproject.toml | 3 +- tests/__init__.py | 0 tests/unit/__init__.py | 0 tests/unit/test_auth_api_client.py | 36 +++++++++++ tests/unit/test_booking_api_client.py | 93 +++++++++++++++++++++++++++ tests/unit/test_booking_models.py | 68 ++++++++++++++++++++ 7 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_auth_api_client.py create mode 100644 tests/unit/test_booking_api_client.py create mode 100644 tests/unit/test_booking_models.py diff --git a/README.md b/README.md index 142f869..6b86341 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,9 @@ qa-api-python/ ├── docs/ │ └── test-cases.md # Matriz de rastreabilidade dos test cases ├── features/ # Cenários em Gherkin (.feature) -├── steps/ # Implementação dos steps do pytest-bdd +├── steps/ # Implementação dos steps do pytest-bdd (testes de integração, batem na API real) +├── tests/ +│ └── unit/ # Testes unitários isolados de api/ e models/ (com requests-mock, sem rede) ├── api/ # API Clients (AuthApiClient, BookingApiClient) ├── models/ # Schemas Pydantic e tipos derivados (shape dos dados da API) ├── reports/ # Relatório HTML e cobertura (coverage.xml) gerados a cada execução (não versionado) @@ -67,6 +69,14 @@ pytest -m smoke Roda só os fluxos ponta-a-ponta mais críticos (autenticação, criação, consulta, atualização e remoção — ver [docs/test-cases.md](docs/test-cases.md)). Como o `pyproject.toml` sempre escreve no mesmo arquivo, rodar isso depois de `pytest` **sobrescreve** `reports/report.html` com só esses 5 cenários. +### Rodar só os testes unitários + +``` +pytest tests/unit +``` + +Testes isolados de `api/` e `models/`, com as chamadas HTTP mocadas via [`requests-mock`](https://requests-mock.readthedocs.io/) — não fazem nenhuma chamada de rede, então rodam em milissegundos e não dependem da API pública estar no ar. Complementam os cenários de `steps/`, que são testes de integração de verdade contra a `restful-booker`. + ### Lint e formatação ``` @@ -118,6 +128,7 @@ Autenticação: `POST /auth` com `{ "username": "admin", "password": "password12 - BDD / Gherkin: cenários claros e legíveis em `.feature`, executados via `pytest-bdd`. - API Client Objects: `AuthApiClient` e `BookingApiClient` encapsulam as chamadas HTTP, do mesmo jeito que Page Objects encapsulam elementos de UI. +- Pirâmide de testes: além dos cenários de BDD (`steps/`), que são testes de integração reais contra a `restful-booker`, `tests/unit/` cobre `api/` e `models/` de forma isolada, com `requests-mock` simulando as respostas HTTP — sem rede, sem depender da API pública estar no ar. - Validação de schema com [Pydantic](https://docs.pydantic.dev/): as respostas da API são validadas em tempo de execução contra os modelos em `models/booking.py` (fonte única de verdade), não só tipadas por anotação — se a API mudar o formato de uma resposta, o teste falha com uma mensagem clara em vez de passar silenciosamente ou quebrar mais adiante. - Cobertura de autenticação, CRUD completo, PUT vs. PATCH, e casos de acesso não autorizado (403). - Relatório HTML automatizado a cada execução (`reports/report.html`, via `pytest-html`), com a versão da `master` publicada em [thomastds.github.io/qa-api-python](https://thomastds.github.io/qa-api-python/) via GitHub Pages. diff --git a/pyproject.toml b/pyproject.toml index 8e828b5..d2e28d0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dev = [ "pytest-rerunfailures>=15.0", "pytest-html>=4.1", "pytest-cov>=6.0", + "requests-mock>=1.12", "ruff>=0.14", "mypy>=1.14", "types-requests>=2.32", @@ -29,7 +30,7 @@ build-backend = "setuptools.build_meta" include = ["api*", "models*"] [tool.pytest.ini_options] -testpaths = ["steps"] +testpaths = ["steps", "tests"] addopts = "--reruns 1 --html=reports/report.html --self-contained-html --cov=api --cov=models --cov-report=term-missing --cov-report=xml:reports/coverage.xml" markers = [ "smoke: fluxos ponta-a-ponta mais críticos (equivalente a npm run test:smoke)", diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_auth_api_client.py b/tests/unit/test_auth_api_client.py new file mode 100644 index 0000000..d0f7718 --- /dev/null +++ b/tests/unit/test_auth_api_client.py @@ -0,0 +1,36 @@ +import requests +from requests_mock import Mocker + +from api.auth_api_client import AuthApiClient + +BASE_URL = "https://example.test" + + +def test_create_token_posts_the_given_credentials(requests_mock: Mocker) -> None: + requests_mock.post(f"{BASE_URL}/auth", json={"token": "abc123"}) + client = AuthApiClient(requests.Session(), BASE_URL) + + response = client.create_token("admin", "password123") + + assert response.status_code == 200 + assert requests_mock.last_request is not None + assert requests_mock.last_request.json() == {"username": "admin", "password": "password123"} + + +def test_get_valid_token_returns_the_token_from_the_response_body(requests_mock: Mocker) -> None: + requests_mock.post(f"{BASE_URL}/auth", json={"token": "abc123"}) + client = AuthApiClient(requests.Session(), BASE_URL) + + token = client.get_valid_token() + + assert token == "abc123" + + +def test_get_valid_token_always_authenticates_as_admin(requests_mock: Mocker) -> None: + requests_mock.post(f"{BASE_URL}/auth", json={"token": "abc123"}) + client = AuthApiClient(requests.Session(), BASE_URL) + + client.get_valid_token() + + assert requests_mock.last_request is not None + assert requests_mock.last_request.json() == {"username": "admin", "password": "password123"} diff --git a/tests/unit/test_booking_api_client.py b/tests/unit/test_booking_api_client.py new file mode 100644 index 0000000..7e7075e --- /dev/null +++ b/tests/unit/test_booking_api_client.py @@ -0,0 +1,93 @@ +import requests +from requests_mock import Mocker + +from api.booking_api_client import BookingApiClient +from models.booking import Booking, BookingDates + +BASE_URL = "https://example.test" + + +def _booking() -> Booking: + return Booking( + firstname="Jim", + lastname="Brown", + totalprice=111, + depositpaid=True, + bookingdates=BookingDates(checkin="2024-01-01", checkout="2024-01-02"), + additionalneeds="Breakfast", + ) + + +def test_get_booking_ids_sends_no_params_when_no_filter_is_given(requests_mock: Mocker) -> None: + requests_mock.get(f"{BASE_URL}/booking", json=[]) + client = BookingApiClient(requests.Session(), BASE_URL) + + client.get_booking_ids() + + assert requests_mock.last_request is not None + assert requests_mock.last_request.qs == {} + + +def test_get_booking_ids_sends_only_the_filters_that_were_given(requests_mock: Mocker) -> None: + requests_mock.get(f"{BASE_URL}/booking", json=[]) + client = BookingApiClient(requests.Session(), BASE_URL) + + client.get_booking_ids(firstname="Jim", checkin="2024-01-01") + + assert requests_mock.last_request is not None + assert requests_mock.last_request.qs == {"firstname": ["jim"], "checkin": ["2024-01-01"]} + + +def test_get_booking_hits_the_booking_by_id_endpoint(requests_mock: Mocker) -> None: + requests_mock.get(f"{BASE_URL}/booking/1", json={"firstname": "Jim"}) + client = BookingApiClient(requests.Session(), BASE_URL) + + client.get_booking(1) + + assert requests_mock.last_request is not None + assert requests_mock.last_request.url == f"{BASE_URL}/booking/1" + + +def test_create_booking_sends_the_booking_payload_and_no_auth_cookie(requests_mock: Mocker) -> None: + requests_mock.post(f"{BASE_URL}/booking", json={"bookingid": 1, "booking": {}}) + client = BookingApiClient(requests.Session(), BASE_URL) + booking = _booking() + + client.create_booking(booking) + + assert requests_mock.last_request is not None + assert requests_mock.last_request.json() == booking.model_dump(exclude_none=True) + assert "Cookie" not in requests_mock.last_request.headers + + +def test_update_booking_sends_the_full_payload_with_the_auth_token_as_cookie(requests_mock: Mocker) -> None: + requests_mock.put(f"{BASE_URL}/booking/1", json={}) + client = BookingApiClient(requests.Session(), BASE_URL) + booking = _booking() + + client.update_booking(1, booking, token="tok123") + + assert requests_mock.last_request is not None + assert requests_mock.last_request.json() == booking.model_dump(exclude_none=True) + assert requests_mock.last_request.headers["Cookie"] == "token=tok123" + + +def test_partial_update_booking_sends_only_the_given_fields(requests_mock: Mocker) -> None: + requests_mock.patch(f"{BASE_URL}/booking/1", json={}) + client = BookingApiClient(requests.Session(), BASE_URL) + + client.partial_update_booking(1, {"firstname": "Jane"}, token="tok123") + + assert requests_mock.last_request is not None + assert requests_mock.last_request.json() == {"firstname": "Jane"} + assert requests_mock.last_request.headers["Cookie"] == "token=tok123" + + +def test_delete_booking_sends_the_auth_token_as_cookie(requests_mock: Mocker) -> None: + requests_mock.delete(f"{BASE_URL}/booking/1") + client = BookingApiClient(requests.Session(), BASE_URL) + + client.delete_booking(1, token="tok123") + + assert requests_mock.last_request is not None + assert requests_mock.last_request.headers["Cookie"] == "token=tok123" diff --git a/tests/unit/test_booking_models.py b/tests/unit/test_booking_models.py new file mode 100644 index 0000000..f6b6618 --- /dev/null +++ b/tests/unit/test_booking_models.py @@ -0,0 +1,68 @@ +from typing import Any + +import pytest +from pydantic import ValidationError + +from models.booking import AuthResponse, Booking, BookingDates, BookingId, CreateBookingResponse + + +def _valid_booking_payload() -> dict[str, Any]: + return { + "firstname": "Jim", + "lastname": "Brown", + "totalprice": 111, + "depositpaid": True, + "bookingdates": {"checkin": "2024-01-01", "checkout": "2024-01-02"}, + "additionalneeds": "Breakfast", + } + + +def test_booking_accepts_a_full_valid_payload() -> None: + booking = Booking.model_validate(_valid_booking_payload()) + + assert booking.firstname == "Jim" + assert booking.bookingdates == BookingDates(checkin="2024-01-01", checkout="2024-01-02") + + +def test_booking_additionalneeds_defaults_to_none_when_omitted() -> None: + payload = _valid_booking_payload() + del payload["additionalneeds"] + + booking = Booking.model_validate(payload) + + assert booking.additionalneeds is None + + +def test_booking_rejects_a_missing_required_field() -> None: + payload = _valid_booking_payload() + del payload["totalprice"] + + with pytest.raises(ValidationError): + Booking.model_validate(payload) + + +def test_booking_rejects_incomplete_bookingdates() -> None: + payload = _valid_booking_payload() + del payload["bookingdates"]["checkout"] + + with pytest.raises(ValidationError): + Booking.model_validate(payload) + + +def test_booking_id_rejects_a_non_numeric_id() -> None: + with pytest.raises(ValidationError): + BookingId.model_validate({"bookingid": "not-a-number"}) + + +def test_create_booking_response_nests_the_booking_model() -> None: + payload = {"bookingid": 1, "booking": _valid_booking_payload()} + + response = CreateBookingResponse.model_validate(payload) + + assert response.bookingid == 1 + assert isinstance(response.booking, Booking) + + +def test_auth_response_requires_a_token() -> None: + with pytest.raises(ValidationError): + AuthResponse.model_validate({})