Модульное тестирование. Стать мастером любого языка программирования полностью зависит от владения его инструментами тестирования. Наличие тестов в базе кода в первую очередь повышает общее качество функционального кода, но когда мы идем дальше и пишем изолированные тесты, это также накладывает различные желательные ограничения на нашу структуру кода. Графики вызовов / графы внешних зависимостей компонентов, которые тестируются изолированно, намного проще рассуждать. В результате улучшается общая структура кода.

Прежде чем наша команда решила разработать новое приложение на Rust, у меня был некоторый опыт использования различных стилей разработки, основанных на тестировании, в Python, Spring Boot (JUnit) и node.js (jest). Я ни в коем случае не являюсь экспертом в написании тестов, так как я перенял этот стиль разработки только последние 2–3 года. Проекты, над которыми я работал раньше, полагались не столько на TDD, сколько на «каскадерское программирование» - скажем так, сейчас я стараюсь быть хорошим профессионалом.

В этой статье я немного расскажу о том, как я сейчас думаю о тестировании, а затем о реализации этих идей в Rust. TL; DR: Не все работает так гладко, как при работе с языками более высокого уровня, что неудивительно.

Разделение кода

Чтобы иметь возможность модульного тестирования нашего кода (изолированно!), Мы можем следовать принципу разработки и писать слабосвязанный код. Компоненты кода просто должны быть слабо связаны с его зависимостями, чтобы последние можно было контролировать в изолированных тестах. Я вернусь к тому, почему слабое связывание немного неудобнее достичь в Rust, чем в языках более высокого уровня. Но сначала я хочу упомянуть различные степени слабой связи, и они, надеюсь, помогут проиллюстрировать мои мысли позже в статье: 1) Слабая связь по причинам времени выполнения. 2) Слабая связь по причинам testtime. 3) Слабая связь по причинам слабой связи (я не буду обсуждать 3), потому что я считаю это абсурдом).

Я лично считаю, что способ разъединения компонентов должен осуществляться с учетом его основной цели. Разделение, которое включает абстракцию среды выполнения в режиме выпуска, должно быть синтаксически и семантически более разделенным, чем разделение, которое просто включает управление зависимостями во время тестирования.

Давайте посмотрим на некоторые известные мне техники разделения. Поскольку эта статья посвящена тестированию, я думаю обо всем этом как о средствах контроля зависимостей во время тестирования.

Функции высшего порядка

Если я хочу протестировать функцию или процедуру A изолированно, которая зависит от другой функции B, я могу передать B в качестве параметра в А. В производственном коде я называю A (B). В тесте я вызываю A (mocked_B), теперь с полным контролем над этой зависимостью.

Объектно-ориентированный «интерфейс»

Если A имеет более одной логической зависимости, сигнатура функции будет иметь больше аргументов, и код не будет выглядеть таким чистым. Если некоторые зависимости достаточно взаимосвязаны, мы можем сгруппировать несколько зависимостей вместе в объектно-ориентированном интерфейсе: B.foo () и B.bar (), где фактическая реализация foo и bar определяется конкретным типом B, который во время тестирования будет так называемым имитацией объекта. Хорошие инструменты тестирования предоставляют нам способы быстро настроить эти фиктивные функции (или методы), например проверка того, сколько раз они вызываются в тесте, запись их входных аргументов для последующей проверки или настройка возвращаемых значений.

В моей команде обсуждали, является ли лучший стиль повсюду зависеть исключительно от абстрактных интерфейсов. Я лично считаю, что это должно определяться намерением времени выполнения. На языках высокого уровня это обычно не проблема, потому что отправка методов обычно динамическая по умолчанию. Это означает, что любые или большинство конкретных типов или классов легко подклассифицировать, и, следовательно, связь с таким классом по имени на самом деле не так сильна под капотом. Все языки python / Java / Kotlin / JS легко поддерживают это: создание макетов конкретных типов.

Модульные макеты

Их, конечно, может быть много, но Jest - единственная структура тестирования, которую я видел, где возможно (и полностью поддерживается) имитировать целые модули кода. Вы пишете свой тестируемый модуль со всеми зависимостями внешних модулей на месте, и все они импортируются напрямую. В JavaScript значение импортированного модуля кода на самом деле является объектом с методами на нем, поэтому неудивительно, что это может работать. Имитация модуля в jest работает путем перенастройки того файла, из которого он загружен, а замещенный модуль активен для всех тестов, определенных в одном и том же тестовом файле. Поскольку импорт модуля - это глобальная деструктивная / постоянная операция, каждый набор тестов Jest выполняется в изолированной среде, чтобы избежать вмешательства в другие макетные конфигурации модуля (или не-макеты) где-либо еще в кодовой базе.

