technical skills - testing without mocks - architectural patterns

Testing works best when you pay careful attention to the dependencies in your codebase. These architectural patterns help you do so. They aren’t required, but they’re useful.

A-Frame Architecture

Code without infrastructure dependencies is much easier to test than code that has infrastructure dependencies. However, a normal layered architecture puts infrastructure at the bottom of the dependency chain:

Application/UI
      |
      V
    Logic
      |
      V
Infrastructure

Therefore:

Structure your application so that infrastructure and logic are peers under the application layer, with no dependencies between Infrastructure and Logic. Coordinate between them at the Application layer with a Logic Sandwich or Traffic Cop. Use Value Objects to pass data between the Logic and Infrastructure layers.

   Application/UI     Values
   /            \
  V              V
Logic   Infrastructure

Build the Logic and Values layers using Logic Patterns. Build the Infrastructure layer using Infrastructure Patterns. Build the Application/UI layer with a Logic Sandwich or Traffic Cop, and use Nullables to test it.

Although A-Frame Architecture is a nice way to simplify application dependencies, it’s entirely optional. This pattern language will work without it.

To build a new application using A-Frame Architecture, Grow Evolutionary Seeds. To convert an existing codebase, Descend the Ladder.

Logic Sandwich

When using an A-Frame Architecture, the infrastructure and logic layers aren’t allowed to communicate with each other. But the logic layer needs to read and write data controlled by the infrastructure layer. Therefore:

Implement the Application layer code as a “logic sandwich,” which reads data using the Infrastructure layer, processes it using the Logic layer, then writes it using the Infrastructure layer. Repeat as needed. Each layer can then be tested independently.

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

This simple algorithm can handle sophisticated needs if put into a stateful loop. In some cases, your Application layer might need a bit of logic of its own, or you might need multiple sandwiches.

For applications that respond to events, use a Traffic Cop instead.

Traffic Cop

The Logic Sandwich boils infrastructure down into simple infrastructure.readData() and infrastructure.writeData() abstractions. But some applications need to respond to changes instigated by the infrastructure and logic layers. Therefore:

Program the application layer to use the Observer pattern to listen for events from the infrastructure and logic layers. For each event, implement a Logic Sandwich.

// Traffic Cop example (JavaScript)
 
server.onPost("/login", (formData) => {               // event from infrastructure layer
  const loginData = processLoginForm(formData);           // application logic
  const userData = userService.logInUser(loginData);      // infrastructure layer
  this._user = new User(userData);                        // logic layer
 
  const userIsValid = this._user.isValid();               // logic layer
  if (userIsValid) {                                      // application logic
    const sessionData = user.sessionData;                 // logic layer
    sessionServer.createSession(sessionData);             // infrastructure layer
    return redirect(loginData.postLoginUrl);              // application logic
  }
  else {
    return redirect(LOGIN_FAILED_URL);                    // application logic
  }
});
 
this._user.onChange((userData) => {                   // event from logic layer
  userService.updateUser(userData);                       // infrastructure layer
});

Be careful not to let your Traffic Cop turn into a God Class. If it gets complicated, better infrastructure abstractions might help. Sometimes taking a less “pure” approach and moving some Logic code into the Infrastructure layer can simplify the overall design. In other cases, splitting the application layer into multiple classes or modules, each with its own Logic Sandwich or simple Traffic Cop, can help.

Grow Evolutionary Seeds

One popular design technique is outside-in design, in which an application is programmed by starting with the externally-visible behavior of the application, then working your way in to the details.

This is typically done by writing a broad integration test to describe the externally-visible behavior, then using interaction tests to build higher-level functions before lower-level functions. But we want to use Narrow Tests, not broad tests, and State-Based Tests, not interaction tests. Therefore:

Use evolutionary design to grow your application from a single file. Choose a simple end-to-end behavior as a starting point, then test-drive a single class to implement a trivial version of that behavior. Hardcode one value that would normally come from the Infrastructure layer, don’t implement any significant logic, and return the result to your tests rather than displaying it in a UI. This class forms the seed of your Application layer.

// Simplest possible Application seed (JavaScript)
 
// Test code
it("renders user name", () => {
  const app = new MyApplication();
  assert.equal("Hello, Sarah", app.render());
});
 
// Production code
class MyApplication {
  render() {
    return "Hello, Sarah";
  }
}

