Skip to content

Instantly share code, notes, and snippets.

@fesor
Created August 19, 2017 08:45
Show Gist options
  • Star 28 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save fesor/db60b4995880925b720be9c7cf75543f to your computer and use it in GitHub Desktop.
Save fesor/db60b4995880925b720be9c7cf75543f to your computer and use it in GitHub Desktop.
Возвращаясь к основам: почему юнит тесты это сложно

https://simpleprogrammer.com/2010/12/12/back-to-basics-why-unit-testing-is-hard/

Back to Basics: Why Unit Testing is Hard

перевод адаптирован под 2017-ый, где у нас нет проблем с mock фреймворками, и нам не обязательно создавать интерфейс дабы сделать мок.

Возвращаясь к основам: почему юнит тесты это сложно

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

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

В конце концов, если бы писать юнит тесты было бы легко, мы бы наверное не задавались бы вопросом "а стоит ли оно того". Именно по этому стоит для начала разобраться почему это сложно и что именно все усложняет.

Идеальный сценарий

Юнит тесты сами по себе штука довольно простая, если вы знаете как их писать. Мы можем даже в легкую овладеть TDD или BDD... во всяком случае для идеального сценария.

Что же из себя представляет этот самый идеальный сценарий?

Это юнит тест для класса, у которого нет никаких внешних зависимостей.

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

Рассмотрим пример. Предположим что у нас есть класс Calculator. У этого калькулятора вполне себе простые методы. Например, давайте попробуем протестировать метод Add. Этот метод будет принимать на вход два целых однозначных числа. Если нам на вход будут подаваться числа с более чем одним знаком (например 10), то этот метод будет кидать нам исключение.

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

Мы можем написать подобное с использованием TDD или BDD с минимальными трудозатратами.

Попробуем составить тест кейсы:

  • Когда я складываю 0 и однозначное целое число, метод должен вернуть однозначное целое число
  • Когда я складываю 0 и 0, метод должен вернуть 0
  • Когда я складываю два однозначных целых числа, метод должен вернуть сумму этих чисел
  • Когда один из аргументов является числом с более чем одним знаком, метод кидает исключение

Было довольно просто подобрать эти тест кейсы, не намного сложнее будет и заимплеменить их:

[Test]
public void ZeroAndANumber_IsANumber()
{
   var calculator = new Calculator();
   var result =  calculator.Add(0, 5);
    
   Assert.AreEqual(5, result);
}

Теперь мы можем в легкую реализовать и код, который сделает эти тесты "зелеными". Это слишком простая штука потому я не стану приводить тут реализацию.

Это идеальный сценарий, или, назовем это Юнит тестом первого уровня

Юнит тестирование первого уровня, это когда у нас есть класс без внешних зависимостей и не зависящий от состояния. Тут мы проверяем алгоритмы.

Поднимаемся на ступеньку выше

Следующий уровень юнит тестирования будет достигнут, если мы добавим какое-либо состояние в наш тестируемый класс.

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

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

Посмотрите на один из тест кейсов, которые мы бы хотели реализовать

[Test]
public void When3AddOperationsThenGetHistory_ShouldReturnThose3Results()
{
   var calculator = new Calculator();
 
   // Arrange   
   calculator.Add(1, 3);
   calculator.Add(2, 5);
   calculator.Add(3, 6);
 
   // Act
   var result = calculator.GetHistory();
 
   // Assert
   Assert.Equal(4, result[0]);
   Assert.Equal(7, resut[1]);
   Assert.Equal(9, result[2]);
   Assert.Equal(3, result.Count);
}

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

Сложность состоит в том, что теперь нам нужно добавить дополнительный стэп "установки предусловий" перед тем как запустить непосредственно тест. В BDD мы имеем дело со специализированными стэпами которые помогают нам определить контекст для теста. В разных BDD кругах эти вещи называются по разному, потому давайте придержиться AAA (Arrangment-Act-Assert) так как это проще запомнить.

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

Вводим зависимости

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

