Работа с API ember-cli на примере генераторов простых юнит-тестов

ember-cli

TL;DR Исходники доступны по ссылке — ember-cli-test-generators

Немного теории

ember-cli — это официальная cli-утилита для работы с приложениями и аддонами, написанными на Ember.js. Первый коммит в репозиторий ember-cli был сделан еще в ноябре 2013 года, а первый релиз — в марте 2014 года. В то время были популярны генераторы для yeoman, например generator-ember (по понятным причинам он уже объявлен устаревшим). Сейчас же ember-cli — это инструмент, без которого невозможно представить свою работу с Ember.js.

Из большого списка функций этой утилиты выделим одну — генераторы. С их помощью по готовым шаблонам (blueprints) можно создавать разные архитектурные единицы, а так же вносить изменения в файлы проекта.

Каждый раз когда вы выполняете команды ember init или ember g, вы используете соответствующий blueprint. Откуда они берутся и как их создать самому? Если выполнить команду ember g --help, то будет выведено следующее (зависит от установленных дополнений):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Requested ember-cli commands:
 
ember generate <blueprint> <options...>
  Generates new code from blueprints.
 
  Available blueprints:
    ember-source:
      controller <name>
      controller-test <name>
      route <name> <options...>
      route-test <name> <options...>
      service <name>
      service-test <name>
    ember-data:
      adapter <name> <options...>
      adapter-test <name>

Часть вывода здесь пропущена для наглядности

Здесь выводится список всех доступных шаблонов для генерации. Обратите внимание, что шаблоны сгруппированны по имени аддона, которых их «содержит». То есть, аддон ember-source дает шаблоны для генерации Контроллеров, Роутов, Сервисов и т.д., а ember-data дает шаблоны для Адаптеров, Сериализаторов, Моделей и т.д. Если заглянуть в репозитории этих аддонов, то в каждом из них вы найдете папку blueprints, где и находятся соответствующие шаблоны.

Из шаблонов можно выделить две категории — одни создают/удаляют файлы целиком, а другие — вносят изменения в уже существующие файлы. Примером тут может быть Роут. При выполнении ember g route создается файл с Роутом, файл с его тестами, а так же вносятся изменения в существующий файл app/router.js.

Общий курс теории на этом можно заканчивать и переходить к практике.

Пишем код

Создадим отдельный аддон для наших шаблонов:

1
ember addon ember-cli-test-generators

Данная команда создает базовую файловую структуру для аддона. Что интересно, эта команда так же использует соответствующий blueprint.

Шаблон test-model-belongs-to

Для создания шаблона есть отдельный blueprint (тут нужна картинка из фильма Начало или «парень с мониторами»). Выполним в консоли:

1
ember g blueprint test-model-belongs-to

В результате будет создан файл blueprints/test-model-belongs-to/index.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* eslint-env node */
module.exports = {
  description: ''
 
  // locals(options) {
  //   // Return custom template variables here.
  //   return {
  //     foo: options.entity.options.foo
  //   };
  // }
 
  // afterInstall(options) {
  //   // Perform extra work here.
  // }
};

С помощью данного шаблона можно будет создать тест для проверки атрибута Модели на то, что он является связью belongs-to с другой Моделью. При этом, мы не будем поддерживать pods, будем работать только с новым синтаксисом для тестов (см. RFC#232) и только с ember-qunit.

Так как в последствии мы будет тестировать работу наших шаблонов для генерации, то сразу установим аддон ember-cli-blueprint-test-helpers:

1
ember i ember-cli-blueprint-test-helpers

После этого мы сможем создать файлы с тестами для шаблонов через команду ember g blueprint-test:

1
ember g blueprint-test test-model-belongs-to

В результате получим файл node-tests/blueprints/test-model-belongs-to-test.js с содержимым:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
'use strict';
 
const blueprintHelpers = require('ember-cli-blueprint-test-helpers/helpers');
const setupTestHooks = blueprintHelpers.setupTestHooks;
const emberNew = blueprintHelpers.emberNew;
const emberGenerateDestroy = blueprintHelpers.emberGenerateDestroy;
 
const expect = require('ember-cli-blueprint-test-helpers/chai').expect;
 
describe('Acceptance: ember generate and destroy test-model-belongs-to', function() {
  setupTestHooks(this);
 
  it('test-model-belongs-to foo', function() {
    let args = ['test-model-belongs-to', 'foo'];
 
    // pass any additional command line options in the arguments array
    return emberNew()
      .then(() => emberGenerateDestroy(args, (file) => {
        // expect(file('app/type/foo.js')).to.contain('foo');
    }));
  });
});

После установки аддона ember-cli-blueprint-test-helpers при выполнении команды ember g blueprint будет создано два файла — один с шаблоном, а второй с тестами.

В команде ember g параметрами передаются имя шаблона, а после него идут опции для заданного шаблона. Сделаем так, чтоб наш шаблон использовался в виде:

1
ember g test-model-belongs-to modelName:attrName --related=relatedAttrName --inverse=inverseAttrName

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

Параметры из командой строки

Для начала надо описать, какие параметры мы ожидаем и как получить их значения. Свойство availableOptions нам в этом поможет:

1
2
3
4
availableOptions: [
  {name: 'related', type: String, aliases: ['r']},
  {name: 'inverse', type: String, aliases: ['in']},
]

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

У каждого экземпляра класса Blueprint есть четыре метода — beforeInstall, afterInstall, beforeUninstall и afterUninstall. Первая пара используется, когда вы выполняете ember generate, а вторая — когда ember destroy. Вторая пара нами никак не будет использоваться, так как наш шаблон поддерживает только вставку кода, а не его удаление. Каждый из указанных методов получает один параметр — объект options. Из него можно получить параметры, с которыми используется генератор. Воспользуемся хуком beforeInstall, что проверить параметры генератора, а так же получить их значения:

1
2
3
4
5
6
7
8
9
10
11
12
13
beforeInstall(options) {
  const chunks = options.entity.name.split(':');
  if (chunks.length === 1) {
    return Promise.reject(new SilentError('Use `modelName:attrName` format'));
  }
  this.model = chunks[0];
  this.attr = chunks.slice(1).join(':');
  this.related = options.related;
  if (!options.related) {
    return Promise.reject(new SilentError('--related is required'));
  }
  this.inverse = options.inverse;
}

Здесь SilentError — это объект из модуля silent-error. Его можно установить командой:

1
npm i --save silent-error

Чтоб класс SilentError стал доступен, его надо подключить:

1
const SilentError = require('silent-error');

Так как мы создаем тест для Модели из ember-data, то надо убедиться, что данный аддон вообще установлен. Есть два пути. В первом случае можно просто проверить наличие шаблона model вызвав lookupBlueprint:

1
this.lookupBlueprint('model');

В случае, если такого шаблона нет, будет выброшена ошибка Unknown blueprint: model.

Второй вариант — это проверить наличие ember-data в файле package.json проекта:

1
2
3
if (!('ember-data' in this.project.dependencies())) {
  return Promise.reject(new SilentError('Please, install `ember-data` before using this generator'));
}

Тут this.project — это экземпляр класса Project. Его метод dependencies возвращает хэш с зависимостями, объявленными в package.json.

Во втором случае сообщение выглядит куда более дружественным и понятным для пользователя (так как мы сами можем его задать).

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

В хуке afterInstall будет выполнена вставка теста в нужный файл. Само собой, что для начала надо проверить, что нужный файл существует:

1
2
3
4
const filePath = `tests/unit/models/${this.model}-test.js`;
if (!fs.existsSync(filePath)) {
  return Promise.reject(new SilentError(`File "${filePath}" was not found`));
}

Здесь fs — это модуль node.js (см. документацию).

Обновление файла с тестами

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

Ниже представлен полный код метода для вставки теста:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
afterInstall() {
  const filePath = `tests/unit/models/${this.model}-test.js`;
  if (!fs.existsSync(filePath)) {
    return Promise.reject(new SilentError(`File "${filePath}" was not found`));
  }
  let msg = `#${this.attr} belongs to "${this.related}"`;
  if (this.inverse) {
    msg = msg + ` inverted as #${this.inverse}`;
  }
  return this.insertIntoFile(filePath, [
    `${EOL}  test('${msg}', function(assert) {`,
    `    const Model = this.owner.lookup('service:store').modelFor('${this.model}');`,
    `    // eslint-disable-next-line`,
    `    const relationship = get(Model, 'relationshipsByName').get('${this.attr}');`,
    `    assert.equal(relationship.type, '${this.related}');`,
    `    assert.equal(relationship.kind, 'belongsTo');`,
    this.inverse ? `    assert.equal(relationship.options.inverse, '${this.inverse}');` : '',
    `  });`
  ].filter(_ => !!_).join(EOL), {after: 'setupTest(hooks);'});
}

Тут EOL — это константа из модуля os.

В нашем случае код теста будет вставлен после строки setupTest(hooks);. В случаях, когда вставка должна выполняться по более сложным условиям, метод insertIntoFile уже не подходит и приходится использовать более сложные решения. Вспомним команду ember g route. При ее выполнении создаются файлы с Роутом, Шаблоном, а так же вносятся изменения в файл app/router.js. Так как Роуты могут быть вложенными в другие Роуты и Router.map содержит в себе всю эту вложенность, то очевидно, что нельзя применить метод insertIntoFile для модификации router.js. В таком случае приходится строить абстрактное синтаксическое дерево этого файла, добавлять в него новые узлы, а потом из этого дерева снова собирать код этого файла. Процесс получается достаточно громоздким, потому он был вынесен в отдельный аддон ember-router-generator. Для работы с AST он использует recast, который, в свою очередь, использует esprima.

Умение работать с AST — достаточно полезный навык, потому рекомендуем ознакомиться с вышеуказанными модулями.

Выполним в консоли следующую команду:

1
ember g test-model-belongs-to user:team --related=team --inverse=users

В файл tests/unit/models/user-test.js будет добавлен код теста и он будет выглядеть так:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// tests/unit/models/user-test.js
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
 
module('Unit | Model | user', function(hooks) {
  setupTest(hooks);
  test('#team belongs to "team" inverted as #users', function(assert) {
    const Model = this.owner.lookup('service:store').modelFor('user');
    // eslint-disable-next-line
    const relationship = get(Model, 'relationshipsByName').get('team');
    assert.equal(relationship.type, 'team');
    assert.equal(relationship.kind, 'belongsTo');
    assert.equal(relationship.options.inverse, 'users');
  });
 
 
  // Replace this with your real tests.
  test('it exists', function(assert) {
    let store = this.owner.lookup('service:store');
    let model = store.createRecord('user', {});
    assert.ok(model);
  });
});

Тест сам себе достаточно простой. Тут стоит обратить внимание на строку, где выключается проверка ESLint. Это сделано из-за того, что get(Model, 'relationshipsByName') возвращает экземпляр класса MapWithDefault, который наследуется от Map. У него есть метод get. При его использовании ESLint «ругается», что надо использовать Эмберовский get, а не get самого объекта (хотя это два разных метода). Данная ситуация обсуждалась в eslint-plugin-ember#28 и там уже есть несколько вариантов ее решения.

Еще стоит обратить внимание на то, что get используется, хотя его импорт из @ember/object отсутствует. Это останется на «самостоятельную работу».

Если в консольной команде выше убрать параметр --inverse=users, то в тесте не будет проверки relationship.options.inverse.

Шаблон для нашего теста готов. Теперь надо покрыть его тестами.

Тестирование шаблона test-model-belongs-to

После установки аддона ember-cli-blueprint-test-helpers становятся доступны шаблоны тестов других шаблонов. Если файл с тестами для шаблона test-model-belongs-to еще не создан, то его можно создать следующей командой:

1
ember g blueprint-test test-model-belongs-to

Будет создан файл node-tests/blueprints/test-model-belongs-to-test.js с содержимым:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
'use strict';
 
const blueprintHelpers = require('ember-cli-blueprint-test-helpers/helpers');
const setupTestHooks = blueprintHelpers.setupTestHooks;
const emberNew = blueprintHelpers.emberNew;
const emberGenerateDestroy = blueprintHelpers.emberGenerateDestroy;
 
const expect = require('ember-cli-blueprint-test-helpers/chai').expect;
 
describe('Acceptance: ember generate and destroy test-model-belongs-to', function() {
  setupTestHooks(this);
 
  it('test-model-belongs-to foo', function() {
    let args = ['test-model-belongs-to', 'foo'];
 
    // pass any additional command line options in the arguments array
    return emberNew()
      .then(() => emberGenerateDestroy(args, (file) => {
        // expect(file('app/type/foo.js')).to.contain('foo');
    }));
  });
});

По умолчанию используется связка mocha.js + chai.js.

Здесь blueprintHelpers — это набор вспомогательных функций, которые имитируют реальное окружение, в котором выполняются консольные команды. Вообще, все наши тесты будут приемочными. Юнит-тестов у нас не будет.

Метод emberGenerateDestroy (который нам любезно предлагают) подходит для тестирования шаблонов, которые создают и удаляют целые файлы, так как в самом конце внутри него проверяется, что файл, с которым он работает, удален. Нам это не подходит. А вот метод blueprintHelpers.emberGenerate — это то, что нужно. Его вызов эквисвалентен ember g .... Метод emberNew должен быть вызван перед тестами, так как он создает скелет приложения на EmberJS.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const {
  emberNew,
  emberGenerate,
  emberDestroy,
  setupTestHooks
} = blueprintHelpers;
 
describe('Acceptance: ember g test-model-belongs-to', function() {
  setupTestHooks(this);
  describe('in app', function () {
    beforeEach(function () {
      return emberNew();
    });
    // all tests are going to be here
  });
});

Тестирование исключений

Для тестирования исключений, брошенных при выполнении генератора, напишем вспомогательную функцию expectError (а точнее, возьмем готовую из репозитория ember.js — helpers/expect-error):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// node-tests/helpers/expect-error.js
'use strict';
 
const chai = require('ember-cli-blueprint-test-helpers/chai');
const expect = chai.expect;
 
module.exports = function expectError(promise, expectedErrorText) {
  return promise
    .then(() => {
      throw 'the command should raise an exception';
    })
    .catch(error => {
      expect(error.message).to.equal(expectedErrorText);
    });
};

Напишем два теста, который проверяет формат параметров:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
describe('errors', function () {
 
  it(`ember g test-model-belongs-to ${modelName}`, function () {
    let args = ['test-model-belongs-to', 'user'];
    return expectError(
      emberGenerate(args),
      'Use `modelName:attrName` format'
    );
  });
 
  it(`ember g test-model-belongs-to ${modelName}:${attrName}`, function () {
    let args = ['test-model-belongs-to', `${modelName}:${attrName}`];
    return expectError(
      emberGenerate(args),
      '--related is required'
    );
  });
 
});

Первый тест проверяет, что имя модели и имя атрибута передаются одним параметром в формате modelName:attrName. Второй тест проверяет, что параметр related является обязательным.

Проверка изменений файлов

Так как в проекте, созданном через emberNew, нет файла с тестами для требуемой Модели, то надо его создать. Всего одна строчка кода нам в этом поможет — emberGenerate(['model-test', 'user']). Это возможно, если аддон ember-data у нас уже установлен (а он ставится по умолчанию).

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

1
2
3
4
5
6
7
8
9
describe('should add test to the correct file', function () {
  beforeEach(function () {
    return emberGenerate(['model-test', 'user']);
  });
 
  afterEach(function () {
    return emberDestroy(['model-test', 'user']);
  });
});

Так как тестируем мы содержимое файлов, то воспользуемся специальным аддоном chai-files. Устанавливать его самостоятельно нет необходимости, потому что он идет в комплекте с аддоном ember-cli-blueprint-test-helpers:

1
2
const chai = require('ember-cli-blueprint-test-helpers/chai');
const {file, expect} = chai;

С помощью file мы получим реальное содержимое файла. А вот где хранить ожидаемое содержимое для сравнения? Лучше всего тут подойдет отдельный каталог fixtures, где каждый файл — это ожидаемое содержимое для какого-то одного теста. Для чтения их содержимого напишем возьмем готовую функцию fixture из репозитория ember.js (#):

1
2
3
4
5
6
7
8
9
// node-tests/helpers/fixture.js
'use strict';
 
const path = require('path');
const file = require('ember-cli-blueprint-test-helpers/chai').file;
 
module.exports = function(filePath) {
  return file(path.join(__dirname, '../fixtures', filePath));
};

Немного выше было представлено содержимое файла user-test.js. Возьмем его и запишем в файл node-tests/fixtures/test-model-belongs-to/user-team-with-inverse.js. Теперь можно написать тест, который проверит выполнение команды:

1
2
3
4
5
6
7
8
it('ember g test-model-belongs-to user:team --related=team --inverse=users', function () {
  let args = ['test-model-belongs-to', 'user:team', '--related=team', '--inverse=users'];
  return emberGenerate(args)
    .then(() => {
      expect(file(`tests/unit/models/user-test.js`))
        .to.equal(fixture('test-model-belongs-to/user-team-with-inverse.js'));
    });
});

Тест для варианта без параметра inverse будет очень похожим:

1
2
3
4
5
6
7
8
it('ember g test-model-belongs-to user:team --related=team', function () {
  let args = ['test-model-belongs-to', 'user:team', '--related=team'];
  return emberGenerate(args)
    .then(() => {
      expect(file(`tests/unit/models/user-test.js`))
        .to.equal(fixture('test-model-belongs-to/user-team.js'));
    });
});

Содержимое user-team.js отличается от user-team-with-inverse.js отсутствием строчки:

1
assert.equal(relationship.options.inverse, 'users');

Запуск тестов

Для запуска тестов снова воспользуемся готовым решением из репозитория ember.js (#). Создаем файл node-tests/nodetest-runner.js с содержимым:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
'use strict';
 
const glob = require('glob');
const Mocha = require('mocha');
 
const mocha = new Mocha({
  timeout: 5000,
  reporter: 'spec',
});
 
mocha.files = glob.sync('node-tests/{blueprints,acceptance,unit}/**/*-test.js');
 
mocha.run(function(failures) {
  process.on('exit', function() {
    process.exit(failures);
  });
});

Здесь задаются настройки для mocha — максимальное время выполнения одного теста, glob с файлами тестов и формат вывода результатов тестов. Так же в этом файле выполняется запуск mocha.

Добавим в package.json следущую строку:

1
2
3
4
5
{
  "scripts": {
    "test:blueprints": "node node-tests/nodetest-runner.js"
  }
}

Теперь для запуска тестов в консоли достаточно выполнить:

1
npm run test:blueprints

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

Мутируем

Мутационное тестирование — это метод тестирования программного обеспечения, который включает небольшие изменения кода программы. Если набор тестов не в состоянии обнаружить такие изменения, то он рассматривается как недостаточный (из Википедии).

В JavaScript/TypeScript есть отличное решение для мутационного тестирования — stryker. Его можно установить через stryker-cli:

1
npm install -g stryker-cli

Далее выполняем stryker init, отвечаем на вопросы и все — Stryker готов к использованию. В нашем случае файл с настройками для Страйкера (stryker.conf.js) выглядит так:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
module.exports = function (config) {
  config.set({
    testRunner: 'mocha',
    reporter: ['clear-text', 'html', 'dashboard'],
    packageManager: 'npm',
    testFramework: 'mocha',
    coverageAnalysis: 'all',
    mutate: ['blueprints/**/index.js'],
    mutator: 'javascript',
    thresholds: {
      high: 90,
      low: 70,
      break: null
    },
    mochaOptions: {
      files: ['node-tests/{blueprints,acceptance,unit}/**/*-test.js'],
      timeout: 5000,
      reporter: 'spec'
    }
  });
};

В package.json добавим еще один скрипт:

1
2
3
4
5
6
{
  "scripts": {
    "test:blueprints": "node node-tests/nodetest-runner.js",
    "test:mut": "./node_modules/.bin/stryker run"
  }
}

Теперь запуск мутационных тестов выглядит так:

1
npm run test:mut

Стоит учитывать, что мутационные тесты выполняются дольше, так как по сути это многократный запуск исходного набора тестов, но с рядом изменений в исходниках. На официальном сайте есть детальное описание технического процесса выполнения тестов «под Страйкером» — Technical reference.

В результате выполнения тестов мы получим подробный отчет о том, какие фрагмента кода не протестированы вообще, а какие покрыты тестами недостаточно. При использовании html reporter после прогона тестов будет создана папка reports, где будет красивый отчет. Из него можно узнать, какие именно мутанты «выжили» и где.

Stryker report example

В консоли же будет выведена сводная таблица по всем файлам:

1
2
3
4
5
6
----------|---------|----------|-----------|------------|----------|---------|
File      | % score | # killed | # timeout | # survived | # no cov | # error |
----------|---------|----------|-----------|------------|----------|---------|
All files |   87.04 |       47 |         0 |          6 |        1 |       0 |
 index.js |   87.04 |       47 |         0 |          6 |        1 |       0 |
----------|---------|----------|-----------|------------|----------|---------|

CI

Шаблон для аддонов ember-cli уже содержит в себе практически все, что надо для использования travis-ci в качестве CI. Остается добавить в него запуск тестов для нашего генератора. Для этого надо сделать всего две вещи. Первое — это добавить один сценарий в config/ember-try.js:

1
2
3
4
5
6
7
{
  name: 'node-tests',
  command: 'npm run test:blueprints',
  npm: {
    devDependencies: {}
  }
}

Второе — это добавить созданный сценарий в .travis.yml:

1
2
matrix:
  - EMBER_TRY_SCENARIO=node-tests

Вообще, все остальные сценарии в этом файле можно удалить, так как у нас никакого «Эмбер-зависимого» кода.

При желании похожим образом можно добавить и выполнение мутационных тестов.

Вместо эпилога

На примере создания одного шаблона мы рассмотрели часть API ember-cli, инструменты для тестирования шаблонов, а так же варианты самих тестов.

Все исходники собраны в отдельный аддон — ember-cli-test-generators, который можно установить следующей командой:

1
ember i ember-cli-test-generators

Помимо шаблона test-model-belongs-to в нем есть еще четыре:

  • test-model-has-many
  • test-model-attr-cpv-length
  • test-model-attr-cpv-number
  • test-model-attr-cpv-confirmation

Первый создает тест для проверки связи has-many между Моделями. Три последующих служат для проверки атрибутов Моделей в случаях, когда для этого используется аддон ember-cp-validations и его валидаторы Length, Number и Confirmation соответственно.

, ,

Оставить комментарий

Top ↑ | Main page | Back