Писать или не писать тесты — выбор очевидный. Конечно, писать. Но если проект масштабный, одних unit‑тестов будет недостаточно: они бессильны на границах модулей, в интеграциях и пользовательских сценариях, а значит в этих местах будут пролезать баги. Такой код будет сложно поддерживать, вносить в него изменения и получать ожидаемый результат.

На связи Александр Кальницкий, ведущий разработчик Mindbox. В прошлой статье мы разбирались, как писать простой и понятный код. В этой поговорим про разные стратегии тестирования под разные риски и кейсы. Поднимемся над привычными unit‑тестами и заглянем, что там есть еще. Спойлер: а еще там workflow‑, integration‑, property‑based‑ и resilience‑тесты.

Q&A в Mindbox: highload и автотесты

У нас в Mindbox нет команды Q&A: разработчики сами пишут автотесты и с их помощью проверяют работоспособность кода после изменений. В своей работе я использую пять видов тестов, о которых подробно и расскажу. Чтобы статья была максимально конкретной, буду опираться на пример одного из наших сервисов — рантайм рассылок. Это высоконагруженный сервис, который обрабатывает до 10 000 запросов в секунду. Он состоит из четырех микросервисов, заинтегрирован с платформой автоматизации маркетинга (CDP) и обрабатывает множество корнер‑кейсов.

Рантайм рассылок со связанной инфраструктурой в упрощенном виде

Workflow-тесты: безопасный рефакторинг

Любое приложение отвечает за один или несколько бизнес‑процессов в границах своего домена. Например, отправляет рассылки, формирует список получателей или мониторит нарушения SLA. Такие бизнес‑процессы, они же workflows, чаще всего можно графически представить как пронизывающие приложение стрелки:

Бизнес-процессы могут быть линейными или развиваться по разным сценариям

Чтобы протестировать весь бизнес‑процесс, нужно писать workflow‑тесты. Каждый такой тест проверяет одну пару входных и выходных контрактов приложения. Если процесс в приложении читает один топик Kafka, а пишет в два, то надо проверить оба сценария. Если при определенных условиях в середине workflow появляются необходимые сайд‑эффекты, это тоже нужно тестировать.

Например, для отправки массовой рассылки нашему рантайму нужно четыре сервиса:

  1. Балансировщик, отвечающий за баланс нагрузки по подам.

  2. Консьюмеры топиков на отправку, которые получают от балансировщика список топиков и читают их по определенным правилам.

  3. CDP, которая асинхронно создает клиентов для рассылок.

  4. Консьюмер ответов CDP, который уже отправляет готовые сообщения в рассылочный гейт.

Чтобы убедиться, что написанный код бизнес‑процесса работает и выполняет свою задачу, нужно писать тесты на весь workflow.

Пример
[TestClass]
public class WorkflowTests
{
    private static WebApplicationFactory<Program> _application = null!;
    private static readonly KafkaContainer _kafkaContainer = KafkaContainerExtensions.CreateKafka();
    private static readonly CassandraContainer _cassandraContainer = CassandraContainerExtensions.CreateCassandra();


    [ClassInitialize]
    public static async Task InitializeAsync(TestContext context)
    {
        // ШАГ 1: Запускаем все необходимые контейнеры
        Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "development");
        await _kafkaContainer.StartAsync();
        await _cassandraContainer.StartAsync();


        // Запускаем Flyway для миграций кассандры
        await using var flyway = CassandraContainerExtensions.CreateFlywayContainer(_cassandraContainer);
        await flyway.StartAsync();


        // ШАГ 2: Создаем топики Kafka
        await _kafkaContainer.CreateTopicsAsync(
        [
            "runtime.messages", // Входной топик
            "mta.messages", // Выходной топик
            "reports" // Топик для отчетов
        ]);


