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

  1. Уронить тест при помощи функции skiptest в теле теста. В этом случае Expecto отловит исключение особым образом, и тест будет переведён в категорию Ignored. Способ уклонения должен быть знаком по другим фреймворкам.

  2. Перевести неудобные тесты в категорию Pending на основе предикатов. Требует доступ к условию на этапе генерации теста, но всё также зажёвывает коннекшены и другие ресурсы, что были созданы попутно.

  3. Вообще не генерировать неудобные поддеревья. Наиболее безопасный способ, т.к. позволяет почти не цеплять сайд-эффекты.

Приоритет получается обратный:

  1. Не создаём тест, если знаем, что он не может быть выполнен.

  2. Переводим в Pending, если всё-таки создали.

  3. Отключаем тест в процессе выполнения, если всё-таки запустили.

Вариантов принятия решения может быть масса. В большинстве своём, должно хватить обычного 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.

В общих чертах, тестирование свойств идёт по следующему плану:

  1. Бомбим код тестами со случайными данными.

  2. Иногда находим контрпримеры.

  3. Фиксируем их.

  4. Правим код, пока все зафиксированные ошибки не будут исправлены.

  5. Избавляемся от фиксаций.

Вы пишете тесты при помощи семейства функций 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.

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