Предположим что наш класс Calculator должен хранить историю всех вычислений. Мы сделали сервис, в который мы можем положить вычисления из метода Add в какое-либо хранилище, например в базу. И наш метод GetHistory теперь сможет запрашивать у этого хранилища историю.

Тут есть очень важный момент. Если бы мы писали интеграционные тесты, то наш имеющийся пример вообще не нужно было бы менять.

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

Давайте думать что теперь должен делать наш тест кейс. Вот небольшой пример того, как мы могли бы это провернуть:

  • Когда я складываю 2 числа, мне возвращается результат и должен быть вызван метод Store у нашего StorageService с этим результатом.
  • Когда я запрашиваю историю, мне возвращается результат вызова метода RetriaveHistory нашего StorageService.
[Test]
public void WhenAdding2NumbersAndServiceOnline_SumIsReturnedAndStored()
{
   // Arrange
   IStorageService storageServiceMock = Mocker.Mock <IStorageService>();
   storageServiceMock.Stub(service => service.IsServiceOnline())
          .Return(true);
 
    var calculator = new Calculator(storageServiceMock);   
 
   // Act
   var result = calculator.Add(3, 4);
 
   // Assert
   storageServiceMock.AssertWasCalled(service => service.Store(7);
   Assert.Equals(7, result);
}

Я называю это юнит тестом 3-его уровня.

Юнит тестирование третьего уровня, это когда у нас есть класс с какими-то внешними зависимостями, но его реализация не зависит от внутреннего состояния.

Теперь все стало уже намного сложнее, поскольку нам нужно думать не только о входах и выходах из функции, но еще и учитывать взаимодействие.

У нас начинает размываться представление о том, какие ожидания у этого юнит теста должны быть дабы проверить тест кейс. Скажем должны ли мы проверять вызывался ли метод isServiceOnline у StorageService или же мы можем убедиться что Store вызывался с нужными аргументами?

Если вы были внимательны, вы могли бы подумать что "пример плохой". Что у класса Calculator теперь более одной зоны ответственности.

  • Он считает штуки и возвращает результат
  • Он хранит результат вычислений

Вы правы, но мы не можем игнорировать эту проблему. Попробуем зарефакторить это дело и вынесем наш StorageService из класса Calculator. Тут есть несколько вариантов. Мы можем сделать декоратор:

var calculator = new Calculator();
var StoringCalculator = new StoringCalculator(calculator);

или мы можем реализовать паттерн медиатор:

var calcMediator = new CalculatorMediator(calculator, storageService);

Но как бы мы не пытались, у нас все-равно будет какой-то класс. который мы будем мокать.

Это довольно простой факт, который мы не можем обойти стороной. Если мы будем использовать StorageService для хранения результатов вычислений, то либо Calculator будет зависеть от него, или же от них обоих будет зависеть кто-то еще. Тут нет других вариантов.

Так что с юнит тестами третьего уровня мы застряли с необходимостью мокать хотя бы одну зависимость.

Дела становятся хуже

Теперь все станет еще хуже. На предыдущем уровне мы не заботились о состоянии нашего калькулятора. Все что нас волновало это внешние зависимости, которые хранили за нас это состояние. Однако довольно часто нам придется волноваться не только о зависимостях но и о состоянии.

Юнит тесты четвертого уровня, это когда наш тестируемый класс имеет хотя бы одну внешнюю зависимость и к тому же зависит от внутреннего состояния.

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

Посмотрим на возможный тест для этого сценария:

[Test]
public void When3AddsThenGetHistory_ShouldReturnOnlyThose3Results()
{
   // Arrange   
   IStorageService storageServiceMock = Mocker.Mock <IStorageService>();
   storageServiceMock.Stub(service => service.IsServiceOnline())
          .Return(true);
   storageServiceMock.Stub(service => service.GetHistorySession(1))
          .Return(new List <int>{4, 7, 9});
   var calculator = new Calculator(storageServiceMock);
 
 
   calculator.Add(1, 3);
   calculator.Add(2, 5);
   calculator.Add(3, 6);
 
   // Act
   var result = calculator.GetHistory();
 
   // Assert
   storageServiceMock.AssertWasCalled(service => 
              service.GetHistorySession(1);
   Assert.Equal(4, result[0]);
   Assert.Equal(7, resut[1]);
   Assert.Equal(9, result[2]);
   Assert.Equal(3, result.Count);
}

Заметте насколько наш юнит тест стал хрупким и сложным. И насколько простым является функциональность, которую мы хотим проверить.

У нас серьезная проблема. Наш код юнит теста намного сложнее кода, который мы им собираемся тестировать! Ничего страшного, если количество кода для тестов больше чем количество тестируемого кода, так оно обычно и бывает. Но вот когда юнит тест сложнее тестируемого кода, то тогда все уже не так радужно. Попробуйте задать себе один очень важный вопрос.

Где по вашему скорее всего будет баг?

Я не хочу ничего этим сказать

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

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

Уровень 3+ влечет за собой весьма высокую стоимость за счет использования моков и добавляет определенную сложность даже для самых тривиальных реализаций.

Может быть нам следует поступать мудрее? Если уж мы собираемся покрывать логику юнит тестами, нам следует стараться инкапсулировать как можно больше логики в отдельных классах без внешних зависимостей и, если это возможно, без состояния.

Другой момент на который стоит обратить внимание, это то как сложность возврастает с каждым уровнем. Так же с каждым уровнем теряется все больше и больше ценности тестов.

Я имею ввиду, что когда мы проверяем как наш класс взаимодействует с другим классом, мы переходим к проверке деталей имплементации этого класса.

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

В случае же если я замокаю наш StorageService и буду проверять вызвали ли мы метод Store, то я буду привязан к конкретной реализации. Если я попробую изменить то, как реализована функциональность, это сломает тест.

Пожалуй пока хватит. Если вы читали какие-либо из моих предыдущих постов то могли заметить определенную тенденцию. Я принижал важность использованием интерфейсов и инъекции зависимостей в угоду юнит тестам, но альтернативы я предложить не могу. И все еще не могу. Если честно, альтернативы у меня и нет. Но я уверен, что если мы попробуем проанализировать причины проблемы, то сможем решить что делать как только определимся с этими проблемами.

В концу цикла статей я думаю у нас будет какое-то решение и рекомендации для решения подобных проблем.

Что еще почитать:

@coyl
Copy link

coyl commented Oct 10, 2017

Вот этот последний тест, что он тестирует?
Я вижу, что главная его задача – протестировать, что внешний сервис дёрнулся правильное количество раз. Остальные ассерты не имеют смысла: проверять, что мок вернул именно три элемента – зачем? Допустим, мы уверены, что наш калькулятор должен отдать и не поменять ничего в том, что отдало хранилище. Но тогда почему нельзя просто вынести результат мока в переменную и потом сравнить с тем, что отдал калькулятор? Я думаю, можно.
Я вижу, что тут нарочно усложнена читаемость кода, чтобы нагнетать недовольство читателя.
Я не уверен, что соблюдаю все правила языка, сам пишу не на C#, но вот как бы я уменьшил этот код с 26 строк до 16

public void testStorage()
{
   // Arrange   
   var list = new List <int>{4, 7, 9};
   IStorageService storageMock = Mocker.Mock <IStorageService>();
   storageMock.Stub(service => service.IsServiceOnline()).Return(true);
   storageMock.Stub(service => service.GetHistorySession(1)).Return(list);
   var calculator = new Calculator(storageMock);

   // Act
   var result = calculator.GetHistory();
   
   // Assert
   storageMock.AssertWasCalled(service => service.GetHistorySession(1));
   Assert.Equal(list, result);
}

Этот юнит-тест надо оставить отдельно от остальных: тех, которые тестируют правильность сложения и прочее. Не надо писать один универсальный юнит-тест. Стоит писать много маленьких тест-кейсов, не лениться создавать для них отдельные методы.
А то, что описано в примере – это просто плохой юнит-тест. Тут полностью согласен с автором – не надо писать плохие юнит-тесты.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment