Pull to refresh
829.8
OTUS
Цифровые навыки от ведущих экспертов

Тестирование без моков: язык паттернов. Часть 1

Reading time26 min
Views4K
Original author: James Shore

Автоматизированные тесты очень важны. Без них программистам приходится тратить огромное количество времени на ручную проверку и исправление кода.

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

Знающие люди используют моки (mocks) и шпионы (spies) (в этой статье я для краткости говорю только «моки») для написания изолированных тестов, основанных на взаимодействии. Их тесты надёжны и быстры, но они имеют тенденцию «блокировать» реализацию, затрудняя рефакторинг, и их приходится дополнять широкими тестами. Кроме того, часто получаются некачественные и трудные для чтения тесты.

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

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

Паттерны сочетают общительные тесты (sociable), основанные на состояниях, с новой техникой под названием "Nullables". На первый взгляд, Nullables выглядят как тестовые двойники, но на самом деле это продакшен код с выключателем. И в этом заключается компромисс: хотите ли вы видеть это в своём коде? Ваш ответ определит, подходит ли вам этот язык паттернов.

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

Оглавление

  1. Примеры

  2. Цели

  3. Компромиссы

  4. Основополагающие паттерны

    1. Узкие тесты

    2. Тесты состояния (State-Based Tests)

    3. Перекрывающиеся общительные тесты (Overlapping Sociable Tests)

    4. Smoke-тесты

    5. Инстанцирование с нулевым воздействием (Zero-Impact Instantiation)

    6. Инстанцирование без параметров (Parameterless Instantiation)

    7. Экранирование сигнатур (Signature Shielding)

  5. Архитектурные паттерны

    1. A-Frame Architecture («А-образная Архитектура»)

    2. Logic Sandwich («Логический Сэндвич»)

    3. Traffic Cop («Регулировщик Трафика»)

    4. Grow Evolutionary Seeds («Выращивание эволюционных зёрен»)

  6. Паттерн тестирования логики

    1. Easily-Visible Behavior («Легко различимое поведение»)

    2. Testable Libraries («Тестируемые библиотеки»)

    3. Collaborator-Based Isolation («Изоляция на основе коллаборации»)


Примеры

Вот пример тестирования простого консольного приложения. Приложение считывает строку из командной строки, кодирует её с помощью ROT-13 и выводит результат.

В продакшен коде используется дополнительный паттерн архитектуры A-Frame. App — это точка входа в приложение. Она зависит от Rot13 — класса логики, и CommandLine — инфраструктурного класса. Дополнительные паттерны упоминаются в исходном коде.

// Example production code (JavaScript + Node.js)
import CommandLine from "./infrastructure/command_line";  // Infrastructure Wrapper
import * as rot13 from "./logic/rot13";

export default class App {
  constructor(commandLine = CommandLine.create()) {   // Parameterless Instantiation
    this._commandLine = commandLine;
  }

  run() {
    const args = this._commandLine.args();

    if (args.length === 0) {    // Tested by Test #2
      this._commandLine.writeOutput("Usage: run text_to_transform\n");
      return;
    }
    if (args.length !== 1) {    // Tested by Test #3
      this._commandLine.writeOutput("too many arguments\n");
      return;
    }

    // Tested by Test #1
    const input = args[0];                          // Logic Sandwich
    const output = rot13.transform(input);
    this._commandLine.writeOutput(output + "\n");
  }
};

Тесты App выглядят как сквозные интеграционные тесты, но на самом деле это модульные тесты. Технически это узкие, общительные тесты (Narrow, Sociable), что означает, что это модульные тесты, выполняющие код в зависимостях.

Будучи узкими тестами, они проверяют только App.run(). Ожидается, что каждая из зависимостей будет иметь свои собственные тесты.

Тесты используют Nullable CommandLine для отбрасывания stdout и настраиваемые ответы (Configurable Responses) для предоставления предварительно сконфигурированных аргументов командной строки. Они также используют отслеживание вывода (Output Tracking), чтобы увидеть, что было бы записано в stdout.