        // ШАГ 3: Создаем WebApplicationFactory с переопределением конфигурации
        // Ключевой момент: указываем эндпоинты на тестконтейнеры
        _application = new WebApplicationFactory<Program>()
            .WithWebHostBuilder(builder =>
            {
                builder.ConfigureAppConfiguration((context, config) =>
                {
                    // Переопределяем конфигурацию: указываем адреса тестконтейнеров
                    var testConfig = new Dictionary<string, string?>
                    {
                        // Kafka endpoint
                        ["KafkaBootstrapServers:MailingsRuntime:bootstrap.servers"] = _kafkaContainer.GetBootstrapServers(),


                        // Cassandra endpoint
                        ["Cassandra:Mailings:NodeHosts:0"] = _cassandraContainer.Hostname,
                        ["Cassandra:Mailings:Port"] = _cassandraContainer.GetMappedPublicPort(9042).ToString(CultureInfo.InvariantCulture),
                        ["Cassandra:Mailings:Datacenter"] = "dc1",
                        ["Cassandra:Mailings:ConsistencyLevel"] = "One"
                    };
                    config.AddInMemoryCollection(testConfig);
                });


                builder.ConfigureServices(services =>
                {
                    // Можно переопределить сервисы для тестирования
                    services.AddLogging(logging => logging.SetMinimumLevel(LogLevel.Error));
                });
            });
    }


    [ClassCleanup]
    public static async Task CleanupAsync()
    {
        await _application.DisposeAsync();
        await _kafkaContainer.DisposeAsync();
        await _cassandraContainer.DisposeAsync();
    }


    [TestMethod]
    [Timeout(40000)]
    public async Task ShouldProcessCustomerDataAndSendToTargetGate()
    {
        // Arrange - подготавливаем входные данные
        const int messagesCount = 100;
        var inputMessages = Enumerable.Range(0, messagesCount)
            .Select(i => new CustomerDataMessage
            {
                CustomerId = i,
                MailingId = "test-mailing-123"
            })
            .ToList();


        // ШАГ 1: Продюсим сообщения во входной топик Kafka. Работаем только через доступные публичные API
        await _kafkaContainer.ProduceMessagesToTopicAsync(
            "runtime.messages",
            inputMessages.Select(msg => JsonSerializer.Serialize(msg)));


        // ШАГ 2: Ждем, пока приложение обработает сообщения
        await WaitUntilAllMessagesAreConsumed();


        // ШАГ 3: Читаем сообщения из выходного топика Kafka
        var outputMessages = _kafkaContainer.ConsumeUniqueMessages("mta.messages", "test-consumer-group");


        // Assert: проверяем вывод программы
        outputMessages.Should().HaveCount(messagesCount);
        foreach (var outputMessageJson in outputMessages)
        {
            var outputMessage = JsonSerializer.Deserialize<OutputMessage>(outputMessageJson);
            outputMessage.Should().NotBeNull();
            outputMessage!.CustomerId.Should().BeGreaterOrEqualTo(0);
            outputMessage.Email.Should().NotBeNullOrEmpty();
            outputMessage.ProcessedAt.Should().BeAfter(DateTime.UtcNow.AddMinutes(-1));
        }
    }


    [TestMethod]
    [Timeout(40000)]
    public async Task ShouldReportErrorsToReportsTopic()
    {
        // Arrange - создаем некорректные данные
        var invalidMessages = new[]
        {
            new CustomerDataMessage { CustomerId = -1, MailingId = "invalid" },
            new CustomerDataMessage { CustomerId = 0, MailingId = "" }
        };


        // Act - отправляем некорректные данные
        await _kafkaContainer.ProduceMessagesToTopicAsync(
            "runtime.messages",
            invalidMessages.Select(msg => JsonSerializer.Serialize(msg)));


        await WaitUntilAllMessagesAreConsumed();


        // Assert - проверяем, что ошибки попали в топик отчетов
        var reportMessages = _kafkaContainer.ConsumeUniqueMessages("reports", "reports-consumer");
        reportMessages.Should().HaveCount(invalidMessages.Length);
    }
}

Workflow‑тесты — база всего рантайма. На них можно положиться и быть уверенным, что код всего бизнес‑процесса делает именно то, что нужно. Тесты проверяют, что:

  • сериализаторы настроены корректно;

  • контракты описаны правильно; 

  • DI в каждом сервисе тянет нужные зависимости, ничего не забыли;

  • параметры в конфигах верные;

  • балансировка отрабатывает;

  • воркеры без ошибок размещают работу к себе;

  • консьюмеры запускаются и консьюмят;

  • интеграция с Cassandra работает;

  • рассылки обрабатываются;

  • кэши работают;

  • ошибки отлавливаются.

