
В прошлой части мы разобрали базис тестового фреймворка Expecto. В этой рассмотрим основные подходы написания тестов в контексте Expecto и постепенно перейдём к обобщённым преимуществам-следствиям концепции “тест-объект”. Часть выводов по ходу статьи могут быть полезны и не F#-истам. Однако, как говорилось в первой части, изначально это был монолитный текст, что был разделён почти механически, и я не берусь оценить усвояемость данного материала в отрыве от первой части.
Тест как результат вычисления
Теперь обратим внимание на преимущества генерации тестов в рантайме. В оригинальном руководстве Expecto с самого начала подчёркивается, что Tests can be composed, reduced, filtered, repeated and passed as values, because they are values. Проблема в том, что люди, прочитав сей гимн, к собственному сожалению надолго забывают об этих возможностях. Так что я приведу наиболее очевидные приёмы, существование которых, было бы невозможно при генерации тестов строго в CompileTime.
Разные тесты на одинаковых данных
В Expecto для этих целей завезли (тут можно не вникать):
-
testParam : (param : ‘a) -> (string * (‘a -> unit -> unit)) seq -> Test seq
-
testFixture : (‘a -> unit -> unit) -> (string * ‘a) seq -> Test seq
Каждому дали по микроразделу в документации, ибо без пол-литра не разберёшь. С моей точки зрения, данные функции являются рудиментом от классических фреймворков, и о их существовании надо вовсе забыть. Мне не ясно, как это:
// Пример из ReadMe.md. testList "numberology 101" ( testParam 1333 [ "First sample", fun value () -> Expect.equal value 1333 "Should be expected value" "Second sample", fun value () -> Expect.isLessThan value 1444 "Should be less than" ] |> List.ofSeq)
Может быть понятнее этого:
testList "numerology 101" [ let value = 1333 testCase "First sample" ^ fun () -> Expect.equal value 1333 "Should be expected value" testCase "Second sample" ^ fun () -> Expect.isLessThan value 1444 "Should be less than" ]
Существование testFixture оправдывают работой с IDisposable. Предполагается, что это замена SetUp и TearDown.
testList "testFixture sample" [ let withMemoryStream f () = use ms = new MemoryStream() f ms yield! testFixture withMemoryStream [ "can read", fun ms -> ms.CanRead |> Expect.isTrue "" "can write", fun ms -> ms.CanWrite |> Expect.isTrue "" ] ]
Но самолепное решение всё равно читаемее:
testList "instead of testFixture" [ let withMemoryStream (name, f) = testCase name ^ fun () -> use ms = new MemoryStream() f ms yield! Seq.map withMemoryStream [ "can read", fun ms -> ms.CanRead |> Expect.isTrue "" "can write", fun ms -> ms.CanWrite |> Expect.isTrue "" ] ]
По итогу, я бы напрочь убрал упоминание testFixture и testParam из руководства, ибо пользы они не приносят, а новичков сбивают. За все годы практики я использовал их лишь на этапе знакомства.
Одинаковые тесты на разных данных
Из-за Property Based Testing, данный кейс находится в тени, однако одним лишь PBT не исчерпывается. В @»{Prefix}Unit» существует несколько вариантов решения данной проблемы разной степени паршивости. Проблемы возникают из-за необходимости выразить динамическую генерацию тестов в статических структурах на уровне типов. Это забавно, т.к. C# после F# воспринимается как язык с очень слабой защитой на этапе компиляции. Здесь же мы видим попытку C# засунуть в компайл тайм то, что в F# полностью оставили в рантайме.
В Expecto для этого даже функций не завезли. Всё решается имеющимися средствами языка.
testList "test collection" [ for item in 0..42 do testCase (sprintf "%i squared >= %i * 2" item item) ^ fun () -> Expect.isGreaterThanOrEqual "" (pown item 2, item * 2) ]
Можно генерировать по 2 и более теста за раз.
testList "2 tests by item" [ for item in 0..42 do testCase (sprintf "%i squared >= %i * 2" item item) ^ fun () -> Expect.isGreaterThanOrEqual "" (pown item 2, item * 2) testCase (sprintf "%i cubed >= %i * 3" item item) ^ fun () -> Expect.isGreaterThanOrEqual "" (pown item 3, item * 3) ]
А ещё, можно (и нужно) генерировать сразу по поддереву на каждую посылку.
testList "test tree by item" [ for item in 0..42 do testList (sprintf "%i" item) [ testCase "squared >= x * 2" ^ fun () -> Expect.isGreaterThanOrEqual "" (pown item 2, item * 2) testCase "cubed >= x * 3" ^ fun () -> Expect.isGreaterThanOrEqual "" (pown item 3, item * 3) ] ]
Генерация тестов на основе внешних данных
До этого все данные для тестов мы вводили сами. Но ничто не мешает извлекать их из внешних источников. Это могут быть .json, .yml, .csv и т.д. файлы с данными от оракула.
testList "customer samples" [ let calc = PressureCalculator() for row in csv.Load(Files.customerSamples).Rows do testCase row.Id ^ fun () -> calc.Eval row.Input |> Expect.equal row.Notes row.Output ]
Формат файлов на самом деле не важен, т.к. чтением управляете тоже вы. Так что можно валидировать уровни mipmap-ов, чётность графов, звуковые дорожки и т.д.
Фильтрация тестов в зависимости от окружения
В большинстве своём я разрабатываю GUI. В половине случаев работаю с внешними устройствами, наличие которых в момент тестирования зависит не от меня. Даже если я очень захочу (нет), никто не притаранит шкаф АСУ ТП ко мне домой. Всё это даёт довольно серьёзный разброс по допустимым к исполнению тестам. При этом у нас нет желания излишне распиливать тесты по проектам или подвязываться на директивы компилятора.
Есть 3 способа не упасть на тесте, когда его прогон невозможен физически.
-
Уронить тест при помощи функции skiptest в теле теста. В этом случае Expecto отловит исключение особым образом, и тест будет переведён в категорию Ignored. Способ уклонения должен быть знаком по другим фреймворкам.
-
Перевести неудобные тесты в категорию Pending на основе предикатов. Требует доступ к условию на этапе генерации теста, но всё также зажёвывает коннекшены и другие ресурсы, что были созданы попутно.
-
Вообще не генерировать неудобные поддеревья. Наиболее безопасный способ, т.к. позволяет почти не цеплять сайд-эффекты.
Приоритет получается обратный:
-
Не создаём тест, если знаем, что он не может быть выполнен.
-
Переводим в Pending, если всё-таки создали.
-
Отключаем тест в процессе выполнения, если всё-таки запустили.
Вариантов принятия решения может быть масса. В большинстве своём, должно хватить обычного if. Но будьте готовы к тому, что у вас может возникнуть очень увесистая подсистема выбора.
Условно стандартный разбор аргументов командной строки
Я видел слишком много странных попыток подружить кастомную конфигурацию с конфигурацией Expecto. Если возникла такая необходимость, то действуйте по аналогии с CLI dotnet-а (н-р. dotnet fsi). Разделите все входные аргументы по —. То, что идёт до разделителя, отправляйте в Expecto. Остальное разбирайте самостоятельно.
[<EntryPoint>] let main args = let expectoArgs = args |> Array.takeWhile (fun p -> p <> "--") let configuration = args |> Array.skipWhile (fun p -> p <> "--") |> Configuration.parse testList "sample project" [ if not configuration.SkipHeavy then analyzesTests () parserTests () if configuration.TestUI then pagesTests () for port in configuration.ComPorts do Diagnostics.tests port.Baudrate port.Name // ... ] |> runTestsWithCLIArgs [] expectoArgs
Например, следующая строка:
dotnet run -- --fail-on-focused-tests -- --ui --device COM6
-
Потребует от Expecto упасть, если обнаружится тест под фокусом.
-
Потребует от нашего проекта протестировать UI и устройство на 6-ом COM-порту.
Этого хватает, чтобы одним проектом без приключения пользовались:
-
Я, с полным доступом ко всему.
-
Человек, что живёт так далеко, что никогда не увидит экземпляр устройства вживую.
-
Обитатель линукса, а также CI, у которых есть проблемы с запуском UI.
-
Разраб со стороны заказчика, что узнал о существовании F# лишь после заключения контракта.
Тонкости параллельного взаимодействия
По умолчанию тесты в Expecto запускаются параллельно, по одному активному тесту на каждое существующее ядро. Часто возникает необходимость эксклюзивного владения ресурсами, чтобы несколько тестов не пытались дёргать один и тот же объект одновременно. Для этого в Expecto ввели категорию sequenced тестов.
Любое дерево тестов можно пометить как sequenced при помощи:
-
testSequenced : Test -> Test
-
testSequencedGroup : (lockKey : string) -> Test -> Test
В первом случае, все конечные тесты дерева будут запущены строго в порядке объявления, не более одного параллельно. Во втором, всё то же самое, плюс дерево не будет пересекаться с другими деревьями с аналогичным ключом. Обратите внимание, что в обоих случаях, никто не даёт гарантий, что параллельно или между последовательными тестами не будут запускаться тесты попроще.
let tests comPort = testList "samples" [ testList "свободные тесты" [ testCase "тест зависимый от Com-порта" ^ fun () -> // ... |> testSequencedGroup comPort.Name ] testList "ещё коллекция тестов зависимая от Com-порта" // ... |> testSequencedGroup comPort.Name ]
Последовательное исполнение можно также использовать в качестве на коленке слепленой user story. На случай, если сценарий напрашивается, но желания ставить полноценный фреймворк нет.
Property Based Testing
Если вы не знакомы с данной темой, то вам лучше обратиться хотя бы к соответствующему разделу в доках Expecto (там же даны ссылки на ресурсы по теме). Я же хочу ограничиться лишь общим описанием процесса в контексте Expecto для тех, кто конкретно нацелен на данную методику.
Тестированию свойств в Expecto уделяют так много внимания, что некоторые настройки FsCheck были запечатаны аж в TestCode. Несмотря на эти огрызки, использовать FsCheck можно будет лишь после подключения Expecto.FsCheck.
В общих чертах, тестирование свойств идёт по следующему плану:
-
Бомбим код тестами со случайными данными.
-
Иногда находим контрпримеры.
-
Фиксируем их.
-
Правим код, пока все зафиксированные ошибки не будут исправлены.
-
Избавляемся от фиксаций.
Вы пишете тесты при помощи семейства функций testProperty{Suffix} (их там много, очень).
testProperty "some property" ^ fun v -> if v = 42 then Tests.failtestNoStack "oops"
При обнаружении ошибки, Expecto выводит её на экран, а также выдаёт семя, которое породило входные данные для ошибки:
Failed after 83 tests. Parameters: 42 Result: ... // детали ошибки Focus on error: // семя - два числа для инициализации генератора случайных чисел. etestProperty (1416359469, 297113847) "some property" [Expecto]
В 3 из 5 случаев контрпримера достаточно, чтобы однозначно исправить код. Но если не повезло, последняя строка – это готовый код для вставки в проект.
etestProperty (1416359469, 297113847) "some property" ^ fun v -> if v = 42 then Tests.failtestNoStack "oops"
Тест по-прежнему будет гонять случайные входные данные. Но сверх этого, он при каждом запуске будет проверять семя, а, соответственно, и посылку, породившую ошибку. Вначале этот момент может показаться излишне абстрактным, особенно, когда вы можете воспроизвести данные для ошибки и поместить их в отдельный тест. Но etestProperty незаменим, когда объём входных данных начинает требовать нечеловеческих усилий.
С моей точки зрения, API не хватает варианта для целого списка гнилых семян. Ибо несколько раз доводилось сталкиваться с кодом, который после “исправления” мог переварить 5 из 6 контрпримеров и бесчисленное количество случайных посылок. Но если приспичит, можно дополнить API самостоятельно.
Должен оговориться, что я очень плотно сижу на PBT, однако вместо FsCheck использую Hedgehog. API Expecto.FsCheck я считаю крайне неудачным, что было исправлено мною в приватном пакете для Hedgehog, благо готовых связок между ним и Expecto всё равно нет.
За годы использования FsCheck мне так и не удалось изящно подружить его Arbitrary и тесты. Поэтому если я когда-то и буду освещать эту тему, то в отдельном тексте, не здесь.
Тест как субъект
Несмотря на многообразие способов объявления тестов, всё это время этап генерации строго предшествовал запуску. Т.е. как и в случае с @»{Prefix}Unit», список тестов фиксировался до его запуска. Однако, как мы видели в случае PBT, результат теста может создать предпосылки к модификации или созданию нового. В случае Expecto это можно автоматизировать.
Амбарные технологии
К сожалению, testProperty не даёт способов быстро извлечь данные семени или контрпримеры. Так что здесь придётся поколдовать самостоятельно, либо через парсинг сообщения, либо через замену testProperty на собственную функцию. Семя можно сохранить в локальные файлы / базу, после чего при следующем запуске обнаружить его на диске и загрузить в FsCheck. То есть:
let factory = match badSeedFromStorage with | None -> testProperty | Some seed -> etestProperty seed factory "title" ^ fun v -> ()
На практике всё сильно сложнее, но для конечного пользователя выглядит ещё проще:
granary.On("8bb88e0c-2db7-4cf8-a1e3-6b61a6ce9910").check "title" ^ fun v -> ()
Дальше начинаются тонкости дискуссионного характера. Типа опций отключения сохранения / загрузки / очистки в CLI и т. д.
Тесты создают тесты
Кроме проверки конкретной посылки, бывает полезно исследовать подозрительные входные данные на специальном наборе тестов. Такое случается при работе с чёрными ящиками, когда вы используете библиотеки, написанные на стороне заказчика и внезапно обнаруживаете подозрительное поведение в одном из тестов. Если либа обкатана, то скорее всего проблема в некорректном понимании бизнес логики с вашей стороны, а значит особый экземпляр будет особо действовать сразу в нескольких тестах. Чем раньше и на большем числе тестов вы это отловите, тем лучше. Тот же принцип мы применяем, когда распихиваем ассерты по разным тестам, а не складируем в одном месте.
Для этого, необходимо создать тесты в рантайме, а потом запустить их после основного списка. Следует обратить внимание на метод main, который запускает программу. Здесь мы настраиваем и запускаем тесты через runTests{Suffix}. То, что при первом знакомстве могло вызвать отторжение своей дремучестью, на деле оказывается точкой дальнейшего развития. Дело в том, что runTests можно вызвать несколько раз на всё новых и новых наборах тестов. Если забыть про параллельность и прочие мелочи бытия, то можно выразить идею следующим кодом:
let generatedTests = ResizeArray() [<EntryPoint>] let main args = List.reduce (|||) [ testList "classic" [ testCase "generator" ^ fun () -> if v % 42 = 0 then testCase "generated" ^ fun () -> v |> Expect.notEqual "" 42 |> generatedTests.Add Tests.failtestNoStackf "%i mod 42 = 0" v ] |> runTestsWithCLIArgs [] args while generatedTests.Count > 0 do let tests = List.ofSeq generatedTests generatedTests.Clear() tests |> testList "generated tests" |> runTestsWithCLIArgs [] args ]
На практике всё это скрывается под очень толстым слоем инфраструктуры. И конечный разработчик будет описывать данную связь приблизительно так:
type Sample with static member checkup (factory : unit -> Sample) = testList "checkup" [] testPipeline .InitProperty("name", fun (sample : Sample, arg) -> // .. ) .TestAfterFailWith(fun factory -> Sample.checkup ^ fun () -> fst ^ factory () ) .AsTest()
Абсолютная власть

В какой-то момент запуск теста перестанет восприниматься как точка в конце пути и станет одним из шагов исполнения кода. Вокруг него начнут образовываться нетривиальные структуры и сценарии заточенные под конкретного разраба, проект и предметную область. С их развитием возникнут новые проблемы управления, лежащие далеко за пределами освещённых тем. Импорт и экспорт проблемных кейсов, бесконечный прогон property-тестов, графики фактического распределения, повторные прогоны, фильтры случайных данных, тесты по локальной сети и т.д. Опираясь на свои навыки, эти проблемы я решал через создание специфичных UI-решений на базе WPF, Avalonia или LXUI.
Во что это выльется у вас, я понятия не имею, но дам несколько советов.
-
Не пытайтесь вместить ваши тесты в Expecto. Это технология последней мили, а не абстракция на все случаи жизни. В первую очередь она нужна для прогона тестов, во вторую – для взаимодействия с CI. Всё остальное лежит за пределами её ответственности. Если какие-то ваши тесты могут быть снабжены дополнительной информацией, сформируйте соответствующую структуру, в идеале без привязки к тестовым фреймворкам (за исключением ассертов). И лишь во время исполнения редуцируйте их в категориях Expecto. Если что-то не влезло в тест прямо сейчас, это не значит, что так будет всегда. Разрабы могут сохранять информацию о подозрительном поведении. Предметник может накатать пару страниц текста, чтобы объяснить какой-то тест. Лично я предпочту иметь эту информацию максимально близко к тесту. Отдельным .md или string-ой в исходниках — дело десятое, главное, чтобы потом это можно было дотащить до UI.
-
Не пытайтесь запилить собственный Expecto (если вы F#-ист). Я мог бы костерить его часами, как и любую технологию, с которой провёл много времени. Но в отличие от большинства фреймворков, от которых я таки отказался, Expecto почти не пытается лезть за пределы своей сферы ответственности (исключая связки с FsCheck, там всё плохо). Это очень в духе F#. Явно обозначать границу ответственности, знания и незнания. Только так и можно безболезненно строить действительно крупные системы. Я допускаю, что на каком-то этапе, у вас наряду с исконными будут существовать собственные структуры тестов, собственные раннеры и т.д. Но универсальность данного решения если и будет выше, то ненамного.
-
Не пытайтесь полностью отказаться от консольного проекта на базе Expecto. Убер-комбайн с прицелом на человека – это очень хорошо. Но дружить его с CI будет сильно дороже, чем адаптировать генерацию тестов под различные варианты старта. Смиритесь с параллельным существованием двух проектов на одну тему. (Кто на кого будет (если) ссылаться — вопрос дискуссионный, но исторически у меня UI ссылается на консоль.)
Абсолютная власть VS Бюрократия

Я пытался построить данный текст как последовательный переход от привычных практик до полностью кастомных решений. В зависимости от ваших страхов и возможностей, вы вольны выбрать, где остановиться и когда продолжить движение вперёд.
Здесь я не буду давать советов, о том, как протаскивать Expecto (и саму концепцию тестов-объектов) в крупных коллективах. Не имею подобного опыта и, с учётом уклонения от крупных компаний, вряд ли приобрету.
Для меня F# это в первую очередь фундамент для one (two/three/..) man army. Возможность малой командой сделать то, что, будучи на C# я бы никогда не сделал. Если вы придерживаетесь сходных взглядов и обитаете в сходных условиях, вам стоит обратить внимание на тесты, как на одну из самых недоразвитых сфер. C#-исты могут объехать вас только при помощи обкатанных технологий, но вы всегда можете сравняться с ними, написав адаптер. В области тестов у них объективные недоработки, а значит, со временем вы можете сконцентировать у себя подавляющий объём информации о системе. В конце концов, для большинства оперившихся F#-истов, вопрос о языке разработки, это не вопрос простого удобства, а вопрос власти.
Послесловие
Если оставить за скобками знакомство с Expecto, то основной посыл данной статьи сводится к следующему: Не надо воспринимать тесты как нечто, находящееся за пределами кода.
Сложно отвечать за всех, но как минимум из моего закутка, я усматриваю странную тенденцию. В какой-то момент, в нашей индустрии произошла ненужная сакрализация тестов. Тестовые проекты пишутся по шаблону, тесты втискиваются в идентичные структуры независимо от пригодности последних к предметной области.
Мне понятно стремление подстроиться под CI. Но это прокрустово ложе ограничивает эффективность тестирования. Если вы третий парень у девятого весла на второй палубе по правому борту, то рецептов исправления ситуации я не вижу.
Но если вы работаете сравнительно малой командой, со специфичным твд, то используйте специфичные решения, в дополнение к традиционным тестам. Часто мне надо сделать пристрой к активно существующему решению. Я несколько раз сталкивался с ситуацией, когда у местных разрабов есть проект с каноничными тестами, и есть решение-огузок для тестирования того, что не влезло в первый проект. При этом без второго решения, часть функций системы может быть недостижима. Повезёт, если гадкого утёнка мне покажут сразу, а не через пару недель безуспешных боёв. Его прячут не потому что это военная тайна, а потому что он настолько не вписывается в привычную классификацию проектов, что разрабы готовы рискнуть, в надежде, что я проскачу (нет). Это оптимистичный сценарий. В худшем же случае, у меня будет по решению от каждого разраба в команде “противника”, а руководства по развёртыванию придётся добывать по личкам.
В F# широко распространены скрипты. Они не позиционируются как полноценные проекты. Поэтому с одной стороны, за ними не особо ухаживают, с другой, в них позволяется делать то, что нельзя делать в привычных проектах. Подобная вульгаризация создаёт предпосылки для бесшовной интеграции “нетрадиционных” тестов в “традиционную” разработку.
C# этой лесенки лишён. Водораздел ширится. И в силу массивности C#, инерция мышления протекает в F#. Как её давить в корне, я не знаю. Но F#-исты хотя бы доступны для перековки.
Автор статьи @kleidemos
НЛО прилетело и оставило здесь промокод для читателей нашего блога:
— 15% на все тарифы VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.
ссылка на оригинал статьи https://habr.com/ru/company/first/blog/706594/
Добавить комментарий