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

от автора

Продолжение первой части.

Паттерны инфраструктуры

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

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

Инфраструктурные обёртки

Инфраструктурный код сложно писать, сложно тестировать и часто сложно понимать. Поэтому:

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

Избегайте создания сложных сетей зависимостей. В некоторых случаях высокоуровневые инфраструктурные классы могут зависеть от общих низкоуровневых классов. Например, LoginClient может зависеть от HttpClient. В других случаях высокоуровневые инфраструктурные классы могут объединять несколько низкоуровневых классов — например, класс DataStore, который зависит от класса RelationalDb и класса NoSqlDb. За исключением таких простых односторонних цепочек зависимостей разрабатывайте инфраструктурные классы так, чтобы они были самостоятельными.

Тестируйте обёртки инфраструктуры с помощью узких интеграционных тестов и паттерна «параноидальная телеметрия». Сделайте их тестируемыми с помощью паттернов Nullability.

Инфраструктурные обёртки также называют «шлюзами» или «адаптерами», хотя эти термины технически являются надмножеством инфраструктурных обёрток.

Узкие интеграционные тесты

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

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

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

Если у вас несколько внешних систем используют одну и ту же технологию — например, несколько веб-сервисов — создайте общую низкоуровневую инфраструктурную обёртку для базовой технологии. Затем создайте высокоуровневые инфраструктурные обёртки для каждой системы. Высокоуровневые обёртки не нуждаются в узких интеграционных тестах. Вместо этого вы можете прибегнуть к паттерну Fake It Once You Make It, делегировав полномочия низкоуровневой обёртке.

Например, вы можете создать высокоуровневый LoginClient, который зависит от низкоуровневого HttpClient. LoginClient будет работать в режиме Fake It Once You Make It, а HttpClient будет проверяться с помощью узких интеграционных тестов.

// Example of narrow integration tests for HttpClient (JavaScript + Node.js) import * as http from "node:http"; import HttpClient from "./http_client";  const HOST = "localhost"; const PORT = 5001;  // Tests describe("HTTP Client", () => {   let server;    before(async () => {     server = new TestServer();     await server.startAsync();   });    after(async () => {     await server.stopAsync();   });    beforeEach(function() {     server.reset();   });    it("performs request", async () => {     await requestAsync({       host: HOST,       port: PORT,       method: "POST",       path: "/my/path",       headers: { myRequestHeader: "myRequestValue" },       body: "my request body"     });      assert.deepEqual(server.lastRequest, {       method: "POST",       path: "/my/path",       headers: { myrequestheader: "myRequestValue" },       body: "my request body"     });   });    it("returns response", async () => {     server.setResponse({       status: 999,       headers: { myResponseHeader: "myResponseValue" },       body: "my response",     });      const response = await requestAsync();     assert.deepEqual(response, {       status: 999,       headers: { myresponseheader: "myResponseValue" },       body: "my response",     });   });    async function requestAsync(options = {     host: HOST,     port: PORT,     method: "GET",     path: "/irrelevant/path",   }) {     const client = HttpClient.create();     return client.requestAsync(options);   }  });  // Localhost HTTP server class TestServer {   constructor() {     this.reset();   }    reset() {     this._lastRequest = null;     this._nextResponse = {       status: 500,       headers: {},       body: "response not specified",     };   }    startAsync() {     return new Promise((resolve, reject) => {       this._server = http.createServer();       this._server.once("listening", resolve);       this._server.once("error", reject);       this._server.on("request", this.#handleRequest.bind(this));       this._server.listen(PORT);     });   }    stopAsync() {     return new Promise((resolve, reject) => {       this._server.once("close", resolve);       this._server.close();     });   }    setResponse(response) {     this._nextResponse = response;   }    get lastRequest() {     return this._lastRequest;   }    // In JavaScript, methods that start with "#" are private.   #handleRequest(request, response) {     let body = "";     request.on("data", (chunk) => {       body += chunk;     });     request.on("end", () => {       this.#storeRequest(request, body);       this.#sendResponse(response);     });   }    #storeRequest(request, body) {     const headers = { ...request.headers };     delete headers.connection;     delete headers["content-length"];     delete headers.host;      this._lastRequest = {       method: request.method,       path: request.url,       headers,       body,     };   }    #sendResponse(response) {     response.statusCode = this._nextResponse.status;     Object.entries(this._nextResponse.headers).forEach(([key, value]) => {       response.setHeader(key, value);     });      response.end(this._nextResponse.body);   } } 

С помощью «параноидальной телеметрии» убедитесь, что код работает в продакшене.

«Параноидальная телеметрия» (Paranoic Telemetry)

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

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

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

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

Паттерны Nullability

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

Nullables

Узкие интеграционные тесты медленны и сложны в настройке. Хотя они полезны для работы низкоуровневых инфраструктурных обёрток, они излишни для кода, который зависит от этих обёрток. Поэтому:

Планируйте код, включающий инфраструктуру, в цепочку зависимостей, чтобы он имел фабричный метод createNull(). Фабрика должна создавать экземпляр «Nulled», который отключает все внешние коммуникации, но ведёт себя нормально во всех остальных отношениях (2). Убедитесь, что она поддерживает инстанцирование без параметров.

(2). Nullables изначально были вдохновлены паттерном Null Object, но в результате эволюции стали совершенно другими.

Например, вызов LoginClient.createNull().getUserInfo(...) должен возвращать ответ по умолчанию без обращения к стороннему сервису авторизации.

Nullables — это продакшен код и он должен быть протестирован соответствующим образом. Хотя Nulled экземпляры часто используются в тестах, они также полезны, когда нужна возможность «отключить» поведение в приложении. Например, с помощью Nullables можно реализовать опцию «формального прогона» в консольном приложении.

// Example of using Nullables to implement "dry run" option (JavaScript + Node.js) async initializeGitWriter(config) {   if (config.dryRun) {     return GitWriter.createNull();   }   else {     return GitWriter.create();   } }

В качестве другого примера можно привести использование Nullables в веб-сервере для кэширования популярных URL-адресов при запуске сервера:

// Example of using Nullables to implement cache warming (JavaScript + Node.js) async warmCacheAsync(popularUrls, log) {   for await (const url of popularUrls) {     await this.routeAsync(HttpRequest.createNull(url, log);   } }

Сделайте низкоуровневые обёртки инфраструктуры Nullable с помощью встроенных стабов. Для всего остального кода используйте паттерн Fake It Once You Make It. Чтобы сделать существующий код Nullable, смотрите паттерны тестирования легаси-кода.

Если Nullable считывает данные из внешних систем или состояния, или это делают какие-либо его зависимости, реализуйте настраиваемые ответы (Configurable Responses). Если он или его зависимости записывают данные, реализуйте паттерн отслеживания вывода (Output Tracking). Если они реагируют на события, реализуйте имитацию поведения (Behavior Simulation).

Встроенная заглушка (Embedded Stub)

Для Nullables необходимо отключить доступ к внешним системам и состояниям, при этом всё остальное должно работать нормально. Очевидный подход — окружить любой код, обращающийся к внешней системе, оператором «if», но это рецепт для спагетти-кода. Поэтому:

Делая код Nullable, не меняйте свой код. Вместо этого сделайте заглушку для стороннего кода, который обращается к внешним системам.

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

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

Вот простой пример создания заглушки JS-библиотеки Math:

// An Infrastructure Wrapper for a random die roller. (JavaScript)  // Infrastructure Wrapper export default class DieRoller {    // Normal factory   static create() {     return new DieRoller(Math);    // "Math" is a built-in JavaScript global   }    // Null factory   static createNull() {     return new DieRoller(new StubbedMath());   }    // Shared initialization code   constructor(math) {     this._math = math;   }    // Infrastructure wrapper implementation.   // This is the same code you would write without a stub.   roll(amount) {     const randomNumber = this._math.random();     return Math.trunc((randomNumber * 6) + 1);    // There's no need to stub Math.trunc, so we use the real Math library here   } };  // Embedded Stub. Note that we only stub the function we use. class StubbedMath {   random() {     return 0;   } }

Вот более сложный пример. В нём создаётся заглушка http-библиотеки Node.js:

// An infrastructure wrapper for a generic HTTP client. (JavaScript + Node.js) import * as http from "node:http"; import { EventEmitter } from "node:events";  export default class HttpClient {    // Normal factory   static create() {     return new HttpClient(http);   }    // Null factory   static createNull() {     return new HttpClient(new StubbedHttp());   }    // Shared initialization code   constructor(http) {     this._http = http;   }    // Infrastructure wrapper implementation.   // This is the same code you would write without a stub.   async requestAsync({ host, port, method, path, headers = {}, body = "" }) {     if (method === "GET" && body !== "") throw new Error("Don't include body with GET requests; Node won't send it");      const httpOptions = { host, port, method, path, headers };     const request = this.#sendRequest(httpOptions, body);      await new Promise((resolve, reject) => {       this.#handleResponse(request, resolve);       this.#handleError(request, reject);     });   }    // In JavaScript, methods that start with "#" are private.   #sendRequest(httpOptions, body) {     const request = this._http.request(httpOptions);     request.end(body);     return request;   }    #handleResponse(request, resolve) {     request.once("response", (response) => {       let body = "";       response.on("data", (chunk) => {         body += chunk;       });       response.on("end", () => {         resolve({           status: response.statusCode,           headers,           body,         });       });     });   }    #handleError(request, reject) {     request.once("error", reject);   } };  // Embedded Stub. Note that it’s built exactly for the needs of the infrastructure code, nothing more. class StubbedHttp {   request() {     return new StubbedRequest();   } }  class StubbedRequest extends EventEmitter {   end() {     // setImmediate() is used to make the emit() call asynchronous, duplicating the behavior of real code     setImmediate(() => this.emit("response", new StubbedResponse()));   } }  class StubbedResponse extends EventEmitter {   constructor() {     super();     setImmediate(() => {       this.emit("data", "Nulled HttpClient response");       this.emit("end");     });   }   get statusCode() {     return 200;   }   get headers() {     return {};   } }

Настройте возвращаемые значения встроенной заглушки с помощью настраиваемых ответов. Если ваш язык требует этого, как, например, Java или C#, создайте тонкую обёртку (об этом ниже).

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

Тонкая обёртка

Такие языки, как Java и C#, потребуют, чтобы встроенная заглушка имела общий интерфейс с реальной зависимостью. Зачастую такого интерфейса нет. Поэтому:

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

// A simple Infrastructure Wrapper for a random die roller. (Java) // It has an embedded stub for Java's standard “Random” library. // Based on an example created with Ted M. Young in his Yacht codebase. public class DieRoller {   private final RandomWrapper random;    // Normal factory   public static DieRoller create() {     return new DieRoller(new RealRandom());   }    // Null factory   public static DieRoller createNull() {     return new DieRoller(new StubbedRandom());   }    // Private constructor with shared initialization code   private DieRoller(RandomWrapper random) {     this.random = random;   }    // Infrastructure wrapper implementation.   // This is the same code you would write without a stub.   public int roll() {     return random.nextInt(6) + 1;   }    // Interface for Thin Wrapper. Note that we match the real code's interface exactly,   // and we only include the function we use.   private interface RandomWrapper {     int nextInt(int bound);   }    // Real implementation of Thin Wrapper   private static class RealRandom implements RandomWrapper {     private final Random random = new Random();      @Override     public int nextInt(int bound) {       return this.random.nextInt(bound);     }   }    // Embedded Stub implementation of Thin Wrapper   private static class StubbedRandom implements RandomWrapper {     @Override     public int nextInt(int bound) {       return 0;     }   } }

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

