TDD с библиотекой тестирования oclif

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

Но подождите — это еще не все! oclif также имеет среду тестирования, которая позволяет вам выполнять CLI так же, как это делает пользователь, фиксируя стандартный вывод и ошибки, чтобы вы могли проверить ожидания. В этой статье я покажу вам, как с легкостью написать и протестировать приложение oclif CLI.

Что мы собираемся строить?

Мы все устали работать над типичным приложением TODO. Вместо этого давайте создадим что-то другое, но простое. Мы будем использовать подход разработки через тестирование (TDD) для создания приложения для отслеживания времени. Наш CLI позволит нам сделать следующее:

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

Вот как выглядит пример взаимодействия с time-tracker CLI:

~ time-tracker add-project project-one
Created new project "project-one"
~ time-tracker start-timer project-one
Started a new time entry on "project-one"
~ time-tracker start-timer project-two
 >   Error: Project "project-two" does not exist
~ time-tracker add-project project-two
Created new project "project-two"
~ time-tracker start-timer project-two
Started a new time entry on "project-two"
~ time-tracker end-timer project-two
Ended time entry for "project-two"
~ time-tracker list-projects
project-one (0h 0m 13.20s)
- 2021-09-20T13:13:09.192Z - 2021-09-20T13:13:22.394Z (0h 0m 13.20s)
project-two (0h 0m 7.79s)
- 2021-09-20T13:13:22.394Z - 2021-09-20T13:13:30.189Z (0h 0m 7.79s)

Мы будем управлять всеми данными о добавленных проектах и ​​активных таймерах в «базе данных» (простой файл данных JSON).

Исходный код нашего проекта приложения для учета рабочего времени можно найти здесь.

Поскольку мы делаем это методом TDD, давайте сначала погрузимся… в тесты!

Наш тайм-трекер: особенности и тесты

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

Создать новый проект

  • Успешный путь: создается новый проект, и его запись сохраняется в базовой базе данных. Пользователь получает подтверждающее сообщение.
  • Печальный путь: если проект уже существует, пользователю будет показано сообщение об ошибке. Базовая база данных останется неизменной.

Запустить таймер в проекте

  • Удачный путь: запрошенный проект уже существует, поэтому мы можем начать новую запись времени, установив startTime на текущую дату/время. Пользователь получит уведомление, когда таймер запустится.
  • Счастливый путь: если таймер уже запущен в другом проекте, то этот таймер остановится и запустится новый таймер в запрошенном проекте. Пользователь получит уведомление, когда таймер запустится.
  • Печальный путь: если проект не существует, пользователю будет показано сообщение об ошибке. Базовая база данных останется неизменной.

Окончание таймера в проекте

  • Удачный путь: в запрошенном проекте активен таймер, поэтому мы можем остановить этот таймер и уведомить пользователя.
  • Печальный путь: если проект не существует, пользователю будет показано сообщение об ошибке. Базовая база данных останется неизменной.
  • Печальный путь: если проект существует без активного таймера, пользователь получит уведомление. Базовая база данных останется неизменной.

Список проектов

  • Счастливый путь: пользователю отображаются все проекты, общее время, записи и время входа.

Наличие базы данных (для всех команд)

  • Печальный путь: если файл time.json не существует в текущем каталоге, пользователю отображается сообщение об ошибке.

Для хранения данных — нашей «базы данных» — мы будем хранить записи о времени на диске в формате JSON в файле с именем time.json. Ниже приведен пример того, как может выглядеть этот файл:

Дизайнерские решения

Наконец, давайте рассмотрим некоторые дизайнерские решения для нашего общего приложения.

Во-первых, мы сохраним activeProject на верхнем уровне наших данных JSON. Мы можем использовать это, чтобы быстро проверить, какой проект активен. Во-вторых, мы будем хранить поле activeEntry в каждом проекте, в котором хранится индекс записи, над которой в данный момент ведется работа.

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

Настройка проекта

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

npx oclif multi time-tracker

Эта команда создает новое многокомандное приложение oclif. С помощью многокомандного интерфейса командной строки мы можем запускать такие команды, как time-tracker add-project project-one и time-tracker start-timer project-one. В этих примерах и add-project, и start-timer являются отдельными командами, каждая из которых хранится в своем собственном исходном файле в проекте, но все они попадают под зонтик time-tracker CLI.

