Модульные тесты и покрытие

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

Проверки покрытия могут определить, какие части кода запускаются модульными тестами, а какие нет. Это делается «под капотом» путем предварительной обработки исходного кода для добавления «датчиков» к каждому оператору (в случае, если мы измеряем покрытие строки) или к каждой области (для покрытия ветки).

Этот датчик представляет собой встроенную функцию, которая при срабатывании увеличивает счетчик, сопоставляя его с метаданными, такими как имя файла и номер строки. После завершения набора тестов результат сводится к отчету о покрытии. Этот отчет является неотъемлемой частью хорошего конвейера CI / CD и обеспечивает хорошую объективную оценку общей надежности кода.

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

Мутационное тестирование

Лучший способ проверить эффективность текущего модульного теста - это намеренно сделать его неудачным. Измените условие в исходном коде, операторе или логическом значении с «истина» на «ложь» и ожидайте сбоя в наборе тестов. Даже если код имеет высокий охват, если ни одно из тестов не завершилось неудачно в результате этого изменения, мы можем быть уверены, что в нашем наборе тестов есть пробел. Автоматизируйте этот процесс, и вы изобрели тестирование мутаций. Такие инструменты, как Stryker или PIT, изменяют или изменяют определенные токены кода и запускают связанные с ними тесты. Неудачные модульные тесты «убивают» мутацию, в противном случае мутация «выжила».

Я был свидетелем того, как все команды внедряли тестирование мутаций для своих проектов, легко интегрируя Stryker для JavaScript, TypeScript, C # и Scala, а также PIT для Java. Однако ничего подобного для развития канала Roku не применялось.

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

Вот как мы это сделали и чему научились на этом пути. Я буду использовать JavaScript в качестве примеров.

Ресурсы

Я обратился к Stryker за его расширяемостью с помощью Plugins. Использование ресурсов и документации на своем веб-сайте, а также полезные чаты на Gitter (теперь группа Slack) с сообществом. Мне нужно было сделать две вещи:

  1. Напишите плагин-мутатор для создания мутаций в BrightScript и средство запуска тестов для запуска модульных тестов на устройстве Roku. Я был направлен на javascript-mutator как на самый простой пример для работы. Для этого мне нужно было понять, как код анализируется и видоизменяется с помощью абстрактного синтаксического дерева и шаблона посетителя.
  2. Напишите test-runner-plugin, который Stryker будет вызывать для запуска модульных тестов на указанной платформе и платформе и будет ждать, пока он получит результаты теста.

АСТ

Мутации вводятся в исходный код (или в байт-код на языках JVM, и так работает PIT). Чтобы ввести мутации, нам нужно прочитать файл в память и разобрать его на токены в виде абстрактного синтаксического дерева (AST).

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

Вот как такие инструменты, как Babel, анализируют современный JavaScript и переписывают его в обратно совместимый синтаксис для работы в браузерах. Stryker использует Babel для синтаксического анализа JavaScript и генерации AST перед запуском в нем мутаций. Другие инструменты, такие как Acorn, ESLint (через Espree), Chevrotain и TypeScript, работают таким же образом. Некоторые из них применили стандарт JavaScript AST под названием ESTree.

Вот тривиальный пример на JavaScript с использованием AST Explorer:

if (true) {
 const hello = “world”;
}

Преобразованный в JSON AST с acorn 7.3.1 будет выглядеть так:

{
  "type": "Program",
  "start": 0,
  "end": 218,
  "body": [
    {
      "type": "IfStatement",
      "start": 181,
      "end": 218,
      "test": {
        "type": "Literal",
        "start": 185,
        "end": 189,
        "value": true,
        "raw": "true"
      },
      "consequent": {
        "type": "BlockStatement",
        "start": 191,
        "end": 218,
        "body": [
          {
            "type": "VariableDeclaration",
            "start": 194,
            "end": 216,
            "declarations": [
              {
                "type": "VariableDeclarator",
                "start": 200,
                "end": 215,
                "id": {
                  "type": "Identifier",
                  "start": 200,
                  "end": 205,
                  "name": "hello"
                },
                "init": {
                  "type": "Literal",
                  "start": 208,
                  "end": 215,
                  "value": "world",
                  "raw": "\"world\""
                }
              }
            ],
            "kind": "const"
          }
        ]
      },
      "alternate": null
    }
  ],
  "sourceType": "module"
}

К счастью для нас, RokuRoad построил Bright, парсер AST для BrightScript, который возвращает AST, подобный ESTree, с использованием специализированного движка Chevrotain. Это движок eslint-plugin-roku для подсветки синтаксиса в языке BrightScript.

Помните, мы упоминали, что тестирование покрытия работает под капотом, помещая функцию sensor в каждую строку? Чтобы это было синтаксически правильным, необходим AST для распознавания областей и соответствующих строк, которые мы хотим отслеживать. Фреймворк тестирования georgejecook / rooibos использует для этого интерпретатор sjbarag / brs.

