Skip to content

task#22 use a mock server

bacn edited this page Sep 16, 2020 · 1 revision

Build a mock server as interceptor

The following tasks #22, #23 and #24 are very large and partly complex. In order to NOT loose to much time with implementation you can copy-paste the solution or compare it with the branch in this repository:

https://github.com/mbachmann/angular-tutorial-2020/tree/task%2322-use-a-fake-server

A mock backend is used for doing backendless development in Angular, which allows you to demo your code without the need of creating a backend server api. It's perfect when you're developing a front end before the backend is available.

The so far used test-aution-api is a simple mock server to handle get-requests for autions and auctions/:id. For the new login and registration requirements we need a more sophisticated mock server, which supports the http verbs post, put and delete. The use of the mock server (enabled or disabled) is controlled by the environments in the src/environments folder.

List of tasks

For creating the mock server, we firstly need to refactor the strucure a bit. Then we create the mock-server it self and finally we create environments to use the mock-server for development but not for production.

  • Create new folders to get the structure src/app/shared/helper/mock.
  • Create or generate an interceptor service MockBackendInterceptor with the name mock-backend-interceptor.service.ts in this folder.
  • Move the existing helper files angular-date-http-interceptor.ts, helper-service.ts, helper-service.spec.ts in the folder src/app/shared/helper.
  • Implement in the service MockBackendInterceptor. The intercept method shall return an Observable<HttpEvent<any>>.
  • Create an observable with the of-operator and pipe the operators mergeMap(handleRoute), materialize, delay and dematerialize.
  • Create a handleRoute() function which checks the received url against the required urls and verbs.
  • Create a function for each required api (see list) and return the apropriate content and http code.
  • Create a method mockBackendProvider in the mock-backend.ts file
  • Create a NgModule with the name MockModule in the file mock.module.ts. The Module is using the mockBackendProvider in the provider[] section.
  • Create in the folder src/environment a file environment.model.ts. It shall contain the Environment interface (see the requirement underneath).
  • Change the 2 files in the folder src/environment and implement the interface Environment. Put the required information to the environment files (see the requirement underneath).
  • Place the MockModule in the import section of the app.module. Check if useMockBackend in the environment uses useMockBackend true or false

Required api

The mock-server shall simulate the real backend in terms of several verbs per resource. The check of the correct authentication and authorisation will be implemented in one of the future tasks.

verb url data
get auctions array of AUCTION_DATA
get auctions/:id AUCTION_DATA filtered to id
post /users/authenticate {username: xxx, password, yyy}
post /users/register user object
get /users array of user objects
get /users/:id user object
get /users/:username user object
delete /users/:id user object

The mock server will be expanded during the project depending on the new api requirements.


Returned status codes

status function
200 ok
204 No Content
401 Unauthorized
404 Not found

User model

The user data shall be returned as a user object. The user object itself contains for now just a few attributes. To keep it simple for the moment, we do not have yet the user-role model.

{
  id: 1,
  username: 'felixmuster',
  firstName: 'Felix',
  lastName: 'Muster',
  token: 'mock-jwt-token'
}

Required environment interface

The command ng cli create at the beginning of this tutorial has generated in the folder src/enviroment two files: environment.ts and environment.prod.ts. The files shall contain some variables to control the default and the prod environment. To get a consistent model to all environments, we will introduce an interface Environment. Create a file environment.model.ts.

export interface Environment {
  production: boolean;
  useMockBackend: boolean;
  version: string;
  endpoints: {
    backendBaseUrl: string;
    backendAuthUrl: string;
  };
}

The required default environment (environment.ts)

The default environment in the folder src/enviroment shall implement the interface Environment and shall contain some useful information. The attribute useMockBackend: true is making sure the MockBackend is used instead of the real backend.

import {Environment} from './environment.model';

export const environment: Environment = {
  production: false,
  useMockBackend: true,
  version: '_VERSION_',
  endpoints: {
    backendBaseUrl: 'http://unused/api',
    backendAuthUrl: 'http://unused/api'
  }
};