Не раз workflow‑тесты спасали меня и других разработчиков от багов в продакшене. Я также могу провести масштабный рефакторинг или быстро внедрить крупную фичу, потому что знаю, как работает приложение в общем случае. Например, мне достался код, который писали и поддерживали больше 10 лет. Workflow‑тесты помогли за две недели перенести всю рассылочную бизнес‑логику из legacy‑системы в новый микросервис. Без багов, конечно, тоже не обошлось, но это были упущенные корнер‑кейсы, которые потом одним днем починились.

Как написать workflow‑тест

Шаг 1. Придумать сценарий. Наш тест работает так:

  1. Создает нужные топики.

  2. Генерирует 1000 сообщений тестируемой рассылки.

  3. Заливает их в Kafka.

  4. Поднимает инфру: один балансер, один консьюмер CDP и четыре обработчика.

  5. Подписывает специальный проверяющий консьюмер на топик с итоговыми сообщениями.

  6. Ждет прохождения всех операций 40 секунд (атрибут [Timeout] ). 

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

  8. Завершается успешно, когда проверяющий консьюмер правильно обработал всю 1000 сообщений.

Шаг 2. Определиться с контрактами: что приходит на вход и какие сайд‑эффекты мы хотим тестировать. В нашем рантайме вход — это топик массовых рассылок, в котором лежат письма на отправку. Выход — это топик, из которого идёт отправка в гейт. Но это может быть и любой другой сайд‑эффект: и база данных, и SMTP‑сервер.

Шаг 3. Поднять тестируемое приложение и всю нужную инфраструктуру целиком. Для этого нужно запустить всю ключевую инфру приложения в тестконтейнерах. С помощью WebApplicationFactory записать в конфиги коннекшены к тестконтейнерам, поменять значения переменных среды окружения или переопределить зависимости в DI при необходимости.

Для экономии времени на инициализацию имеет смысл поднять контейнеры только один раз на несколько тестовых сценариев. Сделать это можно с помощью [ClassInitialize] или [AssemblyInitialize].

Моки или in‑memory версии не гарантируют, что код сработает корректно при взаимодействии приложения с конкретной технологией. Поэтому для критически важной инфраструктуры не стоит использовать моки.

Шаг 4. Поставить параллельный режим выполнения тестов. Это ускорит прохождение CI‑пайплайна, а также поможет убедиться, что тесты изолированы друг от друга, процессы не имеют общего состояния и ресурсы корректно очищаются между тестами.

В итоге тест получится достаточно комплексный, но в то же время честный и надежный. В моем тесте за 40 секунд приложение успевает подняться, сбалансировать нагрузку, создать консьюмеры, обработать рассылки и выдать ожидаемый результат. Честная сделка.

Общий чек‑лист без привязки к рантайму рассылок

  1. Поднять тестконтейнеры для всех ключевых внешних зависимостей.

  2. Через WebApplicationFactory поднять приложение и, переопределив конфигурацию, подключить к нему тестовые зависимости.

  3. Если нужно, мокировать второстепенные сайд‑эффекты. Вся бизнес‑логика должна остаться нетронутой.

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

Integration-тесты: проверка API-клиентов

Integration‑тесты проверяют, что код клиента, конфигурация окружения и сетевые настройки вместе гарантируют корректные вызовы API в реальных условиях. При этом важно понимать границы такой проверки: third‑party библиотеки тестировать не нужно. Они уже протестированы авторами. Да, изредка случается поймать ошибку, но это скорее исключение. А вот собственные методы работы с этими библиотеками точно стоит тестировать.

Например, я доработал административный API Kafka, чтобы запрашивать топик‑лаг по списку топиков и собирать оффсеты, находить между ними разницу и возвращать результат. На новую функциональность этого клиента я написал несколько тестов. Они проверяют, что все работает, как задумано, например что я правильно понял смысл оффсета -1001 в Kafka.

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

