Итак, я написал еще один генератор предикатов типов для TypeScript. Круто, и что дальше?

Так как мой Генератор предикатов это про безопасность типов, корректности и в целом доверие, почему бы не пойти дальше и не сгенерировать еще и тестовый набор для функции предиката прямо рядом с самим кодом?

Прежде чем вы скажете это, я попробовал сначала заставить ИИ сделать всё за меня, но результаты были нестабильными, хоть и вполне приемлемыми.

Зачем

Итак, мы генерируем тесты для функции предиката типов. С чего начать? Давайте сначала посмотрим на простую функцию предиката.

type MyNumber = number

function isMyNumber(value: unknown): value is MyNumber {
  if (typeof value !== "number") {
    return false
  }
  value satisfies MyNumber
  return true
}

Вы можете видеть, что эта функция использует оператор typeof для проверки, является ли value примитивным типом number. В JS/TS все числа относятся к этому типу. Затем функция использует оператор типа satisfies, чтобы убедиться, что тип value правильно сузился до number, который satisfies требуемому типу MyNumber.

На этом историю можно было бы закончить, но в TypeScript тело функции предиката типов не проверяется на фактическую корректность в отношении анализа переданного значения. Единственное требование состоит в том, чтобы функция возвращала boolean. Можно легко написать такую функцию, как ниже, и не получить ошибок от компилятора TypeScript:

function isMyNumber(value: unknown): value is MyNumber {
  // Просто доверься мне!
  return true
}

Вот почему нам нужно тщательно проверять функции предикатов типов, чтобы не вводить себя в заблуждение, что код делает то, что он делает. Один из способов, как вы видели, — это всегда использовать оператор типа satisfies перед фактическим возвращением true (и еще лучше, если в функции будет только один return true, чтобы быть уверенным). Другой способ — это тема этого поста: генерация тестовых значений для данного типа.

Что

Генерация тестовых значений сводится к созданию двух наборов значений: валидных и невалидных. Эти значения затем передаются в функцию предиката типов. Функция должна возвращать true для всех валидных значений и false для всех невалидных значений.

Для нашего простого примера наборы значений будут следующими:

type MyNumber = number

const valid = [0, 42, 3.14]
const invalid = ["foo", null, true]

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

test("valid values", () => {
  expect(isMyNumber(0)).toBe(true)
  expect(isMyNumber(42)).toBe(true)
  expect(isMyNumber(3.14)).toBe(true)
})

test("invalid values", () => {
  expect(isMyNumber("foo")).toBe(false)
  expect(isMyNumber(null)).toBe(false)
  expect(isMyNumber(true)).toBe(false)
})

Фреймворки для тестирования предлагают способ итерации по списку значений, но ради читаемости я использую простой синтаксис.

Пока всё хорошо. В качестве следующего примера давайте вручную сгенерируем набор тестовых значений для более сложного типа.

type MyObject = {
  a: number,
  b: string
}

Чтобы избежать комбинаторного взрыва возможных значений, здесь мы выберем только несколько значений для каждого типа:

  • для типа number мы проверяем, что 0 и 42 являются валидными, а "42" и undefined — невалидными;

  • для типа string мы проверяем, что "" и "foo" являются валидными, а undefined и 1 — невалидными;

  • для типа объекта валидные значения — это любая комбинация двух валидных свойств, а невалидные значения — это такие, где отсутствует любое из свойств a и b или любое из них имеет невалидное значение.

Как

Теперь нам нужно каким-то образом сгенерировать тестовые значения для типов, которые проверяют предикаты. Учитывая, что в процессе генерации предикатов мы уже разбираем входные типы в простую вложенную структуру (см. TypeModel), это должно быть тривиально — пройтись по ней и сбросить кучу тестовых значений. Верно? Не совсем.

Первое, что приходит в голову, это написать рекурсивную функцию (или рекурсивный интерфейс), которая принимает тип, обрабатывает его внутренние детали — например, перебирает атрибуты объекта — и возвращает тестовое значение. Давайте попробуем написать простой пример такой функции:

class MyObjectType {
  attributes: Record
}

class MyNumberType {}

class MyStringType {}

type MyTypes = MyObjectType | MyStringType | MyNumberType

function getTestValuesFor(type: MyTypes) {
  if (type instanceof MyNumberType) {
    return 42
  } else if (type instanceof MyStringType) {
    return "foo"
  } else if (type instanceof MyObjectType) {
    const result = {}
    for (const [key, attrType] of Object.entries(type.attributes)) {
      result[key] = getTestValuesFor(attrType)
    }
  }
}

Кажется, всё хорошо, пока мы не обнаружим, что функция возвращает только одно объединённое значение с единственным тестовым значением для каждого атрибута. Не волнуйтесь, мы всегда можем вернуть массив значений вместо этого! Но тогда мы всё равно будем возвращать вложенную структуру (массив массивов объектов или что-то подобное), которую нужно будет снова пройти, чтобы преобразовать в плоский массив окончательных тестовых значений, подходящих для проверки предикатом. Всё ещё возможно, но что же в итоге возвращает эта функция? Она должна возвращать плоский массив всех преобразованных значений. Прогресс есть, но это сильно напоминает то, с чего мы начали. Может, можно сделать лучше и объединить всё в один эффективный проход?

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

Выдавать значение. По одному шагу за раз. Это явно что-то напоминает... Использовать обратный вызов? Нет. Итератор? Почти. Корутину? Точно! Генератор, если быть точным, так как в JavaScript они известны как функции-генераторы. Генераторы как класс вычислений в JavaScript позволяют получать по одному значению за раз и имеют удобный синтаксис и управление состоянием из коробки с неограниченным уровнем вложенности. Также мы получаем ленивую загрузку, что позволяет избежать генерации всех значений сразу, экономя при этом ресурсы процессора. Реализовать этот уровень контроля с помощью обычных JS-функций тоже возможно, так как JS является тьюринг-полным языком, и каждое замыкание в JS технически может быть корутиной. Но это будет выглядеть не очень красиво (мы всё равно попробуем немного позже в этом посте).