Если вам нравится возиться с метапрограммированием, AST дает вам крылья.

Шаблон посетителя

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

Мы можем определить, например, объект «мутатор» для булевой подстановки. Он посещает каждый узел, и если это буквальное значение «истина», он клонирует его, изменяет его на «ложь» и помещает в массив измененных узлов со всеми соответствующими метаданными. После обхода всего дерева мы можем перейти к другому объекту-мутатору, например, унарным операторам, которые изменяют ++ на - -.

Другие интересные мутаторы:

  • Массивы - замена массивов пустыми.
  • Равенство - замените >= на >, а также все другие варианты равенства.
  • Строковые литералы - замена строк на "Stryker was here!"
  • Пустые функции - удаляет вызовы возвращающих пустоту функций, необходимых для побочных эффектов (не забудьте игнорировать все регистраторы).
  • И так далее (проявите изобретательность!)

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

Проведение тестов

Страйкер не знает, как запускать тесты для каждой платформы, языка или устройства. Он достигает этого с помощью плагинов, реализующих интерфейс «TestRunner». Интерфейс определяет функцию «запустить» и возвращает обещание с «RunResults».

К сожалению, весь код Roku BrightScript необходимо запускать на физическом устройстве, и модульные тесты не являются исключением. По состоянию на 2020 год для Roku Streamer все еще нет эмулятора, такого как для tvOS или Fire TV, а собственная среда выполнения для языка BrightScript находится внутри устройства. Нам нужно скопировать структуру модульного тестирования в код, развернуть код на устройстве, запустить набор тестов и получить результаты через журналы Telnet.

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

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

Для запуска модульных тестов мы используем потрясающую среду тестирования georgejecook / rooibos в стиле мокко. Устройство выводит свои результаты с помощью telnet, поэтому мы создали модуль Nodejs в TypeScript, используя собственный пакет net, чтобы получить их как поток и пройти или не пройти модульные тесты в соответствующей строке журнала:

Если с этим потоком что-то начнется, я могу переключиться на реактивное программирование. Таким образом, я могу несколько раз подписаться на регистратор только с одним подключением к порту Telnet:

import { Subject } from 'rxjs';
const logger$ = new Subject<string>();
// create connection...
socket.on('data', (chunk: Buffer) => {
    const str = chunk.toString();
    logger$.next(str);
});
// subscribe to logger$

Это составляет ядро ​​нашего плагина Test Runner, использующего интерфейс TestRunner из Stryker API. В нем мы определяем функцию run (), которая возвращает обещание с RunResults. Мы добавили функцию, которая форматирует приведенные выше результаты в соответствии с интерфейсом ниже:

interface RunResult {
    tests: TestResult[];
    errorMessages?: string[];
    status: RunStatus;
    coverage?: CoverageCollection | CoveragePerTestResult;
}
interface TestResult {
    name: string;
    status: TestStatus;
    timeSpentMs: number;
    failureMessages?: string[];
}
declare enum TestStatus {
    Success = 0,
    Failed = 1,
    Skipped = 2
}
declare enum RunStatus {
    Complete = 0,
    Error = 1,
    Timeout = 2
}

Собирая все вместе работающий Stryker, мы начинаем видеть логи:

39 мутантов выжили за первые несколько минут. Это означает, что функциональные возможности кода изменились, и в результате ни один тест не завершился неудачно.

Оптимизация

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

Вторая оптимизация включает многопоточность, которая в нашем случае включает развертывание и тестирование сразу на нескольких устройствах Roku. Для этого требуется управление пулом потоков устройств и развертывание по очереди. Кроме того, я немного устал нагружать одно маленькое устройство в течение 19 часов подряд, поэтому было бы неплохо сбалансировать нагрузку с помощью 5 или более устройств.

С устройствами Roku я столкнулся с проблемами подключения Telnet, поскольку предыдущий тест не отключился вовремя для следующего. Я мог бы переключиться на реактивное программирование и одноэлементный экземпляр для соединения. Некоторые мутации привели к сбою набора тестов, а некоторые из них перезагрузили устройство, вызвав таймауты для Страйкера.

Мутатор строкового литерала должен был быть отключен, поскольку простая конкатенация строк, такая как "hello " + name + "!", приводила к двум отдельным мутациям, с двумя отдельными песочницами и двумя полными запусками набора тестов, при этом ни одна из мутаций не была уничтожена, поскольку не было модульных тестов, которые можно было бы отловить. такая банальная замена. Я ищу другой способ видоизменить строки.

Заключение

Мы хотим в конечном итоге доработать эти плагины и выпустить их в общественное достояние. Текущая реализация все еще является подтверждением концепции. Текущая реализация использует только одно устройство Roku, что приводит к множеству тайм-аутов соединения.

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

Конечно ваш набор тестов должен дать сбой, если вы внесете семантические изменения в производственный код. Кто-нибудь реально в этом сомневается? -Дядя Боб, Мутационное тестирование

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