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

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

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

Модельно-ориентированное тестирование

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

Если вы хотите сразу увидеть тестовый код, перейдите к разделу «Написание модели» ниже. В разделах до этого будет рассказано о QuickCheck, обоснованиях использования тестирования свойств и общем мыслительном процессе, связанном с объединением моделей с тестированием свойств.

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

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

Скептицизм - это нормально

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

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

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

В нашем примере ниже мы будем использовать QuickCheck для генерации последовательности операций. Мы применим каждую операцию к нашей модели и нашей реализации. Когда мы замечаем расхождение между ними, наше свойство QuickCheck дает сбой. QuickCheck будет случайным образом удалять операции из последовательности, пока не найдет минимизированную подпоследовательность, которая по-прежнему приводит к расхождению в поведении нашей модели и реализации. Затем у нас есть новый регрессионный тест, который должен воспроизвести нашу проблему! Подарок от машины !!!

Что такое QuickCheck?

(Не стесняйтесь перейти к разделу «QuickCheck + Models = ❤», если вы уже знакомы с QuickCheck!)

QuickCheck - это популярная разновидность библиотеки тестирования свойств, с реализацией на многих языках. Тестирование свойств (также известное как генеративное тестирование) - это своего рода тестирование программного обеспечения, при котором вы пишете функцию, которая проверяет, выполняется ли данное свойство для случайно сгенерированных экземпляров аргументов этой функции. Например, можно написать свойство is_false(input: bool) -> bool, в котором мы просто возвращаем входное значение. Это свойство не сохранится, если вход false предоставлен в качестве аргумента. Более реалистичным может быть свойство, утверждающее, что код сериализации может преобразовать объект в JSON и обратно так, чтобы он выдавал значение, эквивалентное исходному вводу. QuickCheck предоставит случайные экземпляры объекта, который вы хотите сериализовать, и если deserialize(serialize(object)) != object, тогда наше свойство выйдет из строя, давая нам знать, что наша реализация не подходит для конкретного объекта, который QuickCheck предоставил в качестве аргумента.

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

Почему это полезно?

  1. По сути, мы создаем фаззер, резко увеличивая охват, который возможен с относительно небольшим количеством тестового кода.
  2. QuickCheck сгенерирует тестовые примеры, которые вы, возможно, никогда не подумали проверить в более традиционном модульном тесте. Но как только QuickCheck обнаружит неудачный вариант, вы можете скопировать и вставить введенные данные в тело нового регрессионного теста. Он пишет за вас ваши регрессионные тесты, а программа сжатия делает их простыми! Ваши новые регрессионные тесты, сгенерированные компьютером, детерминированы в той мере, в какой ваша реализация детерминирована.
  3. За счет случайной генерации более сложных входных данных наши тесты менее уязвимы для парадокса пестицидов, а более длительные прогоны тестов могут выявлять ошибки в течение длительного периода времени, заставляя ваших инженеров быть занятыми детерминированно воспроизводимыми (гораздо более простыми для исправления) ошибками.
  4. Сгенерированные тестовые примеры QuickCheck не обязательно являются детерминированными (посмотрите, как генератор случайных чисел подбирается для реализации, доступной для выбранных вами языков), поэтому мы можем в основном получить лучшее от детерминированного и недетерминированного тестирования, превратив неудачные входные данные в регрессию тесты. Это особенно удобно для входов, которые выходят из строя реже, поскольку QuickCheck по умолчанию может генерировать от нескольких десятков до нескольких тысяч входов. Это почти всегда настраивается, что особенно удобно при проведении более длительных тестов на обкатку.
  5. Поскольку мы, разработчики, не придумываем конкретные тестовые примеры, наши предубеждения не так сильно влияют на качество тестирования. На машину влияют только смещения наших генераторов, что обычно является значительным улучшением. QuickCheck часто генерирует тестовые примеры, которые меня удивляют, как с точки зрения тонкости обнаруженных ошибок, так и с точки зрения, казалось бы, волшебной способности свести неудачный случай к чему-то, что я могу легко решить, чтобы исправить ошибку.

QuickCheck + Модели = ❤

Итак, как применить QuickCheck к большой неприятной системе? Для меня это долгое время было непонятно. Некоторые люди, которых я уважал, хвалили его за обнаружение интересных ошибок в сложной системе, которую они создавали, и я хотел применить его к сервису, над которым я работал. Но было непонятно, как я могу применить его к чему-то более сложному, чем библиотеки сериализации. Для меня поворотным моментом стало то, что я начал работать в Erlang на работе и познакомился с тем, как люди часто проводят моделирование с отслеживанием состояния в экосистеме Erlang. Чтение статьи Джона Хьюза Опыт QuickCheck: тестирование сложных вещей и сохранение рассудка также открыло мне глаза, как и Тестирование монадического кода с помощью QuickCheck [предупреждение: постскриптум]. Этот пост, по сути, пытается представить методы из этих статей таким образом, чтобы люди, не входящие в сообщества Erlang и Haskell, могли легко их применить, даже если в их распоряжении нет таких крутых вещей, как PULSE.

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

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

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

