Разделы портала

Онлайн-тренинги

.
Имитация API на C# с WireMock.Net
14.12.2022 00:00

Автор: Баз Дейкстра (Bas Dijkstra)
Оригинал статьи
Перевод: Ольга Алифанова

Если вы давно читаете мои статьи, то, возможно, знаете, что я большой поклонник WireMock, сервиса с открытым исходным кодом на Java, созданным для имитации API и виртуализации служб. Я даже создал и опубликовал бесплатный воркшоп с открытым исходным кодом по этому инструменту.

Лишь недавно, готовя курс по тестированию API на C# для заказчика, я узнал, что WireMock также портирован на C#. В этой статье я хочу пристальнее рассмотреть WireMock.Net, как (предсказуемо) называется эта библиотека. Прежде чем начать, сообщу, что честь создания и поддержки библиотеки принадлежит Штефу Хайнрату.

Я не буду глубоко нырять в сценарии использования WireMock.Net, так как ее задачи абсолютно идентичны "оригинальному" WireMock: позволить вам имитировать внутренний или внешний API, дабы писать более эффективные тесты и тестировать раньше, больше и чаще. Это также не исчерпывающий обзор всех функций WireMock.Net – скорее коллекция примеров, которые я считаю наиболее важными, или мощных возможностей.

Определение и тестирование первого ответа имитатора API

После добавления WireMock.Net в проект с использованием пакета NuGet имитировать вызов API в тесте при помощи WireMock.Net можно следующим образом:

private WireMockServer server;
[SetUp]
public void StartServer()
{
// Стартует новая копия сервера-имитатора, слушающего порт 9876
server = WireMockServer.Start(9876);
}
private void CreateHelloWorldStub()
{ // Определяется имитация ответа API на входящий HTTP GET // к конечной точке '/hello-world' с кодом HTTP статуса ответа 200, // заголовком Content-Type со значением `text/plain` и телом ответа, // содержащим текст `Hello, world!`
server.Given(
Request.Create().WithPath("/hello-world").UsingGet()
)
.RespondWith(
Response.Create()
.WithStatusCode(200)
.WithHeader("Content-Type", "text/plain")
.WithBody("Hello, world!")
);
}
[TearDown]
public void StopServer()
{
// Тут сервер-имитатор останавливается, чтобы убрать за собой server.Stop();
}

Затем мы можем написать тест для этого имитатора API с RestSharp, например:

private RestClient client;
[Test]
public async Task TestHelloWorldStub()
{
// Создается имитация ответа API, который мы определили ранее
CreateHelloWorldStub();
        // Определяется HTTP-запрос для отправки
client = new RestClient("http://localhost:9876");
        RestRequest request = new RestRequest("/hello-world", Method.Get);
        // HTTP-запрос отправляется на сервер-имитатор
RestResponse response = await client.ExecuteAsync(request);
        // Проверка, что сервер-имитатор возвращает ответ с ожидаемыми свойствами
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
Assert.That(response.ContentType, Is.EqualTo("text/plain"));
Assert.That(response.Content, Is.EqualTo("Hello, world!"));
}

Этот тест пройдет успешно, говоря нам, что наш имитатор API работает согласно ожиданиям.

Теперь, разобравшись, как работает WireMock.Net, посмотрим на ключевые функции, которые я всегда ищу во всех инструментах и библиотеках имитации API, и посмотрим, как они внедрены тут.

Стратегии совпадения запросов

В первую очередь в инструментах имитации API я изучаю их способности в отношении совпадения запросов, или идентификации определенных характеристик запросов, чтобы выбрать подходящий ответ.

Как упомянуто в документации по совпадению запросов, WireMock.Net может сравнивать запросы на основании пути или URL, метода HTTP, параметров запроса, заголовков, куки и тела.

В примере выше наличествует две из этих стратегий – URL и метод HTTP. Имитация отвечает только на вызовы HTTP GET к конечной точке /hello-world.

