Skip to content

Migrating from Mocks to Fakes

Mocks combine three concerns: behavior replacement, call observation, and assertions. Modern testing prefers separation of concerns using fakes with explicit assertions. This guide shows how to migrate from mocks to simpler alternatives.

Why Migrate?

Mocks are powerful but have drawbacks:

  1. Brittle tests - Tests break when implementation changes
  2. Coupled to internals - Tests know too much about how code works
  3. Complex failures - Mock errors can be cryptic
  4. Upfront expectations - Requires predicting all interactions

Fakes with explicit assertions are better:

  1. Flexible tests - Tests focus on behavior, not implementation
  2. Clear intent - Assertions show what matters
  3. Better errors - Assertion failures are direct
  4. After-the-fact verification - Test what actually happened

When to Keep Using Mocks

Keep mocks when:

  1. Upfront expectations clarify intent - The expectation itself documents behavior
  2. Immediate feedback wanted - Fail fast on first unexpected call
  3. Complex interaction patterns - Multiple related expectations on one object

For most cases, use fakes.

Migration Patterns

Basic Expectation → Fake + Assertion

Before (Mock):

javascript
const obj = {
  method() {
    return "original";
  }
};
const mock = sinon.mock(obj);

mock.expects("method").once();
obj.method();
mock.verify();

After (Fake):

javascript
const obj = {
  method() {
    return "original";
  }
};
const fake = sinon.fake.returns("result");

sinon.replace(obj, "method", fake);
obj.method();

assert.ok(fake.calledOnce);

Benefits:

  • Clearer: Shows what we're checking (calledOnce)
  • More flexible: Can add more assertions
  • Better errors: "expected true to be true" vs mock expectation message

Argument Expectations → Fake + Argument Assertion

Before (Mock):

javascript
mock.expects("greet").withArgs("Alice");
obj.greet("Alice");
mock.verify();

After (Fake):

javascript
const fake = sinon.fake();
sinon.replace(obj, "greet", fake);

obj.greet("Alice");

assert.ok(fake.calledWith("Alice"));

Benefits:

  • Can check multiple argument patterns
  • Can assert on partial matches
  • Better error messages

Multiple Calls → Fake + Call Count

Before (Mock):

javascript
mock.expects("log").atLeast(2).atMost(5);
// ... actions ...
mock.verify();

After (Fake):

javascript
const fake = sinon.fake();
sinon.replace(logger, "log", fake);

// ... actions ...

assert.ok(fake.callCount >= 2, "called at least twice");
assert.ok(fake.callCount <= 5, "called at most 5 times");

Benefits:

  • More flexible assertion logic
  • Can check exact count or ranges
  • Can inspect actual call count

Return Value Control → Fake Factory

Before (Mock - using stub behavior):

javascript
mock.expects("getData").once().returns({ id: 1, name: "Alice" });

const result = obj.getData();
mock.verify();

After (Fake):

javascript
const fake = sinon.fake.returns({ id: 1, name: "Alice" });
sinon.replace(obj, "getData", fake);

const result = obj.getData();

assert.deepEqual(result, { id: 1, name: "Alice" });
assert.ok(fake.calledOnce);

Benefits:

  • Behavior and verification separated
  • Can verify behavior independently
  • Clearer test structure

Sequential Behavior → Multiple Fakes or Stub

Before (Mock with stub behavior):

javascript
mock
  .expects("fetch")
  .onFirstCall()
  .returns("first")
  .onSecondCall()
  .returns("second");

obj.fetch(); // 'first'
obj.fetch(); // 'second'
mock.verify();

After Option 1 (Keep using stub for this case):

javascript
const stub = sinon
  .stub(obj, "fetch")
  .onFirstCall()
  .returns("first")
  .onSecondCall()
  .returns("second");

obj.fetch(); // 'first'
obj.fetch(); // 'second'

assert.ok(stub.calledTwice);

After Option 2 (Fake with custom logic):

javascript
const calls = ["first", "second"];
let callIndex = 0;
const fake = sinon.fake(() => calls[callIndex++]);
sinon.replace(obj, "fetch", fake);

obj.fetch(); // 'first'
obj.fetch(); // 'second'

assert.equal(fake.callCount, 2);

Note: For sequential behavior, stubs are often the best choice.


Context Expectations → Fake + This Check

Before (Mock):

javascript
const obj1 = { name: "obj1" };
const obj2 = { name: "obj2", method() {} };

const mock = sinon.mock(obj2);
mock.expects("method").on(obj1);

obj2.method.call(obj1);
mock.verify();

