Computed properties и «computed» тесты

Ember

В одной из предыдущих заметок я писал о «кастомизации» макросов computed.or и computed.and (ссылка). За кадром остался вопрос тестирования таких функций. А что собственно надо проверить? В первую очередь — надо убедиться, что в dependent keys нет «!». Далее — проверить, что свойства пересчитываются правильно при изменении зависимых значений.

Здесь и далее примеры тестов даны для связки mochajs/sinonjs/chaijs.

// some imports are skipped
 
const {
    computed,
    Object: eO
} = Ember;
 
describe('computed.customAnd', function () {
    beforeEach(function () {
        this.neededKeys = ['p1', 'p2', 'p3'];
        this.obj = eO.create({
            p1: true,
            p2: true,
            p3: true,
            p4: computed.customAnd('p1', 'p2', 'p3'),
            p5: computed.customAnd('!p1', '!p2', '!p3')
        });
    });
 
    it('dependent keys should be without "!" (normal keys)', function() {
        expect(this.obj.p4._dependentKeys).to.include.members(this.neededKeys);
        expect(this.neededKeys).to.include.members(this.obj.p4._dependentKeys);
    });
 
    it('dependent keys should be without "!" (inverted keys)', function() {
        expect(this.obj.p5._dependentKeys).to.include.members(this.neededKeys);
        expect(this.neededKeys).to.include.members(this.obj.p5._dependentKeys);
    });
 
    it('`p4` depends on "normal" keys', function () {
        expect(this.obj.get('p4'), 'default value').to.be.true;
        this.obj.toggleProperty('p1');
        expect(this.obj.get('p4'), 'p1 is `false`').to.be.false;
        this.obj.toggleProperty('p1');
        this.obj.toggleProperty('p2');
        expect(this.obj.get('p4'), 'p2 is `false`').to.be.false;
        this.obj.toggleProperty('p2');
        this.obj.toggleProperty('p3');
        expect(this.obj.get('p4'), 'p3 is `false`').to.be.false;
    });
 
});

Здесь частично оттестирован «сферический computed.customAnd в вакууме». В «настоящих» объектах (контроллеры, компоненты, модели) будут реальные ключи и реальные зависимости. Писать полную таблицу истинности для функции and при каждом использовании макроса — не вариант. Количество комбинаций — 2 ^ n, где n — количество ключей от которых зависит computed property. Серьезно, кто в здравом уме будет «ручками» все это прописывать? 🙂

Вполне реально написать небольшой генератор тестов для and (для or практически тоже самое). Точка входа:

/**
 * @param {Ember.Object} context
 * @param {string} propertyName
 * @param {string[]} dependentKeys
 */
function testAsComputedCustomAnd(context, propertyName, dependentKeys) {
    // some code will be here
}

Здесь context — это объект, у которого есть тестируемое propertyName, которое зависит от dependentKeys.

«Смапим» правильные ключи:

var realKeys = dependentKeys.invoke('replace', '!', '');

Функция and возвращает false всегда кроме случая, когда все ее аргументы true. Так как у нас функция может зависеть от инвертированных значений аргументов, то вариант «все true» не подходит. Подойдет комбинация, которая высчитывается таким образом:

/**
 * Get boolean combination for string-keys
 * Example:
 * <code>
    * var dependentKeys = ['a', '!b', 'c'];
    * var result = getTrulyCombination(dependentKeys);
    * console.log(result); // {a: true, b: false, c: true}
    * </code>
 *
 * @param {string[]} dependentKeys
 */
