Poznaj VCR.py, czyli jak testować zewnętrzne API w Pythonie

Załóżmy, że tworzysz aplikację, która łączy się z kilkoma zewnętrznymi serwisami i chcesz mieć pewność, że kod działa zgodnie z wymaganiami. Do tego służą testy, dlatego bierzmy się od razu do roboty.

Ale jak testować zewnętrzne API? 🤯 Używać tak po prostu zewnętrznych serwisów? 🤔

Co jeśli dany serwis przestanie na chwilę działać? Co jeśli ma jakieś limity? A może zostaniesz obciążony kosztami każdego odwołania się do serwisu?

Hmm… musi być jakieś rozwiązanie. 🤔

Mockowanie! 💡

Tak, świetny pomysł, będziemy niezależni! A może da się inaczej?

A gdyby tak połączyć to razem? 🤔

VCR.py!

Pierwsze kroki z VCR.py

Biblioteka VCR.py pozwala na nagrywanie żądań i odpowiedzi zewnętrznych API.

Przy pierwszym uruchomieniu testu następuje standardowa komunikacja z zewnętrznymi serwisami, a każda integracja zostawia swój ślad w pliku .yaml (lub .json), w tak zwanej kasecie (cassette). To właśnie z kasety będą korzystać testy przy kolejnych uruchomieniach. W skrócie – zapewniamy sobie “automatyczne mockowanie”.

W kolejnych uruchomieniach testów kod nie będzie odwoływać sie do zewnętrznych serwisów, co wpływa na:

  • krótszy czas wykonywania testów,
  • niezawodność – ta sama odpowiedź z serwisu, poprawne asercje w kodzie,
  • niezależność od chwilowej niedostępności zewnętrznego serwisu,
  • możliwość uruchomienia testów nawet offline.

1. Instalacja VCR.py

pip install vcrpy

2. Pierwszy test z użyciem VCR.py

(Przykład z użyciem bibliotek pytest i requests. Zadanie dostosowane do pokazania działania biblioteki VCR.py)

Stwórzmy pierwszy test z VCR.py. Naszym zadaniem jest pobranie danych o zadaniu o identyfikatorze 1 ze strony https://jsonplaceholder.typicode.com/todos/1, a następnie porównanie z oczekiwaną wartością.

Aby użyć VCR.py wystarczy, że dodasz dekorator:

@vcr.use_cassette('fixtures/cassettes/todo_item.yaml')

lub użyjesz Context Managera:

 with vcr.use_cassette('fixtures/cassettes/todo_item.yaml'):

Czas na testy! 🎉

Z użyciem dekoratora:

# tests/test_todo.py
import requests
import vcr

@vcr.use_cassette('fixtures/cassettes/todo_item.yaml')
def test_should_return_correct_todo_item():
    response = requests.get(
        "https://jsonplaceholder.typicode.com/todos/1",
    )
    data = response.json()

    assert response.status_code == 200
    assert data['id'] == 1
    assert data['title'] == 'delectus aut autem'

lub z użyciem Context Managera (with):

# tests/test_todo.py
import requests
import vcr

def test_should_return_correct_todo_item():
    with vcr.use_cassette('fixtures/cassettes/todo_item.yaml'):
        response = requests.get(
            "https://jsonplaceholder.typicode.com/todos/1",
        )

    data = response.json()

    assert response.status_code == 200
    assert data['id'] == 1
    assert data['title'] == 'delectus aut autem'

Możesz wybrać wersję, która Ci bardziej odpowiada 🙂.

3. Uruchomienie testu

# pytest nazwa_folderu/pliku
pytest tests

# pytest nazwa_folderu/pliku -k nazwa_konkretnego_testu
pytest tests/test_todo.py -k test_should_return_correct_todo_item

VCR.py utworzył plik tests/fixtures/cassettes/todo_item.yaml:

interactions:
- request:
    body: null
    headers:
      Accept:
      - '*/*'
      Accept-Encoding:
      - gzip, deflate
      Connection:
      - keep-alive
      User-Agent:
      - python-requests/2.25.1
    method: GET
    uri: https://jsonplaceholder.typicode.com/todos/1
  response:
    body:
      string: !!binary |
        H4sIAAAAAAAAA6vmUlBQKi1OLfJMUbJSMNQBcTMRzJLMkpxUJSsFpZTUnNTkktJihcTSEhBOzVUC
        K0jOzy3ISS1JBWlJS8wpTuWqBQA33AG3UwAAAA==
    headers:
      Access-Control-Allow-Credentials:
      - 'true'
      Age:
      - '26256'
      CF-Cache-Status:
      - HIT
      CF-RAY:
      - 6697c9145fb6417b-HAM
      Cache-Control:
      - max-age=43200
      Connection:
      - keep-alive
      Content-Encoding:
      - gzip
      Content-Type:
      - application/json; charset=utf-8
      Date:
      - Sun, 04 Jul 2021 10:49:11 GMT
      Etag:
      - W/"53-hfEnumeNh6YirfjyjaujcOPPT+s"
      Expect-CT:
      - max-age=604800, report-uri="https://report-uri.cloudflare.com/cdn-cgi/beacon/expect-ct"
      Expires:
      - '-1'
      NEL:
      - '{"report_to":"cf-nel","max_age":604800}'
      Pragma:
      - no-cache
      Report-To:
      - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v2?s=y4OXfEfTTaWiSgAdO2lx6dWfeiQPJCXPIvT%2F3nXlVr5H7nc75msErCCOhjXBoDebkmDXjq241a3vxImdCq0ambg40TMufTdA1wiW4ah7UonikGjGoA5AyCKlpPaBGJopL4818snE%2FG36z5hD4No2bDP7%2F6veBQ%3D%3D"}],"group":"cf-nel","max_age":604800}'
      Server:
      - cloudflare
      Transfer-Encoding:
      - chunked
      Vary:
      - Origin, Accept-Encoding
      Via:
      - 1.1 vegur
      X-Content-Type-Options:
      - nosniff
      X-Powered-By:
      - Express
      X-Ratelimit-Limit:
      - '1000'
      X-Ratelimit-Remaining:
      - '999'
      X-Ratelimit-Reset:
      - '1621165905'
      alt-svc:
      - h3-27=":443"; ma=86400, h3-28=":443"; ma=86400, h3-29=":443"; ma=86400, h3=":443";
        ma=86400
    status:
      code: 200
      message: OK
