TLDR; применяй сразу тут и примеры запуска на github
Пролог
Эта статья является прообразом доклада на Dotnext 2019, где я рассказал о применении подхода мутационного тестирования в проектах на .NET Core.
Доклад вызвал неоднозначную реакцию, от "Зачем это необходимо?" до "Как я жил без этого раньше".
Для популяризации подхода я решил опубликовать прообраз доклада - черновики в нескольких частях. В конце вы найдете ссылку на видео, если просто хотите посмотреть доклад со слайдами.
В предыдущей статье мы рассмотрели первую часть - вводную в теорию мутационного анализа.
Теперь мы разберем сами мутации, которые могут быть применены к вашему коду.
О чем пойдет разговор?
- тестирование и для чего применять еще один вид
- про принцип мутационного тестирования и анализа
Часть 2 - вы читаете эту часть
- виды мутаций
Часть 3
- как применить в .NET
- когда использовать мутационное тестирование и как подготовится
Какие мутации могут быть в программном обеспечении
Все мутации - это не хаотичные изменения кода, а точно выверенные, те, которые на самом деле могут быть из-за человеческого фактора или при рефакторинге приложения применены в коде.
Большинство этих мутаций до тестов никогда не дойдут, так как сработает первая защита - проверка типов и компиляция. Остальные мутации будут применены.
Типы мутаций
1. ООП Мутации в зависимости от языка и его реализации
- Мутации модификаторов доступа
- Мутации наследования - удаление указателей на классы, удаление override методов, удаление вызова base или super, удаление вызова родительского конструктора
- Полиморфные мутации, например, изменения конкретного класса реализации интерфейса в коде
- Мутации перегрузки методов и их аргументов
- Общие мутации - удаление
this
, передача параметров по ссылке или по значению
2. Общие мутации
- Арифметические операции
- Мутации сравнения
- Мутации логических операторов
- Мутации bool типа
- Мутации присвоения и арифметические
- Унарные операции
- Строковые мутации
- Мутации массивов и списков
3. Специфические мутации для языка и библиотек - LINQ мутации
Теперь представьте, что этот массив мутаций будет применен к вашему коду. Вы еще точно уверены, что ваши тесты упадут и мутации будут убиты?
Статистика
Количество общих мутаций зависит от объема кода, для примера приведу статистику из двух библиотек, до того как применить мутационное тестирование и после.
Flurl, 9000 строк, покрытие 85%, 300 тестов, 363 мутантов, 43 сек выполнение тестов, 30 минут выполнение прогона мутаций.
Flurl Url Builder, 108 мутаций, изначально выжило 11 штук, рефакторингом доведено до 0, время прогона 7 минут, найдено критичных мутаций 2.
Flurl Http, 255 мутаций, 40 выжило, после рефакторинга осталось 2 эквивалентные.
Ecwid Client, 5000 строк, покрытие 90%, 200 тестов, 16 секунд выполняются тесты, 300 мутантов, 15 минут прогон тестов со всеми мутациями, изначально выжило 70, после рефакторинга выжило 10. Найдены 4 критичные ошибки.
Итоговая статистика получилась такая:
- Примерно 10% мутаций будет в мертвом коде, который необходимо или удалить или написать на него тесты, если вы считаете что код может понадобиться, например, в случае публичной библиотеки.
- Примерно 50% выживших мутаций укажут вам на недостаточное качество тестов.
- Остальные мутации относятся к эквивалентным.
Распространенные примеры выживших мутаций и их влияние на тесты и на код
Теперь рассмотрим наиболее часто встречаемые примеры выживших мутаций.
Тут необходимо сказать, что вообще бороться с мутантами можно 2 способами - честным и нечестным:
- честный - это когда вы изменяете код или дописываете тесты, чтобы мутант был убит
- нечестный - вы переносите зону ответственности за ваш код, куда мутационное тестирование не может достать.
Строковая мутация
Вы всегда проверяйте все ошибки, которые возвращаются на их корректность? И в разных локалях? А вы проверяете все строковые значения получаемого объекта? Если нет то это прямая дорога получить на главной странице сайта или в мобильном приложении текст внутренней ошибки со stacktrace. Часто это ведет к репутационным потерям компании.
Самая распространенная выжившая мутация - строковая. Кол-во строк, которые курсируют из метода в метод, из exception в exception - очень велико.
Давайте рассмотрим пример 1
private bool ExceptionMethod(int a) {
if (a < 0)
throw new ArgumentException("USER_DB is not path pattern uiier_ksks", "user-name");
...
return true;
}
public string LibraryMethod(int a) {
try {
if (ExceptionMethod(a)) return "Successfully";
}
catch (ArgumentException e) {
throw new Exception("Something happened", e);
}
return null;
}
[Fact]
public void LibraryMethod_NegativeData_ThrowException() {
// Act
var ex = Assert.Throws<Exception>(() => SampleClass.LibraryMethod(-1));
// Assert
Assert.Equal("Something happened", ex.Message);
}
Что тут забыли? Если мы что то не проверяем, то мы это не контролируем.
[Fact]
public void LibraryMethod_NegativeData_ThrowException() {
// Act
var ex = Assert.Throws<Exception>(() => SampleClass.LibraryMethod(-1));
// Assert
Assert.Equal("Something happened", ex.Message);
Assert.Equal("USER_DB is not path pattern uiier_ksks\nParameter name: user-name", ex.InnerException.Message);
}
Мы забыли проверить строку, которая возвращается во внутреннем исключении. И, если код, который использует ваш, прокидывает исключения выше и публикует их на frontend, то результаты могут быть неожиданными.
Пример 2.
В Ecwid CLI был найден баг, при котором сервис не мог сконфигурироваться, когда один из токенов был невалидный. Тесты были написаны, exception возникал правильный, но не проверялись тексты ошибок - тесты были написаны неверно и выявился интересный баг.
[Fact]
public void Credentials_Fail() {
Assert.Throws<Exception>(() => new Ecwid(0, Token, Token));
Assert.Throws<Exception>(() => new Ecwid(ShopId));
Assert.Throws<Exception>(() => new Ecwid(ShopId, " ", Token));
Assert.Throws<Exception>(() => new Ecwid(ShopId, Token, " "));
}
Мутация границ и мутация операций сравнения
Рассмотрим пример
public string Encode(string s, ...) {
if (string.IsNullOrEmpty(s)) return s;
if (s.Length > MAX_URL_LENGTH) {
...
}
...
}
Здесь забыли подать на вход теста для проверки строку равной максимальной длине.
В этом примере, мы проверили граничные условия, но не проверили средний вариант. Обычно тестируются негативные варианты. Не надо забывать и о положительных сценариях.
public Builder Limit(this Builder query, int limit) {
if (limit <= 0)
throw new ArgumentException("Limit must be greater than 0", nameof(limit));
if (limit > 99) limit = 100;
query.AddOrUpdate("limit", limit);
return query;
}
Вот еще распространенный пример мутации операции сравнения
public void Some<T>(object arg, IEnumerable<T> list) {
...
var out = new List<T>();
...
if (out.Count > 0) {
...
}
...
}
Мутируем оператор сравнения.
Тут мы можем применить второй способ ухода от мутации - вынесение ее за наш код.
public void Some<T>(object arg, IEnumerable<T> list) {
...
var out = new List<T>();
...
if (out.Any()) { // FirstOrDefault() != null
...
}
...
}
LINQ мутации
В LINQ мутациях распространенной считается замена First на Last, Any на All, FirstOrDefault на Default.
Рассмотрим пример
public T GetValue(int field) {
var values = await GetValues(new {field});
return values.FirstOrDefault();
}
[Fact]
public void GetValue_ReturnCorrectData() {
//... mock data
var result = await client.GetValue("some data");
Assert.Equal("some data", result.ValueNumber);
//... some other assertion
}
Тест корректен, данные входные тоже. Применяется мутация FirstOrDefault на LastOrDefault.
Можно было бы пометить этот мутант просмотренным, но появление мутации заставило меня покопаться в результатах вывода API хранилища и оказалось, что при запросе хранилище может выдавать реальный результат из нескольких записей и, в зависимости от сортировки, которая задается региональными настройками, первая запись не будет совсем той, которую мы ожидаем.
В случае корректных входных данных тест поймал бы такую ошибку.
В итоге эта мутация закрывается расширением набора входных данных и установлением сортировки по умолчанию.
Кстати, про сортировки, настоятельно прошу тестировать ваше приложение, даже unit тестами именно в том окружении, где ПО будет работать.
Если вы хотите деплоить в docker, то тесты необходимо запускать там же.
На моей практике было несколько случаев, что тесты, работающие на windows, не работали в docker и наоборот. При работе с датами и с округлением денег в decimal необходимо жестко устанавливать локаль в большинстве случаев.
Примеры эквивалентных мутаций и способы ухода от них
Самые сложные мутации - это эквивалентные, те, которые не могут быть убитыми никакими тестами.
Самый простой пример - это условие выхода из цикла:
int index = 0;
while (...) {
// …;
index++;
if (index == 10) { // mutate to >=
break;
}
}
Такие простые мутанты вычищаются как эквивалентные еще на этапе подготовки мутантов библиотеками мутационного тестирования.
А есть такие мутации:
public void Remove(SomeType<T> container, T data) {
if (data == null) return;
if (container.All(c => c.SomeField != data.SomeField)) return;
container.Values.Remove(data); // очень много связанной логики и т.п.
}
[Fact]
public void Remove_NotExist_NotRemoved() {
// Arrange
var expected = container.Values.Count;
var data = new Value();
// Act
TRepository.Remove(container, data);
// Assert
Assert.Equal(expected, container.Values.Count);
}
В чем проблема? Дело в том, что код проверки container.All(c => c.SomeField != data.SomeField) бессмысленный. Даже если условие не выполнится, то удаление произойдет.
Решение первое - удалить проверку вообще.
Решение второе - переписать немного код, и мы найдем проблему в другом тесте.
[Fact]
public void Remove_Exist_Removed() {
// Arrange
var expected = container.Values.Count;
var data = container.Values.Get(1);
// Act
TRepository.Remove(container, data);
// Assert
Assert.Equal(expected - 1, container.Values.Count);
}
Это тест на удаление уже существующего элемента. Он работает, и мутация выжила. Рефакторим.
public void Remove(SomeType<T> container, T data) {
if (data == null) return;
var deleteT = container.FirstOrDefault(c => c.SomeField == data.SomeField);
if (deleteT == null) return;
container.Values.Remove(deleteT); // очень много связанной логики и т.п.
}
[Fact]
public void Remove_Exist_Removed() {
// Arrange
var expected = container.Values.Count;
var data = container.Values.Get(1);
// Act
TRepository.Remove(container, data);
// Assert
Assert.Equal(expected - 1, container.Values.Count);
}
[Fact]
public void Remove_NotExist_NotRemoved() {
// Arrange
var expected = container.Values.Count;
var data = new Value();
// Act
TRepository.Remove(container, data);
// Assert
Assert.Equal(expected, container.Values.Count);
}
Осталось только немного изменить тест с существующим элементом
public void Remove(SomeType<T> container, T data) {
if (data == null) return;
var deleteT = container.FirstOrDefault(c => c.SomeField == data.SomeField);
if (deleteT == null) return;
container.Values.Remove(deleteT); // очень много связанной логики и т.п.
}
[Fact]
public void Remove_Exist_Removed() {
// Arrange
var expected = container.Values.Count;
var someField = container.Values.Get(1).SomeField;
var data = new Value(someField);
// Act
TRepository.Remove(container, data);
// Assert
Assert.Equal(expected - 1, container.Values.Count);
}
Все, мы убили мутации, отрефакторили приложение и все хорошо? Или нет?Нам добавилась еще одна мутация эквивалентная. Какая? SingleOrDefault вместо FirstOrDefault.
Мы совсем не подумали, что бы будем делать, если у нас в контейнере несколько объектов на удаление. Рефакторим.
public void Remove(SomeType<T> container, T data) {
if (data == null) return;
var deleteT = container.SingleOrDefault(c => c.SomeField == data.SomeField);
if (deleteT == null) return;
container.Values.Remove(deleteT); // очень много связанной логики и т.п.
}
[Fact]
public void Remove_ExistDouble_ThrowsException() {
// Arrange
var expected = container.Values.Count;
var someField = container.Values.Get(1).SomeField;
var data = new Value(someField);
// Act
Assert.Throws<InvalidOperationException>(() => _TRepository.Remove(container, data));
// Assert
Assert.Equal(expected, container.Values.Count);
}
Теперь все отлично. А могли просто удалить строку, чтобы избавится от эквивалентной мутации.
В идеале работа с каждой эквивалентной мутацией заканчивается рефакторингом кода и выявлением неочевидных с точки зрения логики мест в коде.
Значительно сложнее мутации в коде, результаты работы которого вы не можете проверить напрямую, так как ваши методы не являются чистыми или присутствует очень много каскадирования по стеку и результаты выполнения многократно трансформируются.
Как пример приведу алгоритмы, основанные на генерации данных.
У вас есть один алгоритм генерации данных, и второй - алгоритм проверки данных на валидность.
Вы генерируете данные, например номера банковских карт или карт лояльности, потом проверяете их своим же валидатором.
Это неверно с точки зрения тестов, так как юнит тестирование призывает вас тестировать модули изолированно друг от друга.
Кол-во мутаций в таких алгоритмах может быть велико. И в большинстве вы ничего не сможете с этим поделать.
Эпилог
Мы рассмотрели основные мутации, которые встречаются в коде, способы рефакторинга кода и тестов для избавления от выживших мутаций.
Продолжение о применении в .NETCore в следующей части...
Ну а если вы любите больше смотреть, чем читать - версия конца 2019 года