Property-based тестирование в C#: ловите баги, которые юнит-тесты пропускают

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Ваш юнит-тест врёт (и вы это знаете)

Представьте: вы написали 50 юнит-тестов, все зелёные, покрытие 90%. Вы довольны. Но через неделю падает продакшн. Оказывается, ваш метод SortArray() отлично работает для [1, 2, 3] и [3, 1, 2], но падает на [int.MaxValue, -1, 0]. Знакомо?

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

Здесь на сцену выходит property-based тестирование в C# (или тестирование на основе свойств). Вместо того чтобы проверять «если A, то B», вы описываете свойства, которые всегда должны быть истинны. А фреймворк сам генерирует сотни случайных входных данных и проверяет ваши утверждения.

В этой статье мы разберём, как внедрить property-based тестирование в C# с помощью библиотеки FsCheck, напишем реальные примеры и посмотрим, как оно ловит баги, которые обычные тесты пропускают.

Что такое property-based тестирование?

Property-based тестирование — это подход, при котором вы формулируете инварианты (свойства) вашей программы. Например:

  • «После сортировки длина массива не меняется»
  • «Обратный порядок разворота строки даёт исходную строку»
  • «Сумма двух положительных чисел всегда положительна»

Фреймворк (например, FsCheck) генерирует десятки и сотни случайных входных данных и проверяет, что свойство выполняется для всех. Если находится контрпример — тест падает, и вы получаете конкретные значения, которые сломали код.

В отличие от юнит-тестов, вы не пишете Assert.AreEqual(3, Add(1,2)). Вы пишете Assert(x + y == y + x) — и тестируете коммутативность для тысяч пар чисел.

Отличие от обычных юнит-тестов

Юнит-тестыProperty-based тесты
Проверяют конкретные примерыПроверяют общие законы
Данные задаёт разработчикДанные генерирует фреймворк
Пропускают граничные случаиАвтоматически исследуют границы
Легко читатьТребуют абстрактного мышления

Библиотека FsCheck: ваш главный инструмент

В мире C# стандартом де-факто для property-based тестирования является FsCheck (изначально порт Haskell-библиотеки QuickCheck). Она интегрируется с xUnit, NUnit и MSTest.

Установка проста:

dotnet add package FsCheck
dotnet add package FsCheck.Xunit  // для интеграции с xUnit

FsCheck умеет генерировать случайные данные для примитивов (int, string, float), списков, массивов, кортежей и даже пользовательских типов (через кастомные генераторы).

Пишем первый property-based тест на C#

Начнём с простого: проверим, что операция сложения целых чисел коммутативна.

using FsCheck;
using FsCheck.Xunit;

public class AdditionTests
{
    [Property]
    public bool Commutative_Addition(int x, int y)
    {
        return x + y == y + x;
    }
}

Запустите тест. FsCheck сгенерирует 100 случайных пар int (по умолчанию) и проверит свойство. Если ни один контрпример не найден — тест зелёный. Если бы мы ошиблись (например, написали x - y == y - x), тест бы упал с конкретным примером: (5, 3) — потому что 5 - 3 != 3 - 5.

Разбираем код

  • [Property] — атрибут, который говорит FsCheck, что это property-based тест.
  • Метод возвращает bool — если возвращает false, тест падает.
  • Параметры int x, int y — FsCheck автоматически подставит случайные значения.

Реальный пример: тестируем сортировку

Допустим, вы написали метод BubbleSort. Какие свойства должны выполняться?

  1. Длина массива после сортировки не меняется.
  2. Все элементы исходного массива присутствуют в отсортированном.
  3. Массив отсортирован по возрастанию (каждый следующий элемент ≥ предыдущего).

Напишем тест:

public class SortTests
{
    [Property]
    public Property Sort_Preserves_Length(int[] arr)
    {
        var sorted = BubbleSort(arr);
        return (sorted.Length == arr.Length).ToProperty()
            .And(sorted.All(item => arr.Contains(item)).ToProperty());
    }

    [Property]
    public bool Sort_Is_Ordered(int[] arr)
    {
        var sorted = BubbleSort(arr);
        for (int i = 1; i < sorted.Length; i++)
        {
            if (sorted[i - 1] > sorted[i]) return false;
        }
        return true;
    }

    private int[] BubbleSort(int[] arr)
    {
        var result = (int[])arr.Clone();
        for (int i = 0; i < result.Length; i++)
        {
            for (int j = 0; j < result.Length - 1; j++)
            {
                if (result[j] > result[j + 1])
                {
                    int temp = result[j];
                    result[j] = result[j + 1];
                    result[j + 1] = temp;
                }
            }
        }
        return result;
    }
}

Обратите внимание: мы проверяем не один конкретный массив, а любой массив целых чисел. FsCheck сгенерирует массивы разной длины, с отрицательными числами, с дубликатами, с int.MinValue. Если в вашей сортировке есть баг с пустым массивом или массивом из одного элемента — тест его найдёт.

Продвинутые техники: условные свойства и кастомные генераторы

Условные свойства (Conditional Properties)

Иногда свойство должно выполняться только при определённом условии. Например: «Если строка не пуста, то её разворот дважды даёт исходную строку».

[Property]
public Property Reverse_Twice_Returns_Original(string s)
{
    return (s != null).Implies(() =>
    {
        var reversed = new string(s.Reverse().ToArray());
        var doubleReversed = new string(reversed.Reverse().ToArray());
        return doubleReversed == s;
    });
}

Метод Implies проверяет условие: если s == null, тест

Блоги

Рекомендаций по книгам