Посмотрим на ряд других примеров и начнем со сравнения запросов, которые содержат (или не содержат) определенный заголовок с определенным значением. Это очень полезно для, к примеру, работы с механизмами аутентификации API.

private void CreateStubHeaderMatching()
{
    server.Given(
Request.Create().WithPath("/header-matching").UsingGet()
// это заставляет имитатор отвечать только на запросы, содержащие
// заголовок 'Content-Type' с точным значением 'application/json'
.WithHeader("Content-Type", new ExactMatcher("application/json"))
// это заставляет имитатор отвечать только на запросы, не содержащие
// заголовок 'ShouldNotBeThere'
.WithHeader("ShouldNotBeThere", ".*", matchBehaviour: MatchBehaviour.RejectOnMatch)
)
.RespondWith(
Response.Create()
.WithStatusCode(200)
.WithBody("Header matching successful")
);
}

Помимо ExactMatcher, проверяющего точность совпадения значений, есть и другие полезные обнаружители совпадений, включая различные мэтчеры JSON и regex.

Мы также можем проверять совпадения элементов JSON-тела запроса, например, так:

private void CreateStubRequestBodyMatching()
{
    server.Given(
Request.Create().WithPath("/request-body-matching").UsingPost()
// это заставляет имитатор отвечать только на запросы с телом запроса JSON,
// совпадающим с заданным выражением пути JSON
.WithBody(new JsonPathMatcher("$.cars[?(@.make == 'Alfa Romeo')]"))
)
.RespondWith(
Response.Create()
.WithStatusCode(201)
);
}

Вернется совпадение с этим телом запроса:

{ "cars": [ { "make": "Alfa Romeo" }, { "make": "Lancia" } ] }

Симуляция проблем и задержек

Одно из преимуществ использования имитаторов перед работой с реальными зависимостями – это тестирование устойчивости системы к проблемному или неожиданному поведению этой зависимости. Так как задержки и ошибки зачастую сложно, если вообще возможно, спровоцировать в реальности, это поведение имеет смысл моделировать в имитаторе.

Помимо возвращения кодов HTTP статуса 4хх или 5хх, что делается путем передачи правильного значения в метод WithStatusCode() при создании ответа, WireMock.Net также поддерживает другие типы неожиданного поведения. Вот пример, возвращающий валидный ответ, но с предустановленной задержкой:

private void CreateStubReturningDelayedResponse()
{
    server.Given(
Request.Create().WithPath("/delay").UsingGet()
)
.RespondWith(
Response.Create()
.WithStatusCode(200)
// ответ возвращается с задержкой в 2000 мс
.WithDelay(TimeSpan.FromMilliseconds(2000))
);
}

Использование WithDelay() позволяет моделировать поведение производительности и проверить, что таймауты и задержка ответов правильно обрабатываются тестируемым приложением.

WireMock.Net также дает возможность возвращать "плохие" ответы:

private void CreateStubReturningFault()
{
    server.Given(
Request.Create().WithPath("/fault").UsingGet()
)
.RespondWith(
Response.Create() // возвращает ответ с кодом ответа 200
// и мусором в теле ответа
.WithFault(FaultType.MALFORMED_RESPONSE_CHUNK)
);
}

Создание заглушек, сохраняющих состояния

Еще одна полезная функция имитатора – это способность имитировать хранение состояний ("память"). Иногда вам нужно моделировать поведение с сохранением состояния, то есть поведение, при котором важен порядок поступающих запросов.

WireMock.Net поддерживает это через создание конечных автоматов (FSM) – коллекций состояний и переходов.

