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

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

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

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

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

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

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

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

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

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

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

Вот некоторые распространенные проблемы, которые делают блок вашего кода плохим:

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

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

Синглтон — это шаблон проектирования программного обеспечения, который допускает существование только одного экземпляра объекта в приложении. В оставшейся части статьи мы будем использовать пример глобальной конфигурации, которую мы хотим использовать во всем приложении. Наш пример — идеальный вариант использования 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);
  });
});

Код можно найти в ветке объектно-ориентированный подход.

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

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

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);
  });
});

Этот код вы можете найти в ветке раздельные методы.

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

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

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

Заключение

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

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

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

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

  • прототипы, эксперименты и проверки концепций
  • приложения, которые вы пишете для удовольствия

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

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

Вам интересно узнать больше?

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

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

Первоначально опубликовано на https://how-to.dev.

Дополнительные материалы на PlainEnglish.io. Подпишитесь на нашу бесплатную еженедельную рассылку новостей. Подпишитесь на нас в Twitter, LinkedIn, YouTube и Discord .

Заинтересованы в масштабировании запуска вашего программного обеспечения? Ознакомьтесь с разделом Схема.