Хватит болтать, вот рецепт! СДЕЛАЙ ЭТО СЕЙЧАС!

  1. Решите реализовать вещь.
  2. Создайте игрушечную модель того, как она должна работать.
  3. Напишите тест, подтверждающий идентичное поведение вашей реализации и вашей игрушечной модели, учитывая случайно сгенерированную последовательность операций, которая будет применяться к обоим.
  4. Постройте вещь. когда модель и реализация расходятся, ваши предположения опровергаются. Ваша реализация может быть неправильной, ваша модель может быть неправильной, или обе неверны.

Строим дерево

Пойдем! Построение простого дерева хорошо иллюстрирует эту технику. Я собираюсь использовать слегка отредактированный код, который Итан Фрей написал на днях, когда мы вместе изучали Rust. Вот упрощенный скелет Tree, который будет компилироваться на Rust 1.21.

Код для этой статьи также доступен по адресу https://github.com/spacejam/quickcheck-tut/.

src/lib.rs:

Если вы хотите следовать указаниям в текстовом редакторе, но раньше не использовали Rust, я рекомендую установить его, вставив этот совершенно безобидный сценарий оболочки в свой терминал. Options - это значения, которые могут присутствовать, а могут и не присутствовать, например, хорошо типизированные null / nill / undefined. Boxes - это значения, которые находятся в куче и могут сохраняться после возврата созданной функцией, если функция их возвращает. Ord означает, что вы можете сравнивать порядок двух значений друг с другом, что полезно для перемещения и вставки элементов в деревья. <K, V> означает, что типы ключей и значений могут быть любыми, которые соответствуют другим требованиям (тип Key должен быть Orderable). #[derive(Default)] позволяет нам автоматически создавать конструктор, который создает для нас новый Tree.

Написание модели

Теперь напишем модель! Но как? Это интересная часть.

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

Для нашего случая использования двоичного дерева модель на самом деле довольно проста. Пишем дерево. В Rust есть хорошо протестированное дерево, встроенное в стандартную библиотеку BTreeMap, поэтому мы можем использовать его в качестве эталонной модели для проверки. Если бы мы писали базу данных, BTreeMap также мог бы быть полезным справочником для этого. Если у вас есть только служба с отслеживанием состояния, которая хранит данные, возможно, HashMap - это все, что вам нужно. Если вы тестируете кеш LRU, возможно, вы сможете смоделировать его с помощью Vec и просто выполнить сканирование O (n), когда вы хотите переместить элемент в конец кеша, к которому недавно осуществлялся доступ.

Цель здесь не в производительности в масштабе, а в том, чтобы с легкостью удерживать модель в памяти. Здесь можно делать O (N²), когда N гарантированно мало входными границами теста. Чем меньше мозговых циклов с вашей стороны, тем лучше. Пусть машина пострадает, если это означает, что вы сможете быстрее добраться до корня неожиданного поведения. Вам понадобится как можно больше свободных умственных способностей для отладки множества проблем, которые эти мощные тесты могут отправить нам.

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

Для начала давайте создадим вещь, которая представляет различные виды операций на нашем Tree:

enum Op {
    // our K and V are both of type u8  
    Insert(u8, u8),
    Get(u8),
}

Теперь давайте сгенерируем их случайную последовательность с помощью QuickCheck, а затем применим каждую операцию как к модели, так и к нашей реализации, возвращая false из функции свойства implementation_matches_model, когда они расходятся:

tests/quickcheck.rs:

и, не забывайте, Cargo.toml файл в корневом каталоге проекта, содержащий наши зависимости:

[package]           
name = "btree"      
version = "0.1.0"   
                   
[dev-dependencies]  
quickcheck = "0.6"  
rand = "0.4"

Вот как должна выглядеть наша общая структура каталогов:

λ tree                    
.                         
├── Cargo.toml            
├── src                   
│   └── lib.rs            
└── tests                 
    └── quickcheck.rs
2 directories, 3 files

Давайте посмотрим на эти тесты в действии!

λ cargo test                                                                                                     
    Finished dev [unoptimized + debuginfo] target(s) in 0.0 secs                                                 
     Running target/debug/deps/btree-9ef8f49ee1976a3b                                                            
                                                                                                                 
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Running target/debug/deps/quickcheck-dc970f46510fa339                                                       
                                                                                                                 
