Сталкивались с тестами, которые толком ничего не тестируют? А тесты, которые что-то тестируют, но при этом всё равно постоянно возникают баги? Может быть, тесты, которые приходится каждый раз исправлять?


Несложно построить тест, обеспечивающий 100% покрытие, но при этом ничего не проверяющий и не гарантирующий. (См., например).


Проблемы юнит-тестов уже затрагивались на Хабре ранее:



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





Примеры бестолковых тестов


Тесты бывают разными. Часто предполагается, что чем больше теста, тем вкуснее, тестов, тем качественнее ПО. Следующие примеры заставляют задуматься, так ли это.


Тавтологичные тесты


Наблюдал в одном проекте вот такой код:


def config(): Config = 
  Config("a", 2)

def testConfig(): Unit =
  val cfg = config()
  assert(cfg.a == "a")
  assert(cfg.b == 2)

Коллеги убеждали меня, что такой тест полезен. И помогает увеличить покрытие кода.


Тесты ради покрытия


Иногда встречаются тесты, вызывающие код, но игнорирующие результат (есть даже видео, где Андрей Иванов рассказывает, как писать такие тесты).


def f(x: Int): Int = x + 1

def test(): Unit =
  f(0)

Покрытие, очевидно, будет на высоте...


Поверхностное тестирование


Похожее явление может возникнуть при тестировании веб-сервисов.


def test(): Unit =
  val request = ???
  val response = http.Get(request)
  assert(response.status == 200)

Если даже тест упадёт, то, несмотря на наличие assert'а, разработчику потребуется изрядная сноровка, чтобы понять, что на самом деле ожидалось и что пошло не так.


Тесты + флаги


Иногда в программе встречаются флаги и конструкции ветвления if-then-else. В особенно запущенных случаях, например, может получиться такой код:


def foo(a: Boolean, b: Boolean, c: Boolean) =
  val resA = if a then
    doa()
  else
    dona()

  val resB = if b then
    dob()
  else
    donb()

  val resC =  if c then
    doc()
  else
    donc()

  resA + resB + resC

(Не говоря даже о вложенных if-ах...)


Как же тестируется такой код? Это же очевидно:


def testFoo(): Unit =
  assert(foo(false, false, false)==1)
  assert(foo(false, false, true)==2)
  assert(foo(false, true, false)==3)
  assert(foo(false, true, true)==4)
  assert(foo(true, false, false)==5)
  assert(foo(true, false, true)==6)
  assert(foo(true, true, false)==7)
  assert(foo(true, true, true)==8)

Естественно, в реальном проекте это многократно растягивается.


Моки 1. "Алгоритм шиворот-навыворот"


Как оказалось, довольно популярная технология. (На Хабре есть заметки — Когда использовать mocks в юнит-тестировании, Моки и стабы, Я сомневался в юнит-тестах, но…, Юнит-тестирование для чайников )


class Foo:
  private var bar: Int = 0
  def inc(): Unit = 
    bar += 1
  def get: Int = bar

def alg(f: Foo): Int =
  f.inc()
  f.inc()
  f.get

Как же протестировать? Апологеты мок-технологии предлагают что-то наподобие следующего кода:


trait IFoo:
  def inc(): Unit
  def get: Int

def testAlg(): Unit =
  val mock = Magic[IFoo]()
  Magic.expectSequence{
    mock.inc()
    mock.inc()
  }.when{
    mock.get
  }.returnResult(2)
  assert(alg(mock) == 2)
  mock.check()

Внимательный читатель может заметить подозрительные параллели между кодом и тестом.


Моки 2. Тесты ради тестов


Когда создаются мок-объекты, предполагается, что есть некоторая логика помимо собственно мок-объекта, которую и требуется протестировать. Но встречается и такой код:


def foo(db: IDatabase): Int =
  db.ReadSomeValue()

def test(): Unit =
  val mock = Magic[IDatabase]()
  Magic.when{
    mock.ReadSomeValue()
  }.returnResult(10)
  assert(foo(mock) == 10)

Вроде как и тест имеется, и модные моки используются...


(Аналогичное наблюдение сделано здесь.)


Тесты — бухгалтеры


Если в веб-сервисе есть несколько отдельных путей, мы же можем их посчитать?


def routes(): List[Route] = ???

def test(): Unit =
  assert(routes().length == 10)

А то вдруг кто-то добавит новый? Пусть тогда пофиксит упавший тест...


Глубокое тестирование


В некоторых тестах просматривается недоверие разработчикам баз данных и тестируется логика работы собственно базы данных. Например,


def insertA(db: IDB, a: A): Unit =
  db.Insert(a)

def test(): Unit =
  val realDb = openTempDb()
  case class B()
  realDb.CreateTable[B]()
  realDb.Insert(B())
  val b = realDb.Read[B]()
  assert(b == B())

И это не опечатка, а так и задумано. Ну действительно, надо же проверить, а то вдруг БД не работает?..


(Справедливости ради стоит отметить, что при использовании нестабильных версий библиотек такие защитные тесты могут иметь право на существование, чтобы, например, предотвратить случайный апгрейд на сломанную версию библиотеки.)


Раздутые тесты


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


def doAandB(a: IA, b: IB) = ???

def testDoAandB(): Unit =
  val mockA
  val mockB
  doAandB(mockA, mockB)
  mockA.check()
  mockB.check()

Невинная функция, которая делает сразу два дела.



Ожидания от тестов


Представители бизнеса и менеджеры ожидают от тестов некоторых полезных свойств:


  1. Юнит-тесты должны помогать обнаруживать ошибки на более ранних этапах жизненного цикла (до запуска в эксплуатацию).
  2. Набор юнит-тестов должен помогать обеспечивать защиту ранее реализованных функций при рефакторинге. (Если функция полностью прекратит работу, то какой-то тест упадёт.)
  3. Написание юнит-тестов должно поощрять разработчиков делать код более модульным, тем самым косвенно улучшая его качество.
  4. Сами юнит-тесты должны служить документацией к API и помогать понять на рабочих примерах, как им пользоваться.

Вышеприведённые примеры тоже называются тестами. Но имеют ли они эти полезные свойства?


Заключение


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


Во второй части мы попробуем разобраться, как качество ПО связано с тестами и какие тесты имеют смысл с точки зрения обеспечения качества.


Вся серия заметок:


  1. Примеры тестов.
  2. Что делать?
    1. Качество ПО.
    2. "Прямолинейность" кода.
    3. Классификация ошибок.
    4. Эквивалентность функций.
    5. Применимость тестов.
  3. Исправление примеров.

Комментарии (0)