Faking HTTP Services in Angular: A comprehensive guide.

Adrien Durot
ekino-france

--

In the world of Angular development, a handy approach known as “faking” services has become a go-to for making frontend work smoother and testing more robust. As developers, we often find ourselves juggling between frontend and backend teams, each with its unique challenges.

Coordinating these two teams can be tricky.

This article breaks down why faking HTTP services is so useful. We’ll explore how it helps synchronize development, improve communication, and create a more efficient software development experience.

Join us as we dive into the world where faking services becomes a key strategy for better teamwork and smoother project execution.

Why fake http services? 🤔

When implementing a new feature, you sometimes need to work side by side with your backend to use a newly created API, which isn’t always working as it should. You need to be efficient and able to do your work. You could manage a workaround by providing temporary mocks and implement your feature. True. But what if, instead of something temporary, you could easily use these mocks and replace the link you have with your backend, what if you could set up your own logic to test error codes without needing a proxy to return a fake response? This is where faking services comes in handy.

Let’s see the pros and cons of doing such thing.

Pros and Cons of Faking HTTP Services

Source : OKO Pros and Cons

Pros 👍

Enhanced Development Speed

Implementing fake http services allows your team to reduce the dependencies that there is between the frontend and backend teams. By agreeing on an interface and the type of data you receive, both teams can work simultaneously on the same task and integrate their work when both sides are ready.

Isolation for Comprehensive Unit Testing

Isolating your frontend, setting up Fake service, is great for testing purposes, no need to provide HTTP_CLIENT or the use of spies you will need to adapt for every test case scenario. By managing your own implementation of the fake service, you eliminate all associated dependencies.

Example : Let’s say you have an http service called DataService that retrieves user information and an associated fake service.

// data.service.ts

export interface UserInfo {
firstName: string;
lastName: string;
}

@Injectable({
providedIn: 'root'
})
export class DataService {
private apiUrl = '/api/users';

constructor(private http: HttpClient) {}

getUsersByFirstName(firstName: string): Observable<UserInfo[]> {
const url = `${this.apiUrl}?firstName=${firstName}`;
return this.http.get<UserInfo[]>(url);
}
}
// data.service.fake.ts

@Injectable({
providedIn: 'root'
})
export class FakeDataService {
// You can also make the "users" public in order to fill it when needed with the data you need.
// You should also consider using mockFactories throughout your tests to avoid creating flacky tests.
// For the simplicity of this article, We'll use static data.
private users: UserInfo[] = [
{ firstName: 'John', lastName: 'Doe' },
{ firstName: 'Jane', lastName: 'Doe' },
{ firstName: 'Alice', lastName: 'Johnson' },
// Add more user data as needed
];

getUsersByFirstName(firstName: string): Observable<UserInfo[]> {
const filteredUsers = this.users.filter(user => user.firstName.toLowerCase().includes(firstName.toLowerCase()));
return of(filteredUsers);
}
}

We also have a component that calls the getUsersByFirstName method and below, you’ll see an example of a typical test case using spies.

// app.component.ts

@Component({
selector: 'app-root',
template: `
<div *ngIf="data">
<p *ngFor="let user of data">{{ user.firstName }} {{ user.lastName }}</p>
</div>
`,
})
export class AppComponent implements OnInit {
private readonly dataService = inject(DataService);
data: UserInfo[];

ngOnInit() {
this.loadData();
}

loadData() {
// 'John' here will be our filter for simplicity.
this.dataService.getUsersByFirstName('John').subscribe(
(result) => {
this.data = result;
},
(error) => {
console.error('Error fetching data:', error);
}
);
}
}
// app.component.spec.ts

describe('AppComponent', () => {
let fixture: ComponentFixture<AppComponent>;
let component: AppComponent;
let dataService: DataService;

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [AppComponent],
providers: [DataService],
imports: [HttpClientTestingModule],
});

fixture = TestBed.createComponent(AppComponent);
component = fixture.componentInstance;
dataService = TestBed.inject(DataService);
}));

it('should fetch users on initialization based on the provided first name', () => {
const expectedUsers: UserInfo[] = [
{ firstName: 'John', lastName: 'Doe' },
{ firstName: 'Jane', lastName: 'Doe' },
];

// Create a spy on the getUsersByFirstName method
const getUsersSpy = spyOn(dataService, 'getUsersByFirstName').and.returnValue(of(expectedUsers));

// Trigger ngOnInit which calls the loadData method
component.ngOnInit();

// Verify that the component's data property is set with the expected filtered users
expect(component.data).toEqual(expectedUsers);

// Verify that the DataService's getUsersByFirstName method was called with the correct argument
expect(getUsersSpy).toHaveBeenCalledWith('John');
});
});

