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