Next, implement a barebones Infrastructure Wrapper for the one infrastructure value you hardcoded. Test-drive it with Narrow Integration Tests and code just enough to provide one real result that your Application class needs. Don’t worry about making it robust or reliable yet. This Infrastructure Wrapper class forms the seed of your Infrastructure layer.

Before integrating your new Infrastructure class into your Application class, use the Nullability Patterns to make the Infrastructure class testable from your application layer. Then modify your Application class to use the Infrastructure class, injecting the Nulled version in your tests.

// Application + read from infrastructure (JavaScript)
 
// Test code
it("renders user name", async () => {
  const usernameService = UsernameService.createNull({ username: "my_username" });  // Nullable with Configurable Responses
  const app = new MyApplication(usernameService);
  assert.equal("Hello, my_username", await app.renderAsync());
});
 
// Production code
class MyApplication {
  static create() {     // Parameterless Instantiation
    return new MyApplication(UsernameService.create());
  }
 
  constructor(usernameService) {
    this._usernameService = usernameService;
  }
 
  async renderAsync() {
    const username = await this._usernameService.getUsernameAsync();
    return `Hello, ${username}`;
  }
}

Next, do the same for your UI. Choose one simple output mechanism that your application will use (such as rendering to the console, the DOM, or responding to a network request) and implement a barebones Infrastructure Wrapper for it. Make it Nullable and modify your Application layer tests and code to use it.

// Application + read/write to Infrastructure (JavaScript)
 
// Test code
it("renders user name", () => {
  const usernameService = UsernameService.createNull({ username: "my_username" });  // Nullable with Configurable Responses
  const uiService = UiService.createNull();   // Nullable
  const uiOutput = uiService.trackOutput();   // Output Tracking
 
  const app = new MyApplication(usernameService, uiService);
 
  await app.renderAsync();
  assert.deepEqual(uiOutput.data, [ "Hello, my_username"]);
});
 
// Production code
class MyApplication {
  static create() {     // Parameterless Instantiation
    return new MyApplication(UsernameService.create(), UiService.create());
  }
 
  constructor(usernameService, uiService = UiService.create()) {
    this._usernameService = usernameService;
    this._uiService = uiService;
  }
 
  async renderAsync() {
    const username = await this._usernameService.getUsernameAsync();
    await uiService.renderAsync(`Hello, ${username}`);
  }
}

Now your application tests serve the same purpose as broad end-to-end tests: they document and test the externally-visible behavior of the application. They’re Narrow Tests, because they’re focused on the behavior of the Application class, and because they use Nullable dependencies, they don’t communicate with external systems. That makes them fast and reliable. But because they’re also Overlapping Sociable Tests, they provide the same safety net that broad tests do.

At this point, you have the beginnings of a walking skeleton: an application that works end-to-end, but is far from complete. You can evolve that skeleton to support more features. Choose some aspect of your code that’s obviously incomplete and test-drive a slightly better solution. Repeat forever.

// Application + read/write to Infrastructure + respond to UI events (JavaScript)
 
// Test code
it("renders user name", async () => {
  const usernameService = UsernameService.createNull({ username: "my_username" });  // Nullable with Configurable Responses
  const uiService = UiService.createNull();   // Nullable
  const uiOutput = uiService.trackOutput();   // Output Tracking
 
  const app = new MyApplication(usernameService, uiService);
  await app.startAsync();
 
  uiService.simulateRequest("greeting");      // Behavior Simulation
  assert.deepEqual(uiOutput.data, [ "Hello, my_username" ]);
});
 
// Production code
class MyApplication {
  static create() {     // Parameterless Instantiation
   return new MyApplication(UsernameService.create(), UiService.create());
 }
 
 constructor(usernameService, uiService = UiService.create()) {
   this._usernameService = usernameService;
   this._uiService = uiService;
 }
 
  async startAsync() {
    this._uiService.on("greeting", () => {
      const username = await this._usernameService.getUsernameAsync();
      await uiService.renderAsync(`Hello, ${username}`);
    });
  }
}

At some point, probably fairly early, your Application layer class will start feeling messy. When it does, look for a concept that can be factored into its own class. This forms the seed of your Logic layer. As your application continues to grow, continue refactoring so that class collaborations are easy to understand and responsibilities are clearly defined.

When working with existing code, use the Legacy Code Patterns instead.