Тестирование чистой функции на типе объединения, который делегирует другим чистым функциям - PullRequest
8 голосов
/ 11 октября 2019

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

function foo(arg: string|number) {
    if (typeof arg === 'string') {
        return fnForString(arg)
    } else {
        return fnForNumber(arg)
    }
}

Предположим, что fnForString() и fnForNumber() также являются чистыми функциями,и они уже сами были проверены.

Как можно пройти тестирование foo()?

  • Следует ли рассматривать тот факт, что он делегирует fnForString() и fnForNumber() как подробности реализации, и, по существу, дублировать тесты для каждого из них при написании тестов для foo()? Допустимо ли это повторение?
  • Должны ли вы написать тесты, которые "знают", что foo() делегируют fnForString() и fnForNumber(), например, путем их высмеивания и проверки, что он делегирует им?

Ответы [ 5 ]

5 голосов
/ 13 октября 2019

Лучшим решением было бы просто тестирование на foo.

fnForString и fnForNumber - это детали реализации, которые вы можете изменить в будущем, не обязательно изменив поведение foo. Если это произойдет, ваши тесты могут сорваться без причины, такая проблема делает ваш тест слишком обширным и бесполезным.

Вашему интерфейсу просто нужно foo, просто протестируйте его.

Если вынужно тестировать на fnForString и fnForNumber, держать этот вид теста отдельно от ваших тестов общедоступного интерфейса.

Это моя интерпретация следующего принципа, изложенного Кент Бек

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

2 голосов
/ 17 октября 2019

Краткий ответ: спецификация функции определяет способ, которым она должна быть проверена.

Длинный ответ:

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

В примере foo указан без спецификации, поэтому следует проверять foo, ничего не делая вообще (или, самое большее, некоторые глупые тесты для проверки). неявное требование, что «foo заканчивается так или иначе»).

Если спецификация является чем-то вроде операции, то «эта функция возвращает результат применения аргументов к fnForString или fnForNumber в соответствии с типом аргументов», тогдаиздевательство над делегатами (вариант 2) - это путь. Независимо от того, что происходит с fnForString / Number, foo остается в соответствии с его спецификацией.

Если спецификация не зависит от fnForType таким образом, то повторное использование тестов для fnFortype (вариант 1) является способомидти (при условии, что эти тесты хороши).

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

1 голос
/ 17 октября 2019

Предположим, что fnForString () и fnForNumber () также являются чистыми функциями, и они уже сами были протестированы.

Хорошо, поскольку детали реализации делегированы fnForString() и fnForNumber() для string и number соответственно, тестирование сводится к тому, чтобы просто убедиться, что foo вызывает правильную функцию. Так что да, я бы посмеялся над ними и убедился, что они вызваны соответствующим образом.

foo("a string")
fnForNumberMock.hasNotBeenCalled()
fnForStringMock.hasBeenCalled()

Поскольку fnForString() и fnForNumber() были проверены индивидуально, вы знаете, что когда вы звоните foo(), он вызываетправильная функция, и вы знаете, что функция делает то, что должна.

foo должна что-то возвращать. Вы можете вернуть что-то из своих макетов, каждый по-своему, и убедиться, что foo вернется правильно (например, если вы забыли return в своей функции foo ).

И все было покрыто.

1 голос
/ 15 октября 2019

В идеальном мире вы пишете доказательства вместо тестов. Например, рассмотрим следующие функции.

const negate = (x: number): number => -x;

const reverse = (x: string): string => x.split("").reverse().join("");

const transform = (x: number|string): number|string => {
  switch (typeof x) {
  case "number": return negate(x);
  case "string": return reverse(x);
  }
};

Допустим, вы хотите доказать, что transform, примененный дважды, является идемпотентным , т. Е. Для всех действительных входных данных x, transform(transform(x)) -равно x. Что ж, сначала нужно доказать, что negate и reverse, примененные дважды, являются идемпотентными. Теперь предположим, что доказательство идемпотентности negate и reverse, примененных дважды, тривиально, т.е. компилятор может это выяснить. Таким образом, у нас есть следующие леммы .

const negateNegateIdempotent = (x: number): negate(negate(x))≡x => refl;

const reverseReverseIdempotent = (x: string): reverse(reverse(x))≡x => refl;

Мы можем использовать эти две леммы, чтобы доказать, что transform является идемпотентом следующим образом.

const transformTransformIdempotent = (x: number|string): transform(transform(x))≡x => {
  switch (typeof x) {
  case "number": return negateNegateIdempotent(x);
  case "string": return reverseReverseIdempotent(x);
  }
};

Естьздесь многое происходит, поэтому давайте разберемся с этим.

  1. Так же, как a|b является типом объединения, а a&b является типом пересечения, a≡b является типом равенства.
  2. Значение x типа равенства a≡b является доказательством равенства a и b.
  3. Если два значения, a и b, не являютсяравным тогда невозможно построить значение типа a≡b.
  4. значение refl, сокращенное до рефлексивность , имеет тип a≡a. Это тривиальное доказательство того, что значение равно самому себе.
  5. Мы использовали refl в доказательстве negateNegateIdempotent и reverseReverseIdempotent. Это возможно, потому что предложения достаточно тривиальны для автоматического подтверждения компилятором.
  6. Мы используем леммы negateNegateIdempotent и reverseReverseIdempotent для доказательства transformTransformIdempotent. Это пример нетривиального доказательства.

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


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

Однако вы можете писать предложения в TypeScript.

const negateNegateIdempotent = (x: number): boolean => negate(negate(x)) === x;

const reverseReverseIdempotent = (x: string): boolean => reverse(reverse(x)) === x;

const transformTransformIdempotent = (x: number|string): boolean => {
  switch (typeof x) {
  case "number": return negateNegateIdempotent(x);
  case "string": return reverseReverseIdempotent(x);
  }
};

Затем вы можете использовать такую ​​библиотекукак jsverify для автоматической генерации тестовых данных для нескольких тестовых случаев.

const jsc = require("jsverify");

jsc.assert(jsc.forall("number", transformTransformIdempotent)); // OK, passed 100 tests

jsc.assert(jsc.forall("string", transformTransformIdempotent)); // OK, passed 100 tests

Вы также можете вызвать jsc.forall с помощью "number | string", но я не могу заставить его работать.


Итак, чтобы ответить на ваши вопросы.

Как нужно идти на тестирование foo()?

Функциональное программирование поощряет тестирование на основе свойств,Например, я протестировал функции negate, reverse и transform, примененные дважды для идемпотентности. Если вы следуете тестированию на основе свойств, то ваши функции предложения должны быть похожи по структуре на функции, которые вы тестируете.

Если учесть тот факт, что он делегирует fnForString() и fnForNumber() как детали реализации и, по существу, дублировать тесты для каждого из них при написании тестов для foo()? Это повторение приемлемо?

Да, приемлемо ли это. Хотя вы можете полностью отказаться от тестирования fnForString и fnForNumber, потому что тесты для них включены в тесты для foo. Однако для полноты я бы рекомендовал включить все тесты, даже если они вводят избыточность.

Если вы пишете тесты, которые "знают", что foo() делегируют fnForString() и fnForNumber(), например, путем насмешкиих и проверяет, что он делегирует им?

Предложения, которые вы пишете в тестировании на основе свойств, соответствуют структуре тестируемых функций. Следовательно, они «знают» о зависимостях, используя предложения других тестируемых функций. Не надо издеваться над ними. Вам нужно всего лишь высмеивать такие вещи, как сетевые вызовы, вызовы файловой системы и т. Д.

0 голосов
/ 18 октября 2019

Я думаю, что бесполезно проверять тип вашей функции, система может сделать это самостоятельно и позволить вам присвоить одно и то же имя каждому из типов объектов, которые вас интересуют

пример кода

  //  fnForStringorNumber String Wrapper  
String.prototype.fnForStringorNumber = function() {
  return  this.repeat(3)
}
  //  fnForStringorNumber Number Wrapper  
Number.prototype.fnForStringorNumber = function() {
  return this *3
}

function foo( arg ) {
  return arg.fnForStringorNumber(4321)
}

console.log ( foo(1234) )       // 3702
console.log ( foo('abcd_') )   // abcd_abcd_abcd_

// or simply:
console.log ( (12).fnForStringorNumber() )     // 36
console.log ( 'xyz_'.fnForStringorNumber() )   // xyz_xyz_xyz_

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

...