// Infrastructure Wrapper for an HTTP request. (Java + Spring Boot's RestTemplate) // Based on an example created with Ted M. Young in his Yacht codebase. public class AverageScoreFetcher {   private static final String YACHT_AVERAGE_API_URI = "http://localhost:8080/api/averages?scoreCategory={scoreCategory}";    private final RestTemplateWrapper restTemplate;    // Normal factory   public static AverageScoreFetcher create() {     return new AverageScoreFetcher(new RealRestTemplate());   }    // Null factory   public static AverageScoreFetcher createNull() {     return new AverageScoreFetcher(new StubbedRestTemplate());   }    // Private constructor with shared initialization code   private AverageScoreFetcher(RestTemplateWrapper restTemplate) {     this.restTemplate = restTemplate;   }    // Infrastructure wrapper implementation   public double averageFor(ScoreCategory scoreCategory) {     ResponseEntityWrapper<CategoryAverage> entity = restTemplate.getForEntity(       YACHT_AVERAGE_API_URI,       CategoryAverage.class,       scoreCategory.toString()     );     return entity.getBody().getAverage();   }    // Interfaces for Thin Wrapper. Note that we only include the functions we use.   interface RestTemplateWrapper {     <T> ResponseEntityWrapper<T> getForEntity(String url, Class<T> responseType, Object... uriVariables);   }    interface ResponseEntityWrapper<T> {     T getBody();   }    // Real implementations of Thin Wrapper   private static class RealRestTemplate implements RestTemplateWrapper {     private final RestTemplate restTemplate = new RestTemplate();      public <T> ResponseEntityWrapper<T> getForEntity(String url, Class<T> responseType, Object... uriVariables) {       return new RealResponseEntity<T>(restTemplate.getForEntity(url, responseType, uriVariables));     }   }    private static class RealResponseEntity<T> implements ResponseEntityWrapper<T> {     private ResponseEntity<T> entity;      RealResponseEntity(ResponseEntity<T> entity) {       this.entity = entity;     }      public T getBody() {       return this.entity.getBody();     }   }    // Stubbed implementations of Thin Wrapper   private static class StubbedRestTemplate implements RestTemplateWrapper {     @Override     public <T> ResponseEntityWrapper<T> getForEntity(String url, Class<T> responseType, Object... uriVariables) {       return new StubbedResponseEntity<>();     }   }    private static class StubbedResponseEntity<T> implements ResponseEntityWrapper<T> {     @Override     public T getBody() {       return (T) new CategoryAverage("Nulled AverageScoreFetcher category", 42.0);     }   }  }

Настраиваемые ответы

Тесты кода с инфраструктурными зависимостями на основе состояния требуют настройки состояния инфраструктуры, но настройка внешних систем является сложной и медленной. Поэтому:

Сделайте зависимости инфраструктуры Nullable и запрограммируйте фабрику createNull() так, чтобы она принимала желаемый ответ в качестве опционального параметра. Определяйте ответы с точки зрения видимого извне поведения зависимости, а не её реализации.

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

Например, следующий тест предназначен для LoginController, который зависит от Nullable LoginClient. Хотя LoginClient используется для выполнения HTTP-запросов, его настраиваемые ответы не связаны с HTTP. Вместо этого они содержат информацию об адресе электронной почты и статусе верификации вошедшего в систему пользователя, что и является тем поведением, о котором заботится LoginController и его тесты.

// Example of configuring multiple types of responses. (JavaScript) it("logs successful login", async () => {   // Configure login client dependency   const loginClient = LoginClient.createNull(     email: "my_authenticated_email",  // configure email address     emailVerified: true,              // configure whether email is verified   );    // Run production code   const { logOutput } = await performLogin({ loginClient }));  // Signature Shielding    // Check results   assert.deepEqual(logOutput.data, [ "Login: my_authenticated_email (verified)" ]);   // Output Tracking });

Если ваш класс должен отвечать по-разному при каждом вызове, настройте ответы в виде массива или списка. Часто полезно поддерживать два типа данных: список значений, который каждый раз даёт разный ответ и вызывает исключение, когда он заканчивается; и одно значение, которое каждый раз возвращает один и тот же ответ и никогда не заканчивается.

Например, следующий тест настраивает Nullable DieRoller с набором ожидаемых бросков кубика:

// Example of a single type of response with multiple return values. (JavaScript) // Inspired by an example created with Ted M. Young in his Yacht codebase. it("rolls a hand of dice", async () => {   // Configure die rolls   const dieRoller = DieRoller.createNull([ 1, 2, 3, 4, 5 ]);    // Run production code   const game = new Game(dieRoller);   const hand = game.roll();    // Check results   assert.deepEqual(hand, HandOfDice.create(1, 2, 3, 4, 5)); });

Если Nullable использует встроенную заглушку, реализуйте ответы в заглушке. В противном случае примените паттерн Fake It Once You Make It. В любом случае, разложите ответы на следующий уровень.

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

// Example of implementing Configurable Responses in an Embedded Stub. (JavaScript)  // Infrastructure Wrapper export default class DieRoller {    static create() {     return new DieRoller(Math);    // "Math" is a built-in JavaScript global   }    // Null factory with Configurable Responses   // If a number is provided, it always returns that number.   // If an array is provided, it returns exactly the numbers provided, then throws an error when it runs out.   // If nothing is provided, it defaults to returning ones.   static createNull(rolls = 1) {                    // set default to 1     return new DieRoller(new StubbedMath(rolls));   // pass configuration to Embedded Stub   }    constructor(math) {     this._math = math;   }    roll(amount) {     const randomNumber = this._math.random();     return Math.trunc((randomNumber * 6) + 1);      // There's no need to stub Math.trunc, so we use the real Math global   } };  // Embedded Stub with Configurable Responses class StubbedMath {    constructor(rolls) {     // Store configured responses     this._rolls = rolls;   }    random() {     // Use configured responses     const roll = this.#nextRoll();    // Get configuration to use     return (roll - 1) / 6;            // Convert to float to match behavior of real Math.random()   }    // Retrieve configured response   #nextRoll() {     if (Array.isArray(this._rolls)) {       // Configuration is an array, so return the next roll in the array       const roll = this._rolls.shift();       if (roll === undefined) throw new Error("No more rolls configured in nulled DieRoller");       return roll;     }     else {       // Configuration is a number, so always return that number       return this._rolls;     }   } }

Вышеприведенный код можно упростить путём выделения #nextRoll() в общий вспомогательный класс. Результат выглядит следующим образом:

// Example of implementing an embedded stub with a ConfigurableResponses helper class (JavaScript) class StubbedMath {   constructor(rolls) {     this._rolls = ConfigurableResponses.create(rolls);   }    random() {     return (this._rolls.next() - 1) / 6;   } }

Это JavaScript-реализация ConfigurableResponses, которую вы можете использовать в своём собственном коде:

// Copyright 2023 Titanium I.T. LLC. MIT License. export default class ConfigurableResponses {    // Create a list of responses (by providing an array),   // or a single repeating response (by providing any other type).   // 'Name' is optional and used in error messages.   static create(responses, name) {     return new ConfigurableResponses(responses, name);   }    // Convert all properties in an object into ConfigurableResponse instances.   // For example, { a: 1 } becomes { a: ConfigurableResponses.create(1) }.   // 'Name' is optional and used in error messages.   static mapObject(responseObject, name) {     const entries = Object.entries(responseObject);     const translatedEntries = entries.map(([ key, value ]) => {       const translatedName = name === undefined ? undefined : `${name}: ${key}`;       return [ key, ConfigurableResponses.create(value, translatedName )];     });     return Object.fromEntries(translatedEntries);   }    constructor(responses, name) {     this._description = name === undefined ? "" : ` in ${name}` ;     this._responses = Array.isArray(responses)       ? [ ...responses ]       : responses;   }    // Get next configured response. Throws an error when configured with a list   // of responses and no more responses remain.   next() {     const response = Array.isArray(this._responses)       ? this._responses.shift()       : this._responses;     if (response === undefined) throw new Error(`No more responses configured${this._description}`);      return response;   }  };

Для тестирования кода с зависимостями, которые записывают данные в инфраструктуру, используйте отслеживае вывода (Output Tracking). Для тестирования кода с зависимостями, реагирующими на события, используйте имитацию поведения (Behavior Simulation).

Отслеживание вывода (Output Tracking)

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

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

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

Один из способов реализации отслеживания вывода заключается в том, чтобы функция trackXxx() возвращала OutputTracker, который прослушивает события, создаваемые продакшен кодом. Следующий пример показывает, как это работает, включая реализации OutputTracker на JavaScript и Java, которые вы можете использовать в своём собственном коде. Он начинается с теста LoginPage, который пишет в структурированный Log, когда пользователь входит в систему.

// Example of using Output Tracking (JavaScript)  // Application layer test it("writes to log when user logs in", async () => {   // Set up a log and track its output   const log = Log.createNull();   const logOutput = log.trackOutput();    // Instantiate the code under test   const loginPage = new LoginPage(log);    // Run the code   const formData = // code to set up "my_email" login here   await loginPage.postAsync(formData);    // Check the log output   assert.deepEqual(logOutput.data, [{     alert: "info",     message: "User login",     email: "my_email",   }]); });  // Application layer code class LoginPage {   constructor(log) {     this._log = log;   }    async postAsync(formData) {     const email = // code to parse formData and verify login goes here      // Code under test     this.log.info({       message: "User login",       email,     });   } }  // High-level "Log" infrastructure wrapper used by the code under test  import Clock from "clock";                        // Low-level infrastructure wrapper import Stdout from "stdout";                      // Low-level infrastructure wrapper import { EventEmitter } from "node:events";       // Standard Node.js event library import OutputTracker from "output_tracker";       // Output tracking library  const OUTPUT_EVENT = "output";                    // Event to emit when output occurs  class Log {   static create() {     return new Log(Clock.create(), Stdout.create());   }    static createNull({     clock = Clock.createNull(),                   // Fake It Once You Make It     stdout = Stdout.createNull(),   } = {}) {     return new Log(clock, stdout);   }    constructor(clock, stdout) {     this._clock = clock;     this._stdout = stdout;      this._emitter = new EventEmitter();           // Event emitter used when output occurs   }    // Output tracker   trackOutput() {     return OutputTracker.create(this._emitter, OUTPUT_EVENT);   }    // The method called by the code under test   info(data) {     data.alert = "info";      // Write the log     const now = this._clock.formattedTimestamp();     const dataJson = JSON.stringify(data);     this._stdout.write(`${now} ${dataJson}`);      // Emit the event. This is received by the OutputTracker.     this._emitter.emit(OUTPUT_EVENT, data);   } }

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

Это JavaScript-версия класса OutputTracker, которую вы можете использовать в своих проектах:

// Copyright 2020-2022 Titanium I.T. LLC. MIT License. export default class OutputTracker {    static create(emitter, event) {     return new OutputTracker(emitter, event);   }    constructor(emitter, event) {     this._emitter = emitter;     this._event = event;     this._data = [];      this._trackerFn = (text) => this._data.push(text);     this._emitter.on(this._event, this._trackerFn);   }    get data() {     return this._data;   }    clear() {     const result = [ ...this._data ];     this._data.length = 0;     return result;   }    stop() {     this._emitter.off(this._event, this._trackerFn);   }  }

Ниже я также включил Java-версию библиотеки OutputTracker, которую создал вместе с Тедом М. Янгом. Поскольку в Java нет встроенного эмиттера событий, она используется немного по-другому. Вот пример её использования в инфраструктурной обертке Log из предыдущего примера: 

// Example of Output Tracking in Java public class Log {   // Instantiate the event emitter   private final OutputListener<LogData> outputListener = new OutputListener<>();    public static Log create...   public static Log createNull...   private Log...    // Create the output tracker   public OutputTracker<LogData> trackOutput() {     return outputListener.createTracker();   }    public void info(Map<String, LogData> data) {     // ...      // Emit the event     outputListener.track(data);   } }

Это Java-версия программы OutputTracker. Разделите её на два файла.

---- OutputListener.java ---- // Copyright 2022 Titanium I.T. LLC and Ted M. Young. MIT License. package com.jamesshore.output_tracker;  import java.util.ArrayList; import java.util.List;  public class OutputListener<T> {   private final List<OutputTracker<T>> listeners = new ArrayList<>();    public void track(T data) {     listeners.forEach(tracker -> tracker.add(data));   }    public OutputTracker<T> createTracker() {     OutputTracker<T> tracker = new OutputTracker<>(this);     listeners.add(tracker);     return tracker;   }    void remove(OutputTracker<T> outputTracker) {     listeners.remove(outputTracker);   } }  ---- OutputTracker.java ---- // Copyright 2022 Titanium I.T. LLC and Ted M. Young. MIT License. package com.jamesshore.output_tracker;  import java.util.ArrayList; import java.util.List;  public class OutputTracker<T> {   private final List<T> output = new ArrayList<>();   private final OutputListener<T> outputListener;    public OutputTracker(OutputListener<T> outputListener) {     this.outputListener = outputListener;   }    void add(T data) {     output.add(data);   }    public List<T> data() {     return List.copyOf(output);   }    public List<T> clear() {     List<T> data = this.data();     output.clear();     return data;   }    public void stop() {     outputListener.remove(this);   } }

