technical skills - testing without mocks - legacy code patterns

If you’d like to convert your existing code and tests to use Nullables, the patterns in this section will help you do so.

Work incrementally. You can mix Nullables with your current approach in the same codebase, and even in the same test, so there’s no need to convert everything at once. Similarly, focus your efforts on code where testing with Nullables will have noticeable benefit. Don’t waste time converting code that’s already easy to maintain, regardless of how it’s tested.

Descend the Ladder

Complex codebases have a lot of dependencies, and it isn’t feasible to improve all the tests at once. Instead, you’ll need to make progress incrementally. Therefore:

When converting a module or class to use Nullables, convert the code and its direct dependencies, but nothing more. Work your way down through the rest of the dependency tree gradually, when time allows.

Each module or class you convert will fall into one of three categories:

A. No Infrastructure Dependencies

If the code doesn’t have infrastructure anywhere in its dependency tree, it doesn’t need to use Nullables. It can be tested with the Logic Patterns instead.

B. Infrastructure Wrapper with Third-Party Dependencies

If the code is an Infrastructure Wrapper with direct third-party infrastructure dependencies, test it with Narrow Integration Tests, then make it Nullable with an Embedded Stub.

C. Everything Else

For everything else, you’ll make your code’s direct dependencies Nullable, then Fake It Once You Make It. To make the dependencies Nullable, apply one of the following options to each one:

After you’ve updated the dependencies, Fake It Once You Make It. (If your code has a Throwaway Stub, replace it.) Replace Mocks with Nullables and add tests as needed.

When you’re done, the code you’re converting will be Nullable and tested. Its dependencies will be Nullable, but not tested. You can move on to other work. When you’re ready to convert another class or module, Descend the Ladder again. Over time, you’ll gradually convert the entire codebase.

Example

Imagine you have the dependency chain RouterLoginControllerAuth0ClientHttpClient, where HttpClient is a low-level Infrastructure Wrapper. To convert Router, you would follow these steps:

  1. Router’s direct dependency is LoginController, which has a mix of logic and infrastructure in its dependency chain. Make LoginController Nullable with a Throwaway Stub.
  2. Make Router Nullable with Fake It Once You Make It.
  3. Convert Router’s tests with Replace Mocks with Nullables.

Later, if you wanted to convert Auth0Client, you would follow these steps:

  1. Auth0Client’s direct dependency is HttpClient, which is a low-level Infrastructure Wrapper. Make HttpClient Nullable by introducing an Embedded Stub.
  2. Make Auth0Client Nullable with Fake It Once You Make It.
  3. Convert Auth0Client’s tests with Replace Mocks with Nullables.

When you wanted to convert LoginController, you would follow these steps:

  1. LoginController’s direct dependency is Auth0Client, which was previously converted, so it’s already Nullable.
  2. LoginController has a Throwaway Stub from when Router was converted. Now that Auth0Client is Nullable, replace the stub with Fake It Once You Make It.
  3. Convert LoginController’s tests with Replace Mocks with Nullables.

Finally, when you were ready to convert HttpClient, you would follow these steps:

  1. HttpClient is a low-level Infrastructure Wrapper, and it was made Nullable when Auth0Client was converted, so it only needs to be tested.
  2. Test HttpClient with Narrow Integration Tests.

Code that’s been converted can be refactored without breaking its tests. Once you’ve converted enough code, you can refactor it to use A-Frame Architecture or any other architecture you like.

Descend the Ladder is for code with large dependency trees. If the code you’re converting has a small dependency tree, Climb the Ladder instead.

Climb the Ladder

Descending the Ladder is a careful, methodical approach to improving existing code. However, it involves creating Throwaway Stubs, which is wasteful, and it takes a long time. Simple dependency trees don’t need so much care. Therefore:

When your dependency tree is simple, convert the entire tree at once. Start by graphing out a dependency tree for the code you want to convert, ignoring third-party dependencies. Then convert each node from the bottom of the tree up. (A post-order depth-first traversal). Apply one of the following options to each node:

When you’re done, the entire dependency tree will be tested and Nullable. You can then refactor it toward A-Frame Architecture or any other architecture you like.

For example, imagine you have the dependency chain RouterLoginControllerAuth0ClientHttpClient, where HttpClient is a low-level Infrastructure Wrapper. To convert Router, you would follow these steps:

  1. HttpClient is a low-level Infrastructure Wrapper. Make it Nullable by introducing an Embedded Stub.
  2. Test HttpClient with Narrow Integration Tests.
  3. Make Auth0Client Nullable with Fake It Once You Make It.
  4. Convert Auth0Client’s tests with Replace Mocks with Nullables.
  5. Make LoginController Nullable with Fake It Once You Make It.
  6. Convert LoginController’s tests with Replace Mocks with Nullables.
  7. Make Router Nullable with Fake It Once You Make It.
  8. Convert Router’s tests with Replace Mocks with Nullables.

Climb the Ladder works best when you have a small dependency tree. If you have a large dependency tree, Descend the Ladder instead.

Replace Mocks with Nullables

Existing code is often tested with mocks, spies, and other test doubles. Some of those tests will get in your way. They might be hard to understand and maintain, or they might make refactoring difficult. Therefore:

When an existing test gets in your way, use Nullables in place of the existing test doubles. Depending on the quality of the existing tests, it might be easiest to inline any setup blocks or helper methods prior to starting. Then apply the following options to each mock, spy, or other test double in each test you want to convert:

  • Start by replacing the test double with a Nulled version of the real dependency.
  • If the test double is configured to return specific values, replace the configuration with Configurable Responses.
  • If the test double is configured to emit events, replace the configuration with Behavior Simulation.
  • If the test checks how a test double is called, replace its assertions with Output Tracking. Convert these test doubles last, after test doubles with only configuration have been replaced.

For example, here’s a controller for a web page. When the user posts to the page, it uses the rot13Client infrastructure wrapper to call a web service, then renders the result.

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

The following test uses spies to check that the above code calls the web service. It’s an interaction-based test that checks whether the dependency’s methods are called correctly.

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

This test can be converted one spy at a time. First, we replace the HttpRequest spy with a 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"));
});

Because Nullables can coexist with test doubles, the tests still pass after this change is made. Next, we replace the WwwConfig spy:

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

The tests continue to pass. Finally, we replace the Rot13Client spy:

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

Here’s a side-by-side comparison of the two tests.

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

To make a dependency Nullable, either Descend the Ladder or Climb the Ladder.

Throwaway Stub

Making a dependency Nullable requires making all of its infrastructure dependencies Nullable, too. Sometimes, that’s too much work to tackle all at once. Therefore:

In the code you’re making Nullable, create Embedded Stubs for any dependencies you don’t want to make Nullable. This will break the chain of Overlapping Sociable Tests, leaving you vulnerable to behavioral changes in the dependencies, so throw away the stub and replace it with Fake It Once You Make It as soon as the dependency is Nullable.

To avoid writing throwaway stubs, Climb the Ladder.