technical skills - testing without mocks - nullability patterns

Sociable Tests run real code. That’s good for catching errors, but if the dependency chain includes infrastructure—external systems or state—they become hard to manage. The following patterns allow you to “turn off” external dependencies while retaining the benefits of sociable and state-based testing.

Nullables

Narrow Integration Tests are slow and difficult to set up. Although they’re useful for ensuring that low-level Infrastructure Wrappers work in practice, they’re overkill for code that depends on those wrappers. Therefore:

Program code that includes infrastructure in its dependency chain to have a createNull() factory method. The factory should create a “Nulled” instance that disables all external communication, but behaves normally in every other respect. Make sure it supports Parameterless Instantiation.

Nullables were originally inspired by the Null Object pattern, but have evolved to be completely different.

For example, calling LoginClient.createNull().getUserInfo(...) should return a default response without actually talking to the third-party login service.

Nullables are production code and should be tested accordingly. Although Nulled instances are often used by tests, they’re also useful whenever you want the ability to “turn off” behavior in your application. For example, you could use Nullables implement a “dry run” option in a command-line application.

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

As another example, you can use Nullables in a web server to cache popular URLs when the server starts up:

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

Make low-level infrastructure wrappers Nullable with Embedded Stubs. For all other code, Fake It Once You Make It. To make existing code Nullable, see the Legacy Code Patterns.

If your Nullable reads data from external systems or state, or any of its dependencies do, implement Configurable Responses. If it or its dependencies write data, implement Output Tracking. If they respond to events, implement Behavior Simulation.

Embedded Stub

Nullables need to disable access to external systems and state while running everything else normally. The obvious approach is to surround any code that accesses the external system with an “if” statement, but that’s a recipe for spaghetti. Therefore:

When making code Nullable, don’t change your code. Instead, stub out the third-party code that accesses external systems.

In your stub, implement the bare minimum needed to make your code run. Ensure you don’t overbuild the stub by test-driving it through your code’s public interface. Put the stub in the same file as the rest of your code so it’s easy to remember and update when your code changes.

Write a stub of the third-party code, not your code, so your Sociable Tests test how your code will really work in production. Be careful to have your stub mimic the behavior of the third-party code exactly. To help you do so, write Narrow Integration Tests that document the behavior of the real code, paying particular attention to edge cases such as error handling and asynchronous code. Then write additional tests of the Nulled instance that will fail if your stub doesn’t have the same behavior.

Here’s a simple example of stubbing out JavaScript’s Math library:

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

Here’s a more complicated example. It stubs out Node.js’s http library:

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

Configure the embedded stub’s return values with Configurable Responses. If your language requires it, as with Java or C#, create a Thin Wrapper.

Nullables are production code, and despite appearances, the Embedded Stub is too. It must be tested accordingly. If you don’t like the idea of having stubs in your production code, you can put the Embedded Stub in a separate test-only file instead. However, this will make dependency management more complicated, and it will prevent you from using Nulled instances in production, which can be useful.

Thin Wrapper

Languages such as Java and C# will require your Embedded Stub to share an interface with the real dependency. Often, there won’t be an interface you can use, or it will be bigger than you need. Therefore:

Create a custom interface for your third-party dependency. Match the signature of the dependency exactly, but only include the methods your production code actually uses. Provide two implementations of the interface: a real version that only forwards calls to the third-party dependency, and an Embedded Stub.

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

If the third-party code returns custom types, you’ll need to wrap those return types as well. Remember to match the third-party code’s signatures exactly.

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

Configurable Responses

State-based tests of code with infrastructure dependencies needs to set up the infrastructure’s state, but setting up external systems is complicated and slow. Therefore:

Make the infrastructure dependencies Nullable and program the createNull() factory to take your desired response as an optional parameter. Define the responses from the perspective of the dependency’s externally-visible behavior, not its implementation.

If the Nullable dependency has multiple types of responses that can be configured, give each one its own configuration parameter. Use named and optional parameters so tests only need to configure the data they care about. If your language doesn’t support optional parameters, use an Options object, as shown in the Signature Shielding pattern.

For example, the following test is for a LoginController that depends on a Nullable LoginClient. Although LoginClient is used to make HTTP requests, its Configurable Responses aren’t about HTTP. Instead, they’re about the logged-in user’s email address and verification status, which is the behavior LoginController and its tests care about.

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

If it makes sense for your class to respond differently each time it’s called, configure the responses with an array or list. It’s often helpful to support two data types: a list of values, that results in a different response each time, and causes an exception when it runs out; and a single value, that returns the same response every time, and never runs out.

For example, the following test configures a Nullable DieRoller with a set of expected die rolls:

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

