¿Qué aprenderá?

Este tutorial lo guía, usando VSCode, en la construcción de una aplicación Angular compuesta del módulo principal y de un módulo llamado BookModule el cual declara un componente BookListComponent para desplegar un catálogo de libros.

¿Qué construirá?

Una aplicación desarrollada en el framework Angular que despliega la lista descrita en la Figura 1.

Figura 1. Aplicación que se desarrolla en este tutorial.

¿Qué necesita?

En particular Ud. debe:

  1. Saber cómo se crean módulos, componentes, servicios utilizando Angular Files en VSCode.

Clonar el proyecto

Haga un fork al proyecto base indicado en el paso anterior y clone el repositorio bifurcado en su máquina local.

Esto debe desplegar una aplicación por defecto que escucha peticiones en el puerto 4200. Para verificar abra un navegador y vaya a la URL http://localhost:4200.

Crear un nuevo módulo

Para crear el nuevo módulo utilizamos la aplicación Angular Files que está integrada dentro de VSCode. Verifique que tiene instaladas las extensiones Angular Essentials (by John Papa) y Angular Files (by Alexander Ivanichev).

Si las extensiones están instaladas vaya a la carpeta src/app, clic derecho, Generate Module. El nombre del nuevo módulo es book.

Borre el componente por defecto

Al crear el nuevo módulo book, se generó un componente por defecto BookComponent.

Para eliminar ese componente que no vamos a utilizar debe borrar los siguientes archivos:

book.component.ts

book.component.css

book.component.html

Borre las referencias a este componente en el archivo book.module.ts que debe quedar así:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
@NgModule({
  imports: [
    CommonModule
  ],
  declarations: []
})
export class BookModule { }
/* Archivo src/app/book/book.module.ts*/

Incluir el nuevo módulo en el principal

Para que la aplicación pueda utilizar el nuevo módulo, este se debe importar en el módulo principal AppModule.

Para importar en el módulo principal el módulo de BookModule debe:

Al final el archivo app.module.ts debe quedar así:

import { NgModule } from '@angular/core';
import { BrowserModule, provideClientHydration } from '@angular/platform-browser';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { BookModule } from './book/book.module';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    BookModule
  ],
  providers: [
    provideClientHydration()
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

/* archivo src/app/app.module.ts */

Crear el componente de listar

En la carpeta del módulo src/app/book, haga clic derecho Generate Component y escriba por nombre book-list.

Dentro de la carpeta src/app/book se debió crear una nueva carpeta para el componente book-list (ver Figura 2).

Figura 2. Nuevo componente generado.

Declarar y exportar el componente en el módulo

El resultado es:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { BookListComponent } from './book-list/book-list.component';

@NgModule({
 imports: [
   CommonModule
 ],
 exports: [BookListComponent],
 declarations: [BookListComponent]
})
export class BookModule { }
/* archivo src/app/book/book.module.ts*/

Invocar el componente

Para invocar el componente de los libros dentro del HTML del componente principal, tenemos que seguir los siguientes pasos:

  1. Buscamos el nombre del selector del nuevo component BookListComponent. Vamos al archivo book-list-component.ts y en el decorador del componente buscamos el valor del atributo selector. Para este caso el nombre es app-book-list.
  2. Usamos ese selector en el componente principal denominado app.component.html. Para esto borramos el contenido del archivo app.component.html y agregamos una nueva etiqueta HTML con el valor correspondiente al nombre del nuevo componente, así:
<app-book-list></app-book-list>
<!--archivo src/app/app.component.html -->

Al guardar los cambios y visualizar la página en el navegador obtenemos la salida descrita en la Figura 4. Significa que ya nuestra aplicación está invocando al componente de listar los libros y podemos seguir desarrollandolo.

src/app/book/book.module.ts

Figura 4. Salida del navegador.

El modelo se refiere a la información que será desplegada o ingresada en o por la vista del componente y que será mantenida por el componente de forma sincronizada (binding). Significa que si el modelo cambia la vista se actualiza de forma automática.

En nuestro ejemplo, debemos definir las clases de la estructura de los libros. El diseño de esta estructura, obviamente, depende de la estructura de los objetos JSON que nos va a retornar el API Rest que estamos utilizando. En nuestro caso, los objetos que retorna el API REST al solicitar Get books son los descritos en la Figura 5.

Figura 5. Objetos que retorna el API.

En este ejemplo, solo vamos a crear la representación básica de Book. Es decir, las clases que están encerradas en la línea amarilla: Book y Editorial.

Crear la clase Book

La clase Book la creamos dentro de la carpeta del módulo book. Para esto hacemos clic derecho sobre la carpeta book > Generate Class y definimos el nombre book. Esta clase define los atributos y su constructor.

import { Editorial } from "../editorial/editorial";

export class Book {
 id: number;
 name: string;
 isbn: string;
 description: string;
 image: string;
 publishingDate: any;
 editorial: Editorial;

 constructor(
   id: number,
   name: string,
   isbn: string,
   description: string,
   image: string,
   publishingDate: any,
   editorial: Editorial
 ) {
   this.id = id;
   this.name = name;
   this.isbn = isbn;
   this.description = description;
   this.image = image;
   this.publishingDate = publishingDate;
   this.editorial = editorial;
 }
}
/* archivo src/app/book/book.ts */

Crear la clase Editorial

Al crear la clase Book aparece un error que indica que no se puede encontrar el nombre Editorial; por tanto debemos crear la clase Editorial. Para hacerlo de forma ordenada, debemos crear el módulo editorial, dado que en nuestro ejemplo este es un módulo funcional distinto.

Creamos el módulo, utilizando el Angular Files, que debe quedar en su propia carpeta. Borramos la referencia al componente y actualizamos el módulo. Luego, dentro la carpeta del módulo creamos la clase Editorial. Los archivos del nuevo módulo deben quedar así:

Dentro del módulo creamos la clase Editorial, que tiene dos atributos y el correspondiente constructor:

export class Editorial {
 id: number;
 name: string;

 constructor(id: number, name: string) {
   this.id = id;
   this.name = name;
 }
}
/*archivo src/app/editorial/editorial.ts*/

No hay que olvidar asociar el módulo EditorialModule en el módulo principal AppModule quien lo debe importar:

...
import { EditorialModule } from './editorial/editorial.module';

@NgModule({
 ...
  imports: [
   ...
    EditorialModule
  ],
 ...
/*archivo src/app/app.module.ts*/

Luego actualizamos la clase Book incluyendo la referencia a la clase Editorial para corregir el error.

Asociar el modelo con el componente

Ahora que tenemos la clase que representa los libros, podemos declarar, dentro de la clase del componente book-list.component.ts un arreglo para los libros:

books: Array<Book> = []; 

Al incluir esta línea nos aparece que Book no está definido, entonces debemos importarlo:

import { Component, OnInit } from '@angular/core';
import { Book } from '../book';

@Component({
 selector: 'app-book-list',
 templateUrl: './book-list.component.html',
 styleUrls: ['./book-list.component.css']
})
export class BookListComponent implements OnInit {

 books: Array<Book> = [];
  constructor() { }

 ngOnInit() {
 }
}

Crear la clase del servicio

Vamos a crear la clase del servicio desde la carpeta book. De nuevo usamos Angular Files y seleccionamos Generate Service. Como nombre, solo escribimos book ya que Angular Files completa con la palabra Service.

En la clase BookService inyectamos en el constructor un atributo privado http de tipo HttpClient e importamos el archivo de la clase correspondiente:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';

@Injectable({
 providedIn: 'root'
})
export class BookService {

constructor(private http: HttpClient) { }

}

Antes de seguir, vamos al módulo principal AppModule e incluimos en el atributo imports del decorador del módulo, HttpClientModule para que BookService pueda usar HttpClient. El módulo debe quedar de la siguiente forma:

import { NgModule } from '@angular/core';
import { BrowserModule, provideClientHydration } from '@angular/platform-browser';

import { AppRoutingModule } from './app-routing.module';
import { AppComponent } from './app.component';
import { BookModule } from './book/book.module';
import { EditorialModule } from './editorial/editorial.module';
import { HttpClientModule } from '@angular/common/http';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    BrowserModule,
    AppRoutingModule,
    BookModule,
    EditorialModule,
    HttpClientModule
  ],
  providers: [
    provideClientHydration()
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

Configurar la URL del back-end

Para esto se necesita conocer la URL donde está el servidor que provee los libros (el back-end).

El valor de la URL de base donde se encuentra el back-end lo vamos a declarar dentro del archivo environment.development.ts que está en la carpeta environments (ver Figura 6).

Si aún no tiene la carpeta de environments puede generarla con el siguiente comando:

ng generate environments

Figura 6. Carpeta environments.

Luego de ejecutar el backend de ejemplo, este quedará desplegado en la siguiente dirección, que de acá en adelante conoceremos como la URL base (baseUrl):

http://localhost:8080/api/

De este modo, el archivo environment.development.ts quedará así:

const baseUrl = 
 'http://localhost:8080/api/';

export const environment = {
 production: false,
 baseUrl
};

Definición de la función http get en el servicio

En la clase del servicio (book.service.ts) vamos a importar la url del API definida en el archivo environment.development.ts. Para eso agregamos este import:

import { environment } from '../../environments/environment.development';

Luego creamos un atributo de clase denominado apiUrl y lo inicializamos así:

private apiUrl: string = environment.baseUrl + 'books';

En el siguiente paso declaramos una función getBooks() que va a utilizar el servicio http para invocar el http.get. Estas funciones de http retornan objetos Observable, entonces la declaración completa de la función es:

 getBooks(): Observable<Book[]> {
   return this.http.get<Book[]>(this.apiUrl);
 }

Para completar este código debemos importar el archivo de Observable y el archivo de Book.

El código completo del servicio es el siguiente:

/src/app/book/book.service.ts

import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { environment } from '../../environments/environment.development';
import { Observable } from 'rxjs';
import { Book } from './book';

@Injectable({
  providedIn: 'root'
})
export class BookService {

  private apiUrl: string = environment.baseUrl + 'books';

  constructor(private http: HttpClient) { }

  getBooks(): Observable<Book[]> {
    return this.http.get<Book[]>(this.apiUrl);
  }

}

Para completar el componente y que se puedan visualizar los libros, se debe importar el servicio book-service en el componente de book-list e invocarlo en el constructor:

...
import { BookService } from '../book.service';
...
constructor(private bookService: BookService) {}
...

Adicionalmente, se debe declarar el método getBooks(). Este método usa el servicio book.service para llamar al método getBooks() dentro de la clase del servicio con el fin de suscribirse y asignar el valor de esa lista al Array de books definido en el componente:

...  
 getBooks(): void {
    this.bookService.getBooks().subscribe((books) => {
      this.books = books;
    });
  }
...

Finalmente, se debe llamar al método getBooks() dentro del método ngOnInit() que se llama cuando se inicia el componente:

...  
 ngOnInit(): void {
    this.getBooks();
  }

Por lo tanto, el código del componente queda de la siguiente manera:

import { Component, OnInit } from '@angular/core';
import { Book } from '../book';
import { BookService } from '../book.service';

@Component({
 selector: 'app-book-list',
 templateUrl: './book-list.component.html',
 styleUrls: ['./book-list.component.css']
})
export class BookListComponent implements OnInit {

 books: Array<Book> = [];

 constructor(private bookService: BookService) { }

 getBooks(): void {
   this.bookService.getBooks().subscribe((books) => {
     this.books = books;
   });
 }

 ngOnInit() {
   this.getBooks();
 }

}

Como ya hemos explicado, la vista es el HTML asociado con el componente. El objetivo es desplegar la lista de libros en una galería con las imágenes de las portadas de los libros.

Vamos a hacer primero un despliegue muy básico de las imágenes de los libros utilizando la grilla de Bootstrap. Cada imagen irá en una columna y debajo de la imagen va el nombre del libro. Las imágenes son responsive y todo está dentro de un container-fluid.

Antes de hacer la vista es importante que instale la dependencia de bootstrap. Para esto ejecute en la terminal el comando npm install bootstrap. Luego de la instalación vaya al archivo angular.json, y en el atributo styles -línea 26- agregue el siguiente valor:

"node_modules/bootstrap/dist/css/bootstrap.min.css"

El archivo quedará así:

"tsConfig": "tsconfig.app.json",
           "assets": ["src/favicon.ico", "src/assets"],
           "styles": [
             "src/styles.css",
             "node_modules/bootstrap/dist/css/bootstrap.min.css"
           ],
           "scripts": []

Para que los cambios surtan efecto debe parar el servidor web con la combinación de teclas CTRL + C en la terminar y volver a ejecutarlo con el comando ng serve (o ng s)

Ahora veamos el código de la vista (archivo book-list.component.html), empezando solo con el HTML básico y el uso de Bootstrap. En el siguiente código HTML, tenemos un container-fluid, una fila y una columna con un margen en la parte inferior.

<div class="container-fluid">
  <div class="row">
      <div class="col mb-2">

      </div>
  </div>
</div>

Queremos crear una columna por cada libro que se encuentra en el arreglo books. Entonces, necesitamos definir un ciclo sobre la etiqueta div que define la columna para que itere sobre books y cree una nueva columna en cada iteración. También vamos a mostrar el nombre del libro en cada columna utilizando la expresión {{book.name}}

<div class="container-fluid">
  <div class="row">
      <div class="col mb-2" *ngFor="let book of books">
         {{book.name}}
      </div>
  </div>
</div>

Repasemos la sintaxis para definir un ciclo dentro del HTML utilizando las directivas de Angular:

*ngFor= "let book of books"

La directiva *ngFor es un atributo que se define en la etiqueta donde queremos iniciar el ciclo. El ciclo se termina donde se termina esa etiqueta. El valor del atributo debe definir:

Ahora vamos a incluir la imagen del libro. Hay muchas formas de hacerlo, utilizando etiquetas como figure o como card.

Vamos a crear una etiqueta card al interior de cada columna. Como se observa, se configura un padding (con la clase p-2) y un ancho y alto a cada card. Adicionalmente, dentro de card tenemos la etiqueta para la imagen, el atributo src donde define la URL de la imagen, el atributo alt contiene el texto alternativo que describe la imagen y que será mostrado cuando la imagen no pueda cargar. Finalmente, se tiene una etiqueta h5 y una etiqueta p las cuales hacen parte del cuerpo de card y sirven para mostrar el nombre del libro y su editorial respectivamente:

<div class="container-fluid">
  <div class="row">
    <div class="col mb-2" *ngFor="let book of books">
      <div class="card p-2" style="width: 15rem; height: 33rem">
        <img
          class="card-img-top"
          src="{{ book.image }}"
          alt="{{ book.name }}"
        />
        <div class="card-body">
          <h5 class="card-title">{{ book.name }}</h5>
          <p class="card-text">[{{ book.editorial.name }}]</p>
        </div>
      </div>
    </div>
  </div>
</div>

Dado que las imágenes en el back pueden tener tamaños diferentes y esto podría afectar la visualización vamos a incluir el siguiente código en el archivo book-list.component.css que deja un tamaño fijo para cada imagen:

img {
 height: 350px;
 width: auto;
 max-width: 400px;
}

Después de ejecutar el back, el nuevo despliegue es el que se observa en la Figura 7.

Figura 7. Aplicación final.

Lo siguiente será construir la prueba del componente. Para esto se crea una lista de 10 libros. Los datos de cada libro serán proporcionados por la librería faker.js. En la prueba se verifica que en la vista están presentes los siguientes elementos:

Lo primero que debe hacerse es ejecutar las pruebas por defecto que se crean al incluir los nuevos componentes. Para esto desde la terminal ejecute el comando ng test.

Al ejecutar el comando aparecerán varios errores. Para resolverlos debe realizar varios ajustes.

Lo primero es actualizar el archivo book.service.spec.ts. Como el servicio hace uso de http se debe importar el módulo HttpClientTestingModule. El archivo book.service.spec.ts quedará así:

/* tslint:disable:no-unused-variable */

import { TestBed, async, inject } from '@angular/core/testing';
import { BookService } from './book.service';
import { HttpClientTestingModule } from '@angular/common/http/testing';

describe('Service: Book', () => {
 beforeEach(() => {
   TestBed.configureTestingModule({
     imports: [HttpClientTestingModule],
     providers: [BookService]
   });
 });

 it('should ...', inject([BookService], (service: BookService) => {
   expect(service).toBeTruthy();
 }));
});

/* Archivo src/app/book/book.service.spec.ts */

Lo segundo es actualizar el archivo book-list.component.spec.ts. Como en el componente se está inyectando un servicio que hace uso de http se debe importar el módulo HttpClientModule. El archivo book-list.component.spec.ts quedará así:

/* tslint:disable:no-unused-variable */
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';

import { BookListComponent } from './book-list.component';
import { HttpClientModule } from '@angular/common/http';

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

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

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

 it('should create', () => {
   expect(component).toBeTruthy();
 });
});
/* Archivo src/app/book/book-list.component.spec.ts */

Finalmente se debe actualizar el archivo app.component.spec.ts. Dado que en el componente principal se está llamando al componente BookListComponent, que a su vez llama a un servicio se deben incluir las referencias a HttpClientModule y a BookListComponent. Adicionalmente como el contenido del componente principal se reemplazó por uno nuevo (en este caso el selector del componente de listar libros) el spec con el título "should render title" no funcionará porque el texto "base-project app is running" ya no existe en el componente; por tanto, debe borrar ese spec de la prueba.

El código del archivo app.component.spec.ts quedará así:

import { HttpClientModule } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { AppComponent } from './app.component';
import { BookListComponent } from './book/book-list/book-list.component';

describe('AppComponent', () => {
 beforeEach(async () => {
   await TestBed.configureTestingModule({
     imports: [RouterTestingModule, HttpClientModule],
     declarations: [AppComponent, BookListComponent],
   }).compileComponents();
 });

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

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

});
/* Archivo src/app/app.component.spec.ts */

Ahora, cuando ejecuta el comando ng test (o ng t) aparecerá un mensaje indicando que todas las pruebas funcionaron correctamente:

C:\Users\desca\Documents\monitoriaSW2\AngularListarBooks\revision\mynewapp>ng t
Node.js version v21.6.1 detected.
Odd numbered Node.js versions will not enter LTS status and should not be used for production. For more information, please see https://nodejs.org/en/about/previous-releases/.
√ Browser application bundle generation complete.
31 01 2024 17:53:28.797:WARN [karma]: No captured browser, open http://localhost:9876/
31 01 2024 17:53:28.875:INFO [karma-server]: Karma v6.4.2 server started at http://localhost:9876/
31 01 2024 17:53:28.880:INFO [launcher]: Launching browsers Chrome with concurrency unlimited
31 01 2024 17:53:28.890:INFO [launcher]: Starting browser Chrome
31 01 2024 17:53:30.484:INFO [Chrome 121.0.0.0 (Windows 10)]: Connected on socket BbJJeSH7XIxjDCG7AAAB with id 71495201
Chrome 121.0.0.0 (Windows 10): Executed 4 of 4 SUCCESS (0.162 secs / 0.12 secs)
TOTAL: 4 SUCCESS

Ahora que todo está funcionando correctamente se puede continuar con la codificación de la prueba para el componente de listar.

Para esto abra el archivo book-list.component.spec.ts y pegue este código:

/* tslint:disable:no-unused-variable */
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { DebugElement } from '@angular/core';
import { faker } from '@faker-js/faker';

import { BookListComponent } from './book-list.component';
import { HttpClientModule } from '@angular/common/http';
import { Editorial } from '../../editorial/editorial';
import { Book } from '../book';
import { BookService } from '../book.service';

describe('BookListComponent', () => {
 let component: BookListComponent;
 let fixture: ComponentFixture<BookListComponent>;
 let debug: DebugElement;

 beforeEach(async(() => {
   TestBed.configureTestingModule({
     imports: [HttpClientModule],
     declarations: [ BookListComponent ],
     providers: [ BookService ]
   })
   .compileComponents();
 }));

 beforeEach(() => {
   fixture = TestBed.createComponent(BookListComponent);
   component = fixture.componentInstance;

   const editorial = new Editorial(
     faker.number.int(),
     faker.lorem.sentence()
   );

   for(let i = 0; i < 10; i++) {
     const book = new Book(
       faker.number.int(),
       faker.lorem.sentence(),
       faker.lorem.sentence(),
       faker.lorem.sentence(),
       faker.image.url(),
       faker.date.past(),
       editorial,
     );
     component.books.push(book);
   }
   fixture.detectChanges();
   debug = fixture.debugElement;
 });

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

 it('should have 10 <div.col.mb-2> elements', () => {
   expect(debug.queryAll(By.css('div.col.mb-2'))).toHaveSize(10)
 });

 it('should have 10 <card.p-2> elements', () => {
   expect(debug.queryAll(By.css('div.card.p-2'))).toHaveSize(10)
 });

 it('should have 10 <img> elements', () => {
   expect(debug.queryAll(By.css('img'))).toHaveSize(10)
 });

 it('should have 10 <div.card-body> elements', () => {
   expect(debug.queryAll(By.css('div.card-body'))).toHaveSize(10)
 });

 it('should have the corresponding src to the book image and alt to the book name', () => {
   debug.queryAll(By.css('img')).forEach((img, i)=>{
     expect(img.attributes['src']).toEqual(
       component.books[i].image)

     expect(img.attributes['alt']).toEqual(
       component.books[i].name)
   })
 });

 it('should have h5 tag with the book.name', () => {
   debug.queryAll(By.css('h5.card-title')).forEach((h5, i)=>{
     expect(h5.nativeElement.textContent).toContain(component.books[i].name)
   });
 });

 it('should have p tag with the book.editorial.name', () => {
   debug.queryAll(By.css('p.card-text')).forEach((p, i)=>{
     expect(p.nativeElement.textContent).toContain(component.books[i].editorial.name)
   });
 });

 it('should have 9 <div.col.mb-2> elements and the deleted book should not exist', () => {
   const book = component.books.pop()!;
   fixture.detectChanges();
   expect(debug.queryAll(By.css('div.col.mb-2'))).toHaveSize(9)

   debug.queryAll(By.css('div.col.mb-2')).forEach((selector, i)=>{
     expect(selector.nativeElement.textContent).not.toContain(book.name);
   });
 });

});

Analicemos todos los elementos de la prueba. Se inicia con la configuración. Para esto se usa el helper asincrónico beforeEach, el cual se ejecuta antes de cada spec. En este helper se incluyen las importaciones de la prueba (imports), el componente implicado en la prueba (declarations) y el servicio que usa el componente (providers).

beforeEach(async(() => {
   TestBed.configureTestingModule({
     imports: [HttpClientModule],
     declarations: [ BookListComponent ],
     providers: [ BookService ]
   })
   .compileComponents();
 }));

Luego se hace la configuración del helper sincrónico beforeEach. Se inicia seteando la variable fixture.

En el contexto de Angular la variable fixture contiene el componente y proporciona una interfaz conveniente para la prueba de esa instancia del Componente y de su DOM.

Para hacer referencia a la instancia del componente se usa la propiedad componentInstance la cual se setea en la variable component.

Luego se crea una constante editorial a la cual se le setean datos ficticios proporcionados por la libería faker. Esa librería se instala con el siguiente comando:

 npm install @faker-js/faker --save-dev

Luego, con ayuda de una iteración se crean 10 libros con datos aleatorios generados también por la librería faker. A cada libro se le setea la editorial creada previamente. Cada libro se agrega al listado de libros (component.books) que ha sido definido en el componente.

beforeEach(() => {
   fixture = TestBed.createComponent(BookListComponent);
   component = fixture.componentInstance;

   const editorial = new Editorial(
     faker.number.int(),
     faker.lorem.sentence()
   );

   for(let i = 0; i < 10; i++) {
     const book = new Book(
       faker.number.int(),
       faker.lorem.sentence(),
       faker.lorem.sentence(),
       faker.lorem.sentence(),
       faker.image.url(),
       faker.date.past(),
       editorial,
     );
     component.books.push(book);
   }
   fixture.detectChanges();
   debug = fixture.debugElement;
 });

Ahora analicemos las pruebas que se han definido. Recordemos que en la vista se hace una iteración usando la directiva *ngFor. Esta iteración se hará dependiendo del número de libros que haya en la variable books de componente. Por cada iteración se crea un div que contiene las clases col y mb-2:

<div *ngFor="let book of books" class="col mb-2">

Por tanto, en la primera prueba verificaremos que en la vista del componente aparezcan 10 contenedores (div) que incluyan las clases col y mb-2.

Para esto necesitamos acceder a los elementos del DOM. Angular tiene una abstracción denominada debugElement que envuelve el elemento DOM nativo. Por tanto, usamos la propiedad debugElement del accesorio (fixture) la cual se setea en la variable debug.

Con esta variable podemos hacer queries al DOM. Cada instancia de DebugElement provee los métodos query y queryAll para encontrar elementos que cumplen determinada condición. El método query devuelve el primer elemento que cumple una condición. El método queryAll devuelve una colección de todos los elementos coincidentes.

Las condiciones que se pasan como parámetros a los métodos query y queryAll se especifican usando selectores CSS. Recordemos que existen diferentes selectores: de tipo, de id, de clase, entre otros. Por ejemplo, la condición By.css(‘div') usa el selector de tipo div. Una condición como By.css(‘.col') usa un selector de clase, mientras que una condición como By.css(‘#main-element') usa un selector de id.

Las condiciones pueden ser más complejas y combinar varios tipos de selectores. En este documento puede consultar una tabla de referencia de los selectores CSS:

https://developer.mozilla.org/es/docs/Learn/CSS/Building_blocks/Selectors#tabla_de_referencia_de_selectores

Para la prueba que queremos construir necesitamos obtener todos los contenedores que tengan las clases col y mb-2, por eso, la condición tiene la siguiente forma:

debug.queryAll(By.css('div.col.mb-2')

Recordemos que queryAll retorna un arreglo con todos los elementos que cumplan la condición. Si todo está correcto se espera que el arreglo tenga un total de 10 elementos, así que ese será el expect que se debe especificar. Por tanto, la prueba quedará de la siguiente forma:

it('should have 10 <div.col.mb-2> elements', () => {
   expect(debug.queryAll(By.css('div.col.mb-2'))).toHaveSize(10)
 });

En la vista del componente tendremos por cada libro una tarjeta de bootstrap. Esta tarjeta se representa por un contenedor con las clases card y p-2.

<div class="card p-2" style="width: 15rem; height: 33rem">

Esa será entonces la siguiente prueba que especificaremos.

it('should have 10 <card.p-2> elements', () => {
   expect(debug.queryAll(By.css('div.card.p-2'))).toHaveSize(10)
 });

Cada libro tiene una imagen asociada. Esto implica que habrá en la vista un total de 10 etiquetas img (una por cada libro).

<img
 class="card-img-top"
 src="{{ book.image }}"
 alt="{{ book.name }}"
/>

A nivel de prueba esto se especifica de la siguiente forma:

it('should have 10 <img> elements', () => {
   expect(debug.queryAll(By.css('img'))).toHaveSize(10)
 });

Por cada libro tendremos también un contenedor en donde se renderizarán el nombre del libro y la editorial. El contenedor tiene asociada la clase card-body.

<div class="card-body">

Esta es la prueba:

it('should have 10 <div.card-body> elements', () => {
   expect(debug.queryAll(By.css('div.card-body'))).toHaveSize(10)
 });

Hasta acá hemos verificado la estructura del componente. No obstante, también debemos verificar que los datos específicos de cada libro se muestran de forma correcta en el componente.

Hemos indicado en la vista que para la imagen de cada libro en el atributo src se seteará el contenido de la variable book.image y que en el atributo alt se seteará el contenido de la variable book.name.

Esta es las prueba para verificar esa información:

it('should have the corresponding src to the book image and alt to the book name', () => {
   debug.queryAll(By.css('img')).forEach((img, i)=>{
     expect(img.attributes['src']).toEqual(
       component.books[i].image)

     expect(img.attributes['alt']).toEqual(
       component.books[i].name)
   })
 });

En la vista el nombre de cada libro aparece como un título de quinto nivel con la clase card-title:

<h5 class="card-title">{{ book.name }}</h5>

Esta es la prueba para verificar la correcta renderización del nombre del libro:

it('should have h5 tag with the book.name', () => {
   debug.queryAll(By.css('h5.card-title')).forEach((h5, i)=>{
     expect(h5.nativeElement.textContent).toContain(component.books[i].name)
   });
 });

La editorial de cada libro aparece como el contenido de un párrafo con la clase card-text:

<p class="card-text">[{{ book.editorial.name }}]</p>

Este es el contenido de la prueba:

it('should have p tag with the book.editorial.name', () => {
   debug.queryAll(By.css('p.card-text')).forEach((p, i)=>{
     expect(p.nativeElement.textContent).toContain(component.books[i].editorial.name)
   });
 });

Finalmente, queremos comprobar que si se elimina un elemento del arreglo books del componente, la vista se actualiza con ese cambio.

Iniciamos la prueba eliminando un elemento de arreglo; actualizamos la vista con el método detectChanges del objeto fixture. Ahora el número de cards debe pasar de 10 a 9 y no puede existir un elemento en el DOM que contenga el nombre del libro eliminado.

it('should have 9 <div.col.mb-2> elements and the deleted book should not exist', () => {
   const book = component.books.pop()!;
   fixture.detectChanges();
   expect(debug.queryAll(By.css('div.col.mb-2'))).toHaveSize(9)

   debug.queryAll(By.css('div.col.mb-2')).forEach((selector, i)=>{
     expect(selector.nativeElement.textContent).not.toContain(book.name);
   });
 });

Cuando se ejecuta el comando ng t se puede verificar que las pruebas se han ejecutado correctamente:

SUMMARY:
✔ 12 tests completed
TOTAL: 12 SUCCESS
TOTAL: 12 SUCCESS