Service Control Panel. Part 3. Reconnaissance

In the previous part, I finished the story about the API and the interface with the frontend. In this article, I will talk about the frontend itself and start with a topic that usually unfolds towards the end. Testing.



File organization


For starters, the structure of the project formed by angular-cli. As unnecessary, part of the files was filtered.


Unit testing in Angular is based on Jasmine , the utilities of Angular and Karma itself . The description of these packages is given on the official website , so I will not duplicate it. In short, Jasmine is a framework that provides the necessary functionality for basic tests, Angular utilities allow you to use the test environment, Karma serves to run tests.



The test files themselves are named with the addition of spec before the extension. According to this pattern: * .spec.ts Karma will find them. There is no consensus on the location of the tests. Allowed the location "closer" to the test object and a separate directory for all application tests. The Angular team has the advantages of placing the tests next to the object:

  • Such tests are easier to find.
  • You immediately see that part of your application lacks a test.
  • When you move the source code of an entity, you will not forget to move the test too.
  • When you rename the source code of an entity, you will not forget to rename the test as well.

I opted for the option proposed by angular-cli - tests are located next to the object.

Karma setup


The entry point for testing the Angular application is the src / test.ts file, which imports all the necessary packages, collects test files using the * .spec.ts template and starts the test execution system, in our case, Karma. The file itself after creating the project looks like this:

// This file is required by karma.conf.js and loads recursively all the .spec and framework files

import 'zone.js/dist/long-stack-trace-zone';
import 'zone.js/dist/proxy.js';
import 'zone.js/dist/sync-test';
import 'zone.js/dist/jasmine-patch';
import 'zone.js/dist/async-test';
import 'zone.js/dist/fake-async-test';
import { getTestBed } from '@angular/core/testing';
import {
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting
} from '@angular/platform-browser-dynamic/testing';

// Unfortunately there's no typing for the `__karma__` variable. Just declare it as any.
declare const __karma__: any;
declare const require: any;

// Prevent Karma from running prematurely.
__karma__.loaded = function () {};

// First, initialize the Angular testing environment.
getTestBed().initTestEnvironment(
  BrowserDynamicTestingModule,
  platformBrowserDynamicTesting()
);
// Then we find all the tests.
const context = require.context('./', true, /\.spec\.ts$/);
// And load the modules.
context.keys().map(context);
// Finally, start Karma to run the tests.
__karma__.start();

Here I want to pay attention to the problem that I managed to encounter. The order of imports is important and should not be changed, otherwise it will be possible to meet errors whose roots are extremely implicit. Therefore, I recommend adding third-party packages to the end of the import list and adding the / * tslint: disable * / flag at the top of the file to disable the linter, or exclude this file in its configuration, because tslint may consider the existing order to be incorrect and fix it.

Before writing tests, you must configure Karma, which will run them. The initial configuration is as follows:

// Karma configuration file, see link for more information
// https://karma-runner.github.io/1.0/config/configuration-file.html

module.exports = function (config) {
  config.set({
    basePath: '',
    frameworks: ['jasmine', '@angular/cli'],
    plugins: [
      require('karma-jasmine'),
      require('karma-chrome-launcher'),
      require('karma-jasmine-html-reporter'),
      require('karma-coverage-istanbul-reporter'),
      require('@angular/cli/plugins/karma'),
      require('karma-mocha-reporter'),
    ],
    client:{
      clearContext: false // leave Jasmine Spec Runner output visible in browser
    },
    coverageIstanbulReporter: {
      reports: [ 'html', 'lcovonly' ],
      fixWebpackSourcePaths: true
    },
    angularCli: {
      environment: 'dev'
    },
    reporters: ['mocha'],
    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: ['Chrome'],
    singleRun: false
  });
};

Since the description of all possible Karma settings is a separate topic for discussion, I will focus only on those we use:

  • browsers : list of browsers to launch. Since full Chrome is used, I prefer to use ChromeHeadless, which is less memory intensive. But it is worth remembering that Chrome> = 59 is required to use it.
  • logLevel : I use LOG_ERROR and LOG_DEBUG in case of errors like "Script error" to get a more detailed log. Allowed values ​​are 5:
    1. config.LOG_DISABLE
    2. config.LOG_ERROR
    3. config.LOG_WARN
    4. config.LOG_INFO
    5. config.LOG_DEBUG

  • reporters : an array for reporting messages and results. In the open spaces of npmjs, you can find almost any output design; for myself, I determined the following set: karma-mocha-reporter for the console, istanbul-threshold - for output in the browser. The log provided by mocha is quite informative for the console, Istanbul arranged us with an output to the browser (I use the full launch of the browser along with logLevel: config.LOG_DEBUG.
  • The Plugins . As the name implies, this is an array of plugins used by karma. Here you can find reporters, browsers, plugins for displaying and calculation of code coverage. A list of available plugins can be seen here (https://www.npmjs.com/browse/keyword/karma-plugin).
  • In the standard configuration, there is no files
  • parameter , the list of values ​​of which will be downloaded by Karma during testing. We use this list to add a standard material theme and remove warnings about its absence.

    The application is as follows:

    files: [{
        pattern: './node_modules/@angular/material/prebuilt-themes/indigo-pink.css', 
        included: true, 
        watched: true
    }],
    

    The included parameter includes the file in the assembly, watched - monitors changes in it.
  • We also use the preprocessors
  • parameter to turn off API code coverage checking:

    preprocessors: {
        '!(./src/api)': ['coverage']
    },
    


So, the preparatory phase is completed, you can run the tests. For a project formed using angular-cli, the ng test command is used. It has several options , among which I would like to mention --sourcemap. Based on the name, this parameter includes sourcemaps in the tests. But this is due to the error 'XMLHttpRequest: failed to load'. Setting false for this parameter solves this problem.

Component testing


The first and most important testing tool for the Angular component is TestBed . Her idea is to create a separate module, which is identical to the component module, but is created in a test environment and isolated from the application.

To create a TestBed, the configureTestingModule method is called , to which the metadata object is passed. This method should be called asynchronously before each test so that the component is in its original state. There is a method beforeEach (async () => {});

beforeEach(async(() => {
  TestBed.configureTestingModule({
    declarations: [
      AppComponent
    ],
  }).compileComponents();
}));

As you can see from the example, we declare an array of components for testing (in this case, only one). In the future, it is possible to add more components, import the necessary modules, directives, pipes for the work. The configureTestingModule method returns the TestBed class, which allows you to call static methods such as compileComponents. This method asynchronously compiles all the components of the module, translates the template and styles into "inline". After that, we can synchronously receive component instances using the TestBed.createComponent () method. This method returns an instance of ComponentFixture, which gives access directly to the component and its DOM through DebugElement.

The result file of the simplest component test
import { TestBed, async } from '@angular/core/testing';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [
        AppComponent
      ],
    }).compileComponents();
  }));
  it('should create the app', async(() => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.debugElement.componentInstance;
    expect(app).toBeTruthy();
  }));
  it(`should have as title 'app'`, async(() => {
    const fixture = TestBed.createComponent(AppComponent);
    const app = fixture.debugElement.componentInstance;
    expect(app.title).toEqual('app');
  }));
  it('should render title in a h1 tag', async(() => {
    const fixture = TestBed.createComponent(AppComponent);
    fixture.detectChanges();
    const compiled = fixture.debugElement.nativeElement;
    expect(compiled.querySelector('h1').textContent).toContain('Welcome to app!');
  }));
});


Component Code
import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'app';
}


Component Template
<div style="text-align:center">
  <h1>
    Welcome to {{title}}!
  </h1>
</div>


Testing a component with a dependency


Developers focus on the fact that you should not use real services and you need to add "stubs" to the tests.

Component Code


import {Component} from '@angular/core';
import {SomeServiceService} from "./some-service.service";

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  title = 'app';
  name: string;

  constructor(private someService: SomeServiceService) {
    this.name = this.someService.getName();
  }
}

Test Code:


import { TestBed, async } from '@angular/core/testing';
import { AppComponent } from './app.component';
import { SomeServiceService } from "./some-service.service";
const SomeServiceStub = {
    getName: () => 'some name'
};
describe('AppComponent', () => {
    beforeEach(async(() => {
        TestBed.configureTestingModule({
            declarations: [
                AppComponent
            ],
            providers: [
                // Обозначаем сервис
                { provide: SomeServiceService, useValue: SomeServiceStub }
            ]
        }).compileComponents();
    }));


    it('token should test component with dependency', () => {
        //Получаем сервис от root-injector
       // Если этот сервис используется в нескольких тестах, имеет смысл вынести его получение в метод beforeEach 
        const someService = TestBed.get(SomeServiceService);
        expect(someService.getName()).toEqual('some name');
    });

    //Сработает аналогично, так как функция inject, которая оборачивает тест, использует тот же root-injector. 
   //Преимущество в ясности, какой тест какой сервис использует
    it('token should test component with dependency', inject(([SomeServiceService], (someService: SomeServiceService)) => {
        const someService = TestBed.get(SomeServiceService);
        expect(someService.getName()).toEqual('some name');
    }));
});

In this case, I use an object that repeats the behavior of SomeService, that is, it contains the getName method. To use it instead of a real service, you need to add an object with the provide property to the providers array, specifying service and useValue with the value of "stub" as the value.

Since we use the swagger-based API, there is no need to write services to communicate with the server. We can only call methods and process the result. And here we can afford to abandon the implementation of methods and get the service through injector.
This is done through the use of MockBackend. In short, we are replacing not the service, but the backend itself.

Component Code
@Component({
    selector: 'app-login',
    templateUrl: 'login.component.html',
    styleUrls: ['login.component.less']
})
export class LoginComponent implements OnInit {
    loading: boolean;
    loginForm: FormGroup;
    error: any;

    constructor(private router: Router,
        private authService: UserService,
        private fb: FormBuilder,
        private ref: ChangeDetectorRef) {
    }

    ngOnInit() {
        this.loading = false;
        this.loginForm = this.fb.group({
            login: [''],
            password: ['']
        });
    }

    get login() {
        return this.loginForm.get('login');
    }

    get password() {
        return this.loginForm.get('password');
    }


    submit() {
        this.loading = true;
        this.submitted = true;
        // Получаем данные с формы
        const data = {
            'login': this.loginForm.controls['login'].value,
            'password': this.loginForm.controls['password'].value
        };

        // Выполняем запрос авторизации
        // В случае успеха - переходим по адресу /home/main
        // Если вернулась ошибка с кодом 400 или 401 - записываем её текст
        this.authService.signIn(data).subscribe(
            resp => this.router.navigateByUrl('/home/main'),
            error => {
                this.loading = false;
                error.status === 401 || error.status === 400 ?
                    this.error = {errorText: error.title} :
                    '';
                this.ref.detectChanges();
            }
        );
    }

}


Test code
class MockError extends Response implements Error {
    name: any;
    message: any;
}

describe('LoginComponent', () => {
    let component: LoginComponent;
    let fixture: ComponentFixture<LoginComponent>;
    let mockBackend: MockBackend;
    const authUrl = 'login_url';
    
    const testUser = {
        login: 'test',
        password: 'test'
    };
    const successResponse = new Response(
        new ResponseOptions({
            status: 200,
            body: ''
        })
    );
    const errorResponse = new MockError(
        new ResponseOptions({
            type: ResponseType.Error,
            status: 401,
            body: JSON.stringify({
                    status: 401,
                    title: 'Unauthorized',
                }
            )
        })
    );

    beforeEach(async(() => {
        TestBed.configureTestingModule({
            declarations: [LoginComponent],
            imports: [
                FormsModule,
                HttpModule,
                RouterTestingModule.withRoutes([]),
            ],
            providers: [
                AuthService,
                {provide: XHRBackend, useClass: MockBackend}
            ],
        }).compileComponents();
        mockBackend = TestBed.get(XHRBackend);
        mockBackend.connections.subscribe((connection: MockConnection) => {
            if (connection.request.method === RequestMethod.Post) {
                expect(connection.request.url).toEqual(authUrl);
                (connection.request.getBody() === JSON.stringify(testUser)) ?
                    connection.mockRespond(successResponse) :
                    connection.mockError(errorResponse);
            } else {
                connection.mockError(errorResponse);
            }
        });
    }));

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

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

    it('should login correctly',
        inject([Router], (router: Router) => {
            const spy = spyOn(router, 'navigateByUrl');
            const login = component.loginForm.controls['login'];
            const password = component.loginForm.controls['password'];

            login.setValue('test');
            password.setValue('test');
            fixture.detectChanges();
            component.submit();
            fixture.detectChanges();
            const navArgs = spy.calls.first().args[0];

            expect(navArgs).toEqual('/home/main');
        }));

    it('should fail login', () => {
        const login = component.loginForm.controls['login'];
        const password = component.loginForm.controls['password'];
        const errorResponse = {
            errorText: 'Unauthorized'
        };

        login.setValue('testad');
        password.setValue('testad');
        fixture.detectChanges();
        component.submit();
        fixture.detectChanges();

        expect(component.error).toEqual(errorResponse);
    });
});


We have a component that should take the login, password from the form, send it to a certain url and either click on the link 'home / main', or process and display an error.

In order not to use the stub for the service and not implement its methods, we use MockBackend, which will process requests and return predefined responses. To do this, we first declare a MockBackend, specify the url that we expect and determine the data that should be in the request:

let mockBackend: MockBackend;
const authUrl = 'login_url';
const testUser = {
        login: 'test',
        password: 'test'
    };

Next, we determine how the answers will look. Suppose that a successful request returns an empty response, and an erroneous one returns a response with status 401 and the name of the error.

const successResponse = new Response(
        new ResponseOptions({
            status: 200,
            body: ''
        })
    );
    const errorResponse = new MockError(
        new ResponseOptions({
            type: ResponseType.Error,
            status: 401,
            body: JSON.stringify({
                    status: 401,
                    title: 'Unauthorized',
                }
            )
        })
    );

Further, after configuring the test module, we get MockBackend and subscribe to the connections. We expect the POST method and match the url of the request to what we indicated above. Also, check that the request body is as expected.

mockBackend = TestBed.get(XHRBackend);
        mockBackend.connections.subscribe((connection: MockConnection) => {
            if (connection.request.method === RequestMethod.Post) {
                expect(connection.request.url).toEqual(authUrl);
                (connection.request.getBody() === JSON.stringify(testUser)) ?
                    connection.mockRespond(successResponse) :
                    connection.mockError(errorResponse);
            } else {
                connection.mockError(errorResponse);
            }
        });

All that remains to be done in the test is to get a router (since we have a transition upon a successful request), fill in the login information and verify that the transition was completed:

it('should login correctly', inject([Router], (router: Router) => {
        const spy = spyOn(router, 'navigateByUrl');
        const login = component.loginForm.controls['login'];
        const password = component.loginForm.controls['password'];

        login.setValue('test');
        password.setValue('test');
        fixture.detectChanges();
        component.submit();
        fixture.detectChanges();
        const navArgs = spy.calls.first().args[0];

        expect(navArgs).toEqual('/home/main');
    }));

Testing a component with input and output data


As you know, components can exchange data through properties with the @Input and @Output decorators . For example, like this:

Parent Component:


import { Component } from '@angular/core';
      
@Component({
    selector: 'my-app',
    template: '<child-comp [userName]="name" [userSurname]="surname"></child-comp>'
})
export class ParentComponent { 
    name = 'Some name';
    surname = 'Some surname';
}

ChildComponent:


import { Input, Component} from '@angular/core';
      
@Component({
    selector: 'child-comp',
    template: `<p>Имя пользователя: {{userName}}</p>
              <p>Фамилия пользователя: {{userSurname}}</p>`
})
export class ChildComponent{ 
    @Input() userName: string;
    @Input() userSurname:string;
}

In order for the Parent component test to run correctly, you must declare a child-component in the declarations array.

parent.component.spec.ts


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

import {ParentComponent} from './parent.component';
import {ChildComponent} from '../child/child.component';

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

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ParentComponent, ChildComponent]
    })
      .compileComponents();
  }));

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

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

And for the Child component, you must pass the input parameters. The easiest way is to manually specify their values ​​after creating the component:

child.component.spec.ts


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

import { ChildComponent } from './child.component';

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

  beforeEach(async(() => {
    TestBed.configureTestingModule({
      declarations: [ ChildComponent ]
    })
    .compileComponents();
  }));

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

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