Contents

  • The Problem
  • Introducing the interface
  • Do they behave the same way?
  • Contract Test (FakeStepCounter)
  • What about the real Fitbit implementation?
  • Final Contract Test
  • Comments
Back to Home
December 8, 2025

Dependency Inversion: Where It All Starts

#testability#architecture#dependencies#plugability

Contents

  • The Problem
  • Introducing the interface
  • Do they behave the same way?
  • Contract Test (FakeStepCounter)
  • What about the real Fitbit implementation?
  • Final Contract Test
  • Comments

The Problem

I once worked with a developer, let's call him John, who to test new features would walk laps around the office with his phone so it would sync real Fitbit steps. He'd pace, check his phone, and even use lunch breaks to gather more data. Accurate? Yes. Practical for development? Absolutely not.

In the meantime I could not do anything related to this feature because, well... I did not have a device. And there was no easy hack for me to make it work in development.

You can probably also imagine using production credentials by accident or hell, even on purpose. Perhaps, the service you are integrating with doesn't allow you to have more than 1 API key.

To give you an idea of what our code looked like:

class StepTracker {
  private readonly _fitbitApi: FitbitApi;
}

The problem here is that StepTracker directly depends on the FitbitApi, which makes it hard for us to "replace" it. we are using TS so we could technically cheat our way!

Loading diagram...

Introducing the interface

An interface in this context is also referred to as a contract1.

A simple way to get rid of this coupling is to introduce an interface between the core service and the external dependency. This interface should be framework-agnostic; it should describe what your app needs, not mirror the Fitbit SDK:

type Result<T> =
  | { success: true; value: T }
  | { success: false; error: string };

interface IStepCounter {
  getStepsToday(): Promise<Result<number>>;
  resetDailyCount(): Promise<Result<void>>;
}

class StepTracker {
  private readonly _stepCounter: IStepCounter;
}

class FitbitStepCounter implements IStepCounter {
  /** fitbit implementation **/
}

With this change we no longer depend on the concrete implementation of Fitbit but instead we depend on our own defined interface. You can see the relation of the dependencies more clearly in the diagram below; notice the arrows.

Loading diagram...

The benefit of this is that we can easily create multiple implementations, like this FakeStepCounter for example.

class FakeStepCounter implements IStepCounter {
  private _steps: number = 0;

  public async getStepsToday(): Promise<Result<number>> {
    return { success: true, value: this._steps };
  }

  public async resetDailyCount(): Promise<Result<void>> {
    this._steps = 0;
    return { success: true, value: undefined };
  }
}

This is the ideal form of plugability, we can always swap to different step tracking services without having to change our own logic.

Loading diagram...

Now I don't have to depend on John anymore and instead, I can just use the FakeStepCounter, wonderful.

Do they behave the same way?

They both appear to return steps, and with a little bit of customization we could insert steps into our FakeStepCounter during development. However, if we look closely at the contract, we can see that it always returns a Result type, but we are never testing for the failed branch. To tackle this we can do 2 things:

  • Improve our fake implementation to be closer to the real deal and/or allow us to simulate states.
  • Go the contract testing route where we simply make sure that every implementation behaves the same way. excluding exceptions

We will do the latter, because, frankly, this gives us enough confidence that it works and during development I don't want to see it fail, generally.

Contract Test (FakeStepCounter)

describe("Contract tests", () => {
  test("should return current step count on success", async () => {
    const stepCounter = new FakeStepCounter(5000, true);

    const result = await stepCounter.getStepsToday();

    expect(result.success).toBe(true);
    expect(result.value).toBe(5000);
  });

  test("should return error on failure", async () => {
    const stepCounter = new FakeStepCounter(0, false);

    const result = await stepCounter.getStepsToday();

    expect(result.success).toBe(false);
    expect(result.error).toBe("Failed to fetch steps");
  });

  test("should reset daily count to zero on success", async () => {
    const stepCounter = new FakeStepCounter(10000, true);
    await stepCounter.resetDailyCount();

    const result = await stepCounter.getStepsToday();

    expect(result.success).toBe(true);
    expect(result.value).toBe(0);
  });
});

