Quality Assurance 05 Test Doubles

Posted by LiYixian on Monday, November 3, 2025 | 阅读 | ,阅读约 3 分钟

End-to-End Testing

A single test can provide lots of coverage, but:

  • Can be fragile
  • Have poor isolation
    • redundancy (e.g., initialization, route forwarding, …)
  • Difficult to automate
    • test environment
  • Slow and expensive

-> Unit Testing!

Test Doubles

We substitute external collaborators with test doubles.

Dummy

Objects that are needed by the program (e.g., parameters) but are never actually used.

public interface Logger {
    public void append(String message);
}

public class LoggerDummy implements Logger {
    public void append(String message) {
        // we do nothing!
	}
}

Stub

Double for a real collaborator that gives predefined answers to calls during testing.

Use it if you just want collaborators to provide canned responses.

// Pass in a stub that was created by a mocking framework.
AccessManager accessManager = new AccessManager(stubAuthenticationService);

// The user shouldn't have access when the authentication service returns false.
when(stubAuthenticationService.isAuthenticated(USER_ID).thenReturn(false);
assertFalse(accessManager.userHasAccess(USER_ID));)

// The user should have access when the authentication service returns true.
when(stubAuthenticationService.isAuthenticated(USER_ID)).thenReturn(true);
assertTrue(accessManager.userHasAccess(USER_ID));

Fake

Provides an optimized, thinned-down version of a collaborator that replicates the same behavior of the original object without certain side effects or consequences.

Use it if you test a complex scenario that relies on a service or component that’s unavailable or unusable for your test’s purposes, and stubbing does not do the job (e.g., database driver).

/*
Behaves like a real ProductDatabase that accesses a database, but is simpler, faster, and side-effect free.
*/
public class FakeProductDatabase implements ProductDatabase {
  private Collection<Product> products = new ArrayList<Product>();
  public void save(Product product) {
    if (findById(product) == null)
      products.add(product);
  }
  public Product findById(long id) {
    for (Product product : products) {
      if (product.getId() == id) return product;
    }
    return null;
  }
}

Spy

Observes calls and arguments to a collaborator without changing behavior.

class Checkout {
      constructor(private pay:{ charge(n:number):Promise<void> }){}
      submit(total:number){ return this.pay.charge(total); }
}

const stripeAdapter = { charge: async (_:number)=>{} };
const spy = vi.spyOn(stripeAdapter,
'charge').mockResolvedValue(undefined);

await new Checkout(stripeAdapter).submit(1299);

expect(spy).toHaveBeenCalledWith(1299);

Mock

Used to test for expected interactions with a collaborator (i.e., method calls). Allows calls to be observed (spy-like) and behavior to be changed (stub-like).

Use it if you want to test interactions between SUT and DOC.

// Pass in a mock that was created by a mocking framework.

AccessManager accessManager =new AccessManager(mockAuthenticationService); accessManager.userHasAccess(USER_ID);

// The test should fail if accessManager.userHasAccess(USER_ID) didn't call
// authenticationService.isAuthenticated(USER_ID) or if it called it more than once.
verify(mockAuthenticationService).isAuthenticated(USER_ID);

Best Practices

  • Don’t share doubles between tests, except possibly fakes
  • Only stub the behavior that is interesting for the current test
  • Assert the minimum!
    • Prefer the weakest double
      • (−intrusive) Stub < Fake < Spy < Mock (+intrusive)
    • For queries (i.e., returning data), use a Stub or Fake
    • For commands (i.e., cause effects), use a Spy, Mock, or Fake