Генераторы, на помощь!

Простой генератор тестовых значений выглядит так:

function* myNumber() {
  yield 0;
  yield 42;
  yield 3.14;
}

Обратите внимание на звёздочку после ключевого слова function. Эта звёздочка указывает движку JS скомпилировать функцию особым образом, чтобы ключевое слово yield работало как в корутине. Пример выше при вызове возвращает итератор, который выдаёт три значения: 0, 42 и 3.14. Мы можем считать их все сразу следующим образом:

console.log([...myNumber()]);

...или извлекать по одному:

for (const value of myNumber()) {
  console.log(value);
}

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

function* myUnion(members) {
  for (const member of members) {
    for (const value of member) {
      yield value;
    }
  }
}

Это также можно записать в более короткой форме:

function* myUnion(members) {
  for (const member of members) {
    yield* member();
  }
}

И всё. Синтаксис генератора, кажется, просто создан специально для вычислений с бесконечными/ленивыми последовательностями. Попробуем реализовать ту же логику вручную для сравнения. Это будет выглядеть так:

function myUnion(members) {
  let i = 0;
  let member = undefined;
  return function () {
    for (; i < members.length; i++) {
      if (!member) {
        // "инициализируем" член
        member = members[i]();
      }
      const value = member();
      // больше нет значений в итераторе члена
      if (value === undefined) {
        continue;
      }
      return value;
    }
    // больше нет значений и в нашем итераторе
    return undefined;
  };
}

Даже без метода next() и всего остального API итерации кода уже значительно больше.

Надеюсь, на этом этапе вы так же влюблены в генераторы, как и я. Тогда вам точно понравится, насколько элегантно можно создавать объект с использованием функции-генератора — наша первая комбинаторно сложная структура (типа произведение):

function* myObject(attrA, attrB, attrC) {
  for (const a of attrA())
    for (const b of attrB())
      for (const c of attrC())
        yield { a, b, c };
}

Здорово, правда? Кажется, что это будет выполняться N^3 раз и генерировать кучу значений перед возвратом какого-либо результата, но это не так. Этот генератор сначала yield возвращает объект с первым значением a, первым значением b и первым значением c, который немедленно передается вызывающему коду. Следующее значение также будет первым значением a, первым значением b и вторым значением c. Это выполнение похоже на DFS, да. Когда любой из внутренних генераторов завершает свою работу, следующий внешний продвигается на один шаг. Когда самый внешний завершает свою очередь, вся функция-генератор возвращается, и вызывающий код завершает чтение тестовых значений. Ура!

Вы могли заметить, что функция myObject() немного упрощена. Она реализована для фиксированного числа атрибутов с фиксированными именами. Это преднамеренное упрощение, которое мы делаем, чтобы продемонстрировать мощь подхода в понятной и простой форме. Конечно, реальный код примерно в 10 раз длиннее, но он охватывает произвольные объекты с любым количеством необязательных атрибутов, и любой из них может содержать некорректные тестовые значения. Далее посмотрите пример ниже, где yield используется как для корректных, так и для некорректных объектов:

function* myObject(attrA, attrB, attrC) {
  for (const a of attrA())
    for (const b of attrB())
      for (const c of attrC()) {
        // корректные
        yield { a, b, c }
        // некорректные
        yield {}
        yield { a }
        yield { b }
        yield { c }
        yield { a, b }
        yield { a, c }
        yield { b, c }
      }
}

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

Наконец, давайте посмотрим, как объединить всё это во что-то полезное:

const values = myObject(
  myUnion([
    myNumber(),
    myString()
  ]),
  myString(),
  myNumber()
);

console.log([...values()])

Результат будет следующим (используются только корректные тестовые значения для удобства чтения):

[
  { a: 0, b: '', c: 0 },
  { a: 0, b: '', c: 42 },
  { a: 0, b: '', c: 3.14 },
  { a: 0, b: 'foo', c: 0 },
  { a: 0, b: 'foo', c: 42 },
  { a: 0, b: 'foo', c: 3.14 },
  { a: 42, b: '', c: 0 },
  { a: 42, b: '', c: 42 },
  { a: 42, b: '', c: 3.14 },
  { a: 42, b: 'foo', c: 0 },
  { a: 42, b: 'foo', c: 42 },
  { a: 42, b: 'foo', c: 3.14 },
  { a: 3.14, b: '', c: 0 },
  { a: 3.14, b: '', c: 42 },
  { a: 3.14, b: '', c: 3.14 },
  { a: 3.14, b: 'foo', c: 0 },
  { a: 3.14, b: 'foo', c: 42 },
  { a: 3.14, b: 'foo', c: 3.14 },
  { a: '', b: '', c: 0 },
  { a: '', b: '', c: 42 },
  { a: '', b: '', c: 3.14 },
  { a: '', b: 'foo', c: 0 },
  { a: '', b: 'foo', c: 42 },
  { a: '', b: 'foo', c: 3.14 },
  { a: 'foo', b: '', c: 0 },
  { a: 'foo', b: '', c: 42 },
  { a: 'foo', b: '', c: 3.14 },
  { a: 'foo', b: 'foo', c: 0 },
  { a: 'foo', b: 'foo', c: 42 },
  { a: 'foo', b: 'foo', c: 3.14 }
]

Знакомая комбинаторная ёлочка. Полный исходный код этого примера можно найти здесь.

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

Комментарии (0)