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#, и мою практику юнит-тестирования можно было описать следующий образом.

  1. Создать проект, подключить нугет.

  2. Создать класс для хранения тестов, снабдить атрибутом при необходимости.

  3. Определить сетап для тестов, снабдить атрибутом.

  4. Создать метод-тест с именем типа ShouldBeBrokenAfterNuke, снабдить атрибутом.

  5. Собрать проект, открыть обозреватель тестов в VS, запустить тест(ы).

После переезда на F#, я делал всё то же самое, только в 3 пункте появилась возможность писать:

[<Test>]
let ``Should be broken after nuke.`` () = 

И тест-вьювер корректно отображал данное имя в списке тестов. Я был счастлив.

Конец статьи.


Трудно линейно проследить и описать мой личный путь к Expecto. Так что поставим себя на место меня 5-6 летней давности и представим, на что я бы обратил внимание, если бы увидел свой современный код:

  1. Имена тестов даны строками. testCase "should be broken after nuke" ^ fun () -> ...

  2. Сами тесты перестали быть методами.

  3. Тесты лежат глубоко в пирамиде судьбы, а не как мемберы типов/модулей. Если потребуется достать их ручками, то лёгкого способа не предусмотрено.

  4. Структура тестов теперь не 2-уровневая “Класс - метод”. А представляет из себя дерево произвольной глубины с неравными ветвями.

  5. Атрибуты присутствуют только в корнях деревьев. Существуют проекты, где атрибуты вообще не используются.

  6. Тесты запускаются через консоль. Стандартные обозреватели тестов не используются. Найти в команде человека, что готов помочь в пусконаладке обозревателя невозможно.

  7. Запуск конкретного теста осуществляется через правку кода и повторным запуском через консоль.

И т. д. В зависимости от испорченности хозяев проекта, неофит может увидеть различные подмножества сего списка. История чата 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. Интеграция

  1. Вывод в консоль/интерактив — штука хорошая. Но по мере роста рабочий код и тесты разбегаются по собственным проектам. В последнем случае это практически сразу приводит подъёму CI, а значит возникает необходимость вывода информации в пайплайн.

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

Большинство проблем в 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 выглядит так:

  1. Создаёте пустой консольный проект.

  2. Подключаете Expecto и подопытные проекты.

  3. Заменяете код Program.fs на:

open Expecto

[<EntryPoint>]
let main args =
    runTestsInAssemblyWithCLIArgs [] args
  1. Запускаете проект через dotnet run, обнаруживаете, что всё работает, и тестов 0.

  2. Добавляете файл с модулем:

module SampleTests

open Expecto
open Expecto.Flip

[<Tests>]
let tests = testList "samples" []
  1. Набиваете 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.

Комментарии (0)