private void CreateStatefulStub()
{
    server.Given(
Request.Create().WithPath("/todo/items").UsingGet()
)
// В этом сценарии, когда текущее состояние -  'TodoList State Started',
// вызов HTTP GET вернет только 'Buy milk'
.InScenario("To do list")
.WillSetStateTo("TodoList State Started")
.RespondWith(
Response.Create()
.WithBody("Buy milk")
);
        server.Given(
Request.Create().WithPath("/todo/items").UsingPost()
)
// В этом сценарии, когда текущее состояние 'TodoList State Started',
// вызов к HTTP POST спровоцирует переход к новому состоянию
// 'Cancel newspaper item added'
.InScenario("To do list")
.WhenStateIs("TodoList State Started")
.WillSetStateTo("Cancel newspaper item added")
.RespondWith(
Response.Create()
.WithStatusCode(201)
);
        server.Given(
Request.Create().WithPath("/todo/items").UsingGet()
)
// В этом сценарии, когда текущее состояние 'Cancel newspaper item added',
// вызов к HTTP GET вернет 'Buy milk;Cancel newspaper subscription'
.InScenario("To do list")
.WhenStateIs("Cancel newspaper item added")
.RespondWith(
Response.Create()
.WithBody("Buy milk;Cancel newspaper subscription")
);
}

HTTP GET запрос к /todo/items, пришедший перед POST-запросом к той же конечной точке, вернет список дел с единственным пунктом "Buy milk" внутри, в то время как тот же самый GET-вызов, полученный после POST, вернет список дел, содержащий как "Buy milk", так и "Cancel newspaper subscription".

Шаблоны ответов

Последняя функция, которую я обычно исследую, изучая имитатор API – это способность повторно использовать значения из входящего запроса в соответствующем ответе.

Это полезно при работе со значениями, необходимыми для успешной обработки ответа на стороне потребителя, которые невозможно заранее предсказать. К примеру, это транзакционные или сессионные ID, куки, и другие подобные значения.

Как и Java-коллега, WireMock.Net поддерживает переиспользование значений запроса через создание шаблонов ответов. Вот пример, исследующий запрос, извлекающий используемый HTTP-метод и указывающий его в теле ответа:

private void CreateStubEchoHttpMethod()
{
    server.Given(
Request.Create().WithPath("/echo-http-method").UsingAnyMethod()
)
.RespondWith(
Response.Create()
.WithStatusCode(200)
// Ручка {{request.method}} извлекает метод HTTP из запроса
.WithBody("HTTP method used was {{request.method}}")
// Это позволяет создать шаблон ответа для конкретно этой имитации ответа
.WithTransformer()
);
}

Метод WithTransformer() нужно добавлять к определению ответа, в противном случае тело ответа будет дословно содержать значение '', и шаблон ответа не будет применен.

К сожалению, вроде бы нет способа применить это глобально (как можно сделать в WireMock на Java, поэтому это нужно добавлять к каждому определению ответа).

Вот еще пример, использующий шаблон ответа – на этот раз для извлечения значения из JSON-тела запроса, чтобы использовать его в текстовом теле ответа:

private void CreateStubEchoJsonRequestElement()
{
    server.Given(
Request.Create().WithPath("/echo-json-request-element").UsingPost()
)
.RespondWith(
Response.Create()
.WithStatusCode(200)
// Извлекаетcя элемент book.title из JSON-тела запроса
// (с использованием выражения JsonPath), и этот элемент повторяется в теле ответа
.WithBody("The specified book title is {{JsonPath.SelectToken request.body \"$.book.title\"}}")
.WithTransformer()
);
}

Как видно, WireMock.Net позволяет извлекать значения  элементов из (JSON) тела запроса, используя выражения JsonPath, и повторно использовать их – к примеру, для повтора в теле ответа.

На этом я завершаю первый обзор WireMock.Net. Повторюсь, это, безусловно, далеко не полный обзор всех ее возможностей – лишь краткий перечень функций, которые я считаю наиболее ценными и интересными.

Меня приятно удивила простота использования – хотя, надо признать, у меня было несправедливое преимущество, так как я несколько лет работал с WireMock на Java. Предвкушаю дальнейшее изучение WireMock.Net.

Если вы хотите узнать об этой библиотеке больше, я рекомендую документацию. Все примеры из статьи я загрузил на GitHub вместе с NUnit + RestSharp-тестами для демонстрации, что имитируемые ответы API работают верно.

Обсудить в форуме