version: 1

Czas wykonania testu – 0.42s.

Spróbujmy uruchomić test jeszcze raz.

pytest tests/test_todo.py -k test_should_return_correct_todo_item

0.20s

Czas w tym przypadku o połowę krótszy! 🎉 Przy ponownym uruchomieniu testu, zamiast faktycznego odwołania sie do zewnętrznego serwisu została użyta nagrana wcześniej kaseta.

Konfiguracja VCR.py

VCR.py pozwala na modyfikację konfiguracji, możesz m.in:

  • zmienić sposób zapisu kasety, do wyboru plik .yaml lub .json,
import vcr
my_vcr = vcr.VCR(
    serializer="json"
)
  • określić miejsce zapisu kaset,

import vcr
my_vcr = vcr.VCR(
    cassette_library_dir=vcr_library,
)
  • wskazać parametry dopasowania żądania (request) – pozwala na wybranie parametrów, po których można stwierdzić, że request jest identyczny, dzięki czemu test pobierze dane z kasety,
import vcr
my_vcr = vcr.VCR(
    match_on=["uri", "method"],
)

Czas na prawdziwy przykład

Mając już całkiem sporą wiedzę, przejdźmy do stworzenia kodu i testów opartych o VCR.py. Sprawdźmy jak możemy wykorzystać VCR.py w bardziej “realistycznym przykładzie”. Stwórzmy endpoint w REST API, odwołujący się do zewnętrznego serwisu. W tym celu wykorzystajmy darmowy API weryfikujące poprawność adresu email – Email Validation.

Zadanie możesz wykonać przy użyciu dowolnego frameworka, poniższy przykład z wykorzystuje biblioteki: fast-api, pydantic, pytest, requests, vcrpy.

Określmy jakich danych będziemy wymagać w body danego endpointa. W związku z tym, że tworzymy kod przy użyciu fast-api użyjemy biblioteki pydantic, do zwalidowania odpowiedniego formatu danych.

from pydantic import BaseModel

class UserIn(BaseModel):
    first_name: str
    last_name: str
    email: str
    description: Optional[str] = None

W kolejnym kroku stwórzmy endpoint odpowiedzialny za weryfikację danych użytkownika.

@app.post("/users", status_code=status.HTTP_201_CREATED)
async def users(user: UserIn):

    # Weryfikacja adresu email
    response = requests.get(
        "https://emailvalidation.abstractapi.com/v1/", params={
            "api_key": settings.email_api_key,
            "email": user.email
        })

    if response.status_code != 200:
        raise HTTPException(status_code=502)

    data = response.json()

    if data["deliverability"] != "DELIVERABLE":
        raise HTTPException(status_code=404)

    return user

Wiedząc, że dostęp do zewnetrznego serwisu jest zabezpieczony api_key, przygotujmy dodatkową konfigurację kaset VCR, tak aby ten klucz nie znalazł się w nagranej kasecie.

my_vcr = vcr.VCR(
    serializer="json",
    filter_query_parameters=['api_key']
)

A teraz wisienka na torcie… testy! 🍒

W pierwszym teście podajmy istniejący adres email i sprawdźmy czy zostanie sklasyfikowany jako prawidłowy, czy użytkownik przejdzie walidację i “zapisze się” w naszym systemie 😉

@my_vcr.use_cassette('fixtures/cassettes/email_verification_success.json')
def test_should_save_user_with_valid_email(client:TestClient):
    response = client.post("/users", json={
        "first_name": "Hermiona",
        "last_name": "Granger",
        "email": "pogromcykodu@gmail.com",
        "description": "Uczę się Pythona"
    })

    assert response.status_code == 201
    # additional asserts...

W kolejnym teście użyjmy “podejrzanego” maila i sprawdźmy, czy będziemy mogli utworzyć konto w serwisie.