Пример
[TestMethod]
	public async Task ShouldReturnConsumerLag_WhenConsumerExists(int topicCount)
	{
		// Arrange
		const int messagesPerTopic = 10;
		const int messagesToConsume = 5;
		var topicNames = Enumerable.Range(1, 4).Select(i => $"test-topic-{i}").ToList();
		var topicConsumerGroups =
			topicNames.Select(t => new TopicConsumerGroup(new(t), new($"test-consumer-group-{t}"))).ToList();
		using var adminClient = new AdminClientBuilder(new AdminClientConfig { BootstrapServers = _bootstrapServers })
			.BuildExtendedClient();

		await CreateTopicsAsync(adminClient, topicNames);
		await ProduceMessagesToTopicsAsync(topicNames, messagesPerTopic);
		await ConsumeMessagesAsync(topicConsumerGroups, messagesToConsume);

		// Act
		var result = await adminClient.GetConsumerGroupsTopicLagAsync(topicConsumerGroups);

		// Assert
		result.Count.Should().Be(topicCount);
		foreach (var lagInfo in result)
		{
			topicNames.Should().Contain(lagInfo.Topic);
			lagInfo.ConsumerGroupId.Value.Should().StartWith("test-consumer-group-");
			lagInfo.TopicLag.Should().Be(messagesPerTopic - messagesToConsume);
			lagInfo.PartitionLags.Count.Should().BeGreaterThan(0);
		}
	}

	[TestMethod]
	public async Task ShouldReturnQueueSize_WhenConsumerDoesNotExists(int topicCount)
	{
		// Arrange
		const int messagesPerTopic = 10;
		var topicNames = Enumerable.Range(1, 4).Select(i => $"test-topic2-{i}").ToList();
		var topicConsumerGroups =
			topicNames.Select(t => new TopicConsumerGroup(new(t), new($"test-consumer-group2-{t}"))).ToList();
		using var adminClient = new AdminClientBuilder(new AdminClientConfig { BootstrapServers = _bootstrapServers })
			.BuildExtendedClient();

		await CreateTopicsAsync(adminClient, topicNames);
		await ProduceMessagesToTopicsAsync(topicNames, messagesPerTopic);

		// Act
		var result = await adminClient.GetConsumerGroupsTopicLagAsync(topicConsumerGroups);

		// Assert
		result.Count.Should().Be(topicCount);
		foreach (var lagInfo in result)
		{
			topicNames.Should().Contain(lagInfo.Topic);
			lagInfo.ConsumerGroupId.Value.Should().StartWith("test-consumer-group2-");
			lagInfo.TopicLag.Should().Be(messagesPerTopic);
			lagInfo.PartitionLags.Count.Should().BeGreaterThan(0);
		}
	}

Как написать integration‑тест

  1. Поднять тестконтейнер с нужным приложением.

  2. Получить коннекшн.

  3. Подключиться.

  4. Выполнить тест.

  5. Проверить публичным API, что тест закончился, как ожидалось.

Resilience-тесты: «что будет, если…»

При разработке приложения следует предусмотреть его отказоустойчивость. Например, что будет, если откажет кеш? Или в Kafka будут дубли сообщений? Или обработка сообщений прервется из‑за сетевых проблем? Или база данных на время выйдет из строя? Чтобы убедиться, что приложение ведет себя предсказуемо и устойчиво к сбоям в нестабильных условиях, его проверяют с помощью resilience‑тестов. 

Чаще всего я пишу resilience‑тесты на идемпотентность операций или на fallback‑сценарии.

Пример
[TestMethod]
public async Task IsSlaSupportedAsync_WhenServiceUnavailable_ReturnsTrueAsFallback()
{
    // Arrange
    using var wireMock = WireMockServer.Start();
    
    wireMock
        .Given(Request.Create()
            .WithPath("/api/sla/campaigns/*")
            .UsingGet())
        .RespondWith(Response.Create()
            .WithStatusCode(500)
            .WithBody("Internal Server Error"));

    await using var serviceProvider = BuildTestServiceProvider(slaBaseUrl: wireMock.Url);
    var client = serviceProvider.GetRequiredService<ISlaMailingsClient>();

    // Act - fallback, not throw
    var isSupported = await client.IsSlaSupportedAsync(campaignId: "any-campaign");

    // Assert
    isSupported.Should().BeTrue(
        because: "при недоступности SLA-сервиса мы оптимистично разрешаем рассылку");
}