Для тестирования кода с зависимостями, которые считывают данные из инфраструктуры, используйте настраиваемые ответы (Configurable Responses). Для тестирования кода с зависимостями, которые вызывают события, используйте имитацию поведения (Behavior Simulation).

Имитация поведения (Behavior Simulation)

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

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

Следующий пример состоит из сервера MessageServer уровня приложения, который выполняет сетевое взаимодействие в режиме реального времени. MessageServer работает на сервере и подключается к веб-браузерам с помощью WebSocketServer, низкоуровневой инфраструктурной обёртки для Socket.IO. Когда подключённый браузер отправляет сообщение на MessageServer, тот передаёт его всем остальным подключённым браузерам.

Тест использует имитацию поведения для имитации подключения веб-браузеров и отправки сообщений, а затем проверяет поведение MessageServer с помощью Output Tracking, чтобы подтвердить, что были отправлены правильные сообщения.

// Example of using Behavior Simulation (JavaScript)  // Application layer test it("broadcasts messages from one client to all others", async () => {   // Set up test data   const clientId = "my_client_id";   const message = new TestClientMessage("my_message");    // Set up the infrastructure wrapper and the code under test   const network = WebSocketServer.createNull();   // Create the infrastructure wrapper   const sentMessages = network.trackMessages();   // Track messages sent by infrastructure wrapper (Output Tracking)   const server = new MessageServer(network);      // Instantiate the application code under test   await server.startAsync();                      // Start listening for messages (Zero-Impact Instantiation)    // Simulate a client connecting   network.simulateConnection(clientId);    // Simulate the client sending a message   network.simulateMessage(clientId, message);    // Check that the message was broadcast (Output Tracking)   assert.deepEqual(sentMessages.data(), [{     type: "broadcast",     excludedClient: clientId,     message   }]); });  // Application layer code class MessageServer {   constructor(webSocketServer) {     this._webSocketServer = webSocketServer;   }    async startAsync() {     // Code under test     this._webSocketServer.onMessage((clientId, message) => {       this._webSocketServer.broadcastToAllClientsExcept(clientId, message);     });      await this._webSocketServer.startAsync();   }    //... } // Example of using Behavior Simulation (JavaScript)  // Application layer test it("broadcasts messages from one client to all others", async () => {   // Set up test data   const clientId = "my_client_id";   const message = new TestClientMessage("my_message");    // Set up the infrastructure wrapper and the code under test   const network = WebSocketServer.createNull();   // Create the infrastructure wrapper   const sentMessages = network.trackMessages();   // Track messages sent by infrastructure wrapper (Output Tracking)   const server = new MessageServer(network);      // Instantiate the application code under test   await server.startAsync();                      // Start listening for messages (Zero-Impact Instantiation)    // Simulate a client connecting   network.simulateConnection(clientId);    // Simulate the client sending a message   network.simulateMessage(clientId, message);    // Check that the message was broadcast (Output Tracking)   assert.deepEqual(sentMessages.data(), [{     type: "broadcast",     excludedClient: clientId,     message   }]); });  // Application layer code class MessageServer {   constructor(webSocketServer) {     this._webSocketServer = webSocketServer;   }    async startAsync() {     // Code under test     this._webSocketServer.onMessage((clientId, message) => {       this._webSocketServer.broadcastToAllClientsExcept(clientId, message);     });      await this._webSocketServer.startAsync();   }    //... }

Логика имитации поведения реализована в виде продакшен кода в WebSocketServer, низкоуровневой обёртке Socket.IO. Обратите внимание, что реальная логика Socket.IO и методы имитации разделяют как можно больше реализации, делегируя методы #handleXxx().

// Example of implementing Behavior Simulation (JavaScript) import { Server } from "socket.io";               // Socket.IO import { EventEmitter } from "node:events";       // Standard Node.js event library import OutputTracker from "output_tracker";       // Output tracking library  const CLIENT_MESSAGE_EVENT = "client_message";    // Event constants const SERVER_MESSAGE_EVENT = "server_message";  class WebSocketServer {   static create(port) {     return new WebSocketServer(io, port);   }    static createNull() {     return new WebSocketServer(StubbedServer, 42);   }    constructor(server, port) {     this._server = server;     this._port = port;     this._emitter = new EventEmitter();     this._connectedSockets = {};   }    // Real Socket.IO event handler   async startAsync() {     this._io.on("connection", (socket) => {       this.#handleConnection(socket);       socket.onAny((event, ...args) => {         const message = this.#deserializeMessage(event, args);         this.#handleMessage(socket.id, message);       });       socket.on("disconnect", () => {         this.#handleDisconnection(socket.id));       });     });   }    // Behavior Simulation   simulateConnection(clientId) {     this.#handleConnection(new StubbedSocket(clientId));   }    simulateMessage(clientId, message) {     this.#handleMessage(clientId, message);   }    simulateDisconnection(clientId) {     this.#handleDisconnection(clientId);   }    // Shared by event handler and behavior simulation   #handleConnection(socket) {     this._connectedSockets[socket.id] = socket;   )    #handleMessage(clientId, message) {     this._emitter.emit(CLIENT_MESSAGE_EVENT, { clientId, message });   }    #handleDisconnection(clientId) {     delete this._connectedSockets(clientId);   }    // Methods called by the code under test   onMessage(fn) {     this._emitter.on(CLIENT_MESSAGE_EVENT, ({ clientId, message }) => {       fn(clientId, message));     });   }    broadcastToAllClientsExcept(clientId, message) {     const socket = this._connectedSockets[clientId];     socket.broadcast.emit(message.name, message.payload);      this._emitter.emit(SERVER_MESSAGE_EVENT, {    // Output Tracking       type: "broadcast",       excludedClient: clientId,       message,     });   });    // Output Tracking   trackMessages() {     return OutputTracker.create(this._emitter, SERVER_MESSAGE_EVENT);   }    //... }

