
Expecto — фреймворк для тестирования, написанный на F# и для F#. Он довольно хорошо известен в рамках F#-сообщества, и у разработчиков, сумевших отгородиться от C# в достаточной степени, используется как платформа для тестов по умолчанию. Новички в F#, а также мимо проходящие C#-еры, как правило, не обращают внимания на данный фреймворк до первого красного теста. А после знакомства впадают в лёгкий аналитический паралич. Ибо то, что со стороны выглядит как ещё один @"{Prefix}Unit" фреймворк для тестирования, на практике оказывается переосмыслением привычных практик.
В данной статье я попробую широкими мазками описать онтологический аппарат Expecto и показать наиболее естественный путь его подчинения. Это не рулбук, а одноразовое введение, которое я предпочёл бы видеть вместо (или до) существующего README.md в официальном репозитории. Также я постараюсь обойтись максимально локальными примерами кода, дабы текст можно было прочитать, не слезая с самоката.
Изначально это был монолитный текст, но формат и объём вынудили разделить его на две части. Вторая часть готова и выйдет через несколько дней, если по каким-то причинам не потребуется её дополнить. В первой части описана базовая составляющая Expecto, во второй – практика применения и возможные перспективы. За исключением данного абзаца и ещё пары на стыках, распиливание монолита на содержимом не сказалось.
Пара предупреждений о коде
Я адепт крышечки. Этот оператор экономит некоторое количество скобочек за счёт игр с приоритетом выполнения. Для того, чтобы код работал, вам потребуется определить:
let inline (^) f x = f x
В Expecto есть две версии модуля Expect, базовый неудобный и дополнительный адаптированный под пайпы. Активировать второй можно через открытие модуля Expecto.Flip. Ровно в таком порядке:
open Expecto open Expecto.Flip
Следующий код адаптирует консольный вывод под REPL, для тех, кто собирается использовать Expecto в интерактиве.
do Expecto.Logging.Global.initialise { Expecto.Logging.Global.defaultConfig with getLogger = fun name -> new Expecto.Logging.TextWriterTarget(name, Logging.LogLevel.Debug, System.Console.Out) }
Причины маргинальности Expecto
До бегства на F# я страдал на C#, и мою практику юнит-тестирования можно было описать следующий образом.
Создать проект, подключить нугет.
Создать класс для хранения тестов, снабдить атрибутом при необходимости.
Определить сетап для тестов, снабдить атрибутом.
Создать метод-тест с именем типа ShouldBeBrokenAfterNuke, снабдить атрибутом.
Собрать проект, открыть обозреватель тестов в VS, запустить тест(ы).
После переезда на F#, я делал всё то же самое, только в 3 пункте появилась возможность писать:
[<Test>] let ``Should be broken after nuke.`` () =
И тест-вьювер корректно отображал данное имя в списке тестов. Я был счастлив.
Конец статьи.
Трудно линейно проследить и описать мой личный путь к Expecto. Так что поставим себя на место меня 5-6 летней давности и представим, на что я бы обратил внимание, если бы увидел свой современный код:
Имена тестов даны строками. testCase "should be broken after nuke" ^ fun () -> ...
Сами тесты перестали быть методами.
Тесты лежат глубоко в пирамиде судьбы, а не как мемберы типов/модулей. Если потребуется достать их ручками, то лёгкого способа не предусмотрено.
Структура тестов теперь не 2-уровневая “Класс - метод”. А представляет из себя дерево произвольной глубины с неравными ветвями.
Атрибуты присутствуют только в корнях деревьев. Существуют проекты, где атрибуты вообще не используются.
Тесты запускаются через консоль. Стандартные обозреватели тестов не используются. Найти в команде человека, что готов помочь в пусконаладке обозревателя невозможно.
Запуск конкретного теста осуществляется через правку кода и повторным запуском через консоль.
И т. д. В зависимости от испорченности хозяев проекта, неофит может увидеть различные подмножества сего списка. История чата F#RU хранит случаи, когда столкнувшиеся с этим беспределом новички хотели сдать оппонентов в дурку. И мне в целом понятна их позиция, однако верной от этого она не становится.
Причина столь радикальных расхождений кроется в том, что Expecto - результат проектирования тестового фреймфорка с нуля. С опорой на современный человеко-ориентированный язык. Читатель сильно упростит себе жизнь, если будет “разрабатывать” Expecto по ходу повествования, вместо попыток инкрементально нарастить знакомый @"{Prefix}Unit".
Тест как объект
Шаг 1. База
Если начать с чистого листа, то тест может быть определён как комбинация имени и функции, дающей тесту положительную или отрицательную оценку. Для простоты данное действие можно выразить функцией unit -> bool.
type TestCode = unit -> bool type SimpleTest = { Name : string Check : TestCode } // Хелперы опустим.
Далее мы просто итерируемся по всем переданным тестам и выводим результат в консоль.
type SimpleTest with static member eval test = printfn "%O %A started" System.DateTime.Now test.Name try if test.Check () then printfn "%O %A passed" System.DateTime.Now test.Name else printfn "%O %A failed" System.DateTime.Now test.Name with | ex -> // Не забываем, что тесты любят падать там, где мы этого не ждём. printfn "%O %A errored\n%A" System.DateTime.Now test.Name ex let tests = [ SimpleTest.create "sample" ^ fun () -> true ] tests |> Seq.iter SimpleTest.eval
Код настолько простой, что скулы сводит. Однако я уйду недалеко от правды, если скажу, что REPL-стадия пет-проектов хоть и использует Expecto, может быть переложена в данный код без фундаментальных изменений.
Шаг 2. Интеграция
Вывод в консоль/интерактив — штука хорошая. Но по мере роста рабочий код и тесты разбегаются по собственным проектам. В последнем случае это практически сразу приводит подъёму CI, а значит возникает необходимость вывода информации в пайплайн.
Очень быстро обнаруживается, что одного лишь факта наличия ошибки недостаточно для её исправления. В случае падения теста хочется получить контекст. Единственным универсальным средством разбора контекста является человек, так что ограничимся текстовой информацией. Стрингификацию контекста повесим на разработчика.
Большинство проблем в F# решается добавлением ещё одного слоя до или после существующего конвейера. Этот момент постоянно ускользает от новичков, что приводит к созданию ложно-простых API. (Действие синонимично добавлению нового уровня абстракции, но, как правило, имеет гораздо более приземлённое наполнение.)
Здесь мы поступим точно также. Выполнение теста будет возвращать результат в виде объекта, который всё также можно вывести в консоль. Но теперь совокупный результат всех тестов может быть подвергнут анализу.
type TestCode = unit -> Result<unit, string> // type SimpleTest = {..} type TestResult = | Passed | Failed of string | Errored of exn type TestResult with member this.AsCode = match this with | Passed -> 0 | Failed _ -> 1 | Errored _ -> 2 member this.Stringify () = match this with | Passed -> "passed" | Failed msg -> sprintf "failed\n%s" msg | Errorred ex -> srintf "errored\n%A" ex type SimpleTest with static member eval test = try match test.Check () with | Ok () -> Passed | Error err -> Failed err with | ex -> Errored ex let main args = let results = [ for test in tests do printfn "%O %A started" System.DateTime.Now test.Name let result = test.Check () printfn "%O %A %s" System.DateTime.Now test.Name ^ result.Stringify () test, result ] let summary label predicate = let tests = results |> List.filter predicate printfn "%s: %i" label tests.Length for test in tests do printfn "\t%s" test.Name summary "Passed" ^ function TestResult.Passed -> true | _ -> false summary "Failed" ^ function TestResult.Failed _ -> true | _ -> false summary "Errored" ^ function TestResult.Errored _ -> true | _ -> false results |> Seq.map ^ fun (_, result) -> result.AsCode |> Seq.fold (|||) 0
Шаг 3. Контроль запуска
Когда тестов становится много, хочется контролировать их запуск произвольным образом. Закоменчивать неактуальные в данный момент тесты - не самый быстрый способ доделать проект. UI прикручивать заметно дольше, но перед этим он потребует адекватную модель в коде. С неё и начнём.
Отныне каждый тест будет характеризоваться приоритетом выполнения.
Normal - вариант по умолчанию.
Pending - для тестов, чьё выполнение точно не требуется.
Focused - для тестов, что надо выполнить в рамках текущего запуска.
Перед выполнением тестов, мы отсечём все тесты со статусом Pending. После чего среди оставшихся найдём все Focused тесты и выполним их. Если окажется, что таких нет, то просто выполним все Normal тесты.
type FocusState = | Normal | Pending | Focused type SimpleTest = { Name : string Check : TestCode FocusState : FocusState } type SimpleTest with static member pend test : SimpleTest = // ... static member focus test : SimpleTest = // ... static member create name check : SimpleTest = // ...
Используя функции pend и focus, можно управлять приоритетом выполнения тестов. Может выглядеть непривычно, но, во-первых, у нас пока нет UI, во-вторых, даже имея UI, не факт, что получится контролировать запуск и перезапуск тестов на аналогичном уровне.
let tests = [ SimpleTest.create "normal sample" ^ fun () -> Ok () SimpleTest.create "focused sample" ^ fun () -> Error "oops" |> SimpleTest.focus ]
Шаг 4. Иерархия
Несмотря на то, что тесты позиционируются как атомы, вряд ли можно столкнуться с ситуацией, когда тесты лишены родства друг с другом. В обычных условиях мы группируем тесты по классам определения. Однако, в отсутствии «классификации», порождённой синтаксисом или фреймворком, можно задуматься о более произвольных структурах. Например, можно использовать в качестве основы всем знакомую файловую структуру.
Каждый тест будет находится в рамках древа. Его имя в этом случае может характеризоваться как путь к тесту.
user / storage / entity / execute / valid / change name
user / storage / entity / execute / invalid / after deletion / change name
Добьёмся этого мы через добавление ещё одного слоя, но теперь до, а не после. У нас будет тип для выражения древовидной структуры, и тип для тестов, полученных в результате упрощения дерева до списка.
type TreeTest = | Leaf of Name : string * TestCode * FocusState | Node of Label : string * TreeTest list * FocusState // Бывший SimpleTest type FlatTest = { Name : string list Check : TestCode FocusState : FocusState } type TreeTest with static member flat (tree : TreeTest) : FlatTest list = // ... static member pend (tree : TreeTest) : TreeTest = // ... static member focus (tree : TreeTest) : TreeTest = // ...
Дальнейший код лежит уже не в области логики, а в области DSL. Простор большой, но для начала:
let testList label (tests : TreeTest) = TreeTest.Node(label, tests, FocusSate.Normal) let testCase name code = TreeTest.Leaf(name, code, FocusSate.Normal) let tests = testList "samples" [ testCase "valid test" ^ fun () -> Ok () testList "invalid tests" [ testCase "failed" ^ fun () -> Error "oops" testCase "errored" ^ fun () -> failwith "oops!" |> pend ] |> focus ]
Результат выполнения окажется следующим:
- "samples / valid test" — проигнорирован из-за наличия фокуса.
- "samples / invalid tests / failed" — запущен, т.к. Focused.
- "samples / invalid tests / errored" — проигнорирован, т.к. помечен как Pending.
Шаг 5. Expecto
Текущего кода недостаточно, чтобы полностью сэмулировать Expecto, но ядро готово. Все остальные надстройки Expecto (такие как ассерты, логирование, конфигурация из консоли и т. д.) имеют куда меньшее отношение к самому тестированию и могут быть освоены как чёрный ящик.
Особо заинтересованные могут обратиться к определению следующих типов в исходниках:
Они заданы сложнее, чем у нас, отрабатывают куда больше специфических случаев, типа асинхронных тестов или FsCheck. Тем не менее, это всё ещё набор алгебраических типов, значение которых может быть выведено линейно, сообразуясь с действительностью. Единственным исключением является TestCode, который в отличие от нашего (unit -> Result<unit, string>) был построен на основе unit -> unit и его асинхронных вариантах.
Авторы решили, что не надо нагружать разработчика работой с Result-ами, и снабдили все ассерты, лежащие в модуле Expect выбросом исключения ExpectoException (или его наследников). То есть мы пишем:
testCase "sample" ^ fun () -> 2 * 2 |> Expect.equal "" 42
А в результате всё также сохраняем различение между ошибкой имплементации и ошибкой теста. Решение редуцировать описание теста до unit -> unit хорошее, тест почти всегда является терминальной нодой, а значит, мы можем довольно точно ограничить сайд-эффекты. Однако я бы не протаскивал это решение на уровень типов. Никто из разработчиков не задумывается, что за кодом функции testCase, testAsync и т.п. скрывается выбор одного из кейсов TestCode и Test. На то нам и даны DSL, чтобы оперировать категориями характерными для предметной области (в данном случае тестов), а не машинными примитивами. Тем не менее, вместо:
type TestCode = | Sync of (unit -> Result<unit, AssertInfo>) // ... let testCase name action = Test.Label( name , Test.TestCase( TestCode.Sync ^ fun () -> try Ok ^ action () with | :? Expecto.AssertException as ex -> Error ^ AssertInfo.create ex.Message | _ -> reraise () , FocusState.Normal ) , FocusState.Normal )
Мы получили:
type TestCode = | Sync of (unit -> unit) // ... let testCase name action = Test.Label( name , Test.TestCase(TestCode.Sync action, FocusState.Normal) , FocusState.Normal ) // Отлов `AssertException` где-то далеко внизу в недрах раннера.
Сигнатура testCase в обоих случаях идентична, как и большинство хелперов над TestCode. Однако информация об особой роли AssertException в первом случае изолируется в рамках конкретного хелпера. Во втором – разрешается лишь в корне раннера. Что на деле приводит к размазанности по всему фреймворку. Тем, кто будет писать расширения к Expecto, в большинстве случаев придётся отрабатывать этот момент.
Для контроля над запуском тестов все хелперы testCase, testAsync, testList и т.д. получили по 2 копии с префиксами p- (от Pending) и f- (от Focused). То есть для отключения какой-то ветви древа достаточно добавить одну букву:
let sampels = testList "samples" [ testList "normal" [ testCase "normal" // .. ftestCase "focused" // .. ] ptestList "pending" // .. ]
Прямых хелперов для переключения FocusState существующего теста по неизвестным причинам не завезли. Однако, их можно определить самостоятельно:
let focus = Test.translateFocusState FocusState.Focused
При этом завезли Test.filter, с предикацией по имени/пути теста.
|> Test.filter defaultConfig.joinWith.asString (not << Seq.contains "storage")
Общий стартап
Отныне и впредь, стандартный алгоритм развёртывания Expecto выглядит так:
Создаёте пустой консольный проект.
Подключаете Expecto и подопытные проекты.
Заменяете код Program.fs на:
open Expecto [<EntryPoint>] let main args = runTestsInAssemblyWithCLIArgs [] args
Запускаете проект через dotnet run, обнаруживаете, что всё работает, и тестов 0.
Добавляете файл с модулем:
module SampleTests open Expecto open Expecto.Flip [<Tests>] let tests = testList "samples" []
Набиваете tests конкретными тестами под задачу.
Если задача заключается в рядовом переезде с @"{Prefix}Unit" фреймворка на Expecto, то данного алгоритма хватит на первое время с избытком. Вам не придётся вручную передавать тесты благодаря TestsAttribute. Ориентируясь на него, runTestsInAssembly{Suffix} функции (их там много) обнаружат тесты в рамках текущего проекта.
Однако, мой опыт говорит о том, что есть смысл отказаться от использования атрибутов с самого начала. Передача тестов вручную не занимает много времени, но при этом соответствует стремлению F# делать неявное явным. Как правило, точек генерации деревьев в проекте не так много. Почти всегда, это поле или метод в конце файла, сводящий всё внутреннее разнообразие к одной входной точке на файл. Так что лучше во втором пункте сразу начинать с чего-то такого:
[<EntryPoint>] let main args = testList "my project name" [ SampleTests.tests ] |> runTestsWithArgs [] args
Дальше разработка будет пребывать в цикле между:
Правка кода
Правка тестов
dotnet run
Кто-то может решить, что имеет смысл запускать тесты как проект через IDE. Но о положительном опыте подобного подхода мне неизвестно. Максимум, чего вы добьётесь – получите более тонкие возможности дебага. Есть ли в этом смысл – для мира F# вопрос, мягко говоря, спорный.
Здесь я вполне сознательно не затрагивал вопрос интеграции с Expecto.VisualStudio.TestAdapter. Я никогда не пытался его полноценно использовать. Любой обозреватель тестов, что не контролируется из кода, будет этому коду уступать. Если у вас стоит задача сохранить практику использования тестов из IDE, то флаг вам в руки. Мне известны люди, что успешно им пользовались, но я никогда не работал с ними в одной команде.
Ниже станет ясно, что возвращение TestAdapter-а в мою жизнь исключено. Т.к. не только не даёт заметных преимуществ, но и отсекает ряд перспективных подходов.
С запуском разобрались, выбранный конкретно вами вариант на дальнейшие рассуждения влияния оказывать не будет.
Промежуточный итог
К данному моменту мы усвоили базовые категории Expecto и научились его заводить. Этого уже достаточно для применения фреймворка в проде, хоть и будет сопряжено с некоторыми трудностями по мере роста тестовой базы. В следующей части мы разберём как правильно писать и генерировать тесты-объекты, как дружить Expecto c Property Based Testing, а сами тесты между собой. После чего коснёмся нестандартных штук на и за границами фреймворка, типа индивидуальных решений под конкретные проекты.
Автор статьи @kleidemos
НЛО прилетело и оставило здесь промокод для читателей нашего блога:
— 15% на все тарифы VDS (кроме тарифа Прогрев) — HABRFIRSTVDS.