Этот метод имеет наиболее неявную развязку. Он не загромождает бизнес-логику фальшивыми квазиабстракционными объектами (служебными объектами или тем, как вы их называете). Другой выгодный паттерн, который может возникнуть из этого, заключается в том, что по мере того, как тестируется все больше кода, иногда требуется рефакторинг модулей, чтобы иметь возможность имитировать на правильном уровне для каждого теста. Конечным результатом может быть лучшая целостность модуля.

Rust - это совсем другое дело по сравнению с вышеупомянутыми языками. Поскольку абстракции - это системный язык, ориентированный на производительность на «голом железе», он не «предоставляется бесплатно». По крайней мере, не те, которые меня здесь интересуют. Прежде всего, все вызовы функций по умолчанию отправляются статически. Это означает, что код, вызывающий другую функцию напрямую, компилируется для вызова точной реализации этой функции, а не с помощью поиска по переменной или таблице. Это верно также при вызове обычных методов конкретных типов - методы структуры не могут быть переопределены.

Два первых метода, упомянутых выше, могут быть прекрасно применены в Rust, но разработчик должен учитывать (и писать!) В каждом случае гораздо больше. Первое, что следует учитывать при введении абстракции, - использовать ли диспетчеризацию метода: статический или динамический. Статическая отправка связана с мономорфизацией и дженериками. Это означает, что функция A, вызывающая неизвестную функцию B, компилируется только лениво - только когда известен вызов некоторого конкретного B, компилятор выдает код для A, вызывающего именно этот B. Это поведение является необязательным, и вместо этого мы можем выбрать динамическую отправку либо с помощью указателя функции, либо с помощью объекта признака , способ вызова методов признака с использованием виртуальной таблицы поиска, переданной вместе с указатель данных на экземпляр объекта.

Итак, способ выразить абстракцию в Rust - использовать trait. Мокинг трейтов в Rust сегодня довольно удобен для разработчиков, но не так удобен, как в языках динамической диспетчеризации. Что я делал до сих пор, так это внедрял новую черту везде, где мне нужна тестовая изоляция, и использовал ящик mockall для автогенерации имитируемой реализации, которую я создаю в своем тесте. Не очень далеко от того, как работают java / mockito или jest, за исключением того, что, как уже упоминалось, нам всегда нужно очень четко указывать на происходящую абстракцию:

#[cfg_attr(test, mockall::automock)]
trait SomethingIWantToMock {
    fn mockable();
}
struct ConcreteSomethingUsedInProduction {}
impl SomethingIWantToMock for ConcreteSomethingUsedInProduction {
    fn mockable() { ... }
}
#[test]
fn test_something() {
    let mock_something = MockSomethingIWantToMock::new();
    // test some code using that dependency
}

В Rust создание кода динамической отправки менее многословно, чем выполнение статической отправки. В некотором смысле это нелогично, потому что в других областях языка кажется осознанным решением, что менее подробный код проще и, следовательно, более эффективен. Распределение кучи - один из примеров, всегда очень очевидно, что будет происходить выделение кучи: Box ‹MyThing› вместо MyThing. Выделение кучи более затратно для ввода и выполнения. Кажется очевидным, что для этого нет недостатка. Сначала рассмотрим динамическую отправку:

fn a(b: &dyn B) {
    b.b();
}

где B - признак, a является абстрактным. Теперь статическая диспетчерская версия вышеперечисленного:

fn a<T: B>(b: T) {
    b.b();
}
// or alternatively
fn a<T>(b: T) where T: B {
    b.b();
}

Чтобы сделать функцию абстрактной со статической отправкой (без потери производительности во время выполнения), нам нужно было ввести два новых имени. Сначала B для самого признака, а затем имя T, которое представляет его конкретный тип, когда a мономорфизируется над ним.

Общий код может быть очень интересным для написания, но мне бы не хотелось, чтобы моя кодовая база была заполнена им только для целей изоляции тестов. Я хочу, чтобы моя бизнес-логика была ясной и удобочитаемой, когда она выполняет конкретные задачи - общий код в основном принадлежит служебным программам. И в принципе, я не хочу делать весь свой код динамической отправкой только потому, что хочу его протестировать. Насколько я знаю, код может быть действительно критически важным для производительности "горячим" путем в моем приложении. И я изначально решил написать это на Rust.

Моки модулей в Rust

Насколько хорошо было бы иметь макеты модулей в Rust? Моей самой первой мыслью было то, что это звучит технически неосуществимо. Все тесты в Rust выполняются в пуле потоков. В Rust запрещено изменять глобальное состояние по уважительной причине. Здесь нет среды песочницы, как в Jest. Все имитируемые функции модуля должны будут неявно принимать скрытый аргумент - или весь исполняемый файл теста должен быть «мономорфизирован» для каждого отдельного тестового примера, что фактически приведет к N копий программы, где N - количество тестов ...

