Обеспечение возможности тестирования ваших модулей в JavaScript

Обеспечение возможности тестирования ваших модулей в JavaScript

2 февраля 2023 г.

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

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

Что такое тестируемость

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

* насколько легко мне планировать и писать модульные тесты и * сколько тестового кода мне нужно написать, чтобы получить почти идеальное покрытие логики моего приложения

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

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

Что такое единицы

Модули – это небольшой фрагмент кода, над которым можно работать отдельно от остального приложения. Это могут быть классы, функции или компоненты.

Хороший блок можно определить как тот, который:

* имеет имя, соответствующее его назначению, * рассматривает входы, выходы и возможные состояния, и * хорошо сочетается с другими единицами в вашем приложении.

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

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

Пример: синглтон с глобальной конфигурацией

Singleton — это шаблон проектирования программного обеспечения, который позволяет существовать только одному экземпляру объекта в приложении. В оставшейся части статьи мы будем использовать пример глобальной конфигурации, которую мы хотим использовать во всем приложении. Наш пример — идеальный пример использования singleton: мы централизуем настройки в одном месте, но не помещаем данные непосредственно в глобальную область.

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

Тестируемый класс с тестами

Для начала создадим простой класс:

export class Configuration {
  settings = [
    { name: "language", value: "en" },
    { name: "debug", value: false },
  ];

  getSetting(name) {
    const setting = this.settings.find(
      (value) => value.name === name
    );

    return setting.value;
  }
}

Для добавления тестов я следую примеру из моей старой статьи. Тестовый файл:

import { Configuration } from "../configuration.js";

describe("Configuration", () => {
  let configuration;

  beforeEach(() => {
    configuration = new Configuration();
  });

  it("should return hard-coded settings", () => {
    expect(configuration.getSetting("language")).toEqual("en");
    expect(configuration.getSetting("debug")).toEqual(false);
  });
});

Код можно найти в ветке initial-implementation.

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

Первый рефакторинг: установка данных извне

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

Обновленный код:

export class Configuration {
  settings = [];

  init(settings) {
    this.settings = settings;
  }

  getSetting(name) {
    const setting = this.settings.find(
      (value) => value.name === name
    );

    return setting.value;
  }
}

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

Обновленные тесты:

import { Configuration } from "../configuration.js";

describe("Configuration", () => {
  let configuration;

  beforeEach(() => {
    configuration = new Configuration();
  });

  it("should return settings provided in init", () => {
    configuration.init([
      { name: "language", value: "en" },
      { name: "debug", value: false },
    ]);

    expect(configuration.getSetting("language")).toEqual("en");
    expect(configuration.getSetting("debug")).toEqual(false);
  });
});

Более гибкая логика требует больше кода в тестах. В этой тестовой реализации наши тесты запускают весь код, который у нас есть, но есть два аспекта, которые не указаны явно:

  1. Поддерживаем ли мы повторный запуск метода init? Код в его нынешнем виде будет работать нормально, но можно представить себе случай, когда мы захотим, чтобы наша логика игнорировала повторные запуски или, возможно, выдавала ошибку.
  2. Мы не проверяем, считываются ли настройки из значений, предоставленных в вызове init. Возможно, у нас есть некоторые жестко закодированные значения, которые совпадают с тем, что есть в нашем тесте.

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

import { Configuration } from "../configuration.js";

describe("Configuration", () => {
  let configuration;

  beforeEach(() => {
    configuration = new Configuration();
  });

  it("should return settings provided in init", () => {
    configuration.init([
      { name: "language", value: "en" },
      { name: "debug", value: false },
    ]);

    expect(configuration.getSetting("language")).toEqual("en");
    expect(configuration.getSetting("debug")).toEqual(false);

    // reinitiate with other values
    configuration.init([
      { name: "language", value: "es" },
      { name: "debug", value: true },
    ]);

    expect(configuration.getSetting("language")).toEqual("es");
    expect(configuration.getSetting("debug")).toEqual(true);
  });
});

Теперь наши тесты проверяют все важные аспекты кода. Вы можете найти эту версию кода в ветке initable-configuration.

Второй рефакторинг: изменение структуры данных

Если вам интересно, почему мы сохраняем настройки в виде массива, у вас есть хорошая мысль: это не совсем подходит для этой цели. Теперь мы преобразуем структуру данных во что-то более осмысленное: в объект.

Код обновления:

export class Configuration {
  settings = {};

  init(settings) {
    this.settings = settings;
  }

  getSetting(name) {
    return this.settings[name];
  }
}

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

import { Configuration } from "../configuration.js";

describe("Configuration", () => {
  let configuration;

  beforeEach(() => {
    configuration = new Configuration();
  });

  it("should return settings provided in init", () => {
    configuration.init({
      language: "en",
      debug: false,
    });

    expect(configuration.getSetting("language")).toEqual("en");
    expect(configuration.getSetting("debug")).toEqual(false);

    // reinitiate with other values
    configuration.init({
      language: "es",
      debug: true,
    });

    expect(configuration.getSetting("language")).toEqual("es");
    expect(configuration.getSetting("debug")).toEqual(true);
  });
});

Вы можете найти код в ветке object-based-approach.< /p>

Третий рефакторинг: более тестируемый класс

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

export class Configuration {
  settings = {};

  init(settings) {
    this.settings = settings;
  }

  getLanguage() {
    return this.settings["language"];
  }

  getDebug() {
    return this.settings["debug"];
  }
}

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


spyOn(configuration, ‘getLanguage’).and.returnValue(‘en’);

Собственные тесты класса также становятся немного более явными:

import { Configuration } from "../configuration.js";

describe("Configuration", () => {
  let configuration;

  beforeEach(() => {
    configuration = new Configuration();
  });

  it("should return settings provided in init", () => {
    configuration.init({
      language: "en",
      debug: false,
    });

    expect(configuration.getLanguage()).toEqual("en");
    expect(configuration.getDebug()).toEqual(false);

    // reinitiate with other values
    configuration.init({
      language: "es",
      debug: true,
    });

    expect(configuration.getLanguage()).toEqual("es");
    expect(configuration.getDebug()).toEqual(true);
  });
});

Вы можете найти этот код в ветке separate-methods.

Непроверяемый контрпример

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

export class Configuration {
  settings = {
      language: "es",
      debug: true,
    };
}

Эти значения можно прочитать с помощью

configuration.settings.language

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

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

export class Configuration {
  settings = [
    { name: "language", value: "en" },
    { name: "debug", value: false },
  ];
}

но чтение значений немного усложняется:

configuration.settings.find(
  value => value.name === ‘language’
).value

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

Заключение

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

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

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

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

* прототипы, эксперименты и проверки концепции * приложения, которые вы пишете для развлечения

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

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

Хотите узнать больше?

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

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


Также опубликовано здесь


Оригинал
PREVIOUS ARTICLE
NEXT ARTICLE