Testing Problems Come From Architecture

Montreal cathedral Marie Rene Du Monde

Thanks to Logicroom for teaching me this.

With testing problems, the fix is rarely in the tests.

The fix is in the architecture.

Most tests should be unit tests.

With only some “integrated tests” (J.B. Rainsberger) at the edges.

But usually, the system is too coupled to have unit tests.

The tests make real REST API calls, and write to a real database.

You might need libraries to mock an HTTP server, and run a test database.

Now, the tests have become “integrated tests,” not unit tests.

As J.B. Rainsberger said, the fix isn’t to improve the “integrated tests.”

Like using better mocking libraries.

The fix is to decouple functions and classes from the outside world, so you can unit test them.

For example, I wrote a VS Code extension.

And its tests were slow.

They bootstrapped a real VS Code instance:

Unit tests opening VS Code instance

That’s because the production code imported a VS Code singleton in dozens of files:

import * as vscode from 'vscode';

export default async function getConfigFilePath() {
  vscode.workspace.getWorkspaceFolder(
Code language: JavaScript (javascript)

It called the singleton without any wrapper.

So my VS Code extension was very coupled to the VS Code singleton (the outside world):

Architecture diagram of VS Code extension coupled to VS Code

There was nothing between my VS Code extension and VS Code.

This made tests almost impossible.

  • I didn’t write many tests
  • If they failed, I’d only see it in CI/CD
  • They were integration, not unit tests

The fix wasn’t to bootstrap VS Code better.

Or to use a better mocking library to mock the imported vscode singleton.

The fix was to decouple the extension from VS Code.

I put EditorGateway between my VS Code extension and the vscode singleton:

Now, it injects EditorGateway in the constructor and calls EditorGateway instead of an imported vscode singleton:

export default class ConfigFile {
  constructor(
    public editorGateway: EditorGateway,
  ) {}

  async getPath() {
    this.editorGateway.editor.workspace.getWorkspaceFolder(
Code language: TypeScript (typescript)

And because of that, you can mock the gateway in tests:

test('stored config file', async () => {
    const { configFile, editorGateway } = getContainer();
    editorGateway.editor.workspace.getWorkspaceFolder = jest
      .fn()
      .mockImplementationOnce(() => ({ name: 'baz' }));

    expect(
      await configFile.getPath(getMockContext('/home/example/baz'))
    ).toEqual('/home/example/baz');
  });
});
Code language: JavaScript (javascript)

Many times, this Dependency Injection is the solution, though not with a DI library…

Just a composition root.

Here’s the repo of my VS Code extension that I fixed.

That was in TypeScript.

But in Clojure, you can also use Dependency Injection, or testing macros.

Now, tests run in 7 seconds:

Terminal running Jest unit tests

But it’s not just speed.

  • It’s easier to write unit tests, so I write more
  • They’re complete, so I can deploy when CI/CD is green
  • They’re not flaky, so I believe them when they fail

The right architecture makes tests easy.

One response to “Testing Problems Come From Architecture”

  1. Ryan Kienstra Avatar

    Testing is like a friendship. If it’s always hard, something’s wrong 😊

Leave a Reply

Your email address will not be published. Required fields are marked *