Несколько слов о заглушках

Мы хотим воспользоваться помощниками по тестированию, предоставленными @oclif/test. Для тестирования нашего конкретного приложения нам нужно написать простую заглушку. Вот почему:

Наше приложение записывает в файл timer.json в файловой системе. Представьте, если бы мы запускали наши тесты параллельно и имели десять тестов, которые одновременно записывались бы в один и тот же файл. Это запутает и приведет к непредсказуемым результатам.

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

Лучшей практикой при написании модульных тестов является замена драйвера чем-то другим. В нашем случае мы заменим драйвер FilesystemStorage по умолчанию драйвером MemoryStorage.

@oclif/test — это простая оболочка вокруг @oclif/fancy-test, которая добавляет некоторые функциональные возможности для тестирования команд CLI. Мы собираемся использовать функциональность заглушки в @oclif/fancy-test, чтобы заменить драйвер хранилища в нашей команде для тестирования.

Наша первая команда: Добавить проект

Теперь давайте поговорим о команде «добавить проект» и важных частях, связанных с макетированием файловой системы. Каждый новый проект oclif начинается с файла hello.js в src/commands. Мы переименовали его в файл add-project.js и заполнили по минимуму.

Сменное хранилище для тестов

Обратите внимание, как я статически назначаю экземпляр FilesystemStorage экземпляру AddProjectCommand.storage. Это позволяет мне — в моих тестах — заменить хранилище файловой системы реализацией хранилища в памяти. Давайте посмотрим на классы FilesystemStorage и MemoryStorage ниже.

FilesystemStorage и MemoryStorage имеют одинаковый интерфейс, поэтому в наших тестах мы можем менять их местами.

Первый тест для команды «Добавить проект»

В test/commands мы переименовали hello.test.js в add-project.test.js и написали наш первый тест:

Волшебство происходит в вызове stub. Мы заменяем FilesystemStorage на MemoryStorage (с пустым объектом для исходных данных). Затем мы устанавливаем ожидания относительно содержимого хранилища.

Распаковка тестовой команды From @oclif/Test

Прежде чем мы реализуем нашу команду, давайте убедимся, что мы понимаем наш тестовый файл. Наш блок describe вызывает test, который является точкой входа в @oclif/fancy-test (повторно экспортируется из @oclif/test).

Затем метод .stdout() фиксирует выходные данные команды, позволяя вам заявить о них с помощью ctx.stdout. Существует также метод .stderr(), но позже мы увидим, что есть еще один более предпочтительный метод обработки ошибок в @oclif/fancy-test..

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

Имейте в виду, что здесь есть большая ошибка! Если вы используете console.log для отладки во время разработки, то .stdout() захватит и эти выходные данные. Если вы не противостоите ctx.stdout, вы, вероятно, никогда не увидите эти выходные данные.

.stub(AddProjectCommand, 'storage', new MemoryStorage({}))

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

.command(['add-project', 'project-one'])

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

.it('test description', () => [...])

Возможно, вы знакомы с блоками it. Здесь вы обычно выполняете всю работу по настройке теста и запуску утверждений по результатам. Здесь все очень похоже, но вы, вероятно, уже проделали тяжелую работу по настройке теста с помощью других помощников из @oclif/test и @oclif/fancy-test, а блоку it нужно только подтвердить вывод команды.

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

1) add project
       should add a new project:
     Error: Unexpected argument: project-one
See more help with --help
      at validateArgs (node_modules/@oclif/parser/lib/validate.js:10:19)
      at Object.validate (node_modules/@oclif/parser/lib/validate.js:55:5)
      at Object.parse (node_modules/@oclif/parser/lib/index.js:28:7)
      at AddProjectCommand.parse (node_modules/@oclif/command/lib/command.js:86:41)
      at AddProjectCommand.run (src/commands/add-project.js:1:1576)
      at AddProjectCommand._run (node_modules/@oclif/command/lib/command.js:43:31)

Идеальный! Неудачный тест, как мы и ожидали. Давайте напишем код, чтобы получить зеленый цвет.

