TLDR; применяй сразу тут и примеры запуска на github

Пролог

Эта статья является прообразом доклада на Dotnext 2019, где я рассказал о применении подхода мутационного тестирования в проектах на .NET Core.

Доклад вызвал неоднозначную реакцию, от "Зачем это необходимо?" до "Как я жил без этого раньше".

Для популяризации подхода я решил опубликовать прообраз доклада - черновики в нескольких частях. В конце вы найдете ссылку на видео, если просто хотите посмотреть доклад со слайдами.

В предыдущей статье мы рассмотрели первую часть - вводную в теорию мутационного анализа.

Теперь мы разберем сами мутации, которые могут быть применены к вашему коду.

О чем пойдет разговор?

Часть 1

  • тестирование и для чего применять еще один вид
  • про принцип мутационного тестирования и анализа

Часть 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 года