After (Fake):

javascript
const obj1 = { name: "obj1" };
const obj2 = { name: "obj2", method() {} };

const fake = sinon.fake();
sinon.replace(obj2, "method", fake);

obj2.method.call(obj1);

assert.equal(fake.thisValues[0], obj1);

Benefits:

  • Access to all this values
  • Can check multiple calls
  • More flexible assertions

Never Called → Fake + Not Called

Before (Mock):

javascript
mock.expects("dangerousMethod").never();
// ... actions ...
mock.verify();

After (Fake):

javascript
const fake = sinon.fake();
sinon.replace(obj, "dangerousMethod", fake);

// ... actions ...

assert.ok(fake.notCalled, "dangerous method should not be called");

Benefits:

  • Clearer assertion
  • Better error message
  • Can be part of larger test

Multiple Expectations → Multiple Assertions

Before (Mock):

javascript
const mock = sinon.mock(service);
mock.expects("load").once();
mock.expects("process").once();
mock.expects("save").once();

// ... actions ...
mock.verify();

After (Fake):

javascript
const loadFake = sinon.fake.returns("data");
const processFake = sinon.fake.returns("processed");
const saveFake = sinon.fake.resolves();

sinon.replace(service, "load", loadFake);
sinon.replace(service, "process", processFake);
sinon.replace(service, "save", saveFake);

// ... actions ...

assert.ok(loadFake.calledOnce, "load called once");
assert.ok(processFake.calledOnce, "process called once");
assert.ok(saveFake.calledOnce, "save called once");

Benefits:

  • Each assertion is independent
  • Failures are more specific
  • Can verify order if needed: sinon.assert.callOrder(loadFake, processFake, saveFake)

Anti-Pattern: Testing Implementation

Mocks make it easy to test implementation details. Avoid this:

Bad (Mock on implementation):

javascript
// Testing HOW the code works
const mock = sinon.mock(database);
mock.expects("query").withArgs("SELECT * FROM users");
mock.expects("query").withArgs("SELECT * FROM posts");

controller.getUserWithPosts(userId);
mock.verify();

Good (Fake on behavior):

javascript
// Testing WHAT the code does
const fake = sinon.fake.resolves({ user: userData, posts: postsData });
sinon.replace(database, "getUserWithPosts", fake);

const result = await controller.getUserWithPosts(userId);

assert.deepEqual(result.user, expectedUser);
assert.deepEqual(result.posts, expectedPosts);
assert.ok(fake.calledWith(userId));

Why this is better:

  • Test survives database query changes
  • Focuses on behavior, not implementation
  • Easier to understand what's being tested

Refactoring Strategy

Step 1: Identify Mock Usage

Find all sinon.mock() usage in your codebase:

bash
grep -r "sinon.mock" test/

Step 2: Categorize by Complexity

Simple mocks (one expectation, no fancy behavior):

  • Migrate immediately to fakes

Medium mocks (multiple expectations, simple behavior):

  • Consider if all expectations are necessary
  • Migrate useful ones to fakes + assertions

Complex mocks (many expectations, complex behavior):

  • Keep as mocks if they clarify intent
  • Or break into smaller, focused tests

Step 3: Migrate One at a Time

For each mock:

  1. Understand the expectation - What's being verified?
  2. Replace with fake - Use appropriate fake factory
  3. Add assertions - Explicitly check what matters
  4. Run tests - Ensure behavior unchanged
  5. Commit - Small, focused commits

Step 4: Simplify

After migration:

  • Remove unnecessary verifications
  • Combine related assertions
  • Focus tests on behavior

Decision Tree

Is the expectation about behavior or implementation?
├─ Behavior → Use fake + explicit assertion
└─ Implementation → Do you really need to test this?
    ├─ Yes, it's critical → Keep mock (rare)
    └─ No, it's an implementation detail → Refactor test to check behavior

Migration Checklist

  • [ ] Find all sinon.mock() usage
  • [ ] For each mock:
    • [ ] Identify what's being verified
    • [ ] Determine if verification is necessary
    • [ ] Replace mock with fake
    • [ ] Add explicit assertions
    • [ ] Remove unnecessary expectations
    • [ ] Update test to focus on behavior
  • [ ] Run all tests
  • [ ] Review test clarity and maintainability

Benefits After Migration

  1. More maintainable - Tests survive refactoring
  2. Clearer intent - Assertions show what matters
  3. Better errors - Know exactly what failed
  4. More flexible - Can verify in different ways
  5. Less brittle - Implementation changes don't break tests

See Also