Написано Пол Коуэн✏️

Каждая написанная вами строка кода требует обслуживания на протяжении всего жизненного цикла приложения. Чем больше у вас строк кода, тем больше требуется обслуживания и тем больше кода вам придется изменить, поскольку приложение адаптируется к меняющимся требованиям.

К сожалению, народная мудрость гласит, что нам нужно больше тестового кода, чем кода приложения. В этом посте я собираюсь обсудить, почему эта идея не совсем верна, и как мы можем улучшить наш код, чтобы на самом деле не писать так много тестов.

Во-первых, мы должны решить несколько вещей:


Advanced TypeScript — это не просто условные типы или специфические особенности языка.

Я видел некоторые онлайн-курсы, заявляющие, что продвинутый TypeScript просто выбирает несколько сложных функций из списка функций TypeScript, таких как условные типы.

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


Разработка на основе типов — всегда лучший подход

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

Документация, не относящаяся к коду, устаревает в момент ее написания. Если документация является исполняемой, у нас нет другого выбора, кроме как поддерживать ее в актуальном состоянии. И тесты, и типы являются примерами исполняемой документации.


Тесты хорошие; невозможные состояния лучше

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

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

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

Перейти вперед:


Недостатки использования единого интерфейса

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

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

export interface AuthenticationStates {
  readonly isAuthenticated: boolean;
  authToken?: string;
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

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

Одна большая проблема с интерфейсом заключается в том, что ничто не может помешать нам назначить правильную строку для authToken поле в то время как isAuthenticated является false. Что, если бы это было возможно для authToken только для того, чтобы быть доступным для кода при работе с известным пользователем?

Другая мелочь использует boolean поле для различения состояний. Ранее мы заявляли, что наши типы должны быть самодокументируемыми, но логические значения — плохой выбор, если мы хотим это поддерживать. Лучшим способом представления этого состояния является использование объединения строк:

export interface AuthenticationStates {
  readonly state: 'UNAUTHENTICATED' | 'AUTHENTICATED';
  authToken?: string;
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Самая большая проблема у нас AuthenticationStates тип заключается в том, что только одна структура данных содержит все наши поля. Что, если после раунда тестирования мы обнаружим, что хотим сообщать пользователю о системных ошибках?

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

export interface AuthenticationStates {
  readonly state: 'UNAUTHENTICATED' | 'AUTHENTICATED';
  authToken?: string;
  error?: {
     code: number;
     message: string;
  }
}
Войти в полноэкранный режим

Выйти из полноэкранного режима


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

Рефакторинг AuthenticationStates нижеприведенный тип известен в высокопоставленных кругах функционального программирования как алгебраический тип данных (ADT):

export type AuthenticationStates =
  | {
      readonly kind: "UNAUTHORIZED";
    }
  | {
      readonly kind: "AUTHENTICATED";
      readonly authToken: string;
    }
  | {
      readonly kind: "ERRORED";
      readonly error: Error;
    };
Войти в полноэкранный режим

Выйти из полноэкранного режима

Одним из видов алгебраического типа (но не единственным) является дискриминированный союзкак и AuthenticationStates типа выше.

Размеченное объединение — это шаблон, который указывает компилятору все возможные значения, которые может иметь тип. Каждый член объединения должен иметь одно и то же примитивное поле (логическое, строковое, числовое) с уникальным значением, известным как дискриминатор.

В приведенном выше примере kind поле является дискриминатором и имеет значение "AUTHENTICATED", "UNAUTHENTICATED"или же "ERRORED". Каждый член союза может содержать поля, относящиеся только к его конкретному члену. kind. В нашем случае authToken не имеет никакого отношения к какому-либо члену профсоюза, кроме AUTHENTICATED.

В приведенном ниже коде этот пример развивается дальше, используя AuthenticationStates введите в качестве аргумента getHeadersForApi функция:

function getHeadersForApi(state: AuthenticationStates) {
  return {
    "Accept": "application/json",
    "Authorization": `Bearer ${state.??}`; // currently the type has no authToken
  }
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

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

Если мы можем программно определить, что state может быть только из kind AUTHENTICATEто мы можем получить доступ к authToken поле безнаказанно: Устранен дискриминационный союз.

Приведенный выше код выдает ошибку, если state не из kind AUTHENTICATED.


Определение того, являются ли члены союза текущими (также известными как сужение типа)

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

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

type AuthenticationStates =
  | {
      readonly kind: "UNAUTHORIZED";
      readonly isLoading: true;
    }
  | {
      readonly kind: "AUTHENTICATED";
      readonly authToken: string;
      readonly isLoading: false;
    }
  | {
      readonly kind: "ERRORED";
      readonly error: Error;
      readonly isLoading: false;
    };

type AuthActions =
  | { 
      type: 'AUTHENTICATING';
    }
  | { 
      type: 'AUTHENTICATE',
      payload: {
        authToken: string
    }
  }
  | {
    type: 'ERROR';
    payload: {
      error: Error;
    }
  }

function reducer(state: AuthenticationStates, action: AuthActions): AuthenticationStates {
  switch(action.type) {
    case 'AUTHENTICATING': {
      return {
        kind: 'UNAUTHORISED',
        isLoading: true
      }
    }

    case 'AUTHENTICATE': {
      return {
        kind: 'AUTHENTICATED',
        isLoading: false,
        authToken: action.payload.authToken
      }
    }

    case 'ERROR': {
      return {
        kind: 'ERRORED',
        isLoading: false,
        error: action.payload.error
      }
    }
    default:
      return state;
  }
} 
Войти в полноэкранный режим

Выйти из полноэкранного режима

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

На скриншоте ниже показан глупый разработчик (я), пытающийся получить доступ к authToken находясь в ERROR заявление по делу. Это просто невозможно: Доступ к authToken в операторе case ERROR.

Что еще хорошо в приведенном выше коде, так это то, что isLoading не является неоднозначным логическим значением, которое может быть неправильно назначено и привести к ошибке. Значение может быть только true в AUTHENTICATING государство. Если поля доступны только текущему члену союза, требуется меньше тестового кода.


С использованием ts-pattern вместо операторов switch

Операторы переключения чрезвычайно ограничены и подвержены опасности провала, что может привести к ошибкам и неправильным практикам. К счастью, несколько пакетов npm могут помочь с сужением типов размеченных объединений, а пакет ts-pattern библиотека отличный выбор.

ts-pattern позволяет вам выражать сложные условия в одном компактном выражении, похожем на сопоставление с образцом в функциональном программировании. Eсть ТС-39 предложение чтобы добавить сопоставление с образцом в язык JavaScript, но он все еще находится на этапе 1.

После установки ts-patternмы можем реорганизовать код так, чтобы он напоминал сопоставление с образцом:

const reducer = (state: AuthenticationStates, action: AuthActions) =>
  match<AuthActions, AuthenticationStates>(action)
    .with({ type: "AUTHENTICATING" }, () => ({
      kind: "UNAUTHORISED",
      isLoading: true
    }))

    .with({ type: "AUTHENTICATE" }, ({ payload: { authToken } }) => ({
      kind: "AUTHENTICATED",
      isLoading: false,
      authToken
    }))

    .with({ type: "ERROR" }, ({ payload: { error } }) => ({
      kind: "ERRORED",
      isLoading: false,
      error
    }))
    .otherwise(() => state);
Войти в полноэкранный режим

Выйти из полноэкранного режима

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


Анализировать, не проверять: использование схемы проверки типов

Мы все написали эти ужасные функции проверки формы которые проверяют ввод пользователя следующим образом:

function validate(values: Form<User>): Result<User> {
  const errors = {};

  if (!values.password) {
    errors.password = 'Required';
  } else if (!/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$/i.test(values.password)) {
    errors.password = 'Invalid password';
  }

  // etc.
  return errors;
};
Войти в полноэкранный режим

Выйти из полноэкранного режима

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

Лучший способ — проанализировать данные и создать типобезопасную схему, которая может выполняться для входящих данных во время выполнения. Отличный пакет Зод привносит семантику времени выполнения в TypeScript без дублирования существующих типов.

Zod позволяет нам определять схемы которые определяют форму, в которой мы ожидаем получить данные, с возможностью извлечения типов TypeScript из схемы. Мы уклоняемся от множества if операторы и необходимость писать много тестов с таким подходом.

Ниже приведен простой UserSchema который определяет четыре поля. Код вызывает z.infer извлечь User введите из схемы, что впечатляет и избавляет от дублирования ввода.

export const UserSchema = z.object({
  uuid: z.string().uuid(),
  email: z.string().email(),
  password: z.string().regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,}$/),
  age: z.number().optional()
});

export type User = z.infer<typeof UserSchema>;
/* returns
type User = {
  uuid: string;
  email: string;
  password: string;
  age?: number | undefined;
}
*/
Войти в полноэкранный режим

Выйти из полноэкранного режима

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

В качестве бонуса Zod поставляется с множеством готовых проверок. Например, uuid поле будет принимать только действительные UUID струны, и email поле будет принимать только строки, правильно отформатированные как электронные письма. Пользовательское регулярное выражение, которое соответствует правилам пароля приложения, передается regex функция для проверки password поле. Все это происходит без каких-либо if операторы или код ветвления.


Вывод: тесты и типы не исключают друг друга

В этом посте я не говорю, что мы должны перестать писать тесты и сосредоточить все свое внимание на написании лучших типов.

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

Помните золотое правило: чем больше у вас кода, тем больше у вас проблем.




ЛогРокет: Полная видимость ваших веб-приложений и мобильных приложений.

ЛогРокет регистрация

ЛогРокет — это решение для мониторинга внешних приложений, которое позволяет воспроизводить проблемы, как если бы они возникли в вашем собственном браузере. Вместо того, чтобы гадать, почему возникают ошибки, или запрашивать у пользователей скриншоты и дампы журналов, LogRocket позволяет вам воспроизвести сеанс, чтобы быстро понять, что пошло не так. Он отлично работает с любым приложением, независимо от фреймворка, и имеет плагины для регистрации дополнительного контекста из Redux, Vuex и @ngrx/store.

Помимо регистрации действий и состояния Redux, LogRocket записывает журналы консоли, ошибки JavaScript, трассировки стека, сетевые запросы/ответы с заголовками и телами, метаданные браузера и пользовательские журналы. Он также использует DOM для записи HTML и CSS на странице, воссоздавая идеальные до пикселя видео даже для самых сложных одностраничных и мобильных приложений.

Попробуйте бесплатно.