If your Nullable uses an Embedded Stub, implement the responses in the stub. Otherwise, Fake It Once You Make It. Either way, decompose the responses down to the next level.

The following example uses an Embedded Stub to make a random die roller. It’s configured at the level its callers care about: dice roll results. In the Embedded Stub, those configured values are decomposed to the level DieRoller operates at: random floating point numbers from between zero and one. For example, a configured roll of 6 is turned into the floating point number 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;
    }
  }
}

The above code can be simplified by factoring #nextRoll() into a generic helper class. The result looks like this:

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

This is a JavaScript implementation of ConfigurableResponses you can use in your own code:

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

To test code with dependencies that write to infrastructure, use Output Tracking. To test code with dependencies that respond to events, use Behavior Simulation.

Output Tracking

State-based tests of code with dependencies that write to external systems need to check whether the writes were performed, but setting up external systems is complicated and slow. Therefore:

Program each dependency with a tested, production-grade trackXxx() method that tracks the otherwise-invisible writes. Have it do so regardless of whether the object is Nulled or not.

Track the writes in terms of the behavior your callers care about, not the underlying implementation of your code. For example, a structured logger might write strings to stdout, but its callers care about the structured data that’s being written. Its Output Tracking would track the data, not the string.

One way to implement Output Tracking is to have trackXxx() return an OutputTracker that listens for events emitted by your production code. The following example shows how this works, including implementations of OutputTracker in JavaScript and Java that you can use in your own code. It starts with a test of LoginPage, which writes to a structured Log when the user logs in.

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

At first glance, Output Tracking might look like the same thing as a spy (a type of test double), but there’s an important difference. Output Tracking records behavior, and spies record function calls. Output Trackers should write objects that represent the action that was performed, not just the function that was called to perform it. That way, when you refactor, you can change your functions without changing your Output Trackers or the tests that depend on them.

This is a JavaScript version of the OutputTracker class you can use in your own projects:

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

Below, I’ve also included a Java version of the OutputTracker library that I created with Ted M. Young. Because Java doesn’t have a built-in event emitter, it’s used slightly differently. Here’s an example of using it in the Log infrastructure wrapper from the earlier example:

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

This is the Java version of OutputTracker. Split it into two files.

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

To test code with dependencies that read from infrastructure, use Configurable Responses. To test code with dependencies that emit events, use Behavior Simulation.

Behavior Simulation

Some external systems will push data to you rather than waiting for you to ask for it. Code that depends on those systems need a way to test what happens when their infrastructure dependencies generate those events, but setting up infrastructure to send events is complicated and slow. Therefore:

Add methods to your dependencies that simulate receiving an event from an external system. Share as much code as possible with the code that handles real external events. Write it as tested, production-grade code.

The following example consists of an Application-layer MessageServer that performs real-time networking. MessageServer runs on a server and connects to web browsers using WebSocketServer, a low-level infrastructure wrapper for Socket.IO. When a connected browser sends a message to MessageServer, it relays the messages to all the other connected browsers.

The test uses Behavior Simulation to simulate web browsers connecting and sending messages, then validates MessageServer’s behavior by using Output Tracking to confirm that the correct messages were sent.

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

The Behavior Simulation logic is implemented as production code in WebSocketServer, the low-level Socket.IO wrapper. Note how the real Socket.IO logic and the simulation methods share as much implementation as possible by delegating to #handleXxx() methods.

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

To test code with dependencies that read from infrastructure, use Configurable Responses. To test code with dependencies that write to infrastructure, use Output Tracking.

Fake It Once You Make It

Narrow Integration Tests are slow and difficult to set up. Similarly, Embedded Stubs can be difficult to create. Although they’re needed for low-level infrastructure wrappers, they’re overkill for code that doesn’t have direct dependencies on third-party infrastructure code. Therefore:

In application-layer code and high-level infrastructure wrappers, delegate to Nullable dependencies rather than using Narrow Integration Tests and Embedded Stubs. In your tests, inject Nulled instances of the code under test’s dependencies. If your production code has a createNull() factory, implement it by creating Nulled dependencies and decomposing your Configurable Responses into the format your dependencies expect.

For example, the following code tests LoginClient, which depends on a low-level HttpClient. The LoginClient tests Fake It Once You Make It by injecting a Nulled version of HttpClient into LoginClient.

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

The production LoginClient code is a wrapper for Auth0, an authentication service. LoginClient.createNull() has Configurable Responses related to authentication, such as configuring the login email address. They’re implemented by creating a Nulled HttpClient and decomposing LoginClient’s Configurable Responses into the actual HTTP responses Auth0 would return.

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

Implement Output Tracking and Behavior Simulation normally, without regard to whether the dependencies are Nulled or not.

To make your dependencies Nullable, either Descend the Ladder or Climb the Ladder.