
Всем привет! Меня зовут Василий Куликов, я работаю ведущим разработчиком в Техплатформе Екома и Райдтеха Яндекса и последние пять лет разрабатываю фреймворк userver. Это веб‑фреймворк, который позволяет создавать высоконагруженные отказоустойчивые сервисы на С++. Сегодня я расскажу, как написать на нём игрушечный, но рабочий сервис, который реализует функциональность pastebin. Сервис будет выполнять роль бэкенда (и частично фронтенда) сайта, на котором можно запостить текстовую заметку и получить ссылку на неё. Также на этом примере можно увидеть, как пишется сервис на userver и какой API используется для решения повседневных задач бэкенда.
Общая архитектура
Сначала определимся с общей архитектурой сайта. Пусть это будет единственный stateless‑сервис upastebin со своей базой данных Postgres. Тот же upastebin будет отдавать статику (HTML, JS, CSS) из памяти. В более сложном варианте с тяжёлой статикой и динамическими страницами понадобился бы nginx с фронтенд‑фреймворком, но в нашем примере обойдёмся без них.

Определимся с тем, что должно быть реализовано в upastebin. Нам нужен эндпоинт, который будет отдавать страницу с конкретной заметкой. Чтобы не рендерить страницу на стороне бэкенда, мы будем рендерить её в браузере. За счёт этого она будет статичной и не будет отличаться от заметки к заметке.
Также нужна страница для создания новой заметки. Из API‑эндпоинтов нам нужны следующие: для создания заметки, для получения текста заметки и для получения списка последних заметок.
Для работы с базой данных (БД) нужны:
POST /api/v1/posts— для создания новой заметки;GET /api/v1/posts/{id}— для получения текста конкретной заметки;GET /api/v1/latest— для получения списка последних N заметок.
Для отдачи статики:
/{id}— для отдачи страницы с заметкой;/— для написания нового поста;/r/*— для отдачи остальной статики (JS, CSS).
Приготовления
Итак, приступаем к работе. Мы можем начать писать наш проект с нуля, а можем взять за основу готовый шаблон сервиса из списка. И нам вполне подойдёт один из шаблонов. Нажимаем кнопку Use this template и создаём новый проект. Согласно README, клонируем репозиторий:
git clone https://github.com/segoon/upastebin/ && cd upastebinДля замены имени сервиса pg_service_template на upastebin копипастим из README команду переименования:
find . -not -path "./third_party/" -not -path ".git/" -not -path './build_*' -type f | xargs sed -i 's/pg_service_template/upastebin/g'Теперь нам нужно подключить к проекту userver. Есть несколько способов, как это сделать:
скачать deb‑пакет с GitHub с последним релизом (сейчас есть пакеты для Ubuntu 22.04);
самостоятельно собрать userver и установить его либо через cmake ‑install, либо через cpack + dpkg ‑i;
подключить userver к нашему проекту как отдельную директорию и собирать его как часть проекта;
собрать проект в подготовленном докер‑образе Ubuntu 22.04 с предустановленным userver.
В нашем случае выберем путь отдельной директории:
cd third_party
git clone --depth 1 https://github.com/userver-framework/userver.gitНе забудьте добавить third_party/userver в gitignore! Если бы мы разрабатывали несколько сервисов, то более эффективным было бы клонировать userver в свою собственную директорию, а third_party/userver в каждом сервисе сделать симлинками на настоящий userver.
Теперь мы готовы запустить сборку сервиса командой:
make build-debugЭта команда автоматически запустит cmake в директории build_debug и соберёт бинарник сервиса build_debug/upastebin. Для сборки релиза замените debug на release.
Прогнать тесты (юнит‑тесты gtest и testsuite, т. е. тесты уровня сервиса) можно с помощью команды:
make test-debugЕсли хочется быстро запустить сервис локально, чтобы подёргать эндпоинты с помощью curl или открыть локальный сайт в браузере, можно воспользоваться следующей командой:
make service-start-debugОна поднимет инстанс сервиса локально от текущего пользователя и временную базу Postgres. Сервис будет запущен со статическим конфигом, в котором указан порт временного Postgres‑демона. При завершении команды по Ctrl + C сервис, Postgres и остальное окружение будет завершено и подчищено.
Сейчас можем подёргать ручку /ping, которая проверяет живость сервиса с помощью curl:
$ curl -vs http://localhost:8080/ping
* Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /ping HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.81.0
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Date: Wed, 22 Jan 2025 08:54:34 UTC
< Content-Type: application/octet-stream
< Server: userver/2.3-rc (20240801002050; rv:ac9b1cd)
< Accept-Encoding: gzip, zstd, identity
< X-YaRequestId: 5036899c44a844408d6a8c2520dc3556
< X-YaTraceId: da1003fad6ef4532a714350ff1c2a3a7
< X-YaSpanId: c48dc23f495a3476
< Connection: keep-alive
< Content-Length: 0
<
* Connection #0 to host localhost left intactОжидаемо получаем статус 200, а также видим набор хедеров, добавляемых для отладки и трейсинга (X-Ya*).
Отдача статики
Наконец‑то начнём программировать! Добавим несколько эндпоинтов, для каждого из которых нужны секция с описанием в статическом конфиге и код этого эндпоинта (.cpp‑ и.hpp‑файлы).
Начнём с раздачи статики. Любой эндпоинт в userver описывается в виде класса, который унаследован от server::handlers::HttpHandlerBase. Инстанс класса будет создан при инициализации компонентной системы в процессе старта сервиса.
В конструкторе мы получаем ссылки на зависимости эндпоинта из других компонентов. Для раздачи статики нам нужен components::FsCache, а точнее его клиент fs::FsCacheClient, который на старте рекурсивно вычитывает содержимое директории в память и может очень быстро отдать его по запросу. При этом FsCacheClient регулярно перечитывает содержимое директории, что может оказаться полезным в случае изменений файлов без перезапуска сервиса (при желании можно отключить эту функцию).
В методе HandleRequestThrow() описывается код обработки HTTP‑запроса. Мы хотим взять параметр из URL, по нему сходить в FsCacheClient и получить содержимое скачиваемого файла. Для корректной работы в браузере нам также нужно выставить корректный Content‑Type, который мы будем определять по статическому отображению «расширение файла — тип контента». Если файл с таким именем не был найден, мы возвращаем код ошибки 404. При желании код ошибки можно сопроводить телом для отображения в браузере, но мы это опустим для простоты.
Пишем хедер:
src/resources.hpp
#pragma once
#include <userver/http/common_headers.hpp>
#include <userver/server/handlers/http_handler_base.hpp>
#include <userver/fs/fs_cache_client.hpp>
namespace upastebin {
class ResourcesHandler final
: public userver::server::handlers::HttpHandlerBase {
public:
// Имя требуется для регистрации компонента, для поиска настроек в статическом конфиге и для поиска со стороны других компонентов
static constexpr std::string_view kName = "handler-resources";
ResourcesHandler(const userver::components::ComponentConfig& config,
const userver::components::ComponentContext& context);
std::string HandleRequestThrow(
const userver::server::http::HttpRequest& request,
userver::server::request::RequestContext&) const override;
private:
const userver::fs::FsCacheClient& fs_client_;
};
} // namespace upastebinПишем cpp‑файл:
src/resources.cpp
#include "resources.hpp"
#include <userver/components/component_config.hpp>
#include <userver/components/component_context.hpp>
#include <userver/components/fs_cache.hpp>
namespace upastebin {
namespace {
std::string_view GetContentType(std::string_view extention) {
if (extention == ".js")
return "application/javascript";
else if (extention == ".css")
return "text/css";
else if (extention == ".html")
return "text/html; charset=UTF-8";
else
return "application/octet-stream";
}
} // namespace
ResourcesHandler::ResourcesHandler(
const userver::components::ComponentConfig& config,
const userver::components::ComponentContext& context)
: HttpHandlerBase(config, context),
fs_client_(
context.FindComponent<userver::components::FsCache>("resources-cache")
.GetClient()) {}
std::string ResourcesHandler::HandleRequestThrow(
const userver::server::http::HttpRequest& request,
userver::server::request::RequestContext&) const {
auto subpath = request.GetPathArg("subpath");
// Note: нет нужды валидировать subpath, т.к. FsCacheClient не отдаст файл на отсутствующий путь
auto file_ptr = fs_client_.TryGetFile("/" + subpath);
auto& response = request.GetHttpResponse();
if (file_ptr) {
response.SetContentType(GetContentType(file_ptr->extension));
return file_ptr->data;
} else {
auto& response = request.GetHttpResponse();
response.SetStatus(userver::server::http::HttpStatus::kNotFound);
return {};
}
}
} // namespace upastebinКонструктор обычно содержит в себе две вещи: вычитывание из ComponentConfig тех параметров, которые указаны в секции компонента в статическом конфиге, и поиск других компонентов через интерфейс ComponentContext. В нашем случае параметров у компонента эндпоинта нет, поэтому мы только ищем компонент FsCache по типу, из него достаём клиента и сохраняем по ссылке в поле класса. После выхода из конструктора компонент готов к работе.
В HandleRequestThrow мы должны сделать всё, чтобы обработать входящий HTTP‑запрос. Каждый запрос обрабатывается в независимой корутине. В процессе обработки запросов может создаваться множество корутин, много больше, чем число потоков ОС. Если в эндпоинте мы ходим в базу или в другие сервисы, то из‑за ожидания корутина может долгое время находиться в состоянии сна. Это состояние не отнимает процессорного времени, но занимает некоторое количество памяти. Если есть риски превышения доступной памяти, можно ограничить число одновременно обрабатываемых запросов (параметр max_requests_in_flight) или RPS (параметр max_requests_per_second). Подробнее это описано в документации userver.
Далее мы получаем параметр пути из request.GetPathArg(). Query‑параметр мы получали бы через GetArg(). Указатель на файл в памяти получаем через TryGetFile(), который возвращает shared_ptr. Если указатель пустой, то мы получаем HttpResponse из HttpRequest через GetHttpResponse() и выставляем статус 404 через SetStatus(). Если файл найден, мы смотрим на его расширение, исходя из чего выставляем хедер Content‑Type с помощью вызова HttpResponse::SetContentType(). На этом код эндпоинта готов.
Теперь займёмся конфигом. У каждого компонента есть своя подсекция конфига в yaml‑файле в секции components_manager.components.<component>. Для эндпоинта там описываются обрабатываемый путь, принимаемые методы, используемый таск‑процессор и другие настройки.
Для большинства ручек подойдёт основной таск‑процессор main-task-processor, в котором запускаются все корутины по умолчанию. В других таск‑процессорах могут работать корутины, которые способны заблокировать поток ОС (обычно fs-task-processor), а также специальные корутины, которые должны работать даже при частичном отказе основных таск‑процессоров (обычно stats-task-processor для отдачи метрик).
configs/static_config.yaml
handler-resources:
path: /r/{subpath}
method: GET
task_processor: main-task-processorНаш эндпоинт готов. Зарегистрируем его в компонентной системе, добавив в main():
Если мы сейчас попробуем запустить сервис, получим ошибку отсутствия компонента resources-cache. Мы его запросили, но не зарегистрировали в компонентной системе и в статическом конфиге. Исправим ситуацию и добавим компонент в main.cpp:
component_list.Append<userver::components::FsCache>("resources-cache");И в статический конфиг:
resources-cache:
dir: /var/www
update-period: 1m
fs-task-processor: fs-task-processorТеперь сервис стартует.
Отдача / и /{id} будет упрощённой версией этого компонента. Вместо выбора файла будет отдаваться статичная страница из кеша.
Сохранение заметки
Напишем эндпоинт для сохранения новой заметки. Это будут файлы src/store.{hpp,cpp}. Заведём новый класс StoreHandler, унаследованный от server::handlers::HttpHandlerBase. Добавим в него поле типа storages::postgres::ClusterPtr, потому что мы хотим обращаться к Postgres.
Пишем hpp‑файл:
src/store.hpp
#pragma once
#include <userver/server/handlers/http_handler_base.hpp>
#include <userver/storages/postgres/postgres_fwd.hpp>
namespace upastebin {
class StoreHandler final : public userver::server::handlers::HttpHandlerBase {
public:
static constexpr std::string_view kName = "handler-store";
StoreHandler(const userver::components::ComponentConfig& config,
const userver::components::ComponentContext& context);
std::string HandleRequestThrow(
const userver::server::http::HttpRequest& request,
userver::server::request::RequestContext&) const override;
private:
userver::storages::postgres::ClusterPtr pg_;
};
} // namespace upastebinЗаметим, что используется инклуд‑файл postgres_fwd.hpp, а не postgres.hpp. Для многих хедеров существует облегчённый вариант с _fwd, который только объявляет типы, но не описывает их. Это позволяет заинклудить гораздо более лёгкий хедер из большого числа пользовательских хедеров. Это может существенно ускорить компиляцию большого проекта.
В конструкторе будем искать компонент userver::components::Postgres и забирать у него указатель на кластер через GetCluster(). Через этот указатель мы сможем обращаться к БД.
В HandleRequestThrow() мы должны сгенерировать новый id для заметки — для простоты пусть это будет UUID. Из query забираем параметр author, из тела забираем текст заметки, из HttpRequest забираем IP пользователя. После этого делаем запрос к БД на INSERT. В тексте ответа возвращаем JSON с id заметки.
Пишем cpp‑файл:
src/store.cpp
#include "store.hpp"
#include <userver/components/component_context.hpp>
#include <userver/formats/json/inline.hpp>
#include <userver/http/common_headers.hpp>
#include <userver/server/handlers/exceptions.hpp>
#include <userver/server/handlers/http_handler_base.hpp>
#include <userver/storages/postgres/cluster.hpp>
#include <userver/storages/postgres/component.hpp>
#include <userver/utils/boost_uuid4.hpp>
#include <userver/utils/datetime.hpp>
namespace upastebin {
StoreHandler::StoreHandler(const userver::components::ComponentConfig& config,
const userver::components::ComponentContext& context)
: HttpHandlerBase(config, context),
pg_(context.FindComponent<userver::components::Postgres>("postgres")
.GetCluster()) {}
std::string StoreHandler::HandleRequestThrow(
const userver::server::http::HttpRequest& request,
userver::server::request::RequestContext&) const {
auto uuid = userver::utils::generators::GenerateBoostUuid();
auto author = request.GetArg("author");
auto ip_source = request.GetRemoteAddress().PrimaryAddressString();
auto text = request.RequestBody();
auto created_at = userver::utils::datetime::Now();
pg_->Execute(userver::storages::postgres::ClusterHostType::kMaster,
"INSERT INTO upastebin.texts (uuid, author, ip_source, text, created_at) VALUES "
"($1, $2, $3, $4, $5);",
uuid, author, ip_source, text, created_at);
auto json_response = userver::formats::json::MakeObject("uuid",
userver::utils::ToString(uuid));
return ToString(json_response);
}
} // namespace upastebinДля вычисления текущего времени мы используем utils::datetime::Now() вместо std::chrono::system_clock::now(), так как первый вариант обладает возможностью мокать время в тестах. Это может пригодиться при написании тестов и сверке результатов INSERT. Сам запрос отправляется через Cluster::Execute().
Cluster — абстрактный интерфейс к БД. За ним стоит не одно соединение, а целый пул. Драйвер Postgres самостоятельно поддерживает его в актуальном состоянии, переоткрывая соединения при их недостатке, согласно настройкам статического конфига. В крайнем случае Execute() синхронно откроет соединение с БД.
Первый аргумент Execute() — тип хоста в кластере БД. Может быть мастер, синк слейв, обычный слейв, ближайший хост. Для записи в БД нам нужен мастер.
Второй аргумент у Execute() — сам SQL‑запрос. Следом идут параметры этого запроса. Если он будет выполнен, то Execute() вернёт ResultSet с результатами. В нашем случае они неинтересны, поэтому результат игнорируется. Если SQL‑запрос завершится по ошибке любого типа — сетевой, серверной (например, конфликт), клиентской (например, синтаксическая ошибка) — Execute() выкинет исключение. Это исключение мы никак не обрабатываем: код, вызывающий HandleRequestThrow(), по умолчанию ловит все исключения и выставляет код HTTP‑ошибки 500, что для нас вполне подходит. В конце обработки мы создаём JSON‑объект с единственным полем id заметки и возвращаем его из эндпоинта в теле ответа.
Получение заметки
Перейдём к эндпоинту получения заметки по id. Заголовочный файл отличается от от прошлого файла src/store.hpp только именем класса и значением kName. В HandleRequestThrow() нам нужно взять id из query, сконвертить его в UUID и сделать запрос к БД. Execute() возвращает ResultSet, из которого можно получить единственный элемент и его поле с именем text. Возвращаем это поле в виде тела ответа. Если ResultSet оказался пустым, то заметка с таким id не найдена и мы возвращаем страницу 404.
src/retrieve_by_uuid.cpp
#include "retrieve_by_uuid.hpp"
#include <userver/components/component_context.hpp>
#include <userver/storages/postgres/cluster.hpp>
#include <userver/storages/postgres/component.hpp>
#include <userver/utils/boost_uuid4.hpp>
namespace upastebin {
RetrieveHandler::RetrieveHandler(
const userver::components::ComponentConfig& config,
const userver::components::ComponentContext& context)
: HttpHandlerBase(config, context),
pg_(context.FindComponent<userver::components::Postgres>("postgres")
.GetCluster()) {}
std::string RetrieveHandler::HandleRequestThrow(
const userver::server::http::HttpRequest& request,
userver::server::request::RequestContext&) const {
auto uuid_str = request.GetPathArg("uuid");
auto uuid = userver::utils::BoostUuidFromString(uuid_str);
auto result = pg_->Execute(
userver::storages::postgres::ClusterHostType::kSlave,
"SELECT text FROM upastebin.texts WHERE uuid=($1::TEXT);", uuid);
if (result.Size() == 0) {
request.GetHttpResponse().SetStatusNotFound();
return {};
}
auto row = result.Front();
auto text = row["text"].As<std::string>();
return text;
}
} // namespace upastebinСписок последних заметок
Нам нужна ещё одна ручка для получения набора последних заметок, чтобы вывести их списком на странице. Создаём ещё один эндпоинт — LatestHandler. Хедер устроен точно так же, как и у предыдущих двух. В HandleRequestThrow() мы должны сделать SELECT‑запрос к Postgres для получения последних N заметок. Execute() возвращает нам эти заметки в ResultSet. Мы можем по нему итерироваться с помощью for-each.
Сами заметки мы будем отдавать в JSON‑форме. Работа с JSON происходит одним из двух способов. Во‑первых, для доступа read‑only используется formats::json::Value — лёгкая обёртка над нодой дерева JSON. Во‑вторых, для создания нового JSON value используется билдер formats::json::ValueBuilder. Разделение требуется для упрощения краевых случаев, в которых происходит присваивание типа x["foo"] = y["bar"]. Тут может возникнуть путаница: это рекурсивное копирование дерева или копирование ссылки на поддерево? Чтобы не возникало подобных вопросов, типы на чтение и запись разнесены. Результат сборки JSON получает через ExtractValue() у билдера.
Наша ручка возвращает JSON, из‑за чего её можно было бы отнаследовать от JsonHandlerBase. В этом случае можно было бы не совершать лишние операции по сериализации JSON в строку. Однако JSON handler и принимает JSON‑запрос, и возвращает JSON‑ответ, а мы лишь возвращаем JSON.

Код src/latest.cpp:
src/latest.cpp
#include "latest.hpp"
#include <userver/components/component_context.hpp>
#include <userver/formats/json/inline.hpp>
#include <userver/formats/json/value_builder.hpp>
#include <userver/storages/postgres/cluster.hpp>
#include <userver/storages/postgres/component.hpp>
#include <userver/utils/boost_uuid4.hpp>
namespace upastebin {
LatestHandler::LatestHandler(
const userver::components::ComponentConfig& config,
const userver::components::ComponentContext& context)
: HttpHandlerBase(config, context),
pg_(context.FindComponent<userver::components::Postgres>("postgres")
.GetCluster()) {}
std::string LatestHandler::HandleRequestThrow(
const userver::server::http::HttpRequest&,
userver::server::request::RequestContext&) const {
auto result = pg_->Execute(
userver::storages::postgres::ClusterHostType::kSlave,
"SELECT author, substring(text for $1::INTEGER) AS text_tr, ip_source "
"FROM "
"upastebin.texts ORDER BY created_at DESC LIMIT $2::INTEGER;",
1000, 10);
userver::formats::json::ValueBuilder response(
userver::formats::common::Type::kArray);
for (const auto& item : result) {
userver::formats::json::ValueBuilder response_item;
response_item["author"] = item["author"].As<std::string>();
response_item["ip"] = item["ip_source"].As<std::string>();
response_item["text"] = item["text_tr"].As<std::string>();
response.PushBack(response_item.ExtractValue());
}
return ToString(
userver::formats::json::MakeObject("items", response.ExtractValue()));
}
} // namespace upastebinТесты
Мы написали самые простые эндпоинты для создания и просмотра заметок — давайте теперь напишем на них тесты!
Для userver тесты пишутся на фреймворке yandex‑taxi‑testsuite. Он основан на pytest и предоставляет большой набор фикстур и инструментов для работы с сервисами, моками, базами данных и т. д. Для работы тестов поднимается upastebin в замоканном окружении, в котором поднят свой PostgreSQL.
Чтобы сервис в testsuite‑окружении корректно работал, ему необходима БД с инициализированной схемой данных. Эта схема описывается в файлах postgresql/schemas/*.sql. Ожидается, что одни и те же файлы используются для инициализации схемы данных и в тестах, и в продакшне. Поэтому схема данных записывается в форме файлов миграций, которые никогда не меняются, но изменения дописываются в новые файлы миграций.
Мы обойдёмся одним файлом миграций db_1.sql:
postgresql/schemas/db_1.sql
DROP SCHEMA IF EXISTS upastebin CASCADE;
CREATE SCHEMA IF NOT EXISTS upastebin;
CREATE TABLE IF NOT EXISTS upastebin.texts (
uuid TEXT PRIMARY KEY,
author TEXT NOT NULL,
ip_source TEXT NOT NULL,
text TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);Чтобы testsuite работал с локальной директорией со статическими файлами, а не с /var/www-data, ему необходимо подменить путь к директории в статическом конфиге. Для этого мы должны в файле conftest.py добавить соответствующую фикстуру и прописать её в список USERVER_CONFIG_HOOKS. В этом списке хранятся имена фикстур, которые меняют статический конфиг. В самой фикстуре мы возвращаем функцию, которая меняет аргумент‑конфиг.
Первым аргументом функции выступает статический конфиг, представленный в форме словаря. В нём мы должны поменять все значения, которые специфичны для testsuite.
tests/conftest.py
USERVER_CONFIG_HOOKS = ['prepare_service_config_resources']
WWW_DATA = pathlib.Path(__file__).parent.parent / 'www-data'
...
@pytest.fixture(scope='session')
def prepare_service_config_resources():
def patch_config(config, config_vars):
components = config['components_manager']['components']
components['resources-cache']['dir'] = str(WWW_DATA)
return patch_configТеперь, если мы запустим команду из корня репозитория make test-debug, она отработает без ошибок.
Добавим туда тестов — разберём один из них по косточкам. Добавим исходник pytest‑теста в директорию tests/ с именем с префиксом test_:
tests/test_crud.py
...
async def test_create_and_retrieve(service_client):
response = await service_client.post(
'/api/v1/posts/', params={'author': 'foo'}, data=TEXT,
)
assert response.status == 200
json_response = response.json()
assert list(json_response) == ['uuid']
uuid = json_response['uuid']
response = await service_client.get(f'/api/v1/posts/{uuid}')
assert response.status == 200
assert response.text == TEXT
response = await service_client.get('/api/v1/latest')
assert response.status == 200
assert response.json() == {
'items': [{'author': 'foo', 'ip': '::ffff:127.0.0.1', 'text': TEXT}],
}По сути, тест — это функция, которая в pytest‑тесте должна начинаться с test_ и принимать аргументы‑фикстуры. В нашем случае тесту требуется фикстура service_client, с помощью которой происходит обращение к сервису upastebin. Мы хотим написать тест, который постит заметку, получает её по айдишнику, а также находит её в списке последних заметок.
GET/POST‑запрос происходит с помощью метода get/post фикстуры service_client. Передаём туда HTTP path, query parameters в params, тело запроса в data. В результате получаем response, у которого поле status равняется HTTP status code, метод json возвращает словарь или список с распаршенным JSON‑телом ответа, а поле text содержит plaintext тела ответа.
Проверяем, что ручки сервиса отвечают успешно и возвращают ожидаемые данные.
Если мы теперь запустим команду make test-debug, то увидим успешно пройденные тесты:
make test-debug
1: collecting... collected 9 items1:1:../tests/test_crud.py::test_404 PASSED [ 11%]
1:../tests/test_crud.py::test_empty_db PASSED [ 22%]
1:../tests/test_crud.py::test_create_and_retrieve PASSED [ 33%]
1:../tests/test_resources.py::test_found[index.html‑text/html; charset=UTF-8] PASSED [ 44%]
1:../tests/test_resources.py::test_found[index.js‑application/javascript] PASSED [ 55%]
1:../tests/test_resources.py::test_not_found[] PASSED [ 66%]
1:../tests/test_resources.py::test_not_found[.] PASSED [ 77%]
1:../tests/test_resources.py::test_not_found[..] PASSED [ 88%]
1:../tests/test_resources.py::test_not_found[missing.html] PASSED [100%]
1:
1: ‑--‑--‑--‑--‑--‑--‑--‑--‑ Service logs ‑--‑--‑--‑--‑--‑--‑--‑--‑
1: — service log file: /home/segoon/projects/upastebin/build_debug/Testing/Temporary/service.log —
1: ============================== 9 passed in 1.68s ===============================
1/1 Test #1: testsuite‑upastebin.............. Passed 1.99 sec
Если вам не хватило информации о фикстурах, то загляните в документацию. Чтобы изучить все существующие фикстуры, вы можете зайти в директорию third_party/userver/testsuite/ для поиска userver‑специфичных фикстур или в build_debug/venv-userver-default/lib/python3.10/site-packages/testsuite/ для поиска базовых фикстур (testsuite по умолчанию скачивается в билд‑директорию). Фикстуры помечены декоратором @pytest.fixture.
Итак, мы рассмотрели процесс написания простого сервиса на userver. Конечно, с помощью этого фреймворка можно писать гораздо более сложные сервисы, составленные из тысяч и десятков тысяч строк. Если он вам приглянулся и вы хотите продолжить с ним работать, загляните на страницу userver и в телеграм‑чат (есть и отдельный чат на английском).
Исходники примера можно скачать с GitHub.
