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"],
)
- i inne 😉
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 <a href="https://fastapi.tiangolo.com/">fast-api</a>
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 🙃
Świetny artykuł, na pewno się przyda w przyszłości 🙂