// Example tests (JavaScript + Node.js)
import assert from "assert";
import CommandLine from "./infrastructure/command_line";
import App from "./app";

describe("App", () => {
  // Test #1
  it("reads command-line argument, transform it with ROT-13, and writes result", () => {
    const { output } = run({ args: [ "my input" ] });     // Signature Shielding, Configurable Responses
    assert.deepEqual(output.data, [ "zl vachg\n" ];       // Output Tracking
  });

  // Test #2
  it("writes usage when no argument provided", () => {
    const { output } = run({ args: [] });                                 // Signature Shielding, Configurable Responses
    assert.deepEqual(output.data, [ "Usage: run text_to_transform\n" ]);  // Output Tracking
  });

  // Test #3
  it("complains when too many command-line arguments provided", () => {
    const { output } = run({ args: [ "a", "b" ] });                       // Signature Shielding, Configurable Responses
    assert.deepEqual(output.data, [ "too many arguments\n" ]);            // Output Tracking
  });

  function run({ args = [] } = {}) {                      // Signature Shielding
    const commandLine = CommandLine.createNull({ args }); // Nullable, Infrastructure Wrapper, Configurable Responses
    const output = commandLine.trackOutput();             // Output Tracking

    const app = new App(commandLine);
    app.run();

    return { output };                                    // Signature Shielding
  }
});

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

// Example Nullable infrastructure wrapper (JavaScript + Node.js)
import EventEmitter from "node:events";
import OutputTracker from "output_tracker";

const OUTPUT_EVENT = "output";

export default class CommandLine {
  static create() {
    return new CommandLine(process);                  // 'process' is a Node.js global
  }

  static createNull({ args = [] } = {}) {             // Parameterless Instantiation, Configurable Responses
    return new CommandLine(new StubbedProcess(args)); // Embedded Stub
  }

  constructor(proc) {
    this._process = proc;
    this._emitter = new EventEmitter();               // Output Tracking
  }

  args() {
    return this._process.argv.slice(2);
  }

  writeOutput(text) {
    this._process.stdout.write(text);
    this._emitter.emit(OUTPUT_EVENT, text);           // Output Tracking
  }

  trackOutput() {                                     // Output Tracking
    return OutputTracker.create(this._emitter, OUTPUT_EVENT);
  }
};

// Embedded Stub
class StubbedProcess {
  constructor(args) {
    this._args = args;                                // Configurable Responses
  }

  get argv() {
    return [ "nulled_process_node", "nulled_process_script.js", ...this._args ];
  }

  get stdout() {
    return {
      write() {}
    };
  }
}

Паттерны проявляют себя в более сложном коде с множеством уровней зависимостей. Больше примеров приведено здесь:

  • Простой пример. Полный исходный код приведённого выше примера. (JavaScript или TypeScript с Node.js)

  • Сложный пример. Усовершенствованная версия приведённого выше примера. Веб-приложение и микросервис, выполняющий кодировку ROT-13. Код с обработкой ошибок, логированием, таймаутами и отменой запросов. (JavaScript или TypeScript с Node.js)

  • TDD Lunch & Learn Screencast. Серия часовых вебинаров, демонстрирующих использование паттернов. (JavaScript с Node.js)

  • Nullables Livestream. Серия трёхчасовых прямых трансляций с Джеймсом Шором и Тедом М. Янгом. Они рассказывают о применении паттернов к существующему веб-приложению. (Java с Spring Boot)

Цели

Этот язык шаблонов был создан для удовлетворения следующих целей:

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

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

  • Читабельные тесты. Тесты следуют прямой структуре «настройка, вызов, проверка» (AAA — Arrange, Act, Assert). Они описывают видимое извне поведение тестируемого модуля, а не его реализацию. Они могут служить документацией для тестируемого модуля.

  • Никакой магии. Инструменты, автоматически устраняющие бесполезную работу, такие как фреймворки для инъекции зависимостей и фреймворки для автомокинга, не требуются.

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

Опыт выявил следующие дополнительные преимущества:

  • Быстрее, чем фреймворки для создания моков. При прямом сравнении тесты, использующие эти паттерны, оказались на 2-3 порядка быстрее, чем тесты, использующие фреймворки для создания моков. (Сравнительный код здесь).

  • Простая настройка тестов. Настройка тестов проста и легко инкапсулируется во вспомогательные методы.

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

  • Тестирование инфраструктуры in-memory. Высокоуровневые инфраструктурные обёртки, такие как клиент для конкретного веб-сервиса, можно тестировать без сетевых вызовов и сложной настройки. (Пример теста)

  • Поддержка пограничных случаев. Лёгкое тестирование сложных пограничных случаев, таких как состояния ошибки и тайм-ауты. (Примеры тестов)

  • Совместимость с унаследованным кодом. Паттерны полностью совместимы с моками и другими тестовыми двойниками и даже могут использоваться вместе в одном тесте. Унаследованный код можно преобразовывать постепенно, не затрагивая существующий.

Компромиссы

Нет ничего идеального. Вот минусы использования этого языка шаблонов:

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

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

  • Многочисленные сбои в тестах. Хотя тесты пишутся для того, чтобы сфокусироваться на конкретных концепциях, тестируемые модули выполняют код в своих зависимостях. (Джей Филдс придумал термин «общительные тесты» для обозначения такого поведения). Это может привести к тому, что при появлении бага несколько тестов не сработают.

Основополагающие паттерны

Эти паттерны устанавливают основные правила, начните с них.

Узкие тесты

Широкие тесты (например, сквозные) обычно медленные и хрупкие. Они сложны для чтения и записи, часто дают случайные сбои и требуют много времени для выполнения. Поэтому:

Вместо широких тестов используйте узкие. Узкие тесты проверяют конкретную функцию или поведение, а не всю систему в целом. Распространённый тип узких тестов — модульные тесты.

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

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

Тесты состояния / State-Based Tests

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

Используйте тесты, основанные на состоянии, вместо тестов, основанных на взаимодействии. Тесты, основанные на состоянии, проверяют вывод или состояние тестируемого кода, не обращая внимания на его реализацию. Например, если взять следующий продакшен код:

// Production code to describe phase of moon (JavaScript)
import * as moon from "astronomy";
import { format } from "date_formatter";

export function describeMoonPhase(date) {
  const visibility = moon.getPercentOccluded(date);
  const phase = moon.describePhase(visibility);
  const formattedDate = format(date);
  return `The moon is ${phase} on ${formattedDate}.`;
}

Тест, основанный на состоянии, вводит дату и проверяет результат. Например, так:

// State-based test of describeMoonPhase() (JavaScript)
import { describeMoonPhase } from "describe_phase";

it("describes phase of moon", () => {
  const dateOfFullMoon = new Date("8 Dec 2022");    // a date when the moon was actually full
  const description = describeMoonPhase(dateOfFullMoon);
  assert.equal(description, "The moon is full on December 8th, 2022.";
});

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

// Interaction-based test of describeMoonPhase() (JavaScript and fictional mocking framework)
const moon = mocker.mockImport("astronomy");
const { format } = mocker.mockImport("date_formatter");
const { describeMoonPhase } = mocker.importWithMocks("describe_phase");

it("describes phase of moon", () => {
  const date = new Date();    // specific date doesn't matter

  mocker.expect(moon.getPercentOccluded).toBeCalledWith(date).thenReturn(999);
  mocker.expect(moon.describePhase).toBeCalledWith(999).thenReturn("PHASE");
  mocker.expect(format).toBeCalledWith(date).thenReturn("DATE");

  const description = describeMoonPhase(date);
  mocker.verify();
  assert.equal(description, "The moon is PHASE on DATE");
};

Тесты, основанные на состояниях, естественным образом приводят к перекрывающимся общительным тестам. Чтобы использовать тесты на основе состояний в коде с инфраструктурными зависимостями, используйте паттерны Nullability.

Перекрывающиеся общительные тесты / Overlapping Sociable Tests

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

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

Например, следующий тест проверяет, что describeMoonPhase правильно использует зависимости Moon и format. Если они работают не так, как считает describeMoonPhase, тест не пройдёт.

// Example of sociable tests (JavaScript)

// Test code
it("describes phase of moon", () => {
  const dateOfFullMoon = new Date("8 Dec 2022");
  const description = describeMoonPhase(dateOfFullMoon);
  assert.equal(description, "The moon is full on December 8th, 2022.";
};

// Production code
describeMoonPhase(date) {
  const visibility = moon.getPercentOccluded(date);
  const phase = moon.describePhase(visibility);
  const formattedDate = format(date);
  return `The moon is ${phase} on ${formattedDate}.`;
}

Пишите узкие тесты, ориентированные на поведение тестируемого кода, а не на поведение его зависимостей. Каждая зависимость должна иметь свой собственный подробный набор узких тестов. Например, не проверяйте все фазы луны в тесте describeMoonPhase(), но проверяйте их в тесте Moon. Аналогично, не проверяйте тонкости форматирования даты в тестах describeMoonPhase, но проверяйте их в тестах format(date).

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

Например, представьте цепочку зависимостей LoginControllerAuth0ClientHttpClient:

  • Тесты LoginController проверяют правильность работы LoginController, включая то, как он использует Auth0Client. (Auth0Client, в свою очередь, запускает HttpClient, но это не проверяется тестами LoginController в явном виде).

  • Тесты Auth0Client проверяют корректность работы Auth0Client, в том числе то, как он использует HttpClient.

  • Тесты HttpClient проверяют корректность HttpClient, включая использование тестов узкой интеграции для проверки того, как он взаимодействует с HTTP-серверами.

  • Вместе они обеспечивают проверку всей цепочки. Даже если HttpClient и его тесты будут изменены намеренно, если это изменение сломает Auth0Client, его тесты не пройдут (и, возможно, тесты LoginController тоже). Изменение поведения Auth0Client аналогично приведёт к поломке тестов LoginController.

Напротив, если бы тесты LoginController имитировали Auth0Client, цепочка была бы разорвана. Изменение поведения Auth0Client не нарушит тесты LoginController, потому что ничто не проверит, как LoginController использует настоящий Auth0Client.

Чтобы не создавать вручную всю цепочку зависимостей, используйте инстанцирование без параметров и инстанцирование с нулевым воздействием. Чтобы изолировать тесты от изменений в поведении зависимостей, используйте изоляцию на основе взаимодействия. Чтобы предотвратить взаимодействие тестов с внешними системами и состояниями, используйте Nullables. Чтобы отлавливать нежелательные изменения во внешних системах, используйте «параноидальную телеметрию». Для подстраховки используйте Smoke-тесты.

Smoke-тесты

Перекрывающиеся общительные тесты должны охватывать всю систему целиком. Но никто не совершенен, и ошибки случаются у всех. Поэтому:

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

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

Инстанцирование с нулевым воздействием / Zero-Impact Instantiation

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

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

Инстанцирование без параметров / Parameterless Instantiation

Перекрывающиеся общительные тесты требуют инстанцирования всего дерева зависимостей, но многоуровневые цепочки зависимостей сложно настроить в тестах. Фреймворки внедрения зависимостей (Dependency injection, DI) обходят эту проблему, но мы не требуем такой магии. Поэтому:

Убедитесь, что у всех классов есть конструктор или фабрика, которая не принимает никаких параметров. Эта фабрика (или конструктор) должна иметь разумные значения по умолчанию, которые устанавливают всё, что нужно объекту, включая инстанцирование его зависимостей. При желании вы можете сделать значения по умолчанию переопределяемыми. (Если ваш язык не поддерживает переопределяемые значения по умолчанию, используйте перегрузку методов или объект Options, как показано в паттерне Экранирование сигнатур).

Для некоторых классов, особенно для объектов-значений, фабрика без параметров — не лучшая идея в продакшене, потому что люди могут забыть проставить необходимое значение. Например, неизменяемый класс Address должен быть создан с указанием улицы, города и так далее. Указание города по умолчанию может привести к тому, что адреса вроде работают, но на самом деле содержат неправильный город.

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

// Test-specific factory using named, optional parameters (JavaScript)
class Address {
  // Production constructor
  constructor(street, city, state, country, postalCode) {
    this._street = street;
    this._city = city;
    //...etc...
  }

  // Test-specific factory
  static createTestInstance({
    street = "Address test street",
    city = "Address test city",
    state = State.createTestInstance(),
    country = Country.createTestInstance(),
    postalCode = PostalCode.createTestInstance(),
  } = {}) {
    return new Address(street, city, state, country, postalCode);
  }
}

Этот специфичный для тестов фабричный метод проще всего поддерживать, если он находится в продакшен коде рядом с реальными конструкторами. Однако если в продакшене не нужен код, специфичный для тестов, или если логика становится сложной, можно использовать паттерн Object Mother, чтобы поместить его в вспомогательный модуль, предназначенный только для тестов.

Экранирование сигнатур / Signature Shielding

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

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

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

// Optional parameters and multiple return values (JavaScript)

// Example test
it("uses hosted page for authentication", () => {
  const { url } = getLoginUrl({       // Use the helper function
    host: "my.host",
    clientId: "my_client_id",
    callbackUrl: "my_callback_url"
  });

  assert.equal(url, "https://my.host/authorize?response_type=code&client_id=my_client_id&callback_url=my_callback_url");
});

// Example helper function
function getLoginUrl({
  host = "irrelevant.host",           // Optional parameters
  clientId = "irrelevant_client_id",
  clientSecret = "irrelevant_secret",
  connection = "irrelevant_connection"
  username = "irrelevant_username",
  callbackUrl = "irrelevant_url",
} = {}) {
  const client = new LoginClient(host, clientId, clientSecret, connection);
  const url = client.getLoginUrl(username, callbackUrl);

  return { client, url };             // Multiple return values
}

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

// Optional parameters and multiple return values (Java)

// Example tests
@Test
public void usesHostedPageForAuthentication() {
  GetLoginUrlResult actual = getLoginUrl(new GetLoginUrlOptions()   // Use the helper function and Options object
    .withHost("my.host")
    .withClientId("my_client_id")
    .withCallbackUrl("my_callback_url")
  );
  assert.equal(actual.url, "https://my.host/authorize?response_type=code&client_id=my_client_id&callback_url=my_callback_url");
}

// Helper function using Options object and return data structure
private GetLoginUrlResult getLoginUrl(GetLoginUrlOptions options) {
  LoginClient client = new LoginClient(options.host, options.clientId, options.secret, options.connection);
  String url = client.getLoginUrl(options.username, options.callbackUrl);
  return new GetLoginUrlResult(client, url);
}

// Options object
private static final class GetLoginUrlOptions {
  public String host = "irrelevant.host";
  public String clientId = "irrelevant_client_id";
  public String clientSecret = "irrelevant_secret";
  public String connection = "irrelevant_connection";
  public String username = "irrelevant_username";
  public String callbackUrl = "irrelevant_url";

  GetLoginUrlOptions withHost(String host) {
    this.host = host;
    return this;
  }

  GetLoginUrlOptions withClientId(String clientId) {
    this.clientId = clientId;
    return this;
  }

  GetLoginUrlOptions withCallbackUrl(String url) {
    this.callbackUrl = url;
    return this;
  }
}

// Return data structure
private static final class GetLoginUrlResult {
  public LoginClient client;
  public String url;

  public GetLoginUrlResult(LoginClient client, String url) {
    this.client = client;
    this.url = url;
  }
}

Архитектурные паттерны

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

  • A-Frame Architecture («А-образная архитектура»)

  • Logic Sandwich («Логический cэндвич»)

  • Traffic Cop («Регулировщик трафика»)

  • Grow Evolutionary Seeds («Выращивание эволюционных зёрен»)

A-Frame Architecture 

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

Application/UI
      |
      V
    Logic
      |
      V
Infrastructure

Поэтому:

Постройте приложение так, чтобы инфраструктура и логика были равноправны на уровне приложения, без зависимостей между инфраструктурой и логикой. Координируйте их работу на уровне приложения с помощью Logic Sandwich или Traffic Cop. Используйте объекты значений для передачи данных между уровнями логики и инфраструктуры.

   Application/UI     Values
   /            \
  V              V
Logic   Infrastructure

Постройте слои "Logic" и "Values", используя паттерны тестирования логики. Постройте инфраструктурный слой, используя паттерны инфраструктуры. Постройте слой приложения / UI с помощью Logic Sandwich или Traffic Cop и используйте Nullables для тестирования.

Хотя паттерн A-Frame Architecture — это хороший способ упростить зависимости приложения, он совершенно необязателен. Этот язык паттернов будет работать и без него.

Чтобы создать новое приложение с использованием A-Frame архитектуры, используйте Grow Evolutionary Seeds. Преобразовать существующую кодовую базу поможет Descend the Ladder.

Logic Sandwich / «Логический cэндвич»

При использовании A-Frame архитектуры инфраструктурный и логический уровни не могут взаимодействовать друг с другом. Но логическому слою необходимо читать и записывать данные, контролируемые инфраструктурным слоем. Поэтому:

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

// JavaScript
const input = infrastructure.readData();
const output = logic.processInput(input);
infrastructure.writeData(output);

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

Для приложений, реагирующих на события, используйте Traffic Cop.

Traffic Cop / «Регулировщик трафика»

Logic Sandwich сводит инфраструктуру к простым абстракциям infrastructure.readData() и infrastructure.writeData(). Но некоторые приложения должны реагировать на изменения, инициируемые инфраструктурным и логическим слоями. Поэтому:

Запрограммируйте уровень приложения на использование паттерна Observer для прослушивания событий с инфраструктурного и логического уровней. Для каждого из событий реализуйте Logic Sandwich.

// Traffic Cop example (JavaScript)

server.onPost("/login", (formData) => {               // event from infrastructure layer
  const loginData = processLoginForm(formData);           // application logic
  const userData = userService.logInUser(loginData);      // infrastructure layer
  this._user = new User(userData);                        // logic layer

  const userIsValid = this._user.isValid();               // logic layer
  if (userIsValid) {                                      // application logic
    const sessionData = user.sessionData;                 // logic layer
    sessionServer.createSession(sessionData);             // infrastructure layer
    return redirect(loginData.postLoginUrl);              // application logic
  }
  else {
    return redirect(LOGIN_FAILED_URL);                    // application logic
  }
});

this._user.onChange((userData) => {                   // event from logic layer
  userService.updateUser(userData);                       // infrastructure layer
});

Будьте осторожны, чтобы Traffic Cop не превратился в God Class. Если всё усложняется, помочь могут лучшие инфраструктурные абстракции. Иногда менее «чистый» подход и перенос части кода логики в инфраструктурный слой может привести к упрощению общего проектного решения. В других случаях может помочь разделение уровня приложения на несколько классов или модулей, каждый со своим Logic Sandwich или простым Traffic Cop.

Grow Evolutionary Seeds / «Выращивание эволюционных зёрен»

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

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

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

// Simplest possible Application seed (JavaScript)

// Test code
it("renders user name", () => {
  const app = new MyApplication();
  assert.equal("Hello, Sarah", app.render());
});

// Production code
class MyApplication {
  render() {
    return "Hello, Sarah";
  }
}

Затем реализуйте «голую» инфраструктурную обёртку для того значения инфраструктуры, которое вы жёстко закодировали. Протестируйте его с помощью узких интеграционных тестов и напишите достаточно кода, чтобы обеспечить один реальный результат, который нужен вашему классу приложения. Пока не беспокойтесь о том, чтобы сделать его надёжным. Класс Infrastructure Wrapper формирует основу инфраструктурного слоя.

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

// Application + read from infrastructure (JavaScript)

// Test code
it("renders user name", async () => {
  const usernameService = UsernameService.createNull({ username: "my_username" });  // Nullable with Configurable Responses
  const app = new MyApplication(usernameService);
  assert.equal("Hello, my_username", await app.renderAsync());
});

// Production code
class MyApplication {
  static create() {     // Parameterless Instantiation
    return new MyApplication(UsernameService.create());
  }

  constructor(usernameService) {
    this._usernameService = usernameService;
  }

  async renderAsync() {
    const username = await this._usernameService.getUsernameAsync();
    return `Hello, ${username}`;
  }
}

Теперь сделайте то же самое для пользовательского интерфейса. Выберите один простой механизм вывода, который приложение будет использовать (например, вывод в консоль, DOM или ответ на сетевой запрос), и реализуйте для него голую инфраструктурную обёртку. Сделайте его Nullable и измените тесты и код уровня приложения для его использования.

// Application + read/write to Infrastructure (JavaScript)

// Test code
it("renders user name", () => {
  const usernameService = UsernameService.createNull({ username: "my_username" });  // Nullable with Configurable Responses
  const uiService = UiService.createNull();   // Nullable
  const uiOutput = uiService.trackOutput();   // Output Tracking

  const app = new MyApplication(usernameService, uiService);

  await app.renderAsync();
  assert.deepEqual(uiOutput.data, [ "Hello, my_username"]);
});

// Production code
class MyApplication {
  static create() {     // Parameterless Instantiation
    return new MyApplication(UsernameService.create(), UiService.create());
  }

  constructor(usernameService, uiService = UiService.create()) {
    this._usernameService = usernameService;
    this._uiService = uiService;
  }

  async renderAsync() {
    const username = await this._usernameService.getUsernameAsync();
    await uiService.renderAsync(`Hello, ${username}`);
  }
}

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

На данный момент у вас есть зачатки «ходящего скелета» (walking skeleton) — приложения, которое работает от начала и до конца, но которое при этом далеко от завершённости. Вы можете развивать этот скелет, чтобы поддерживать больше фич. Выберите какой-то аспект кода, который явно недоработан, и протестируйте чуть более совершенное решение. Повторяйте до бесконечности.

// Application + read/write to Infrastructure + respond to UI events (JavaScript)

// Test code
it("renders user name", async () => {
  const usernameService = UsernameService.createNull({ username: "my_username" });  // Nullable with Configurable Responses
  const uiService = UiService.createNull();   // Nullable
  const uiOutput = uiService.trackOutput();   // Output Tracking

  const app = new MyApplication(usernameService, uiService);
  await app.startAsync();

  uiService.simulateRequest("greeting");      // Behavior Simulation
  assert.deepEqual(uiOutput.data, [ "Hello, my_username" ]);
});

// Production code
class MyApplication {
  static create() {     // Parameterless Instantiation
   return new MyApplication(UsernameService.create(), UiService.create());
 }

 constructor(usernameService, uiService = UiService.create()) {
   this._usernameService = usernameService;
   this._uiService = uiService;
 }

  async startAsync() {
    this._uiService.on("greeting", () => {
      const username = await this._usernameService.getUsernameAsync();
      await uiService.renderAsync(`Hello, ${username}`);
    });
  }
}

В какой-то момент класс слоя приложения начнёт казаться беспорядочным. Когда это произойдёт, ищите концепцию, которую можно вынести в отдельный класс. Это станет основой слоя логики. По мере роста приложения продолжайте рефакторинг, чтобы совместная работа классов была понятна, а обязанности — чётко определены.

При работе с существующим кодом используйте паттерны тестирования легаси-кода.

Паттерны тестирования логики

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

Чистые вычисления легко тестировать. Ещё более упрощают процесс следующие паттерны, о которых и пойдёт речь далее.

  • Easily-Visible Behavior («Легко различимое поведение»)

  • Testable Libraries («Тестируемые библиотеки»)

  • Collaborator-Based Isolation («Изоляция на основе коллаборации»)

Easily-Visible Behavior / «Легко различимое поведение»

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

По возможности отдавайте предпочтение чистым функциям. Возвращаемые значения чистых функций определяются только их входными параметрами.

// JavaScript
function add(a, b) {
  return a + b;
}

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

// JavaScript
class Value {
  constructor(initialValue) {
    this._value = initialValue;
  }

  plus(addend) {
    return new Value(this._value + addend);
  }
}

Для изменяемых объектов предусмотрите способ наблюдения за изменениями состояния — метод getter или событие.

// JavaScript
class RunningTotal {
  constructor(initialValue) {
    this._total = initialValue;
  }

  add(addend) {
    this._total += addend;
  }

  getTotal() {
    return this._total;
  }
}

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

Testable Libraries / «Тестируемые библиотеки»

Сторонний код не всегда имеет «легко обозримое поведение». Он также имеет тенденцию вносить изменения в API с новыми релизами или просто переставать поддерживаться. Поэтому:

Оберните сторонний код в код, который вы контролируете. Убедитесь, что использование стороннего кода вашим приложением происходит через вашу обёртку. Напишите API обёртки так, чтобы он соответствовал потребностям приложения, а не стороннего кода, и при необходимости добавьте методы, чтобы обеспечить «легко обозримое поведение». (Обычно это включает в себя написание геттеров для раскрытия «глубоко скрытого состояния»). Если сторонний код вносит изменения или нуждается в замене, модифицируйте обёртку так, чтобы не затронуть другой код.

Фреймворки и библиотеки с разветвлённым API сложнее обернуть, поэтому отдавайте предпочтение библиотекам, имеющим узко определённую цель и простой API.

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

Используйте инфраструктурную обёртку, если сторонний код взаимодействует с внешней системой или состоянием.

Collaborator-Based Isolation / «Изоляция на основе коллаборации»

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

Если поведение зависимости не имеет отношения к тестируемому коду, используйте зависимость, чтобы помочь определить ожидания от тестирования. Например, если вы тестируете отчёт, содержащий в заголовке адрес, не нужно жёстко кодировать "123 Main St." в качестве ожиданий. Вместо этого спросите сам адрес, как он будет отображаться, и используйте это в качестве части тестовых ожиданий.

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

// JavaScript

// Example test
it("includes the address in the header when reporting on one address", () => {
  // Instantiate the unit under test and its dependency
  const address = Address.createTestInstance();                 // Parameterless Instantiation
  const report = new InventoryReport(Inventory.create(), [ address ]);

  // Define the expected result using the dependency
  const expected = "Inventory Report for " + address.renderAsOneLine();

  // Run the production code and make the assertion
  assert.equal(report.renderHeader(), expected);
});

// Example production code
class InventoryReport {
  constructor(inventory, addresses) {
    this._inventory = inventory;
    this._addresses = addresses;
  }

  renderHeader() {
    let result = "Inventory Report";
    if (this._addresses.length === 1) {
      result += " for " + this._address[0].renderAsOneLine();
    }
    return result;
  }
}

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


Продолжение выйдет совсем скоро, в нём подробно рассмотрим паттерны инфраструктуры, Nullability и паттерны тестирования легаси-кода. А пока всех заинтересованных тестировщиков приглашаем на открытые уроки:

Ещё больше актуальных знаний и инструментов в области автоматизации тестирования ищите на онлайн-курсах OTUS.

Tags:
Hubs:
Total votes 10: ↑8 and ↓2+8
Comments15

Articles

Information

Website
otus.ru
Registered
Founded
Employees
101–200 employees
Location
Россия
Representative
OTUS