TL;DR
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 из Нидерландов, и мы вместе стали искать решение. В процессе проектирования стало понятно: нужен ранний отказ от заглушки по явным признакам запроса — 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 и фикстурами
WireMock.Grpc.Protobuf
Несмотря на мои усилия по исправлению ошибок и улучшению архитектуры, 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‑индустрии!