Но оказывается, что вы все еще можете сделать это, используя ночной компилятор! Войдите в Mocktopus, фиктивную библиотеку на уровне модуля. Используя макрос для преобразования определения модуля, mocktopus превращает любую общедоступную функцию в этом модуле в макет, переписывая его определение на первую проверку (во время вызова), был ли зарегистрирован указатель функции, который хранится в локальной таблице поиска потока. . Макрос действует только в тестовой сборке, но не в отладке или выпуске. Я думаю, что эта техника может хорошо вписаться в наш проект, но, если говорить серьезно, мы не будем зависеть от экспериментальных ночных возможностей Rust.

К сожалению, мое текущее решение состоит в том, что я не пытаюсь изолировать свои тесты там, где это не является строго необходимым для правильности или где это привело бы к неудобному для чтения коду бизнес-логики. Следствием этого является то, что во многих местах кода я тестирую одно и то же снова и снова. Но это также связано с совершенно другим (небольшим) недостатком в экосистеме тестирования Rust: основные макросы утверждения.

Макросы утверждения

Мои тестовые утверждения являются исключительно вариациями assert_eq! и assert_ne!. Существуют служебные библиотеки утверждений, которые можно использовать, но ни одна из них, похоже, не используется широко, нет четкого принятого отраслевого стандарта поверх std::assert. Давайте рассмотрим очень простой код, чтобы выделить небольшое неудобство:

struct Answer {
    foo: Vec<String>,
    bar: Vec<String>
}
fn compute_answer(some_input: &str) -> Answer {
    Answer {
        foo: some_input.split("."),
        bar: external_module::compute_bar(some_input)
    }
}

Изолированная проверка этой функции аналогична проверке правильности разделения входной строки. Тест external_module принадлежит другому файлу, поэтому мы должны игнорировать значение bar в нашем тесте. Но:

#[test]
fn test() {
    assert_eq!(
        compute_answer("input.string"),
        Answer {
            foo: vec!["input".to_string(), "string".to_string()],
            bar: vec!["?"]
        }
    );
}

используя assert_eq! Я должен сравнить полную структуру. Конечно, я мог бы явно сравнить член (ы):

#[test]
fn test() {
    let aswer = compute_answer("input.string");
    assert_eq!(
        answer.foo,
        vec!["input".to_string(), "string".to_string()],
    );
}

но это не так элегантно, как наличие одного выражения соответствия для всего типа, который я тестирую. Но что, если существует макрос для сопоставления только того, что меня волнует? Давайте попробуем макрос assert_matches! из ящика совпадений, макрос, который позволяет утверждать, что что-то соответствует шаблону, подобному выражению match:

#[test]
fn test() {
    assert_matches!(
        compute_answer("input.string"),
        Answer {
            foo: ["input", "string"], // Does not compile
            bar: _, // Actually works!
        }
    );
}

AFAIK невозможно сопоставить такую ​​структуру глубоко, потому что прямое сопоставление Vec с шаблоном среза не работает. Только вещи, которые уже являются срезами, могут быть сопоставлены с шаблоном, а члены Answer не являются срезами, а являются Vec, и не принудительно.

Что я хочу сказать о макросах утверждений? Даже если я не смогу полностью изолировать все, что делает мой тестируемый модуль, я надеялся, что было бы просто и элегантно сопоставить хотя бы только ту часть вывода, которая меня волнует. Это шаблон, который я часто использую в jest: expect(actual).toMatchObject({…}).

Заключение

На самом деле у меня вообще не было проблем с «продажей» Rust для проектов, которые обычно писались бы на JVM или node.js. Мы все жаловались на неудержимую нехватку памяти и безумно медленную загрузку Spring Boot. Мы создаем веб-приложения, и проблемная область обычно находится в области высокого уровня. Разработчики, работающие с доменами аналогичного уровня, избалованы хорошими инструментами тестирования, потому что их традиционные языки выбора также являются высокоуровневыми. Rust, наконец, дает нам жизнеспособный способ решения проблем высокого уровня с использованием языка низкого уровня - runtime-bloat-be-away. Но нынешнюю историю тестирования Руста будет сложнее продать пуристам TDD.

Я думаю, что в 2020 году сообщество Rust должно сделать своим приоритетом потрясающее, интуитивно понятное, полное и хорошо документированное тестирование. Не знаю как. Но Rust уже сумел преодолеть так много невероятных препятствий - просто ощущение использования высокоуровневого языка, когда на самом деле это не так, - это меня потрясает. А теперь давайте проведем тестирование и убийственный продукт.