The main issue with this testing implementation is that the test case isn’t truly executed as intended. We receive the expectedUser because we’ve instructed Jasmine to return it, even though it’s not actually filtered.

Let’s explore how this works using our FakeDataService.

describe('AppComponent', () => {
let fixture: ComponentFixture<AppComponent>;
let component: AppComponent;
let fakeDataService: FakeDataService;

beforeEach(() => {
TestBed.configureTestingModule({
declarations: [AppComponent],
providers: [{ provide: DataServiceBase, useClass: FakeDataService }], // We provide the FakeDataService instead of a spy
});

fixture = TestBed.createComponent(AppComponent);
component = fixture.componentInstance;
});

it('should fetch users on initialization based on the provided first name', () => {
// Trigger ngOnInit which calls the loadData method
component.ngOnInit();

// Verify that the component's data property is set with the expected filtered users
const expectedUsers: UserInfo[] = [
{ firstName: 'John', lastName: 'Doe' }
];
expect(component.data).toEqual(expectedUsers);
});
});

With this use case, we have tested that when seaching for John, the data is properly filtered to get the correct value. There is nothing more to add if you want to test with Jane or with a user that isn’t part of our list ! This leads us to the next advantage.

Error Simulation and Handling

By implementing your own service, you can easily add ways to trigger errors in your code to test all potential error cases you might encounter when working with an API.

Example: We want to test that if no users were found, we receive a 404 HttpErrorResponse User not found , and we also want to receive a 403 HttpErrorResponse Forbidden if we try to retrieve an admin user.

So let’s modify of FakeDataService to handle these scenarios.

// data.service.fake.ts

@Injectable({
providedIn: 'root'
})
export class FakeDataService {
private users: UserInfo[] = [
{ firstName: 'John', lastName: 'Doe' },
{ firstName: 'Jane', lastName: 'Doe' },
{ firstName: 'Alice', lastName: 'Johnson' },
// Add more user data as needed
];

getUsersByFirstName(firstName: string): Observable<UserInfo[]> {
// We add the check for admin
if(firstName === 'admin'){
throwError(() =>new HttpErrorResponse({ error: 'You are not allowed to retrieve admin users', status: 403, statusText: 'Forbidden' }))
}

const filteredUsers = this.users.filter(user => user.firstName.toLowerCase().includes(firstName.toLowerCase()));

// We add the check for empty
if(!filteredUsers.length) {
throwError(() => new HttpErrorResponse({ error: 'User not found', status: 404, statusText: 'Not Found' }))
}

return of(filteredUsers);
}
}

The advantages described above contribute to work in a more agile and efficient workflow, enabling your team to iterate quickly and deliver a more robust and user-friendly application.

With these improvements, you can now more easily test and handle cases where the data might be empty.

getUserByFirstName("Adrien").subscribe({
next: (users: UserInfo[]) => {
// ...
}
error: (err: HttpErrorException) => {
if(err.status === 404){
// E.g. Show an error tooltip
}
});

Cons 👎

Consistency Challenges

Faking HTTP services introduces the risk of inconsistencies between the fake services and the actual backend. This becomes particularly relevant during API changes or updates. Fake services may not accurately mirror the behavior of the real backend, potentially leading to discrepancies in data formats, response structures, or supported features.

A practical solution to mitigate these challenges is to establish a contract-based API, utilizing tools such as an OpenAPI specification. By enforcing a shared contract between the frontend and backend, development teams synchronize their expectations, minimizing the risk of desynchronization. This approach acts as a reliable blueprint, ensuring that both ends align seamlessly and reducing potential inconsistencies in the development process.

Limitations in Real-World Testing

While faking HTTP services is valuable for isolating frontend development, it comes with the trade-off of not fully testing interactions with the actual backend. Certain nuances, such as network latency, database variations, or backend service intricacies, may not be accurately represented by the faked services. This limitation could lead to undiscovered issues that only surface in the real-world, production environment.

Maintenance Overhead

Maintaining harmony between faked services and the actual backend requires ongoing effort, especially in dynamic development environments. As the real backend evolves, the corresponding faked services must be kept up-to-date to reflect changes accurately. This introduces an additional maintenance overhead that developers need to manage diligently.

To avoid this, you could use a contract based API. All the APIs are written before the actual implementation and both backend and frontend must respect the contract. If changes need to be made, a new version of the contract should be created.

Now, let’s explore a robust solution to address this challenge and establish a reliable mechanism for ensuring the continuous maintenance of our fake services.

Best practices and Tips

Source : Top Nature tips

Use abstract class for contracts

While maintaining these simulated services undoubtedly requires time, adopting a strategic pattern can help mitigate certain drawbacks. Implementing an abstract class offers a mechanism to receive notifications when changes occur in the actual DataService. Below, you’ll find an illustrative example of how to implement this pattern.

// data.service.base.ts

// We create a new interface
export abstract class DataServiceBase {
abstract getUserByFirstName(firstName: string): Observable<UserInfo[]>;
}
// data.service.ts
@Injectable({
providedIn: 'root'
})
export class DataService extends DataServiceBase { // Here we extends our base class
private apiUrl = '/api/users';

constructor(private http: HttpClient) {}

getUsersByFirstName(firstName: string): Observable<UserInfo[]> {
const url = `${this.apiUrl}?firstName=${firstName}`;
return this.http.get<UserInfo[]>(url);
}
}
// data.service.fake.ts

@Injectable({
providedIn: 'root'
})
export class FakeDataService extends DataServiceBase { // Here we extends our base class
private users: UserInfo[] = [
{ firstName: 'John', lastName: 'Doe' },
{ firstName: 'Jane', lastName: 'Doe' },
{ firstName: 'Alice', lastName: 'Johnson' },
// Add more user data as needed
];

getUsersByFirstName(firstName: string): Observable<UserInfo[]> {
const filteredUsers = this.users.filter(user => user.firstName.toLowerCase().includes(firstName.toLowerCase()));
return of(filteredUsers);
}
}

By introducing this new abstract class, any modification to the signature of the getUsersByFirstName method will promptly raise an error in our FakeDataService, indicating that the specified contract is not being adhered to.

Mock complex scenarios

While you can provide a basic implementation of a method with a FakeService, it’s important to also cover edge cases such as network timeouts, server errors, and slow responses. This ensures the best user experience and allows for graceful handling of these scenarios.

Angular schematics

Leverage Angular Schematics or custom scripts to automate the generation of faked services. Automation streamlines the process and reduces the likelihood of manual errors in setting up these services.

API Documentation

Keep a close eyes to the API documentation. Leave a link directly to the swagger for exemple. the main objective behind this practice is to limit desynchronization between the backend and the frontend.

These best practices and tips aim to enhance the effectiveness and reliability of faked HTTP services in your Angular project while promoting a seamless and collaborative development environment.

Applying fake services for local development

We have covered numeral tests cases, but we haven’t yet covered using it for local development. Let’s take a look on how this could be implemented to isolate your frontend application.

First, we still want to be able to communicate with our backend, so the objective here is not to replace but to add it as an option. So let’s create a new configuration file.

// environment.fake.ts
import {environment as dev} from './environment.dev';

export const environment: MyAppEnvironment = {
...dev,
name: 'fake' // By doing this, we will still be able to use dev Apis, be we will also know that we want the fake versions of the services if they have one
}

Then we will create providers for our fake services.

// data.service.fake-provider.ts

export const provideBaseDataService = () => {
return (): Provider | undefined => {
environment = inject(environment);

if(environment.name === 'fake'} {
return {
provide: DataServiceBase,
useClass: FakeDataService
}
}

return {
provide: DataServiceBase,
useClass: DataService
}
}
}

We can now add our function in the providers (In this case, in our app.module.ts)

// app.module.ts

@NgModule({
// declartions...
// imports...
providers: [
// all your providers
provideBaseDataService();
]
})
export class AppModule{}

Update your angular.json to add the fake configuration and file replacement.

Update your package.json to add the new ng serve command with the--configuration=fake

You are now ready to work with your fake services !

Another strategic approach is to consider implementing an HTTP interceptor. In the event of a status code 503 (Service Unavailable), this interceptor can seamlessly activate your fake service, providing a smooth development experience.

Conclusion

In the end, the utilization of faked HTTP services in Angular projects presents a powerful approach to streamline frontend development, enhance testing, and foster collaboration between frontend and backend teams. Also, adhering to best practices contributes to the success of integrating faked services into Angular projects.

In the dynamic realm of software development, dedicating time to faked HTTP services proves to be a valuable investment.

When approached with care and consideration, faked services not only streamline development but also become a foundational element, contributing to the efficiency, collaboration, and overall high-quality outcomes of Angular development.

Go Further 🚀

If you want to go further in mocking the data for your frontend app, take a look at Mswjs. Using this tools, you can mock all or part of the data and much more… Go take a peek !

--

--