The required prod environment (environment.prod.ts)

The prod environment in the folder src/enviroment shall implement the interface Environment and shall contain some useful information. The attribute useMockBackend: false is making sure the MockBackend is NOT used, We will use the real backend. The place holders '_VERSION_', '_BACKEND_BASE_URL_' and '_BACKEND_AUTH_URL_' can be used to be replaced by a shell script before generating a docker container with environment variables.

import {Environment} from './environment.model';

// not for local serve/run!! (-> environment.prod-local.ts)

export const environment: Environment = {
  production: true,
  useMockBackend: false,
  version: '_VERSION_',
  endpoints: {
    backendBaseUrl: '_BACKEND_BASE_URL_',
    backendAuthUrl: '_BACKEND_AUTH_URL_'
  }
}

Persistent User Objects

The persistence (simulation of the backend database) shall be implemented by the browsers local-store. It will allow us to save users for a longer time.


Logging Output of the Mock-Server

The use of the interceptor mock server will not show anymore the network traffic in the browser. For inspecting the calls it is useful to see the mockRequest and mockResponse.


Result

Stop the test-auction-api server in the second console (use ctrl-c).

Compile and start the application with

$ ng serve

Open the project in the browser

  • Open your browser with the address http://localhost:4200/
  • Click in the menu to home and auctions
  • You should see a page like the following illustration.

Home

The home screen shall be displayed

home-desktop.png

Auction List

Navigate to the auction list. The content shall be delivered by the mock server. One drawback of the mock server is... you will not see anymore the network traffic between frontend and backend in the browser developers tool (tab network). Instead, the mock server itself is creating request and response log content.


auction-list-view.png


Auction Detail

Navigate to the auction detail. The content shall be delivered by the mock server.

auction-detail-temp.png

Hints

The new folder structure after creating the helper folder, the files in the mock folder and the model file in src/environments:

src

\- app
   \- auction
      \- auction-list
         \- auction-list.component.css
         \- auction-list.component.html
         \- auction-list.component.ts
         \- auction-list.component.spec.ts
      \- auction-list-detail
         \- auction-list-detail.component.css
         \- auction-list-detail.component.html
         \- auction-list-detail.component.ts
         \- auction-list-detail.component.spec.ts
      \- mouse-event-display
         \- mouse-event-display.component.css
         \- mouse-event-display.component.html
         \- mouse-event-display.component.ts
         \- mouse-event-display.component.spec.ts
      \- shared
         \- auction.ts
         \- auction-data.service.spec.ts
         \- auction-data.service.ts
         \- auction-data.ts
      \- auction.module.ts
      \- auction-routing.module.ts
   \- home
      \- home.component.css
      \- home.component.html
      \- home.component.ts
      \- home.component.spec.ts
   \- nav-bar
      \- nav-bar.component.css
      \- nav-bar.component.html
      \- nav-bar.component.ts
      \- nav-bar.component.spec.ts
   \-shared
      \- helper
         \- mock
            \- mock.module.ts
            \- mock-backend-interceptor.service.ts
            \- mock-backend-interceptor.service.spec.ts
         \- angular-date-http-interceptor.ts
         \- helper.service.spec.ts
         \- helper.service.ts
   \- app.module.ts
   \- app.component.html
   \- app.component.spec.ts
   \- app.component.ts
   \- app.component.scss
   \- app-routing.module.ts
\- assets
    \- 01-yamaha-blue.png
    \- 02-yamaha-aquamarine.png
    \- 03-yamaha-red.png
\- environments
   \- environment.prod.ts
   \- environment.ts
   \- environment.model.ts
\- _variables.scss
\- app.constansts.ts
\- favicon.ico
\- index.html
\- main.ts
\- polyfills.ts
\- styles.scss
\- test.ts
\- tsconfig.app.jsop
\- tsconfig.spec.json
\- typings.d.ts