running 1 test
test implementation_matches_model ... FAILED                                                                     
                                                                                                                 
failures:
---- implementation_matches_model stdout ----
        thread 'implementation_matches_model' panicked at '[quickcheck] TEST FAILED. Arguments: ([Insert(0, 192), Get(0)])', /home/t/.cargo/registry/src/github.com-1ecc6299db9ec823/quickcheck-0.6.0/src/tester.rs:171:27
note: Run with `RUST_BACKTRACE=1` for a backtrace.
failures:
    implementation_matches_model
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out
error: test failed, to rerun pass '--test quickcheck'

Это много текста. Для меня самое интересное:

[quickcheck] TEST FAILED. Arguments: ([Insert(0, 192), Get(0)])

Это говорит нам о том, что после того, как мы insert значение как в реализации, так и в модели, get операции для одного и того же ключа, 0, разошлись. Наша BTreeMap модель вернула другой результат, чем наша реализация. По крайней мере, один из них должен ошибаться! Возможно, нам стоит реализовать эти заглушки: P Мы только что механически сгенерировали наш первый регрессионный тест, с которым мы можем работать, если захотим.

Все находятся под присмотром машин любящей благодати

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

Еще более удивительно то, что когда он находит последовательность из 100 операций, которые вызывают расхождение между вашей реализацией и вашей моделью, он сокращает эти 100 операций до минимального упорядоченного подмножества тех же самых операций, необходимых для возникновения расхождения, делая проблему далеко легче отлаживать. Из 100 операций в тесте, которые он запустил (и потерпел неудачу), он сузил их до конкретных 2, которые вызывают расхождение! Это достигается путем исключения определенных элементов последовательности и повторного запуска теста до тех пор, пока тест не пройдет успешно или пока не останется непроверенных элементов, которые нужно удалить. Реализации могут различаться, но должны примерно соответствовать этому поведению.

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

Подобные тесты - это подарки, которые просто продолжают приносить. Они не просто проверяют ошибки, которые вы можете себе представить. Работая со случайно сгенерированными тестовыми примерами, мы можем исключить из тестирования значительную часть человеческих предубеждений. Это может быть очень унизительно, когда вы впервые добавляете QuickCheck таким образом в модуль, который вы написали, и следующие несколько дней, недель или даже месяцев вы проводите, гоняясь за всеми ошибками, которые ему удается устранить, но вы, вероятно, будете выступите с другой стороны как более сильный инженер.

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

Вам бросили вызов !!!!

¡¡¡¡Если вы готовы принять вызов, я призываю вас пройти этот крошечный тест, реализовав методы get и insert Дерева !!!!

Если вы не потратите целый день на написание проверенных структур данных в Rust (вы все двое: P), я ожидаю, что этот тест обнаружит несколько удивительных недостатков в вашей реализации по мере продвижения! Я определенно не продвинулся далеко до того, как это произошло, несмотря на то, что мне казалось, что я довольно близко знаю деревья!

Работа с новыми абстракциями

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

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

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

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

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

У меня есть персональный органайзер с пользовательским интерфейсом терминала, который я тестирую только с одним тестом QuickCheck, который выдаёт случайные входные данные, а код завален утверждениями. В некотором смысле это похоже на использование фаззинга для поиска способов вызвать повреждение памяти. Я обнаружил, что хорошая модель QuickCheck может дать смехотворно высокий коэффициент test code:test coverage. Тест на основе одной модели может заменить большинство модульных тестов в стиле TDD с гораздо меньшим объемом кода и гораздо более высоким охватом. Это также подталкивает вас к расстановке приоритетов в качестве информации об ошибках, выводимой вашей системой, поскольку вы можете больше полагаться на нее, чтобы разобраться в интересных ошибках, обнаруженных машиной.

На этом все!

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

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

В следующей статье из серии Надежные системы я сосредоточусь на моделировании: мощном методе построения защищенных распределенных систем. Мы напишем реализацию CASPaxos, используя QuickCheck для генерации разделов и задержек, имитирующих реалистичные сетевые условия. Мы напишем свойство, которое утверждает, что клиенты соблюдают линеаризуемые истории. При этом система работает в ускоренном режиме, так что мы можем запустить на несколько порядков больше тестов за пару секунд (на одном ноутбуке), чем если бы мы развернули кластер Jepsen.

Большое спасибо Адаму Крейну, Петеру Коллоху, Матиасу Нельсену, Алексу Клеммеру, Дайи и Йошуа Вуйтсу за то, что они перенесли мою (даже более) болезненно несфокусированную раннюю версию и дали мне отзывы Это в конечном итоге привело меня к тому, что я разделил это на целую серию и Итан Фрей за то, что позволил мне использовать для этого код из нашего сеанса сопряжения Rust.