Для тестирования кода с зависимостями, которые считывают данные из инфраструктуры, используйте настраиваемые ответы (Configurable Responses). Для тестирования кода с зависимостями, которые пишут в инфраструктуру, используйте отслеживание вывода (Output Tracking).

Fake It Once You Make It

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

В коде уровня приложения и высокоуровневых инфраструктурных обёртках делегируйте Nullable-зависимостям, а не используйте узкие интеграционные тесты и встроенные заглушки. В своих тестах внедряйте Nulled экземпляры зависимостей тестируемого кода. Если в продакшен коде есть фабрика createNull(), реализуйте её, создавая Nulled-зависимости и декомпозируя конфигурируемые ответы в формат, ожидаемый зависимостями.

Например, следующий код тестирует LoginClient, который зависит от низкоуровневого HttpClient. LoginClient тестирует с применением Fake It Once You Make It, внедряя в LoginClient Nulled-версию HttpClient.

// Example of a using Fake It Once You Make It in a test (JavaScript)  it("performs network request", async () => {   // Set up the low-level HTTP client (Configurable Responses)   const httpClient = HttpClient.createNull({     "/oauth/token": [{                        // The Auth0 endpoint our code will call.       status: VALID_STATUS,                   // Status, headers, and body Auth0 could really return.       headers: VALID_HEADERS,       body: VALID_BODY,     }],   });    // Track requests made with the HTTP client (Output Tracking)   const httpRequests = httpClient.trackRequests();    // Instantiate the code under test, injecting the Nulled httpClient   const client = new LoginClient(httpClient, "my_client_id", "my_client_secret", "my_management_api_token");    // Run the code under test   await client.validateLoginAsync("my_login_code", "my_callback_url");    // Assert that the correct HTTP request was made (Output Tracking)   assert.deepEqual(httpRequests.data, [{     host: HOST,     port: PORT,     method: "post",     path: "/oauth/token",     headers: {       authorization: "Bearer my_management_api_token",       "content-type": "application/json; charset=utf-8",     },     body: JSON.stringify({       client_id: "my_client_id",       client_secret: "my_client_secret",       code: "my_login_code",       redirect_uri: "my_callback_url",       grant_type: "authorization_code"     }),   }]); });

Продакшен код LoginClient является обёрткой для Auth0, службы аутентификации. LoginClient.createNull() имеет настраиваемые ответы (Configurable Responses), связанные с аутентификацией, такие как настройка адреса электронной почты для входа в систему. Они реализуются путём создания Nulled HttpClient и декомпозиции настраиваемых ответов LoginClient в реальные HTTP-ответы, которые Auth0 будет возвращать.

// Example of a using Fake It Once You Make It to make a class Nullable (JavaScript) class LoginClient {    // Normal factory   static create(host, clientId, clientSecret, managementApiToken) {     const httpClient = HttpClient.create();     return new LoginClient(httpClient, host, clientId, clientSecret, managementApiToken);   }    // Null factory with Configurable Responses   static createNull({     // Configurable Responses for user’s login     email = "null_login_email",   // The email address associated with the login     emailVerified = true,         // True if the email address has been verified     forbidden = undefined,        // Set to a string to simulate an Auth0 "forbidden" response      // Other parameters unrelated to Configurable Responses     host = "null.host",     clientId = "null_client_id",     clientSecret = "null_client_secret",     managementApiToken = "null_management_api_token",   } = {}) {     // Convert LoginClient's Configurable Response into the response Auth0 would actually return     const auth0Response = nullValidateLoginResponse({ email, emailVerified, forbidden });      // Create a Nulled HttpClient that's configured to return the Auth0 response     const httpClient = HttpClient.createNull({       [VALIDATE_LOGIN_ENDPOINT]: auth0Response;     });      // Instantiate the LoginClient using the Nulled HttpClient     return new LoginClient(httpClient, clientId, clientSecret, managementApiToken);   }    // Shared initialization   constructor(httpClient, host, clientId, clientSecret, managementApiToken) {     this._httpClient = httpClient;     this._host = host;     this._clientId = clientId;     this._clientSecret = clientSecret;     this._authHeaders = {       authorization: `Bearer ${managementApiToken}`,     };   }    // Shared production code   async validateLoginAsync(code, callbackUrl) {     const response = await this._httpClient.requestAsync(       host: this._host,       method: "POST",       path: VALIDATE_LOGIN_ENDPOINT,       headers: this._authHeaders,       body: {         client_id: this._clientId,         client_secret: this._clientSecret,         code,         redirect_uri: callbackUrl,         grant_type: "authorization_code"       },     );      const decodedToken = /* code to validate and decode response here */      return {       email: decodedToken.email,       emailVerified: decodedToken.email_verified     };   }    // Configurable Responses translation code   // This function decomposes the responses passed to LoginClient.createNull() down   // into responses for HttpClient.createNull(). HttpClient.createNull() is configured   // with a status, optional headers, and a body.   function nullValidateLoginResponse({ email, emailVerified, forbidden }) {     // If the "forbidden" response is set, return a 403 (Forbidden) response.     if (forbidden) return { status: STATUS.FORBIDDEN_403, body: forbidden };      // Otherwise, create a JSON Web Token, because that's what Auth0 returns     const response = { email, email_verified: emailVerified };     const id_token = jwt.sign(response, "irrelevant_secret", { noTimestamp: true });      // Return the JWT in a 200 (OK) response     return {       status: STATUS.OK_200,       body: JSON.stringify({ id_token }),     };   } }

Реализуйте отслеживание вывода (Output Tracking) и имитацию поведения (Behavior Simulation) в обычном режиме, не обращая внимания на то, могут ли зависимости принимать значение null или нет.

Чтобы зависимости могли принимать значение null, используйте либо Descend the Ladder, либо Climb the Ladder (о них ниже).

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

