понедельник, 9 мая 2016 г.

TDD: Test-Driven vs. Type-Driven development

Боб «не люблю статическую типизацию» Мартин произвел отменный вброс в своем недавнем посте “Type Wars”, в котором он выразил мысль, что наличие TDD и 100%-е покрытие тестами вполне заменяет статическую типизацию:

“You don't need static type checking if you have 100% unit test coverage. And, as we have repeatedly seen, unit test coverage close to 100% can, and is, being achieved. What's more, the benefits of that achievement are enormous.”

После чего поднялась небольшая волна в этих ваших тырнетах, в результате даже Марк “Я-то точно знаю TDD” Сииман был обвинен в ереси и непонимании принципов, заложенных в TDD.

Совсем недавно я столкнулся с проблемой, которая проявлялась лишь во время выполнения, но которая могла бы быть решена на уровне системы типов. Вот приблизительный пример: нам нужно написать простую утилиту для логгирования (structured logging), которая бы принимала идентификатор события и определенные данные, связанные с ним. При этом, мы хотим, чтобы идентификатор был числовым и лежал в определенных границах, например, от 0 до 65535.

У нас есть несколько способов сделать это:

// Три варианта метода WriteEvent: допустимый диапазон для eventId [0, 65535]
public abstract class EventSource
{
   
protected abstract void WriteEvent(ushort eventId, params object
[] args);
   
protected abstract void WriteEvent(int eventId, params object
[] args);
   
protected abstract void WriteEvent(object eventId, params object[] args);
}

Данное решение будет строго типизированным, вопрос лишь в том, когда проверка типов будет осуществляться: во время компиляции или во время исполнения. С точки зрения реализации, разницы большой не будет. Мы покроем все тестами, и наши тесты будут падать во втором и третьем случае при передаче аргумента с неправильным значением. Но чем тесты реализации помогут в правильном использовании библиотеки? Правильно, ничем. Тогда, адепт TDD скажет, что перед началом работы с любой библиотекой нам нужно начать с набора тестов, которые покажут, как правильно ею пользоваться. Разумно, хотя далеко не все этому следуют (читай, почти никто так не делает), но остается такой вопрос: а как набор этот набор тестов гарантирует, что все клиенты, вызывающие метод WriteEvent будут передавать корректные данные? Тоже не ясно.

Данный пример навеян реальной проблемой, с которой я столкнулся при работе с ETW (Event Tracing For Windows). Идентификатор события в ETW должен быть от 0 до 65535, но при этом тип идентификатора – это int. В результате, когда я «случайно» стал использовать идентификаторы с большими значениями, то наши тесты начали падать (что хорошо), но я потратил пару часов на разбирательства, поскольку падали они довольно хитрым образом (что совсем не хорошо). После этого, инфраструктурный код был изменен таким образом, чтобы использование невавлидных идентификаторов приводило к ошибке времени компиляции.

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

Три кита корректности

Существует несколько способов выразить намерения и описать ожидаемое поведение системы:

  • Система типов
  • Контракты
  • Юнит-тесты

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

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

image

При этом, чем дальше от кода находится спецификация, тем сложнее ее обнаружить пользователю. Система типов бьёт по пальцам сразу же и читателю кода очевидно, что нужно передавать в этот метод. Контракты тоже увидеть довольно просто, хоть они и вряд ли будут доказаны статически. С тестами в виде спецификации все обстоит сложнее, поскольку у пользователя далеко не всегда есть доступ к юнит-тестам используемого компонента, а даже если он и есть, то вычленить нужную информацию оттуда не так просто.

Достоинства типов

