Итак, я написал еще один генератор предикатов типов для 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 }
]
Знакомая комбинаторная ёлочка. Полный исходный код этого примера можно найти здесь.
Надеюсь, вам понравилось это путешествие. Если вы заинтересованы в том, как инструмент генератора типовых предикатов справляется с огромным количеством комбинаций, вы можете продолжить чтение примечаний здесь.