Переход к зеленому: реализация нашей команды

Теперь нам просто нужно следить за ошибками, чтобы написать нашу команду. Во-первых, нам нужно обновить класс AddProjectCommand, чтобы он знал аргументы, которые мы хотим передать. В этом случае мы передаем только имя проекта. Давайте внесем это изменение со следующим:

class AddProjectCommand extends Command {
  ...
}
AddProjectCommand.storage = new FilesystemStorage()
AddProjectCommand.description = 'Add a new project to the time tracking database'
// This is the update
AddProjectCommand.args = [
  {name: 'projectName', required: true},
]

Нам нужно сообщить oclif об ожидаемых аргументах нашей команды и их свойствах. В нашем случае аргумент всего один, projectName, и он обязателен. Вы можете узнать больше об аргументах oclif здесь и флагах oclif здесь.

Теперь мы снова запускаем тест, как показано ниже:

1) add project
       should add a new project:
AssertionError: expected {} to deeply equal { Object (activeProject, projects) }
      + expected - actual
-{}
      +{
      +  "activeProject": [null]
      +  "projects": {
      +    "project-one": {
      +      "activeEntry": [null]
      +      "entries": []
      +    }
      +  }
      +}
at Context.<anonymous> (test/commands/add-project.test.js:11:55)
      at async Object.run (node_modules/fancy-test/lib/base.js:44:29)
      at async Context.run (node_modules/fancy-test/lib/base.js:68:25)

Замечательный! Теперь мы видим, что, хотя мы и ожидали создания «проекта один», не было внесено никаких изменений в базовую структуру данных.

Давайте добавим в команду минимальное количество кода, необходимого для прохождения этого теста. Для краткости мы будем отображать только метод run() в src/commands/add-project.js.

По умолчанию, если файла нет, то при загрузке из хранилища мы получим пустой объект. Этот код создает любые свойства по умолчанию и их значения, если они не существуют (например, activeProject и projects), а затем создает новый проект со структурой по умолчанию — пустой массив entries и activeEntry, установленный на null.

Снова запустив тест, мы видим следующую ошибку:

1) add project
       should add a new project:
     AssertionError: expected '' to include 'Created new project "project-one"'
      at Context.<anonymous> (test/commands/add-project.test.js:20:27)
      at async Object.run (node_modules/fancy-test/lib/base.js:44:29)
      at async Context.run (node_modules/fancy-test/lib/base.js:68:25)

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

this.log(`Created new project "${args.projectName}"`)

Вуаля! Наше первое счастливое испытание пути проходит. Теперь мы путешествуем!

add project
    ✓ should add a new project (43ms)
1 passing (44ms)

Еще один тест

У нас есть еще один тест для AddProjectCommand. Нам нужно убедиться, что пользователь не может добавить другой проект с тем же именем, что и текущий проект. Для этих тестов нам неоднократно потребуется создавать базу данных для одного проекта. Давайте создадим помощник для этого.

В test/test-helpers.js добавить следующее:

Теперь мы можем добавить следующий тест в add-project.test.js:

В этом тесте есть новый метод:

.catch('Project "project-one" already exists')

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

После повторного запуска нашего теста мы видим следующее:

1) add project
       should return an error if the project already exists:
     Error: expected error to be thrown
      at Object.run (node_modules/fancy-test/lib/catch.js:8:19)
      at Context.run (node_modules/fancy-test/lib/base.js:68:36)

Сразу после того, как мы загрузим db из хранилища, нам нужно проверить, существует ли уже проект, и выдать ошибку, если он существует.

const db = await AddProjectCommand.storage.load()
// New code
if (db.projects?.[args.projectName]) {
    this.error(`Project "${args.projectName}" already exists`)
}

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

add project
    ✓ should add a new project (46ms)
    ✓ should return an error if the project already exists (76ms)

Заключение (на данный момент)

В этой статье — первой части нашей серии, состоящей из двух частей, посвященной библиотеке тестирования oclif — мы рассказали о oclif, его среде тестирования, о том, чем полезны заглушки и как их использовать. Затем мы начали писать тесты и реализацию для нашего time-tracker CLI.

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