technical skills - testing without mocks - infrastructure patterns

Infrastructure code is for communicating with the outside world. Although it may contain some logic, that logic should be focused on making infrastructure easier to work with. Everything else belongs in Application or Logic code.

Infrastructure code is unreliable and difficult to test because of its dependencies on external systems and state. The following patterns work around those problems.

Infrastructure Wrappers

Infrastructure code is complicated to write, hard to test, and often difficult to understand. Therefore:

Isolate your Infrastructure code. For each external system—service, database, file system, or even environment variables—create one wrapper class that’s solely responsible for interfacing with that system. Design your wrappers to provide a crisp, clean view of the messy outside world, in whatever format is most useful to the rest of your code.

Avoid creating complex webs of dependencies. In some cases, high-level Infrastructure classes may depend on generic, low-level classes. For example, LoginClient might depend HttpClient. In other cases, high-level infrastructure classes might unify multiple low-level classes, such as a DataStore class that depends on a RelationalDb class and a NoSqlDb class. Other than these sorts of simple one-way dependency chains, design your Infrastructure classes to stand alone.

Test your Infrastructure Wrappers with Narrow Integration Tests and Paranoic Telemetry. Make them testable with the Nullability Patterns.

Infrastructure Wrappers are also called “Gateways” or “Adapters,” although those terms are technically a superset of infrastructure wrappers.

Narrow Integration Tests

Ultimately, Infrastructure code talks over a network, interacts with a file system, or involves some other communication with external systems or state. It’s easy to make a mistake. Therefore:

Test your external communication for real. For file system code, read and write real files. For databases, access a real database. Make sure that your test systems use the same configuration as your production environment. Otherwise your code will fail in production when it encounters subtle incompatibilities.

Run your narrow integration tests against test systems that are reserved exclusively for one machine’s use. It’s best if they run locally on your development machine, and are started and stopped by your tests or build script. Otherwise, you could experience unpredictable test failures when multiple people run the tests at the same time.

If you have multiple external systems that use the same technology, such as multiple web services, create a generic, low-level infrastructure wrapper for the underlying technology. Then create higher-level infrastructure wrappers for each system. The high-level wrappers don’t need Narrow Integration Tests. Instead, you can Fake It Once You Make It by delegating to the low-level wrapper.

For example, you could create a high-level LoginClient that depended on a low-level HttpClient. The LoginClient would Fake It Once You Make It and the HttpClient would be tested with Narrow Integration Tests.

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

Ensure your code works in production with Paranoic Telemetry.

Paranoic Telemetry

External systems are unreliable. The only thing that’s certain is their eventual failure. File systems lose data and become unwritable. Services return error codes, suddenly change their specifications, and refuse to terminate connections. Therefore:

Assume they really are out to get you, and instrument your code accordingly. Expect that everything will break eventually. Test that every failure case either logs an error and sends an alert, or throws an exception that ultimately logs an error and sends an alert. Remember to test your code’s ability to handle requests that hang, too.

All these failure cases are expensive to support and maintain. Whenever possible, use Testable Libraries rather than external services.

Paranoic Telemetry may be supplemented with Contract Tests. Contract Tests are most effective when run by the supplier (but provided by you), because they can’t catch changes that happen between test runs.