Если вы хотите перевести существующий код и тесты на использование Nullables, паттерны из этого раздела помогут вам в этом.

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

Descend the Ladder («Спуск по лестнице»)

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

При преобразовании модуля или класса для использования Nullables преобразуйте код и его прямые зависимости, но не более того. Работайте над остальным деревом зависимостей постепенно — когда позволяет время.

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

A. Отсутствие инфраструктурных зависимостей

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

B. Инфраструктурная обёртка со сторонними зависимостями 

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

C. Всё остальное

Для всего остального вы сделаете прямые зависимости кода Nullable, а затем воспользуетесь паттерном Fake It Once You Make It. Чтобы зависимости могли принимать значение null, примените к каждой из них один из следующих вариантов:

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

  • Если зависимость уже является Nullable или если у неё нет инфраструктурных зависимостей, никаких изменений не требуется.

  • Если зависимость не является Nullable, но все её зависимости — Nullable, примените паттерн Faking It Once You Make It.

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

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

После того, как обновите зависимости, примените Fake It Once You Make It. (Если в коде есть временная заглушка, замените её). Замените моки на Nullables и добавьте тесты по мере необходимости.

Когда вы закончите, код, который вы преобразовываете, будет Nullable и протестирован. Его зависимости будут Nullable, но не протестированы. Когда вы будете готовы преобразовать другой класс или модуль, снова используйте Descend the Ladder. Со временем вы постепенно преобразуете всю кодовую базу.

Пример

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

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

  2. Сделайте Router Nullable с помощью Fake It Once You Make It.

  3. Преобразуйте тесты Router с помощью замены моков на Nullables.

Позже, если бы вы захотели преобразовать Auth0Client, вы бы выполнили следующие шаги:

  1. Прямой зависимостью Auth0Client является HttpClient, который представляет собой низкоуровневую обёртку инфраструктуры. Сделайте HttpClient Nullable, внедрив встроенные заглушки.

  2. Сделайте Auth0Client Nullable с помощью Fake It Once You Make It.

  3. Преобразуйте тесты Auth0Client с помощью замены моков на Nullables.

Если захотите преобразовать LoginController, выполните следующие шаги:

  1. Прямой зависимостью LoginController является Auth0Client, который был ранее преобразован, так что он уже Nullable.

  2. У LoginController есть временная заглушка, оставшаяся после преобразования Router. Теперь, когда Auth0Client стал Nullable, замените заглушку на Fake It Once You Make It.

  3. Преобразуйте тесты LoginController с помощью замены моков на Nullables.

Наконец, когда вы будете готовы преобразовать HttpClient, выполните следующие шаги:

  1. HttpClient — это низкоуровневая обёртка инфраструктуры, и она стала Nullable при преобразовании Auth0Client, поэтому её нужно только протестировать.

  2. Проверьте HttpClient с помощью узких интеграционных тестов.

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

Descend the Ladder («Спуск по лестнице») предназначен для кода с большими деревьями зависимостей. Если преобразуемый код имеет небольшое дерево зависимостей, вместо этого используйте Climb the Ladder («Подъём по лестнице»).

Climb the Ladder 

Descend the Ladder («Спуск по лестнице») — это тщательный, методичный подход к улучшению существующего кода. Однако он включает в себя создание временных заглушек, что расточительно и занимает много времени. Простые деревья зависимостей не требуют такой тщательности. Поэтому:

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

  • Если узел — чистая логика, без инфраструктурных зависимостей, убедитесь, что он обладает Easily-Visible Behavior («Легко различимым поведением»), а затем добавьте тесты по мере необходимости.

  • Если узел уже является Nullable, преобразуйте его тесты, заменив моки на Nullables и добавив тесты по мере необходимости.

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

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

  • Если узел не является инфраструктурной обёрткой и не использует сторонний инфраструктурный код, то примените паттерн Fake It Once You Make It. Преобразуйте его тесты, заменив моки на Nullables и добавив тесты по мере необходимости.

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

Например, представьте, что у вас есть цепочка зависимостей RouterLoginControllerAuth0ClientHttpClient, где HttpClient — это низкоуровневая инфраструктурная обёртка. Чтобы преобразовать Router, нужно выполнить следующие шаги:

  1. HttpClient — это низкоуровневая обёртка инфраструктуры. Сделайте его Nullable, внедрив встроенную заглушку.

  2. Протестируйте HttpClient с помощью узких интеграционных тестов.

  3. Сделайте Auth0Client Nullable с помощью Fake It Once You Make It.

  4. Преобразуйте тесты Auth0Client с помощью замены моков на Nullables.

  5. Сделайте LoginController Nullable с помощью Fake It Once You Make It.

  6. Преобразуйте тесты LoginController в замену моков на Nullables.

  7. Сделайте Router Nullable с помощью Fake It Once You Make It.

  8. Преобразуйте тесты Router в замену моков на Nullables.

Climb the Ladder лучше всего работает, когда дерево зависимостей небольшое. Если же оно большое, вместо этого используйте Descend the Ladder.

Замена моков на Nullables

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

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

  • Начните с замены тестового двойника на Nulled версию реальной зависимости.

  • Если тестовый двойник настроен на возврат определённых значений, замените конфигурацию на Configurable Responses («Настраиваемые ответы»).

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

  • Если тест проверяет, как вызывается тестовый двойник, замените его утверждения на Output Tracking («Отслеживание вывода»). Преобразуйте эти тестовые двойники в последнюю очередь.

Например, вот контроллер для веб-страницы. Когда пользователь отправляет сообщение на страницу, он использует инфраструктурную обёртку rot13Client для вызова веб-сервиса, а затем отображает результат.

// Example web page controller (JavaScript + Node.js)  import * as homePageView from "home_page_view"; import Rot13Client from "rot13_client"; import HttpRequest from "http_request"; import WwwConfig from "www_config";  export default class HomePageController {   constructor(rot13Client) {     this._rot13Client = rot13Client;   }    // 'request' is an HttpRequest instance   // 'config' is a WwwConfig instance   async postAsync(request, config) {     // Parse the 'text' field from the request's JSON body     const body = await request.readBodyAsync();     const formData = new URLSearchParams(body);      const textFields = formData.getAll("text");     const userInput = textFields[0];      // Call the web service     const output = await this._rot13Client.transformAsync(config.rot13ServiceHost, userInput);      // Render the page     return homePageView.homePage(output);   } };

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