To support this, we modify the fake to be controllable:

class FakeStepCounter implements IStepCounter {
  constructor(
    private _steps: number = 0,
    private _shouldSucceed: boolean = true
  ) {}

  async getStepsToday(): Promise<Result<number>> {
    if (!this._shouldSucceed) {
      return { success: false, error: "Failed to fetch steps" };
    }
    return { success: true, value: this._steps };
  }

  async resetDailyCount(): Promise<Result<void>> {
    if (!this._shouldSucceed) {
      return { success: false, error: "Failed to reset steps" };
    }
    this._steps = 0;
    return { success: true, value: undefined };
  }
}

Now we can simulate both success and error paths.

What about the real Fitbit implementation?

To test FitbitStepCounter, we inject a stubbed2 SDK. This isolates our adapter3 logic, verifying that we correctly translate SDK responses to our Result contract without hitting real servers. For the actual API connection, rely on a staging smoke test or my favorite: see it work once and move on.

Final Contract Test

To ensure both our fake and our real adapter follow the same rules, we write a reusable test suite.4 With a small refactoring, we get the following:

type Factory<T> = (initialSteps: number, shouldSucceed: boolean) => T;

function testStepCounterContract(
  name: string,
  factory: Factory<IStepCounter>
): void {
  describe(`${name}`, () => {
    test("should return current step count on success", async () => {
      const stepCounter = factory(5000, true);

      const result = await stepCounter.getStepsToday();

      expect(result.success).toBe(true);
      expect(result.value).toBe(5000);
    });

    test("should return error on failure", async () => {
      const stepCounter = factory(0, false);

      const result = await stepCounter.getStepsToday();

      expect(result.success).toBe(false);
      expect(result.error).toBeDefined();
    });

    test("should reset daily count to zero on success", async () => {
      const stepCounter = factory(10000, true);
      const resetResult = await stepCounter.resetDailyCount();

      expect(resetResult.success).toBe(true);

      const result = await stepCounter.getStepsToday();

      expect(result.success).toBe(true);
      expect(result.value).toBe(0);
    });
  });
}

describe("IStepCounter Contract", () => {
  testStepCounterContract(
    "FakeStepCounter",
    (initialSteps, shouldSucceed) =>
      new FakeStepCounter(initialSteps, shouldSucceed)
  );

  testStepCounterContract(
    "FitbitStepCounter",
    (initialSteps, shouldSucceed) => {
      return new FitbitStepCounter(stub(initialSteps, shouldSucceed));
    }
  );
});

When you run this, you'll see output like:

PASS  tests/StepCounterContractTests.ts
  IStepCounter Contract
    FakeStepCounter
      ✓ should return current step count on success
      ✓ should return error on failure
      ✓ should reset daily count to zero on success
    FitbitStepCounter
      ✓ should return current step count on success
      ✓ should return error on failure
      ✓ should reset daily count to zero on success

Test Files  1 passed (1)
     Tests  6 passed (6)
  Start at  14:23:45
  Duration  245ms

At this point, you've proven that both implementations follow the contract. Whether the actual Fitbit API works is a separate concern; one tested through a form of an integration test or a manual check, not part of the contract. This also protects us from changes in the official SDK. If we only wanted to solve the real life walking problem, then a proxy (or what the cool kids call a wrapper) could also be enough.

Takeaway: define a stable contract, keep your core depending on it, and use contract tests to keep every adapter aligned; save integration checks for the real API.

In case John finally makes it back from lunch, I hope this helps you, buddy.

Thanks for reading!

Footnotes

  1. A contract is a stable shape that others can depend on. Usually in the form of an interface. ↩

  2. A stub is a test double that returns an expected result. ↩

  3. An adapter in this case is the implementation of a contract. It translates between two-sides so it works together. ↩

  4. A test suite is a collection of related tests. ↩

Comments

Download as Markdown
Get the raw markdown content

More Articles

Multiplayer Game Netcode Explained

December 12, 2025

© 2025 Donny Roufs. All rights reserved.