function getTrulyCombination(dependentKeys) {
  var hash = {};
  dependentKeys.forEach(key => {
    if ('!' === key[0]) {
      hash[key.substr(1)] = false;
    }
    else {
      hash[key] = true;
    }
  };
  return hash;
}

Далее надо построить «таблицу истинности» для количества dependentKeys. Почему в кавычках? Потому что, по сути, будут созданы только возможные комбинации, а вот результаты вычислений функции and не будут вычисляться:

/**
 *Generates array of all possible boolean combinations
 * Example:
 * <code>
    *   var keys = ['a', 'b'];
    *   var result = getBinaryCombos(keys);
    *   console.log(result); // [{a: true, b: true}, {a: true, b: false}, {a: false, b: true}, {a: false, b: false}]
    * </code>
 * @param {string[]} items
 */
function getBinaryCombos(items) {
  var result = [];
  var l = items.length;
  var combosCount = Math.pow(2, l);
  for (var i = 0; i < combosCount; i++) {
    var combo = {};
    for (var j = 0; j < l; j++) {
      var x = Math.pow(2, j);
      var key = items[j];
      combo[key] = !!(i & x);
    }
    result.push(combo);
  }
  return result;
}

Теперь дело за малым. Для всех сгенерированных комбинаций надо выполнить вычисление computed property и сравнить результат с ожидаемым. Тут стоит учесть, что свойства, от которых зависит тестируемое поле, сами могут быть computed properties со своими наборами ключей (и своими get, set). По-хорошему, надо написать тест так, что было все равно на то, что являет собой зависимое свойство. Говоря проще, надо «застабать» метод get для context. Как-то так:

// some imports are skipped
const {get} = Ember;
/**
 * Stub `get`-method for `context`
 * Example:
 * <code>
    *  var hash = {a: 1, b: 2};
    *  var obj = Ember.Object.create({a: 4, b: 5, c: 6});
    *  stubGet(obj, hash);
    *  console.log(obj.get('a')); // 1
    *  console.log(obj.get('b')); // 2
    *  console.log(obj.get('c')); // 6
    * </code>
 *
 * @param {Ember.Object} context
 * @param {object} hash result of `getBinaryCombos`
 */
function stubGet(context, hash) {
  sinon.stub(context, 'get', function (k) {
    if (hash.hasOwnProperty(k)) {
      return hash[k];
    }
    return get(context, k);
  });
}

Такой stubGet надо выполнять для каждой комбинации, полученной из getBinaryCombos. Остается вопрос с «ожидаемым результатом». Как его определить? Для нашей функции and результат будет true для комбинации, которая равна результату getTrulyCombination. В других случаях будет false. Два объекта будем сравнивать используя функцию:

/**
 * Check if two object have same keys with same values
 *
 * @param {object} hash1
 * @param {object} hash2
 */
function checkBinaryCombosEquality(hash1, hash2) {
  if (Object.keys(hash1).length !== Object.keys(hash2).length) {
    return false;
  }
  function subObj(obj1, obj2) {
    var keys = Object.keys(obj1);
    for (let i = 0; i < keys.length; i++) {
      const k = keys[i];
      if (obj1[k] !== obj2[k]) {
        return false;
      }
    }
    return true;
  }
  if (!subObj(o1, o2) || !subObj(o2, o1)) {
    return false;
  }
  return true;
}

Пришло время собрать все это в кучу:

// some imports are skipped
const {propertyWillChange, propertyDidChange, tryInvoke} = Ember;
/**
 * @param {Ember.Object} context
 * @param {string} propertyName
 * @param {string[]} dependentKeys
 */
function testAsComputedCustomAnd(context, propertyName, dependentKeys) {
 
    // initial calculations
    var realKeys = dependentKeys.invoke('replace', '!', '');
    var trulyCombination = getTrulyCombination(dependentKeys);
    var binaryCombos = getBinaryCombos(realKeys);
 
    // tests
    describe(`#${propertyName} as Ember.computed.customAnd`, function () {
 
        afterEach(function () {
            tryInvoke(context.get, 'restore');
        });
 
        it('has valid dependent keys (without `!`)', function() {
            expect(context[propertyName]._dependentKeys).to.include.members(realKeys);
            expect(realKeys).to.include.members(context[propertyName]._dependentKeys);
        });
 
        binaryCombos.forEach(function (combo) {
            var expectedResult = checkBinaryCombosEquality(trulyCombination, combo);
 
            it(`${expectedResult} for ${JSON.stringify(combo)}`, function () {
                propertyWillChange(context, propertyName);
                stubGet(context, combo);
                propertyDidChange(context, propertyName);
                var value = context.get(propertyName);
                expect(value).to.equal(expectedResult);
            });
 
        });
 
    });
 
}

В afterEach-хуке выполняется restore (если он есть) для метода get проверяемого объекта.

Крик из зала: «Зачем вообще городить такой огород? Сколько кода написано, который всего-то проверяет простую логическую функцию and! Та я лучше проверю каждый зависимый ключик через false и еще всех на true! Придумал тут…»

Из своей практики я могу сказать следующее:

  • Если computed property являет собой какую-то простую логическую функцию на два аргумента, то на нее будет написано 4 теста (TT, TF, FT, FF). Все они будут написаны «вручную» полностью — без каких-либо циклов/генераторов и т.д.
  • Если computed property являет собой функцию and на три ключа, то скорее всего на нее будет написана отдельно таблица истинности, по которой будет идти цикл и подставлять ее варианты как значения зависимых ключей. Не исключен вариант, что тест будет всего один — на все true.
  • Для and на 4+ аргументов тесты будут разве что на вариант, когда все зависимые ключи true.
  • Для любого из вышеизложенных вариантов тестов может не быть вообще.

Ответ: «Если человеку надо написать 20-30 строк тестов, он может не написать и ни одной. А если надо написать всего одну, то (скорее всего) он ее напишет».

Есть еще пара вариантов, когда наличие таких функций-генераторов тестов будет полезным:

  • В каком-то объекте есть computed property (назовем его prop1), реализацию которого «вроде бы» можно заменить на computed.customAnd с определенным набором ключей. Перед такой заменой неплохо бы прогнать набор тестов для computed.customAnd для текущей реализации prop1. Если тесты прошли, то можно делать замену.
  • В каком-то объекте есть computed property (prop2), которое уже реализовано через computed.customAnd и для него уже написан ряд тестов («вручную» проверяется несколько комбинаций значений для зависимых ключей). В таком случае, можно уменьшить количество кода в тестах, заменив старые тесты для prop2 на однострочный вызов функции testAsComputedCustomAnd(/* args */), которая создаст как минимум такое-же покрытие (а чаще — большее).

С and все решено. Теперь надо дописать код для тестирования макроса на основе or.

Функция or всегда возвращает true, кроме случая, когда все ее аргументы false (тут, опять-таки, надо помнить про то, что ключи могут быть с инверсией). Соответственно, если для and мы брали «truly combination», то для or надо брать «falsy combination»:

/**
 * Get boolean combination for string-keys
 * Example:
 * <code>
    * var dependentKeys = ['a', '!b', 'c'];
    * var result = getFalsyCombination(dependentKeys);
    * console.log(result); // {a: false, b: true, c: false}
    * </code>
 *
 * @param {string[]} dependentKeys
 */
function getFalsyCombination(dependentKeys) {
  var hash = {};
  dependentKeys.forEach(function (key) {
    if (key.startsWith('!')) {
      hash[key.substr(1)] = true;
    }
    else {
      hash[key] = false;
    }
  });
  return hash;
}

Метод-обертка для тестов or очень незначительно отличается от аналогичного для тестов and:

// some imports are skipped
const {propertyWillChange, propertyDidChange, tryInvoke} = Ember;
/**
 * @param {Ember.Object} context
 * @param {string} propertyName
 * @param {string[]} dependentKeys
 */
function testAsComputedCustomOr(context, propertyName, dependentKeys) {
 
    var realKeys = dependentKeys.invoke('replace', '!', '');
    var falsyCombination = getFalsyCombination(dependentKeys);
    var binaryCombos = getBinaryCombos(realKeys);
 
    describe(`#${propertyName} as Ember.computed.customOr`, function () {
 
        afterEach(function () {
            tryInvoke(context.get, 'restore');
        });
 
        it('has valid dependent keys (without `!`)', function() {
            expect(context[propertyName]._dependentKeys).to.include.members(realKeys);
            expect(realKeys).to.include.members(context[propertyName]._dependentKeys);
        });
 
        binaryCombos.forEach(function (combo) {
            var expectedResult = !checkBinaryCombosEquality(falsyCombination, combo);
 
            it(`${expectedResult} for ${JSON.stringify(combo)}`, function () {
                propertyWillChange(context, propertyName);
                stubGet(context, combo);
                propertyDidChange(context, propertyName);
                var value = context.get(propertyName);
                expect(value).to.equal(expectedResult);
            });
 
        });
 
    });
 
}

Код готов к использованию. Пример: testAsComputedCustomOr(myObject, 'myCoolProperty', ['dependentKey1', 'dependentKey2']). На выходе получается describe с понятным пояснением и 5 (1 + 2 ^ 2) it с тестами (и описаниями, в которых есть как ожидаемый результат, так и входные данные).

Представленный подход можно применять при тестировании практически любых Ember.computed-макросов. Ведь каждый макрос сам по себе описывает одно простое поведение. Отличия только в контексте и зависимых ключах. Так зачем писать больше? 🙂

Код из заметки собран в отдельном gist’е — ссылка.

, , , ,

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

Top ↑ | Main page | Back