Помимо корректности, у системы типов есть ряд других важных преимуществ (да, даже у такой слабой, как в C#/Java/C++):

Документирование кода

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

Иногда, можно встретить использование fluent-интерфейсов, которые просто за счет системы типов существенно упрощают использование библиотеки. Многие современные языки (Swift, Go, F#, Eiffel последние версии C# и TypeScript) умеют различать nullable и non-nullable типы, что существенно упрощает понимание семантики метода.

Мне всегда в этом плане импонировал C++, в котором сигнатура метода могла сказать гораздо больше о предполагаемом поведении, чем сигнатура метода в C#/Java: ага, метод константный, значит это «запрос», а не «команда» (помните о Command-Query Separation?). А вот этот метод принимает аргумент по константной ссылке, значит это обязательный входной параметр, а вот это константный указатель, значит это необязательный входной параметр.

В языках с более навороченными системами типов, типа того же F#, из сигнатуры метода можно вычленить еще больше информации, но я не готов жертвовать даже той, что у меня имеется в распоряжении в языках типа C#.

Улучшенные инструменты

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

Современные среды для JavaScript или Python хачат код и запускают его прямо в IDE, чтобы понять, объекты каких типов может принимать тот или иной метод. Это позволяет реализовать Intellisense, но некоторые вещи, типа Find All References, сделать все равно довольно сложно.

Реализация инструментов для языков со статической типизацией является более простой задачей, да и функционал обычно более богатый.

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

Производительность

Современные среды исполнения для JavaScript или Python добились невероятных успехов в плане производительности за счет количества оптимизаций, но все равно, многие оптимизации им недоступны за счет того, что структура объекта может поменяться со временем.

В качестве заключения

Мне кажется не честным и не логичным обсуждать виды типизации без надлежащего контекста. Есть сценарии, когда типы не нужны: bash script, веб-клей на JavaScript-е, тысяча строк кода на Python для сбора и анализа данных. Иногда просто нет смысла завязываться на типы, давать им имена и управлять зависимостями явно. (хотя проблема с именами – это проблема не статической типизации, а номинальной системы типов; статическая структурная типизация как в языках Go и TypeScript позволяет объявить метод, который принимает что-то с полями x и y, при этом имя этого типа может быть любым).

Но я здесь сравниваю не столько статическую vs динамическую систему типов, а то, насколько тесты покрывают задачи системы типов. И здесь я склонен к тому, что при масштабировании кодовой базы, система типов начинает играть весьма полезную роль при навигации по коду, и существенно упрощает его понимание при чтении.

Тесты полезны, но они находятся слишком далеко, их не всегда легко найти для этого метода и из них не всегда легко понять, какой же у него контракт. К тому же, тесты – это тоже код, который нужно поддерживать, а это значит, что если система типов позволит уменьшить количество тестов даже на 10%, то это существенно отразится на сопровождаемости системы.

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

Дополнительные ссылки

19 комментариев:

  1. Мне кажется нечестным обсуждать идею замены развитой системы типов на 100% покрытие тестами без обсуждения преимуществ этого самого 100% покрытия.

    >What's more, the benefits of that achievement are enormous.
    Если бы ты развернул этот тезис и потом противопоставил тому что ты написал про достоинства развитой системы типов - вот это было бы интересно. А так - описывать чего есть в типизации из того чего нету в 100% покрытии - только половина разговора, что как и с правдой, хуже чем отсутствие разговора.

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

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

      Удалить
    2. В сравнении, в качестве аргумента, можно представить, что в случае строгой типизации написанием и прогонкой части тестов занимается компилятор в момент компиляции.

      Удалить
    3. В сравнении, в качестве аргумента, можно представить, что в случае строгой типизации написанием и прогонкой части тестов занимается компилятор в момент компиляции.

      Удалить
    4. >А значит в строготипизированном языке та овчинка будет ближе к телу, а значит и выгоды будут наступать раньше.

      Это да. Но ты опять со своего старого ракурса аргументируешь. Ракурс же Боба, как я его понял, это мыслительный эксперимент - а что если язык не сильно типизирован. Как показывает реальность, строгая типизация, несмотря на все её прелести и достижения компьютерной науки по производству средств производства, не является доминантным признаком необходимым для выживания языка. Места Хаскеля и Яваскрипта в экосистеме недвусмысленно намекают. Поэтому, несмотря на умозрительность вброса Боба, на мой взгляд, он не особо далёк от нашей безумной реальности со своим сферическим вопросом в вакууме. Просто через пару лет, созерцая очередного мутанта непонятно как покорившего бОльшую часть индустрии, вспомни старину Боба витающего в облаках и вбрасывающего оттуда нам вниз безумные идеи. :)

      Удалить
  2. Я вот встречался с мнением, что 100% покрытие тестами — это скорее плохо, чем хорошо (
    http://blog.ploeh.dk/2015/11/16/code-coverage-is-a-useless-target-measure/ )
    поскольку оно легко становится самоцелью.

    Вообще, как много есть проектов со 100%-ным покрытием?

    ОтветитьУдалить
    Ответы
    1. Михаил, более того, я еще не встречал ни одного вменяемого человека, который бы считал, что у 100-го покрытия нет негативного эффекта:).

      Кажется об этом многие писали. И Сииман, и Фаулер и ряд других товарищей.

      Удалить
    2. Ну то есть получается, что идея Боба Мартина чисто умозрительная? (Не говоря уж о том, что разумный человек не будет отказываться от статической типизации, так еще и рассматриваемая ситуация существует только в сферическом вакууме?)

      Удалить
    3. Собственно, именно поэтому мне его высказывания, как в книгах, так и в постах и не нравятся. Они рассчитаны на какаую-то несуществующую вселенную, живущую по своим собственным законам. А поскольку он эти законы и вселенную не описывают, читатели наивно полагают, что речь идет о реальном мире.

      Удалить
    4. Вот у нас сейчас так: требуют 100% покрытие юнит тестами.
      С одной стороны люди тянутся и ну как минимум в среднем не ниже 90.
      С другой стороны времени на написание тестов = 100-120% от написания функционала.
      Изменение логики (даже небольшое) = взрыв мозга. как следствие начинают выдумывать локальные костылики, что бы где-то в базовых вещах не переделывать (ну чтоб тесты не ломались).

      Удалить
  3. Как-то вроде бы недавно спорили, а нужно ли вообще 100% покрытие :). А тут вон какая пьянка. 100% покрытие поддерживать архисложно. По-моему, был вброс на поспорить...

    ОтветитьУдалить
    Ответы
    1. Жень, ты знаешь, я правда не удивлюсь, если старина Боб и правда так думает:)

      Удалить
  4. К нам тут парниша приходил, дали ему тестовое задание (фу, какие мы бяки). Так вот, у него было 100% покрытие тестами. Когда я прогнал его решение через мои тесты, которые покрывают только основные сценарии, то 10% *моих* тестов упало.
    Что как бы намекает, что 100% покрытие и вообще тесты - вещь в себе.

    ОтветитьУдалить
    Ответы
    1. Вот именно!
      А сколько раз фиксились баги, которые каккуратненько были покрыты юнет-тестами..

      Удалить
    2. Ну, тесты решают как минимум две проблемы, а не одну-единственную, как думают многие "сто-покрывальщики". Первая: контракт использования, она помогает самому программисту понять какое API будет удобным при использовании его кода плюс заменяет документацию (в примитивном смысле). Вторая: не допустить возрождения уже пофикшеных багов, как показано у Stan Ku. В этом смысле, 100% покрытие в общем случае попросту невозможно, плюс это тесты уже не юнитовые, а зачастую интеграционные. Плюс, многие забывают про сценарии negative testing - т.е. подача неверных данных должна возвращать предсказуемую ошибку, а не проглатывать втихаря эксепшны и возвращать успешный (но и бессмысленный, а порой опасный) результат.

      Удалить
  5. 100% покрытие по строкам кода? или по логическим цепочкам? Или 100% покрытие логических цепочек со 100% верефикацией всех результатов.

    Как говориться строки можно покрыть все, но ничего не проверить.

    Так что Боб может и говорит, но...

    ОтветитьУдалить
  6. При всей моей любви к статическому типизированию, по-моему, с хорошей вероятностью программист который использует WriteEvent будет использовать int для идентификаторов (ну например потому, что арифметику с int использовать гораздо проще, чем с ushort). Он просто будет перед вызовом WriteEvent конвертировать int в ushort. И если случайно использовать идентификаторы с большими значениями - то найти подобную ошибку будет не проще чем оригинальную.

    ОтветитьУдалить
    Ответы
    1. Если по фен-шую, то тут надо не жонглировать интами, а завести отдельный тип, "Идентификатор события" и наколбасить типизированный enum с обозначениями возможных событий - тогда попытка воткнуть неверное значение будет невозможна без грубых хаков а-ля "суровый каст" (кстати, вот что мне не нравится в том же C++ - тайпдефы являются просто псевдонимами, для вывода самостоятельного и различимого компилятором типа приходится круговертить с шаблонами, то ли дело в Pascal - каждый тип индивидуально различим компилятором, есть типы-диапазоны и т.п.).

      Удалить