Skip to main content

How to Cook a Fake

Let's assume you're working on a cookbook app. You want to test the search UI, which allows users to search for cookbooks by keywords and other filters. The app relies on a CookbookRepository service to fetch the cookbooks.

We can break down the process of creating a fake into five steps.

๐Ÿฝ๏ธ Before You Startโ€‹

๐Ÿ“„๏ธ Fake It Till You Mock It

Learn how to tame Angular test doubles, from pragmatic fakes to the rare true mock, and know when each one keeps your tests lean.

1. [Optional] Define or Derive the Interfaceโ€‹

First, define the interface shared between the fake and the real service:

interface CookbookRepository {
searchCookbooks(keywords: string): Observable<Cookbook[]>;
}
Hexagonal Port

This interface is the port in a hexagonal architecture.

Alternatively, you can derive the interface from the service implementation:

/* Extracts the public properties and methods. */
type Public<T> = Pick<T, keyof T>;

class CookbookRepositoryFake implements Public<CookbookRepositoryImpl> {}
warning

While deriving the interface is convenient for simple cases, it has some drawbacks:

  • It discourages designing the service's API upfront.
  • It doesn't ensure the fake depends only on core types, avoiding infrastructure-specific types (e.g., third-party libraries or remote services).
  • It creates a direct dependency between the fake and the real service, which can lead to issues in environments where certain dependencies cause problems (e.g., third-party libraries).
โš–๏ธ Abstraction & Tree-Shakability: Organizing interfaces, implementations, and providers

You can use the interface as an injection token by turning it into an abstract class:

abstract class CookbookRepository {
abstract searchCookbooks(keywords: string): Observable<Cookbook[]>;
}

This allows you to inject it as follows:

const repo = inject(CookbookRepository);

However, this approach requires configuring providers:

providers: [
{
provide: CookbookRepository,
useClass: CookbookRepositoryImpl,
},
],

In most cases, you will prefer providing the default implementation automatically in the root injector using providedIn: 'root' for tree-shakability.

While the following code works:

@Injectable({
providedIn: 'root',
useFactory: () => inject(CookbookRepositoryImpl),
})
abstract class CookbookRepository {
abstract searchCookbooks(keywords: string): Observable<Cookbook[]>;
}

it introduces some caveats:

  • It creates a circular dependency between CookbookRepository and CookbookRepositoryImpl.
  • It makes CookbookRepositoryImpl a transitive dependency of CookbookRepositoryFake.

To avoid these issues, separate the interface from the abstract class used as the injection token:

interface CookbookRepositoryDef {
searchCookbooks(keywords: string): Observable<Cookbook[]>;
}

@Injectable({
providedIn: 'root',
useFactory: () => inject(CookbookRepositoryImpl),
})
abstract class CookbookRepository implements CookbookRepositoryDef {}

@Injectable({ providedIn: 'root' })
class CookbookRepositoryImpl implements CookbookRepositoryDef {
...
}

@Injectable()
class CookbookRepositoryFake implements CookbookRepositoryDef {
...
}

For simpler cases where there is only one implementation and no need for the CookbookRepository => CookbookRepositoryImpl indirection:

interface CookbookRepositoryDef {
searchCookbooks(keywords: string): Observable<Cookbook[]>;
}

@Injectable({ providedIn: 'root' })
class CookbookRepository implements CookbookRepositoryDef {
...
}

class CookbookRepositoryFake implements CookbookRepositoryDef {
...
}

2. Implement the Fakeโ€‹

@Injectable()
class CookbookRepositoryFake implements CookbookRepository {
private _cookbooks: Cookbook[] = [];

searchCookbooks({ keywords }: { keywords: string }): Observable<Cookbook[]> {
return defer(async () => {
return this._cookbooks.filter((cookbook) =>
cookbook.title.includes(keywords),
);
});
}

...
}
tip

You don't need to implement the entire service โ€” only the methods you actually use. Any others should throw an error if called.

@Injectable()
class CookbookRepositoryFake implements CookbookRepository {
private _cookbooks: Cookbook[] = [];

searchCookbooks({
keywords,
difficulty,
}: {
keywords: string;
difficulty?: Difficulty;
}): Observable<Cookbook[]> {
return defer(async () => {
if (difficulty != null) {
throw new Error(
'๐Ÿšง CookbookRepositoryFake#searchCookbooks does not support difficulty filtering yet',
);
}

return this._cookbooks.filter((cookbook) =>
cookbook.title.includes(keywords),
);
});
}

updateCookbook(cookbookId: string, data: Partial<Cookbook>) {
throw new Error(
'๐Ÿšง CookbookRepositoryFake#updateCookbook is not implemented yet',
);
}

...
}
info

In contrast to stubs, if the fake is used in an unexpected way (e.g., calling a not implemented method, passing invalid or unhandled arguments), it will throw an error instead of silently returning undefined.

3. Extend the Fake with Testing Utilitiesโ€‹

It might be tempting to hardcode some data in the fake, but this approach is inflexible and can result in brittle tests.

Instead, you should provide methods to configure the fake with the data required for your tests, such as CookbookRepositoryFake#configure.

If the service mutates its state, consider adding methods to inspect the state of the fake. For example, if the service includes a method like updateCookbook, you might want to implement a method such as CookbookRepositoryFake#getCookbookSync to verify the current state of the fake.

Only implement these methods when necessary. The goal is to keep the fake as simple and maintainable as possible.

@Injectable()
class CookbookRepositoryFake implements CookbookRepository {
private _cookbooks: Cookbook[] = [];

configure({cookbooks}: {cookbooks: Cookbook[]}) {
this._cookbooks = cookbooks;
}

getCookbooksSync(): Cookbook[] {
return this._cookbooks;
}

...
}

4. Create a Provider Factoryโ€‹

To enhance the developer experience, you can create a provider factory that supplies the fake and replaces the real service.

import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';

export function provideCookbookRepositoryFake(): EnvironmentProviders {
return makeEnvironmentProviders([
CookbookRepositoryFake,
{
provide: CookbookRepository,
useExisting: CookbookRepositoryFake,
},
]);
}
tip

With useExisting, the same instance of the fake is provided as both CookbookRepositoryFake and CookbookRepository.

This approach allows you to inject the fake in your tests and use its specific methods without needing to downcast it.

// โœ…
const fake = TestBed.inject(CookbookRepositoryFake);

// โŒ
const fake = TestBed.inject(CookbookRepository) as CookbookRepositoryFake;
tip

Note that the fake is not "provided in root" on purpose so that you do not forget to override the real service in your tests by using the provider factory.

5. Use the fake in testsโ€‹

You can now use the fake in your tests as follows:

TestBed.configureTestingModule({
providers: [provideCookbookRepositoryFake()],
});

Additionally, fakes can be useful in your app for demos.

Source Codeโ€‹

๐Ÿ’ปย Angular Testing using a Fake
Pragmatic Angular Testing
๐Ÿ‘จ๐Ÿปโ€๐Ÿณ Let's cook some tests โ†’

๐Ÿ’ฐ 80โ‚ฌ ยท 170โ‚ฌ ยท Lifetime access

Learn how to write reliable tests that survive upgrades and refactorings.