The mock Backend Server

The following code represents the mock backend server in the file mock-backend-interceptor.service.ts. Registered users are saved in the localStore of the browser. The response has a programmed delay for simulating the backend.

Pass Through Unmocked Requests to Real Backend

Sometimes there's a need to pass through specific requests to the server instead of being caught by the fake backend, for example when the real backend is partially completed and has some endpoints available.

With Angular 7/8/9/10 HTTP interceptors this is done by calling return next.handle(request);, you can see the code for passing through requests at the bottom of the code sample below.

The code is copied and adapted from the web site:

https://jasonwatmore.com/post/2019/05/02/angular-7-mock-backend-example-for-backendless-development

  • Create new folders to get the structure src/app/shared/helper/mock.
  • Create or generate an interceptor service MockBackendInterceptor with the name mock-backend-interceptor.service.ts in this folder.
ng generate service shared/helper/mock/mock-backend-interceptor
import {Injectable} from '@angular/core';
import {
  HttpRequest,
  HttpResponse,
  HttpHandler,
  HttpEvent,
  HttpInterceptor,
  HTTP_INTERCEPTORS
} from '@angular/common/http';
import {Observable, of, Subscription, throwError} from 'rxjs';
import {delay, mergeMap, materialize, dematerialize, first, tap} from 'rxjs/operators';
import {AUCTION_DATA} from '../../../auction/shared/auction-data';


@Injectable()
export class MockBackendInterceptor implements HttpInterceptor {

  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    const {url, method, headers, body} = request;
    // wrap in delayed observable to simulate server api call
    return of(null)
      .pipe(mergeMap(() => handleRoute()))
      .pipe(materialize()) // call materialize and dematerialize to ensure delay even if an error is thrown (https://github.com/Reactive-Extensions/RxJS/issues/648)
      .pipe(delay(500))
      .pipe(dematerialize())
      .pipe(tap({
        next: data => {
          console.log('mockResponse', data);
        },
        error: error => {
          console.log('mockResponse', JSON.stringify(error));
        },
        complete: () => console.log('mockResponse: on complete')
      }));

    function handleRoute() {
      console.log('mockRequest: ' + url, method, headers, body);
      let response: Observable<HttpEvent<any>>;


      switch (true) {
        case url.endsWith('/users/authenticate') && method === 'POST':
          response = authenticate();
          break;
        case url.endsWith('/users/register') && method === 'POST':
          response = register();
          break;
        case url.endsWith('/users') && method === 'GET':
          response = getUsers();
          break;

        case url.match(/(\/users[\/])/) && method === 'GET':
          if (isNumber(nameFromUrl())) response = getUser();
          else response = getUserByName();
          break;

        case url.match(/\/users\/\d+$/) && method === 'DELETE':
          response = deleteUser();
          break;

        case url.match(/\/auctions\/\d+$/) && method === 'GET':
          response = getAuction();
          break;

        case url.endsWith('/auctions') && method === 'GET':
          response = getAuctions();
          break;

        default:
          // pass through any requests not handled above
          response = next.handle(request);
      }
      return response;

    }


    // route functions

    function authenticate() {
      const {username, password} = body;
      let users = JSON.parse(localStorage.getItem('users')) || [];
      const user = users.find(x => x.username === username && x.password === password);
      if (!user) {
        return error('Username or password is incorrect');
      } else {
        return ok({
          id: user.id,
          username: user.username,
          firstName: user.firstName,
          lastName: user.lastName,
          token: 'fake-jwt-token'
        });
      }
    }

    function register() {
      const user = body;
      let users = JSON.parse(localStorage.getItem('users')) || [];
      console.log(users);
      if (users.find(x => x.username === user.username)) {
        return error('Username "' + user.username + '" is already taken')
      }
      console.log(user);
      user.id = users.length ? Math.max(...users.map(x => x.id)) + 1 : 1;
      console.log(user);
      users.push(user);
      localStorage.setItem('users', JSON.stringify(users));

      return ok();
    }

    function getUsers() {
      let users = JSON.parse(localStorage.getItem('users')) || [];
      if (!isLoggedIn()) return unauthorized();
      return ok(users);
    }

    function getUser() {
      let users = JSON.parse(localStorage.getItem('users')) || [];
      users = users.filter(x => x.id === idFromUrl());
      if (users.length > 0) {
        return ok(users[0]);
      } else {
        return noContent('User with id ' + idFromUrl() + ' not found.')
      }
    }

    function getUserByName() {
      let users = JSON.parse(localStorage.getItem('users')) || [];
      users = users.filter(x => x.username === nameFromUrl());
      if (users.length > 0) {
        return ok(users[0]);
      } else {
        return noContent('User with name ' + nameFromUrl() + ' not found.')
      }
    }

    function getAuctions() {
      return ok(AUCTION_DATA);
    }

    function getAuction() {
      let auctions = AUCTION_DATA.filter(x => x.id === idFromUrl());
      if (auctions.length > 0) {
        return ok(auctions[0]);
      } else {
        return noContent('Auction item with id ' + idFromUrl() + ' not found.')
      }
    }

    function deleteUser() {
      if (!isLoggedIn()) return unauthorized();
      let users = JSON.parse(localStorage.getItem('users')) || [];
      users = users.filter(x => x.id !== idFromUrl());
      localStorage.setItem('users', JSON.stringify(users));
      return ok();
    }

    // helper functions

    function ok(body?) {
      return of(new HttpResponse({status: 200, body}))
    }

    function error(message) {
      return throwError({error: {message}});
    }

    function unauthorized() {
      return throwError({status: 401, error: {message: 'Unauthorised'}});
    }

    function notFound() {
      return throwError({status: 404, error: {message: 'Not found'}});
    }

    function noContent(message) {
      return throwError({status: 204, error: {message: message}});
    }


    function isLoggedIn() {
      return headers.get('Authorization') === 'Bearer fake-jwt-token';
    }

    function idFromUrl() {
      const urlParts = url.split('/');
      return parseInt(urlParts[urlParts.length - 1]);
    }

    function nameFromUrl() {
      const urlParts = url.split('/');
      return urlParts[urlParts.length - 1];
    }

    function isNumber(value: string | number): boolean
    {
      return ((value != null) && !isNaN(Number(value.toString())));
    }

  }
}

export const mockBackendProvider = {
  // use fake backend in place of Http service for backend-less development
  provide: HTTP_INTERCEPTORS,
  useClass: MockBackendInterceptor,
  multi: true
};

The Mock Module

The Mock Module is needed to load the mock server in terms of the chosen angular environment. The standard environment is using the mock server, the prod environment is not using it.

  • Create or generate an angular module MockModule with the name mock.module.ts in the folder src/app/shared/helper/mock.
ng g module shared/helper/mock
import {NgModule} from '@angular/core';
import {CommonModule} from '@angular/common';
import {mockBackendProvider} from './mock-backend-interceptor.service';

@NgModule({
  declarations: [],
  imports: [
    CommonModule
  ],
  providers: [mockBackendProvider]
})
export class MockModule {
}

The changes in the AppModule

Make sure to add MockModule the in the file app.module.ts. The MockModule shall be used only for environments with environment.useMockBackend: true.

import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { AppComponent } from './app.component';
import { FormsModule } from '@angular/forms';
import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http';
import { AngularDateHttpInterceptorService } from './shared/helper/angular-date-http-interceptor.service';

import { NavBarComponent } from './nav-bar/nav-bar.component';
import { HomeComponent } from './home/home.component';
import { AppRoutingModule } from './app-routing.module';
import { environment } from '../environments/environment';
import { MockModule } from './shared/helper/mock/mock.module';

// import/use mock module only if configured (mock module will not be included in prod build!):
const mockModule = environment.useMockBackend ? [MockModule] : [];

@NgModule({
  declarations: [AppComponent, NavBarComponent, HomeComponent],
  imports: [
    BrowserModule,
    FormsModule,
    HttpClientModule,
    AppRoutingModule,
    ...mockModule
  ],
  providers: [
    {
      provide: HTTP_INTERCEPTORS,
      useClass: AngularDateHttpInterceptorService,
      multi: true
    },
  ],

  bootstrap: [AppComponent]
})
export class AppModule {}

The unit test of the mock server

The unit test is not yet testing anything. We will introduce some real tests during the next task.

  • use the file /mock-backend-interceptor.service.spec.ts in the folder src/app/shared/helper/mock.
import { TestBed } from '@angular/core/testing';

import { MockBackendInterceptor } from './mock-backend-interceptor.service';
import {HttpClientTestingModule} from '@angular/common/http/testing';
import {RouterTestingModule} from '@angular/router/testing';

describe('MockBackendInterceptor', () => {

  let service: MockBackendInterceptor;
  beforeEach(() => {
    TestBed.configureTestingModule({
        imports: [
          HttpClientTestingModule,
          RouterTestingModule,
        ],
        providers: [
          MockBackendInterceptor
        ]

    });
    service = TestBed.get(MockBackendInterceptor);
  });

  it('should be created', () => {
    const service: MockBackendInterceptor = TestBed.get(MockBackendInterceptor);
    expect(service).toBeTruthy();
  });
});

Running the unit tests

Start the unit testing with:

ng test

unit-tests-task22-error.jpg

The result show, some test are not working. We need to correct them:

  • AppComponent
  • AuctionDataService
  • AuctionListComponent
  • AuctionDetailComponent
  • AuctionListDetailComponent

We need to fix the tests by importing the missing modules.

Fix the Unit Test Random Order Problem

Jasmine 2.x was running in the defined order. From Jasmine 3.x test are executed randomly. The configuration in karma.config.js is correcting it:

    client: {
      clearContext: false, // leave Jasmine Spec Runner output visible in browser
      jasmine: {
        random: false,
      }
    },

AppComponentTest app.component.spec.ts

import { TestBed, async } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {AppRoutingModule} from './app-routing.module';
import {HttpClientModule} from '@angular/common/http';
import {HomeComponent} from './home/home.component';
import {NavBarComponent} from './nav-bar/nav-bar.component';


describe('AppComponent', () => {
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [
        RouterTestingModule,
        FormsModule,
        ReactiveFormsModule,
        AppRoutingModule,
        HttpClientModule,
      ],
      declarations: [
        AppComponent,
        HomeComponent,
        NavBarComponent,
      ],
    }).compileComponents();
  }));

  it('should create the app', () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.debugElement.componentInstance;
    expect(app).toBeTruthy();
  });

  it(`should have as title 'app works!'`, () => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.debugElement.componentInstance;
    expect(app.title).toEqual('app works!');
  });

});

AuctionDataService auction-data.service.spec.ts

import { TestBed } from '@angular/core/testing';

import { AuctionDataService } from './auction-data.service';
import {RouterTestingModule} from '@angular/router/testing';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {HttpClientTestingModule} from '@angular/common/http/testing';

describe('AuctionDataService', () => {
  beforeEach(() => TestBed.configureTestingModule({
    imports: [
      RouterTestingModule,
      FormsModule,
      ReactiveFormsModule,
      HttpClientTestingModule
    ],

    providers: [AuctionDataService]
  }));

  it('should be created', () => {
    const service: AuctionDataService = TestBed.get(AuctionDataService);
    expect(service).toBeTruthy();
  });
});

AuctionListComponent auction-list.component.spec.ts

import { async, ComponentFixture, TestBed } from '@angular/core/testing';

import { AuctionListComponent } from './auction-list.component';
import {RouterTestingModule} from '@angular/router/testing';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {HttpClientTestingModule} from '@angular/common/http/testing';
import {AuctionListDetailComponent} from '../auction-list-detail/auction-list-detail.component';
import {AuctionDataService} from '../shared/auction-data.service';
import {HelperService} from '../../shared/helper/helper.service';


describe('AuctionListComponent', () => {
  let component: AuctionListComponent;
  let fixture: ComponentFixture<AuctionListComponent>;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [
        RouterTestingModule,
        FormsModule,
        ReactiveFormsModule,
        HttpClientTestingModule,
      ],
      declarations: [
        AuctionListComponent,
        AuctionListDetailComponent
      ],
      providers: [
        AuctionDataService,
        HelperService
      ]

    })
      .compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(AuctionListComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});

AuctionDetailComponent auction-detail.component.spec.ts

import { async, ComponentFixture, TestBed } from '@angular/core/testing';

import { AuctionDetailComponent } from './auction-detail.component';
import {RouterTestingModule} from '@angular/router/testing';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {HttpClientTestingModule} from '@angular/common/http/testing';
import {AuctionDataService} from '../shared/auction-data.service';
import {HelperService} from '../../shared/helper/helper.service';

describe('AuctionDetailComponent', () => {
  let component: AuctionDetailComponent;
  let fixture: ComponentFixture<AuctionDetailComponent>;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [
        RouterTestingModule,
        FormsModule,
        ReactiveFormsModule,
        HttpClientTestingModule
      ],
      declarations: [ AuctionDetailComponent ],
      providers: [
        AuctionDataService,
        HelperService
      ]
    })
    .compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(AuctionDetailComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});

AuctionListDetailComponent auction-list-detail.component.spec.ts

import { async, ComponentFixture, TestBed } from '@angular/core/testing';

import { AuctionListDetailComponent } from './auction-list-detail.component';
import {RouterTestingModule} from '@angular/router/testing';
import {FormsModule, ReactiveFormsModule} from '@angular/forms';
import {HttpClientTestingModule} from '@angular/common/http/testing';
import {AuctionDataService} from '../shared/auction-data.service';
import {HelperService} from '../../shared/helper/helper.service';

const dummyAuction =
  {
    id: 1,
    seller: 'Felix',
    category: {id: 1, name: 'Bikes', description: 'Motor bikes'},
    auctionItem:
      {
        title: 'Yamaha YZF R1 Sports Bike',
        description: 'Ready to take a "walk" on the wild side ',
        picture: '01-yamaha-blue.png'
      },
    startDateTime: getDate(-5),
    startingPrice: 4000,
    fixedPrice: 5000,
    bids:
      [
        {id: 1, amount: 4100, cancelExplanation: '', placedAtDateTime: getDate(-4), buyer: 'Max'},
        {id: 2, amount: 4200, cancelExplanation: '', placedAtDateTime: getDate(-3), buyer: 'Klaus'},
        {id: 3, amount: 4300, cancelExplanation: '', placedAtDateTime: getDate(-4), buyer: 'Nadja'}
      ],
    endDateTime: getDate(1),
    isFixedPrice: false,
    minBidIncrement: 100
  };

function getDate(days: number): Date {
  return addDays(new Date(), days);
}

function addDays(date: Date, days: number): Date {
  date.setDate(date.getDate() + days);
  return date;
}


describe('AuctionListDetailComponent', () => {
  let component: AuctionListDetailComponent;
  let fixture: ComponentFixture<AuctionListDetailComponent>;

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      imports: [
        RouterTestingModule,
        FormsModule,
        ReactiveFormsModule,
        HttpClientTestingModule
      ],

      declarations: [ AuctionListDetailComponent ],
      providers: [
        AuctionDataService,
        HelperService
      ]
    })
      .compileComponents();
  }));

  beforeEach(() => {
    fixture = TestBed.createComponent(AuctionListDetailComponent);
    component = fixture.componentInstance;
    component.auction = dummyAuction;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});

unit-tests-task22-fixed.jpg

Clone this wiki locally