WireMock.Net умеет мокать gRPC. Но для этого он заставляет разработчика таскать .proto файлы и поддерживать лишнюю конфигурацию. Я контрибьютил в проект несколько месяцев, разобрался в кишках и написал своё расширение WireMock.Grpc.Protobuf Пакет предлагает gRPC моки через типизированные контракты Google.Protobuf. Получилось короче, надёжнее и заметно удобнее. В статье покажу, почему мой способ удобнее стандартного, как всё устроено под капотом и зачем вашему проекту пакет WireMock.Grpc.ProtobufКстати, подобные инженерные разборы, open-source и внутренности .NET я регулярно публикую в Telegram-канале StepOne. Если статья зайдёт — заглядывайте. dotnet add package WireMock.Grpc.Protobuf
WireMock.Net умеет мокать gRPC, но штатный механизм заставляет читать .proto файлы в память, основан на динамической типизации и сопоставляет тела запросов через JSON без возможности передать собственные настройки сериализации.
Я контрибьютил в проект несколько месяцев, разобрался в кишках и написал своё расширение.
WireMock.Grpc.Protobuf решает эту проблему, связывая запросы и ответы со сгенерированными моделями Google.Protobuf. Сопоставление из коробки позволяет проверить входящий запрос как на точное равенство, так и на частичное через предикат
Request.Create().WithBodyAsGoogleProtobuf(new HelloRequest { Name = "StepOne" });
Request.Create().WithBodyAsGoogleProtobuf((HelloRequest x) => x.Name == "StepOne");
Response.Create().WithBodyAsGoogleProtobuf(new HelloReply { Message = "Hello, StepOne!" });Исходники расширения уже доступны на GitHub, а проект уже экономит вечера борьбы с gRPC моками! Звезда и скачивание будут лучшей благодарностью:
Кстати, подобные инженерные разборы, open‑source и внутренности.NET я регулярно публикую в Telegram‑канале StepOne. Если статья зайдёт — заглядывайте.
Моё знакомство с WireMock.Net и gRPC мокамиСначала стоит рассказать, как я вообще оказался настолько глубоко внутри WireMock.Net.
Если вы давно следите за каналами StepOne, то наверняка помните, что моё погружение в глубины WireMock.Net началось с банального бага. Я использую WireMock на работе, чтобы мокировать gRPC‑вызовы внешних сервисов и тестировать транспортный уровень
// Пример встроенного grpc мока WireMock.Net
// Требуется загрузить .proto файл в память программы
var protoDefinitionText = File.ReadAllText(@"c:\grpc\greet.proto");
var protoDefinitionId = "GrpcGreet";
var server = WireMockServer.Start(useHttp2: true);
server
.AddProtoDefinition(protoDefinitionId, protoDefinitionText)
// обратите внимание на отсутствие типизации
// и возможности контролировать сериализацию JSON
.Given(Request.Create()
.UsingPost()
.WithPath("/grpc/greet.Greeter/SayHello")
.WithBodyAsProtoBuf("greet.HelloRequest", new JsonMatcher(new { name = "stef" })))
.WithProtoDefinition(protoDefinitionId)
.RespondWith(Response.Create()
.WithHeader("Content-Type", "application/grpc")
.WithTrailingHeader("grpc-status", "0")
.WithBodyAsProtoBuf("greet.HelloReply",
new
{
message = "hello {{request.BodyAsJson.name}} {{request.method}}"
})
.WithTransformer());Компонентные тесты придают уверенности в интеграциях до выхода в реальные окружения. Всё должно было работать гладко, но я столкнулся с неприятностью. Логи мок‑сервера внезапно заполнились ошибками. Это произошло, как только моков стало несколько и ко всем пошли запросы. Стало ясно: здесь что‑то не так!
16.04.2026 13:50:43 [Error] : Getting a Request MatchResult for Mapping 'e4601a45-5ba3-4777-9e8f-f69c2465be93' failed. This mapping will not be evaluated. Exception: System.IO.EndOfStreamException: Attempted to read past the end of the stream.
at ProtoBuf.ProtoReader.State.ThrowEoF() in /_/src/protobuf-net.Core/ProtoReader.State.ReadMethods.cs:line 809
at ProtoBuf.ProtoReader.StreamProtoReader.Ensure(State& state, Int32 count, Boolean strict) in /_/src/protobuf-net.Core/ProtoReader.Stream.cs:line 395
at ProtoBuf.ProtoReader.StreamProtoReader.ImplSkipBytes(State& state, Int64 count) in /_/src/protobuf-net.Core/ProtoReader.Stream.cs:line 403
at ProtoBuf.ProtoReader.State.ReadWrapped[T](SerializerFeatures features, T value, ISerializer`1 serializer) in /_/src/protobuf-net.Core/ProtoReader.State.ReadMethods.cs:line 1092
at proto_4(State&, GetCustomerSegmentsRequest)
at ProtoBuf.Internal.Serializers.SimpleCompiledSerializer`1.ProtoBuf.Serializers.ISerializer<T>.Read(State& state, T value) in /_/src/protobuf-net/Internal/Serializers/CompiledSerializer.cs:line 107
at ProtoBuf.ProtoReader.State.ReadAsRoot[T](T value, ISerializer`1 serializer) in /_/src/protobuf-net.Core/ProtoReader.State.ReadMethods.cs:line 1157
at ProtoBuf.ProtoReader.State.DeserializeRoot[T](T value, ISerializer`1 serializer) in /_/src/protobuf-net.Core/ProtoReader.State.ReadMethods.cs:line 1137
at ProtoBuf.Internal.DynamicStub.ConcreteStub`1.TryDeserializeRoot(TypeModel model, State& state, Object& value, Boolean autoCreate) in /_/src/protobuf-net.Core/Internal/DynamicStub.cs:line 211
at ProtoBuf.Meta.TypeModel.DeserializeRootAny(State& state, Type type, Object value, Boolean autoCreate) in /_/src/protobuf-net.Core/Meta/TypeModel.cs:line 1083
at ProtoBuf.ProtoReader.State.DeserializeRootFallback(Object value, Type type) in /_/src/protobuf-net.Core/ProtoReader.State.ReadMethods.cs:line 1247
at ProtoBuf.Serializer.Deserialize(Type type, Stream source) in /_/src/protobuf-net/Serializer.Deserialize.cs:line 55
at ProtoBufJsonConverter.Utils.SerializeUtils.ConvertProtoBufToObject(Assembly assembly, String inputTypeFullName, Byte[] protoBufBytes, Boolean skipGrpcHeader)
at ProtoBufJsonConverter.Converter.ConvertAsync(ConvertToObjectRequest request, CancellationToken cancellationToken)
at WireMock.Matchers.ProtoBufMatcher.DecodeAsync(Byte[] input, Boolean throwException, CancellationToken cancellationToken)
at WireMock.Matchers.ProtoBufMatcher.IsMatchAsync(Byte[] input, CancellationToken cancellationToken)Я нырнул в исходный код WireMock и начал отлаживать его шаг за шагом, как сложный механизм. Проверял всё: как protobuf‑байты превращаются в gRPC C#‑объекты и обратно, где именно ломается логика.
И в один момент щёлкнуло: сервер просто перебирает все заглушки подряд и пытается сконвертировать байты в неподходящие типы сообщений. Представьте ребёнка с сортером: он не ищет сразу квадратное отверстие, а методично примеряет кубик ко всем подряд.

WireMock.Net ищет маппинг для входящего protobuf сообщения
Я вышел на связь с автором WireMock из Нидерландов, и мы вместе стали искать решение. В процессе проектирования стало понятно: нужен ранний отказ от заглушки по явным признакам запроса — URL, header, cookie и другим. Проще говоря, система должна сразу выбирать «квадратное отверстие» для «кубика», а не перебирать всё подряд. Это не только устранило ошибки, но и избавило сервер от лишней работы.
Дискуссия доступна на GitHub по ссылке — https://github.com/wiremock/WireMock.Net/issues/1442
Сам процесс, когда инженер из России и разработчик из Нидерландов вместе решают проблему — это и есть настоящая магия опен‑сорса и глобализации. Мой Pull Request уже влит, новая версия NuGet пакета уже в релизе. Благодаря такой международной коллаборации у WireMock.Net появилась фича EarlyMismatch:
Request.Create()
.UsingPost()
.WithPath("/grpc/greet.Greeter/SayHello")
.WithEarlyMismatch(RequestMatcherType.Path)
.WithBodyAsProtoBuf("greet.HelloRequest", new JsonMatcher(new { name = "stef" })));Как я чинил настройки JSONИспользование WireMock в компонентных тестах вскрыло серьёзную проблему.
Как только транспортный слой настраивал JSON сериализацию по своим конфигам, оказалось, что WireMock игнорирует конфигурацию приложения. Мок сервер работал с данными по своим внутренним правилам. В результате WireMock и боевой сервис начинали по‑разному понимать один и тот же контракт.
Проблема была в архитектуре библиотеки. Пользователь вообще не мог передать собственный экземпляр настроек JSON. WireMock прятал конфигурацию под модификатором private без точек расширения. Хочешь свою настройку — переписывай весь слой.
Я предложил изменить API и дать возможность передавать настройки через конструктор. После нескольких итераций ревью PR приняли — https://github.com/StefH/JsonConverter/pull/22
WireMockServer = WireMockServer.Start(
new WireMockServerSettings
{
DefaultJsonSerializer = new SystemTextJsonConverter(
new JsonSerializerOptions
{
// ...
}),
});На первый взгляд изменение выглядит маленьким — всего лишь новый конструктор. Но именно такие расширения позволяют встроить open‑source библиотеку в большой корпоративный проект, а не заставлять проект подстраиваться под библиотеку.
Потерянные логи xUnitСледующая доработка снова устраняла недостатки архитектуры.
WireMock умеет писать логи прямо в ITestOutputHelper, что очень удобно для отладки мок‑сервера. Но как только сервер переезжал в xUnit Fixture, эта возможность практически исчезала. В момент создания фикстуры объекта ITestOutputHelper ещё не существует, поэтому логгер требовал то, чего xUnit физически не может предоставить.
Приходилось выбирать: либо нормальная архитектура тестов с общей фикстурой, либо удобные логи WireMock.
Я предложил простую идею — вместо готового ITestOutputHelper принимать фабрику Func<ITestOutputHelper>. Тогда логгер получает актуальный объект только в момент записи сообщения, когда текущий тест уже запущен — https://github.com/wiremock/WireMock.Net/pull/1488
public class WireMockFixture
{
public WireMockFixture()
{
WireMockServer = WireMockServer.Start(
new WireMockServerSettings
{
Logger = new TestOutputHelperWireMockLogger(() => TestOutputHelper)
});
}
public WireMockServer WireMockServer { get; }
public ITestOutputHelper? TestOutputHelper { get; set; }
}Изменение заняло буквально несколько строк кода, зато WireMock стал нормально работать с привычной инфраструктурой xUnit и фикстурами
Несмотря на мои усилия по исправлению ошибок и улучшению архитектуры, gRPC мокирование WireMock.Net оставалось слабым и неудобным:
необходимость тащить в память .proto файлы, что приводило к ошибкам в GitLab раннерах
отсутствие статической типизации и как следствие автокомплита IDE
сопоставление только через JSON без возможности настроить сериализацию, так как логика сравнения использовала свой изолированный пайплайн обработки данных
Всё это толкнуло меня включить инженера, копнуть глубже и решить проблему, создав свой NuGet пакет.
В основе WireMock.Grpc.Protobuf лежит довольно простая идея: сгенерированный класс Google.Protobuf — это не просто DTO. Он уже содержит знание о protobuf‑контракте и умеет превращать себя в бинарное сообщение wire format через ToByteArray(). Поэтому расширению не нужно повторно загружать .proto, строить динамические дескрипторы или перегонять объект через JSON. Источником истины остаётся тот же тип IMessage<T>, с которым работает приложение.
При этом сервер WireMock.Net даёт доступ к исходному телу запроса в виде массива байт byte[]. Мне остаётся только встроиться в пайплайн обработки и сопоставления:
internal sealed class RequestGoogleProtobufMatcher(IObjectMatcher matcher) : IRequestMatcher
{
public double GetMatchingScore(IRequestMessage requestMessage,IRequestMatchResult requestMatchResult)
{
var matchResult = matcher.IsMatch(requestMessage.BodyAsBytes);
return requestMatchResult.AddMatchDetail(
new MatchDetail
{
Name = matchResult.Name,
MatcherType = nameof(RequestGoogleProtobufMatcher),
Score = matchResult.Score,
Exception = matchResult.Exception
});
}
}Само сопоставление даже не требует проверки равенства C# объектов через Equals. Мне достаточно разобрать входящее gRPC сообщение и выделить полезную нагрузку protobuf. Стандарт очень простой. Перед отправкой к полезной нагрузке добавляется 5-ти байтовый заголовок: 1 байт хранит флаг компрессии, следующие 4 — размер сообщения в формате big‑endian. Дальше идут исходные байты protobuf.
internal sealed class GrpcMessageMatcher<TMessage> : IObjectMatcher
where TMessage : IMessage<TMessage>
{
// ...
private readonly TMessage _messageValue;
private bool IsMessageMatch(byte[] inputBytes)
{
const int compressionFlagIndex = 0;
const int headerLength = 5;
if (inputBytes[compressionFlagIndex] != 0)
return false;
var sizeHeader = new ReadOnlySpan<byte>(inputBytes, 1, 4);
var length = BinaryPrimitives.ReadUInt32BigEndian(sizeHeader);
if (inputBytes.Length - headerLength < length)
return false;
var messageBytes = new ReadOnlySpan<byte>(_messageValue.ToByteArray());
return messageBytes.SequenceEqual(new ReadOnlySpan<byte>(inputBytes, headerLength, (int)length));
}
}Получается, что сопоставление мока по телу запроса сводится к простому вопросу: совпадают ли protobuf‑байты, пришедшие по сети, с байтами ожидаемого типизированного объекта? Без рефлексии, JSON и динамической десериализации. В этом и заключается главная сила решения: сложность уже один раз решил генератор Google.Protobuf. Он знает номера полей, wire types, правила кодирования строк и вложенных сообщений. Расширение не пытается создать ещё одну модель поверх protobuf — оно просто использует готовое бинарное представление контракта.
Для менее строгих сценариев пакет предлагает второй путь — типизированный предикат Func<T, bool>. Здесь также требуется обработать 5-ти байтовый заголовок входящего сообщения для выделения полезной нагрузки. Однако, получившиеся байты превращаются в объект с помощью MessageParser<T> и передаются в заданный предикат
internal sealed class GrpcPredicateMatcher<TMessage> : IObjectMatcher
where TMessage : IMessage<TMessage>, new()
{
// ...
private static readonly TMessage Empty = new();
private static readonly MessageParser<TMessage> Parser = new(() => Empty);
private readonly Func<TMessage, bool> _predicate;
private bool IsPredicateMatch(byte[] inputBytes)
{
const int compressionFlagIndex = 0;
const int headerLength = 5;
if (inputBytes[compressionFlagIndex] != 0)
return false;
var sizeHeader = new ReadOnlySpan<byte>(inputBytes, 1, 4);
var length = BinaryPrimitives.ReadUInt32BigEndian(sizeHeader);
if (inputBytes.Length - headerLength < length)
return false;
var inputMessage = Parser.ParseFrom(new ReadOnlySpan<byte>(inputBytes, headerLength, (int)length));
return _predicate.Invoke(inputMessage);
}
}Так пользователь сам выбирает уровень строгости: сравнить сообщение «байт‑в‑байт» или проверить только значимые для теста поля. В обоих случаях WireMock остаётся коробочным мок‑сервером, а понимание protobuf‑контракта делегируется библиотеке, которая для этого и была создана — Google.Protobuf.
Даже мощные инструменты не решают все проблемы индустрии. Не бойтесь признать, что ваша любимая библиотека не идеальна. Это повод сделать её лучше!
Так появился WireMock.Grpc.Protobuf — типобезопасный плагин для WireMock.Net, который работает напрямую со сгенерированными контрактами Google.Protobuf, дружит с компилятором и убирает лишнюю конфигурацию.
Исходники расширения уже доступны на GitHub, а проект уже экономит вечера борьбы с gRPC моками! Звезда и скачивание будут лучшей благодарностью:
Ещё я веду Telegram канал StepOne, куда выкладываю много интересного контента о программировании на C#, даю карьерные советы, рассказываю истории из личного опыта и раскрываю все тайны IT‑индустрии!
| # | Наименование новости | Тональность | Информативность | Дата публикации |
|---|---|---|---|---|
| 1 | Wiremokjs как я создавал свой скриптовый язык для wiremock | 0 | 5.9 | 03-08-2026 |
| 2 | Blog: gRPC-Rust Client API Evolution (pt. 1/2) | 0 | 11.54 | 29-05-2026 |
| 3 | Пишем Zero‑Allocation конвейер обработки G‑кода на C# (.NET 10): как выжать максимум из железа без GC‑пауз | 0 | 9.94 | 07-08-2026 |
| 4 | Blog: gRPC-Rust Preview Release | 0 | 9.58 | 28-05-2026 |
| 5 | Blog: gRPC-Rust Client API Evolution (pt. 2/2) | 0 | 13.85 | 01-06-2026 |
| 6 | Как мы сделали протобаф-адаптер CONTRACT быстрее libprotobuf | 0 | 16.06 | 28-07-2026 |
| 7 | .NET Matrix: взвешенный выбор библиотеки, а не по звёздам на GitHub | 0 | 9.2 | 30-07-2026 |
| 8 | CONTRACT: одна схема вместо N×M сериализаторов | 0 | 8.87 | 31-07-2026 |
| 9 | Как Google Apps Script незаметно ломает вебхуки Telegram-ботов | 0 | 10.36 | 03-08-2026 |
| 10 | Считаем пакеты на Rust | 0 | 9.87 | 04-07-2026 |