Как написать resilience‑тесты

  1. Определить, отказ каких компонентов системы критически повлияет на работу сервиса.

  2. Смоделировать различные варианты деградации или отказов для таких компонентов, например дубли запросов, увеличение времени ответа или полная недоступность.

  3. Выбрать стратегию митигации отказов, например retry, fallback, idempotency, и написать тест, который это проверяет.

Unit-тесты: корнер-кейсы и ветвления

Если workflow‑тест проверяет happy path приложения, то unit‑тестами можно покрыть побочные сценарии, убедиться, правильно ли работают фичи и корректно ли обрабатываются ошибки. При условии, что все перечисленное — не отдельный workflow. 

Идеальный unit‑тест не работает с моками, он проверяет только публичные методы. Если требуется, unit‑тест можно параметризировать. Такие тесты добавляют уверенности в отдельных сложных алгоритмах приложения, но не дают убедиться в его полной работоспособности. Полагаться только на них — ошибка.

Обычный unit-тест
[TestClass]
public class RateLimitCalculatorTests
{
	[TestMethod]
	[DataRow(3600, 1, 1)]
	[DataRow(3600, 4, 0.25)]
	[DataRow(500, 1, 0.138)]
	[DataRow(500, 4, 0.0347)]
	public void CalcRateLimitPerSecond_SimulateHourlyBehavior_SumEqualsExpectedTotal(
		int messagesPerHour,
		int activeConsumersCount,
		double expectedRateLimit)
	{
		var rateLimit = RateLimitCalculator.CalcRateLimitPerSecond(
			messagesPerHour,
			activeConsumersCount);

		rateLimit.Should().BeApproximately(expectedRateLimit, 0.01);
	}
}

Property-based тестирование: проверка алгоритмов на стероидах

Балансировщик рантайма — сложный механизм, но разработчики реализовали всю его работу в вызове одного статического метода. На первый взгляд, покрыть тестами вызов такого метода просто. Но система после балансировки может принять одно из тысяч разных состояний. И как их все охватить в тесте — неочевидно. 

Вот тут и нужны property‑based тесты. Они не проверяют один конкретный случай, а генерируют огромные наборы случайных тестов с одной целью: проверить, что определенное свойство тестируемой системы всегда соблюдается. Для property‑based тестов я использую библиотеку FSCheck — стандартный выбор.NET‑разработчика.

Примеры тестов для балансировщика рантайма
[TestMethod]
    public void ShouldHaveAllTopicsInDifferentRacks()
    {
        Prop.ForAll(
            DataGenerators.TopicsArb(20, 80),
            DataGenerators.WorkerArb(8, 20),
            DataGenerators.EmptyAssignmentsArb(),
            AllNotInSameRack).Check(Config.QuickThrowOnFailure.WithMaxTest(200));
        return;


        static bool AllNotInSameRack(
            List<TopicData> topics,
            List<WorkerId> currentWorkers,
            List<AssignmentList<TopicAssignment>> currentAssignments)
        {
            var settings = new BalancerSettings();
            var balanced = Balancer.RackAwareStickyRoundRobinAlg(
                Calculator,
                topics,
                currentWorkers,
                currentAssignments,
                settings);
            var notInSameRack = balanced.AssignedLoad.All(assignment =>
                assignment.Assignments.GroupBy(a => a.TopicName).All(grp => grp.Count() == 1));
            return notInSameRack;
        }
    }


    [TestMethod]
    public void ShouldRemoveFinishedTopics()
    {
        Prop.ForAll(
            DataGenerators.TopicsArb(20, 80),
            DataGenerators.WorkerArb(8, 20),
            DataGenerators.EmptyAssignmentsArb(),
            WithoutFinishedTopics).Check(Config.QuickThrowOnFailure.WithMaxTest(200));
        return;


        static bool WithoutFinishedTopics(
            List<TopicData> topics,
            List<WorkerId> currentWorkers,
            List<AssignmentList<TopicAssignment>> currentAssignments)
        {
            var settings = new BalancerSettings();
            var someOldTopics = topics.Take((int)(topics.Count * 0.4)).ToList();
            var someNewTopics = topics.Skip((int)(topics.Count * 0.4)).ToList();
            var balanced = Balancer.RackAwareStickyRoundRobinAlg(
                Calculator,
                someOldTopics,
                currentWorkers,
                currentAssignments,
                settings);
            var balancedAgain = Balancer.RackAwareStickyRoundRobinAlg(
                Calculator,
                someNewTopics,
                currentWorkers,
                balanced.AssignedLoad,
                settings);
            var noOldTopics = !balancedAgain.AssignedLoad
                .SelectMany(assignment => assignment.Assignments.Select(a => a.TopicName))
                .Intersect(someOldTopics.Select(t => t.Name)).Any();
            return noOldTopics;
        }
    }

