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.Net ищет маппинг для входящего protobuf сообщения
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 и фикстурами

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‑индустрии!