Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

```
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)",
Expand Down
Empty file added tests/__init__.py
Empty file.
Empty file added tests/unit/__init__.py
Empty file.
36 changes: 36 additions & 0 deletions tests/unit/test_auth_api_client.py
Original file line number Diff line number Diff line change
@@ -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"}
93 changes: 93 additions & 0 deletions tests/unit/test_booking_api_client.py
Original file line number Diff line number Diff line change
@@ -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"
68 changes: 68 additions & 0 deletions tests/unit/test_booking_models.py
Original file line number Diff line number Diff line change
@@ -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({})
Loading