Как написать property‑based тест

Шаг 1. Определить свойства системы. Для балансировщика свойства такие:

  • при достаточном количестве ресурсов вся нагрузка должна быть распределена,

  • ограничения не должны нарушаться никогда.

При этом балансировка должна:

  • учитывать предыдущий результат;

  • быть детерминированной и rack‑aware, то есть не должно быть двух консьюмеров из одной группы на одном поде;

  • удалять ненужную нагрузку.

Шаг 2. Написать нужные генераторы данных — механизмы, которые на основе сида генерируют потоки данных для тестов. Если тест упадет на каком‑то случае, всегда можно воспроизвести его, использовав тот же сид.

Шаг 3. Написать тест, который проверяет нужное свойство. Указать, сколько тестов нужно делать. Например, 1000.

Такие тесты помогли мне запустить балансировщик одним днем на проде без багов. В дальнейшем я неоднократно глубоко модифицировал алгоритм и при этом был абсолютно уверен, что основные свойства балансировки сохраняются. Например, в один момент я понял, что команде сложно понимать алгоритм, который использует абстрактные единицы скорости для балансировки. За несколько дней я заменил его на другой алгоритм, который подсчитывает количество выделяемых Task на каждую работу во всех воркерах. Property‑тесты при этом переписывать не пришлось, так как они не проверяют имплементацию алгоритма, но с их помощью я проверил, что не случилось регрессии в логике. А еще они помогли отловить баги во время разработки.

Test-first подход: тесты, которые заранее подтвердят работоспособность кода

Хоть я и большой любитель TDD, но в рантайме использовал Type Driven и Test‑first комбинацию. Все потому, что в основе приложения используется паттерн Functional Core, Imperative Shell. Поэтому вся бизнес‑логика реализована чистыми функциями — статическими методами. А HTTP, БД и очереди — вся инфраструктура — вынесены в тонкую обвязку с DI. Такой подход удобен для покрытия кода тестами. Unit‑тесты будут без моков, а в integration‑тесты не будет протекать бизнес‑логика.

Цикл разработки у меня выглядит следующим образом:

  1. Формирую типы данных, которые будут представлять домен приложения.

  2. Формирую наброски функций с сигнатурами: имя, входящие параметры, что возвращает.

  3. Для взаимодействия I/O создаю необходимые интерфейсы.

  4. Итеративно описываю тестовые сценарии для каждой функции или интерфейса и пишу реализацию. На этом этапе использую unit‑, property‑based и integration‑тесты.

  5. Добавляю workflow‑тесты, проверяющие бизнес‑процессы приложения целиком.

  6. Добавляю resilience‑тесты для проверки отказоустойчивости определенных компонентов.

  7. Когда тесты готовы, делаю рефакторинг. Тесты не дадут коду сломаться.

Когда мы делали масштабный рефакторинг в прошлый раз, не используя test‑first подход, мы получили большой техдолг, который до сих пор приходится обслуживать. Зато после внедрения практики мы за полгода перенесли и переписали десятилетнее legacy из мультитенантного монолита в микросервис без каких‑либо серьезных инцидентов и в срок. 

Должен заметить, что рекомендации из этой статьи можно оформить в виде skills для ИИ‑агента. По описанию проекта он будет подсказывать, какие тесты нужны, и генерировать заготовки. Об этом расскажу в следующий раз.