Ваш юнит-тест врёт (и вы это знаете)
Представьте: вы написали 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. Какие свойства должны выполняться?
- Длина массива после сортировки не меняется.
- Все элементы исходного массива присутствуют в отсортированном.
- Массив отсортирован по возрастанию (каждый следующий элемент ≥ предыдущего).
Напишем тест:
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, тест