@my_vcr.use_cassette('fixtures/cassettes/email_verification_fail.json')
def test_should_reject_user_with_invalid_email(client:TestClient):
    response = client.post("/users", json={
        "first_name": "Hermiona",
        "last_name": "Granger",
        "email": "xxxxyyyyzzzzz123@gmail.com",
        "description": "Uczę się Pythona"
    })

    assert response.status_code == 404
    # additional asserts...

Wszystko poszło zgodnie z planem. Zielone testy 💚

Wskazówki

Filtrowanie wrażliwych danych

Pamiętaj! Nigdy nie upubliczniaj haseł – swoich i innych. Co stanie się, gdy dostępy aplikacji produkcyjnej ujrzą światło dzienne? Bądź czujny i nie zapominaj o kodzie, który wrzucasz do repozytorium. Nie umieszczaj bezpośrednio w kodzie haseł, tokenów autoryzacyjnych❗

W związku z tym, że vcr.py nagrywa całe żądanie i odpowiedź, w kasecie mogą znaleźć się wrażliwe dane, takie jak token autoryzacyjny do zewnętrznego serwisu. Aby tego uniknąć możesz zdefiniować, które pola chcesz wykluczyć z kasety.

W zależności od tego, gdzie znajdują się wrażliwe dane możesz użyć filtru nagłówka filter_headers lub parametru filter_query_parameters.

my_vcr = vcr.VCR(
    ...
    filter_headers=["authorization", "app_key", "secret"],
    filter_query_parameters=['api_key']
)

Aby podana konfiguracja miała wpływ na kasety, zamiast:

@vcr.use_cassette('fixtures/cassettes/todo_item.yaml')
def test_should_return_correct_todo_item():
    pass

użyj nowo zdefiniowanego VCR:

@my_vcr.use_cassette('fixtures/cassettes/todo_item.yaml')
def test_should_return_correct_todo_item():
    pass

Więcej informacji w dokumentacji.

“Oszukane” kasety

Jeśli z dowolnej przyczyny nie jesteś w stanie wygenerować kasety z konkretną odpowiedzią zewnętrznej aplikacji, pamiętaj, że możesz ją dowolnie edytować. Po prostu dopasuj zawartość swojej kasety do tego, czego potrzebujesz.

Zależność od kilku serwisów, endpointów?

To żaden problem! VCR.py nagrywa wszystkie odwołania do zewnętrznych serwisów, które zostały wywołane w ramach oznaczonego bloku kodu.

@vcr.use_cassette('fixtures/cassettes/two_requests_test.json')
def test_two_requests(client:TestClient):
    requests.get(
        "https://jsonplaceholder.typicode.com/posts/1",
    )
    # Dodatkowe odwołanie się po dane do innego endpointa
    requests.get(
        "https://jsonplaceholder.typicode.com/todos/1",
    )

Kaseta zawiera w sobie informacje o żądaniu i odpowiedzi z każdego z endpointów – https://jsonplaceholder.typicode.com/posts/1 i https://jsonplaceholder.typicode.com/todos/1.
(Dla lepszej widoczności odpowiedzi zostały zastąpione znakiem […])

interactions:
- request:
    body: null
    headers:
      Accept:
      - '*/*'
      Accept-Encoding:
      - gzip, deflate
      Connection:
      - keep-alive
      User-Agent:
      - python-requests/2.25.1
    method: GET
    uri: https://jsonplaceholder.typicode.com/posts/1
  response:
    [...]
- request:
    body: null
    headers:
      Accept:
      - '*/*'
      Accept-Encoding:
      - gzip, deflate
      Connection:
      - keep-alive
      User-Agent:
      - python-requests/2.25.1
    method: GET
    uri: https://jsonplaceholder.typicode.com/todos/1
  response:
  [...]

Używasz pytest? Sprawdź bibliotekę pytest-vcr

pytest-vcr jest dodatkową biblioteką, którą możesz użyć gdy pracujesz z VCR.py. Dzięki niej otrzymasz między innymi konfigurację z użyciem pytest fixture, a także automatyczne nazwy twoich kaset.

Używając pytest-vcr nie musisz podawać za każdym razem nazwy pliku kasety, biblioteka zrobi to za Ciebie tworząc kasetę o tej samej nazwie jak nazwa testu.

@pytest.mark.vcr()
def test_pytest_vcr():
     requests.get(
        "https://jsonplaceholder.typicode.com/posts/1",
    )
    assert response.status_code == 200

Utworzona kaseta znajduje się w pliku cassettes/test_pytest_vcr.yaml.

Więcej w dokumentacji pytest-vcr.


To tyle, jeśli chodzi o VCR.py, zachęcam do zapoznania się z dokumentacją i możliwościami tej biblioteki. W moim przypadku narzędzie sprawdza się doskonale – usprawnia i przespiesza tworzenie testów z integracjami. Daj znać co myślisz o tym narzędziu, a może stosujesz go w projekcie? Masz jakieś pytania? Napisz do nas 🙂

Miłego dnia 🙃

5 1 vote
Article Rating
guest
1 Komentarz
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Patryk
Patryk
3 miesięcy temu

Świetny artykuł, na pewno się przyda w przyszłości 🙂