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.