// Example of spy-based test (JavaScript + testdouble.js)    it("POST asks ROT-13 service to transform text", async () => {   // Create spies   const rot13Client = td.instance(Rot13Client);   const request = td.instance(HttpRequest);   const config = td.instance(WwwConfig);    // Configure spies   config.rot13ServiceHost = "my.rot13.host";   // rot13ServiceHost is a getter, but testdouble.js can’t configure getters’ responses, so we just set the property directly   td.when(request.readBodyAsync()).thenResolve("text=hello%20world");    // Run the code under test   const controller = new HomePageController(rot13Client);   await controller.postAsync(request, config);    // Check that the web service’s wrapper was called correctly   td.verify(rot13Client.transformAsync("my.rot13.host", "hello world")); });

Этот тест можно преобразовывать по одному шпиону за раз. Сначала мы заменим шпиона HttpRequest на Configurable Response («Настраиваемые ответы»).

// Replace HttpRequest spy (JavaScript + testdouble.js)  it("POST asks ROT-13 service to transform text", async () => {   const rot13Client = td.instance(Rot13Client);    // Replace the HttpRequest spy with a real HttpRequest. (Nullable with Configurable Responses)   const request = td.instance(HttpRequest);   const request = HttpRequest.createNull({ body: "text=hello%20world" });    const config = td.instance(WwwConfig);    config.rot13ServiceHost = "my.rot13.host";   td.when(request.readBodyAsync()).thenResolve("text=hello%20world");    // Old configuration no longer needed    const controller = new HomePageController(rot13Client);   await controller.postAsync(request, config);    td.verify(rot13Client.transformAsync("my.rot13.host", "hello world")); });

Поскольку Nullables могут сосуществовать с тестовыми двойниками, тесты всё равно пройдут после этого изменения. Далее мы заменим шпиона WwwConfig:

// Replace WwwConfig spy (JavaScript + testdouble.js)  it("POST asks ROT-13 service to transform text", async () => {   const rot13Client = td.instance(Rot13Client);   const request = HttpRequest.createNull({ body: "text=hello%20world" });    // Replace the WwwConfig spy with a real WwwConfig. (Nullable with Configurable Responses)   const config = td.instance(WwwConfig);   const config = WwwConfig.createNull({ rot13ServiceHost: "my.rot13.host" });    config.rot13ServiceHost = "my.rot13.host";     // Old configuration no longer needed (and real WwwConfig doesn't allow property to be set)    const controller = new HomePageController(rot13Client);   await controller.postAsync(request, config);    td.verify(rot13Client.transformAsync("my.rot13.host", "hello world")); });

Тесты продолжают проходить. Наконец, мы заменим шпиона Rot13Client:

// Replace Rot13Client spy (JavaScript + testdouble.js)  it("POST asks ROT-13 service to transform text", async () => {   // Replace the Rot13Client spy with a real Rot13Client. (Nullable)   const rot13Client = td.instance(Rot13Client);   const rot13Client = Rot13Client.createNull();    // Track the requests made by the Rot13Client. (Output Tracking)   const rot13Requests = rot13Client.trackRequests();    const request = HttpRequest.createNull({ body: "text=hello%20world" });   const config = WwwConfig.createNull({ rot13ServiceHost: "my.rot13.host" });    const controller = new HomePageController(rot13Client);   await controller.postAsync(request, config);    // Replace the method call check with a state-based output check. (Output Tracking)   td.verify(rot13Client.transformAsync("my.rot13.host", "hello world"));   assert.deepEqual(rot13Requests, [{     host: "my.rot13.host",               text: "hello world",               });                                });

Вот наглядное сравнение двух тестов.

// Side-by-side comparison of spy-based test and Nullables-based test (JavaScript + testdouble.js)  // Interaction-based test using spies it("POST asks ROT-13 service to transform text", async () => {   // Create dependencies   const rot13Client = td.instance(Rot13Client);   const request = td.instance(HttpRequest);   const config = td.instance(WwwConfig);    // Configure dependencies   config.rot13ServiceHost = "my.rot13.host";   td.when(request.readBodyAsync()).thenResolve("text=hello%20world");    // Run code under test   const controller = new HomePageController(rot13Client);   await controller.postAsync(request, config);    // Check that rot13Client was called   td.verify(rot13Client.transformAsync("my.rot13.host", "hello world")); });  // State-based test using Nullables it("POST asks ROT-13 service to transform text", async () => {   // Create and configure dependencies   const rot13Client = Rot13Client.createNull();   const rot13Requests = rot13Client.trackRequests();   const request = HttpRequest.createNull({ body: "text=hello%20world" });   const config = WwwConfig.createNull({ rot13ServiceHost: "my.rot13.host" });    // Run code under test   const controller = new HomePageController(rot13Client);   await controller.postAsync(request, config);    // Check that rot13Client made the correct request   assert.deepEqual(rot13Requests, [{     host: "my.rot13.host",     text: "hello world",   }]); });

Чтобы сделать зависимость Nullable, используйте либо Descend the Ladder, либо Climb the Ladder.

Временная заглушка

Чтобы сделать зависимость Nullable, нужно сделать все её инфраструктурные зависимости тоже Nullable. Иногда это слишком большая работа, чтобы решить её за один раз. Поэтому:

В коде, который вы делаете Nullable, создайте встроенные заглушки для всех зависимостей, которые вы не хотите делать Nullable. Это разорвёт цепочку перекрывающихся общительных тестов и сделает вас уязвимым к изменениям в поведении зависимостей, поэтому выбросьте заглушки и замените их с помощью Fake It Once You Make It, как только зависимость станет Nullable.

Чтобы избежать написания временных заглушек, используйте Climb the Ladder.

Заключение

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


Приглашаем на открытые уроки по автоматизации тестирования с использованием JavaScript:


ссылка на оригинал статьи https://habr.com/ru/articles/799939/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *