Lesson 10-Angular Performance Optimization and Testing

Angular Performance Optimization

Ahead-of-Time (AOT) Compilation

AOT compilation occurs during the build phase, unlike Just-in-Time (JIT) compilation, which happens at runtime. AOT offers several advantages:

  • Reduced Startup Time: The browser doesn’t need to parse and compile templates at runtime.
  • Smaller JavaScript Bundle Size: Compiled code is more compact.
  • Improved Runtime Performance: Compiled code executes directly without additional compilation.

Enabling AOT Compilation

In the Angular CLI, enable AOT by modifying the angular.json file’s build configuration:

"architect": {
  "build": {
    "options": {
      "aot": true
    }
  }
}

Run ng build --prod or ng build --configuration=production to build your application with AOT compilation.

Lazy Loading Modules

Lazy loading is an optimization technique that loads modules and components on demand during runtime, rather than loading the entire application at startup. This significantly reduces initial load time and memory usage.

Implementing Lazy Loading

  1. Create Feature Modules: Generate a separate module for each major feature.
   ng generate module feature --route feature
  1. Configure Routes: In app-routing.module.ts, use the loadChildren directive to lazily load modules.
   const routes: Routes = [
     { path: 'feature', loadChildren: () => import('./feature/feature.module').then(m => m.FeatureModule) },
     // Other routes...
   ];
  1. Update HTML: In app.component.html, use the <router-outlet> tag to render lazily loaded modules.
   <router-outlet></router-outlet>

Change Detection Strategies

Change detection is Angular’s mechanism for detecting and updating the view. By default, Angular uses a “dirty-checking” mechanism, which can cause performance issues in complex applications.

  • Default Change Detection Angular’s default strategy is ChangeDetectionStrategy.Default, where the framework checks the entire component tree on every event, timer, or asynchronous operation.
  • OnPush Strategy Setting changeDetection to ChangeDetectionStrategy.OnPush instructs Angular to check a component only when its input properties change or it receives an event, reducing unnecessary view updates.
  import { Component, ChangeDetectionStrategy } from '@angular/core';

  @Component({
    selector: 'app-my-component',
    template: `...`,
    changeDetection: ChangeDetectionStrategy.OnPush
  })
  export class MyComponent {
    // ...
  }

Optimizing Change Detection

  • Avoid Unnecessary Input Changes: Only update input properties when necessary.
  • Use Pure Pipes: Mark pipes as pure to prevent unnecessary recalculations.
  • Leverage ngDoCheck: Use the ngDoCheck lifecycle hook for custom change detection logic when component state depends on non-input properties.

Combining AOT, Lazy Loading, and Change Detection

Using AOT compilation, lazy loading, and optimized change detection strategies together can significantly enhance Angular application performance. AOT reduces runtime compilation overhead, lazy loading minimizes initial load time, and optimized change detection reduces unnecessary view updates.

Code Splitting and Lazy Loading Components

Code splitting divides application code into smaller bundles loaded on demand, improving initial load time and user experience.

Implementing Code Splitting and Lazy Loading

  1. Create Feature Modules: Generate a module for each major feature to enable code splitting.
   ng generate module feature --route feature
  1. Configure Routes: In app-routing.module.ts, use loadChildren for dynamic module loading.
   const routes: Routes = [
     { path: 'feature', loadChildren: () => import('./feature/feature.module').then(m => m.FeatureModule) },
     // Other routes...
   ];
  1. Update HTML: Use <router-outlet> in app.component.html to render lazily loaded modules.
   <router-outlet></router-outlet>
  1. Optimize Lazy Loading: Ensure lazy-loaded modules include only necessary components and services. Avoid importing globally shared modules like CommonModule, which can lead to duplicate code loading.

Image Optimization

Images are often the largest resources on a webpage, so optimizing them can significantly improve load times.

Image Optimization Strategies

  • Use Appropriate Formats: Choose formats like JPEG, PNG, SVG, or WebP to balance file size and quality.
  • Compress Images: Use tools like TinyPNG, ImageOptim, or online services to reduce image file sizes.
  • Size Appropriately: Match image dimensions to their display size, avoiding CSS scaling to prevent unnecessary downloads.
  • Lazy Load Images: Delay loading images outside the viewport until users scroll to them.
  <img src="placeholder.jpg" data-src="actual-image.jpg" class="lazy-load">

Implement lazy loading in TypeScript using a directive or libraries like ngx-lazy-load:

import { Directive, ElementRef, HostListener } from '@angular/core';

@Directive({
  selector: '[appLazyLoad]'
})
export class LazyLoadDirective {
  constructor(private el: ElementRef) {}

  @HostListener('window:scroll', ['$event'])
  onScroll(event) {
    const rect = this.el.nativeElement.getBoundingClientRect();
    if (rect.top <= window.innerHeight && rect.bottom >= 0) {
      const img = this.el.nativeElement;
      img.src = img.getAttribute('data-src');
      img.removeAttribute('data-src');
    }
  }
}

Use the directive in the template:

<img src="placeholder.jpg" [appLazyLoad]="true" data-src="actual-image.jpg">

Angular Testing

Unit Testing with Jest

Installing and Configuring Jest

Jest, developed by Facebook, is a JavaScript testing framework known for its fast execution and rich feature set. To use Jest, install it and configure it for Angular.

Install Jest

npm install --save-dev jest @testing-library/jest-dom @testing-library/angular @types/jest ts-jest

Configure Jest

Edit or create jest.config.js in the project root to configure Jest’s environment and transformers:

module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/setupTests.js'],
};

Create setupTests.js to initialize the Angular testing environment:

import '@testing-library/jest-dom';
import { configureTestingModule } from './testing.module'; // Custom testing module configuration

beforeEach(() => {
  configureTestingModule();
});

Writing Component Tests

Testing Angular components with Jest involves mocking services, rendering components, and interacting with them.

Component Test Example

Consider a MyComponent that depends on a DataService:

// my.component.ts
import { Component } from '@angular/core';
import { DataService } from '../data.service';

@Component({
  selector: 'app-my-component',
  template: `
    <p>{{ data }}</p>
    <button (click)="fetchData()">Fetch Data</button>
  `,
})
export class MyComponent {
  data: string;

  constructor(private dataService: DataService) {}

  fetchData() {
    this.dataService.getData().subscribe(res => {
      this.data = res;
    });
  }
}

Writing Tests

In my.component.spec.ts, write the tests:

import { render, screen, fireEvent } from '@testing-library/angular';
import { MyComponent } from './my.component';
import { of } from 'rxjs';
import { DataService } from '../data.service';

describe('MyComponent', () => {
  const mockDataService = {
    getData: () => of('Test Data'),
  };

  beforeEach(async () => {
    await render(MyComponent, {
      providers: [{ provide: DataService, useValue: mockDataService }],
    });
  });

  it('displays data after fetching', async () => {
    const button = screen.getByRole('button');
    fireEvent.click(button);
    expect(await screen.findByText(/Test Data/i)).toBeInTheDocument();
  });
});

Unit Testing with Karma

Installing and Configuring Karma

Karma is the default testing framework supported by Angular CLI, ideal for unit and integration testing in Angular projects.

Install Karma

If using an Angular CLI-generated project, Karma is pre-installed.

Configure Karma

Configure Karma in karma.conf.js:

module.exports = function(config) {
  config.set({
    basePath: '',
    frameworks: ['jasmine', '@angular-devkit/build-angular'],
    files: [
      { pattern: './src/test.ts', watched: false },
    ],
    preprocessors: {
      './src/test.ts': ['webpack', 'sourcemap'],
    },
    webpack: {
      mode: 'development',
      devtool: 'inline-source-map',
      module: {
        rules: [
          {
            test: /\.ts$/,
            use: [
              {
                loader: 'ts-loader',
                options: {
                  compilerOptions: require('./tsconfig.spec.json').compilerOptions,
                },
              },
            ],
            exclude: /node_modules/,
          },
        ],
      },
    },
    reporters: ['progress'],
    port: 9876,
    colors: true,
    logLevel: config.LOG_INFO,
    autoWatch: true,
    browsers: ['Chrome'],
    singleRun: false,
    restartOnFileChange: true,
  });
};

Writing Service Tests

Service unit tests typically involve mocking external dependencies and verifying method behavior.

Service Test Example

Consider a DataService that fetches data from an API:

// data.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Injectable({
  providedIn: 'root'
})
export class DataService {
  constructor(private http: HttpClient) {}

  getData() {
    return this.http.get('https://api.example.com/data');
  }
}

Writing Tests

In data.service.spec.ts, write the tests:

import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { DataService } from './data.service';

describe('DataService', () => {
  let httpMock: HttpTestingController;
  let service: DataService;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [DataService],
    });
    httpMock = TestBed.inject(HttpTestingController);
    service = TestBed.inject(DataService);
  });

  it('should fetch data from the API', () => {
    const testData = { message: 'Hello, World!' };
    service.getData().subscribe(data => {
      expect(data).toEqual(testData);
    });

    const req = httpMock.expectOne('https://api.example.com/data');
    expect(req.request.method).toBe('GET');
    req.flush(testData);
  });
});

End-to-End Testing with Protractor

Protractor is an end-to-end testing framework for Angular and AngularJS applications, built on WebDriverJS. It provides APIs to simulate user interactions like clicking buttons, filling forms, and asserting page elements.

Installing Protractor

Ensure Node.js and npm are installed, then install Protractor and dependencies:

npm install -g protractor
npm install --save-dev protractor @types/protractor

Configuring Protractor

Protractor requires a configuration file. Create protractor.conf.js in the project root:

exports.config = {
  allScriptsTimeout: 11000,
  specs: [
    './e2e/**/*.e2e-spec.ts'
  ],
  capabilities: {
    'browserName': 'chrome'
  },
  directConnect: true,
  baseUrl: 'http://localhost:4200/',
  framework: 'jasmine',
  jasmineNodeOpts: {
    showColors: true,
    defaultTimeoutInterval: 30000,
    print: function() {}
  }
};

Writing End-to-End Tests

End-to-end tests focus on complete application workflows from a user’s perspective. Below is an example testing a login flow.

Test File Structure

Create test files in the e2e directory, e.g., login.e2e-spec.ts.

Test Example

import { browser, by, element } from 'protractor';

describe('Login E2E Test', function() {
  beforeEach(() => {
    browser.get('/login');
  });

  it('should login with valid credentials', () => {
    element(by.model('username')).sendKeys('testuser');
    element(by.model('password')).sendKeys('testpass');
    element(by.id('login-button')).click();

    browser.wait(() => element(by.css('.welcome-message')).isPresent(), 10000);

    expect(element(by.css('.welcome-message')).getText()).toEqual('Welcome, testuser!');
  });

  it('should not login with invalid credentials', () => {
    element(by.model('username')).sendKeys('invaliduser');
    element(by.model('password')).sendKeys('invalidpass');
    element(by.id('login-button')).click();

    browser.wait(() => element(by.css('.error-message')).isPresent(), 10000);

    expect(element(by.css('.error-message')).getText()).toEqual('Invalid username or password.');
  });
});

Running End-to-End Tests

Ensure the Angular application is running, then execute:

protractor ./protractor.conf.js

Advanced Techniques

  • Page Object Pattern: Organize and reuse code by creating a class for each page, encapsulating elements and actions.
  // login.po.ts
  export class LoginPage {
    navigateTo() {
      return browser.get('/');
    }

    setUsername(username) {
      return element(by.model('username')).sendKeys(username);
    }

    setPassword(password) {
      return element(by.model('password')).sendKeys(password);
    }

    clickLoginButton() {
      return element(by.id('login-button')).click();
    }

    getErrorMessage() {
      return element(by.css('.error-message')).getText();
    }
  }

Use the page object in tests:

  import { LoginPage } from '../pages/login.po';

  describe('Login E2E Test', function() {
    let loginPage: LoginPage;

    beforeEach(() => {
      loginPage = new LoginPage();
      loginPage.navigateTo();
    });

    it('should not login with invalid credentials', () => {
      loginPage.setUsername('invaliduser');
      loginPage.setPassword('invalidpass');
      loginPage.clickLoginButton();

      browser.wait(() => loginPage.getErrorMessage().then(text => text !== ''), 10000);

      expect(loginPage.getErrorMessage()).toEqual('Invalid username or password.');
    });
  });
  • Test Data Management: Use external files (e.g., JSON) to manage test data for flexibility and maintainability.
  • Parallel Testing: Leverage Protractor’s multi-browser and parallel testing capabilities to run tests simultaneously, speeding up the process.

End-to-End Testing with Cypress

Cypress is a modern end-to-end testing framework offering rich features and an intuitive API for testing web applications. Its real-time test rerun, time-travel debugging, and network request interception make it ideal for Angular end-to-end testing.

Installing Cypress

Ensure Node.js and npm are installed, then install Cypress in the project root:

npm install cypress --save-dev

Initializing Cypress

Run Cypress to initialize it and create necessary configuration files:

npx cypress open

This launches Cypress’s GUI, where you can run tests, view results, and configure settings.

Configuring Cypress

Cypress uses a cypress.json file for configuration. Create or edit it in the project root:

{
  "baseUrl": "http://localhost:4200",
  "defaultCommandTimeout": 10000,
  "pageLoadTimeout": 20000,
  "video": true,
  "videoUploadOnPasses": false
}

Writing End-to-End Tests

Cypress uses Mocha as its test runner and Chai for assertions. Tests are typically stored in the cypress/integration directory.

Example: Login Test

Create a test file, e.g., login_spec.js:

describe('Login page', () => {
  beforeEach(() => {
    cy.visit('/login');
  });

  it('logs in with correct credentials', () => {
    cy.get('#username').type('testuser');
    cy.get('#password').type('testpass{enter}');

    cy.url().should('include', '/dashboard');
    cy.contains('Welcome, testuser!').should('be.visible');
  });

  it('displays error for incorrect credentials', () => {
    cy.get('#username').type('wronguser');
    cy.get('#password').type('wrongpass{enter}');

    cy.contains('Invalid username or password').should('be.visible');
  });
});

Running Tests

Run tests via Cypress’s GUI or the command line:

npx cypress run

Advanced Features

  • Time-Travel Debugging: Cypress’s debugging tool allows tracing back every test action, aiding in issue identification.
  • Network Request Interception: Use cy.intercept() to mock server responses, useful for testing frontend apps reliant on backend APIs.
  • Test Isolation: Use beforeEach() and afterEach() hooks to set up and clean test environments, ensuring test independence.

Integration with CI/CD

Integrate Cypress tests into CI/CD pipelines. For example, add a job in .gitlab-ci.yml to run Cypress tests:

stages:
  - test

cypress:
  stage: test
  script:
    - npx cypress run
Share your love