¿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.

En este punto nos vamos a enfocar en la implementación de pruebas End to End (E2E) utilizando Cypress.

Te recomendamos hacer este tutorial básico de Cypress antes de continuar: https://misovirtual.virtual.uniandes.edu.co/codelabs/ISIS2603_Angular_PruebasE2E/index.html#0

Si deseas información complementaria de Cypress, te sugerimos revisarla acá: https://www.cypress.io/

Ahora sí, continuemos con nuestra prueba la cual obtendrá dinámicamente los datos desde el backend mediante una solicitud GET a http://localhost:8080/api/books. La prueba hace lo siguiente:

Antes de comenzar, asegúrate de tener:

El comando creará en el proyecto una nueva carpeta denominada Cypress la cual contiene:

En la carpeta `cypress/e2e`, crea un archivo llamado `book-list.cy.ts` y agrega las siguientes pruebas:

describe('Prueba Book List', () => {
  beforeEach(() => {
    cy.visit('/')
  })

  let booksFromBackend: string | any[] = [];
 
    it('get back',()=> {
      cy.request('GET', 'http://localhost:8080/api/books')
        .then((response) => {
          expect(response.status).to.eq(200);
          booksFromBackend = response.body;
          cy.log(`Se obtuvieron ${booksFromBackend.length} libros del backend`);
        });
    });

 
    it('should display the book list', () => {
      cy.get('app-book-list').should('exist');
      cy.log('La página de libros cargó correctamente');
  });
 
 
    it('should display the correct number of books', () => {
      cy.get('div.col.mb-2')
        .should('have.length', booksFromBackend.length)
        .then(() => {
          cy.log(`Se muestran ${booksFromBackend.length} libros en la vista`);
        });
    });

    it('should have the correct number of <div.card.p-2> elements', () => {
        cy.get('div.card.p-2').should('have.length', booksFromBackend.length);
      });
 
      it('should have the correct number of <img> elements', () => {
        cy.get('img').should('have.length', booksFromBackend.length);
      });
 
      it('should have the correct number of <div.card-body> elements', () => {
        cy.get('div.card-body').should('have.length', booksFromBackend.length);
      });
 
    it('should display correct image src and alt attributes', () => {
      cy.get('img').each(($img, index) => {
        cy.wrap($img)
          .should('have.attr', 'src', booksFromBackend[index].image)
          .should('have.attr', 'alt', booksFromBackend[index].name)
          .then(() => {
            cy.log(`Imagen correcta para el libro: ${booksFromBackend[index].name}`);
          });
      });
    });
 
    it('should have h5 tag with the book.name', () => {
      cy.get('h5.card-title').each(($title, index) => {
        cy.wrap($title)
          .should('contain.text', booksFromBackend[index].name)
          .then(() => {
            cy.log(`Título correcto: ${booksFromBackend[index].name}`);
          });
      });
    });
 
    it('should have p tag with the book.editorial.name', () => {
      cy.get('p.card-text').each(($p, index) => {
        cy.wrap($p)
          .should('contain.text', booksFromBackend[index].editorial.name)
          .then(() => {
            cy.log(`Editorial correcta para el libro ${booksFromBackend[index].name}: ${booksFromBackend[index].editorial.name}`);
          });
      });
    });
 
    //No se pueden borrar libros si tiene autor asociado
    it('should correctly update the book list if a book is removed', () => {
      cy.request('GET', 'http://localhost:8080/api/books')
        .then((response) => {
          expect(response.status).to.eq(200);
          const books = response.body;
 
          cy.log(`Total de libros antes de eliminar: ${books.length}`);
 
          // Filtrar los libros que no tienen autores
          const bookWithoutAuthors = books.find((book: { authors: string | any[]; }) => !book.authors || book.authors.length === 0);
 
          if (!bookWithoutAuthors) {
            cy.log('No hay libros sin autores disponibles para eliminar');
            return;
          }
 
          cy.log(`Eliminando el libro: ${bookWithoutAuthors.name} (ID: ${bookWithoutAuthors.id})`);
 
          // Intentamos eliminar el libro sin autores
          cy.request({
            method: 'DELETE',
            url: `http://localhost:8080/api/books/${bookWithoutAuthors.id}`,
            failOnStatusCode: false,
          }).then((deleteResponse) => {
            expect(deleteResponse.status).to.be.oneOf([200, 204]);
 
            cy.log(`Libro eliminado con éxito: ${bookWithoutAuthors.name}`);
 
            //Verificamos que el libro ya no esté en la lista
            cy.reload();
            cy.request('GET', 'http://localhost:8080/api/books')
              .then((updatedResponse) => {
                expect(updatedResponse.status).to.eq(200);
                const updatedBooks = updatedResponse.body;
 
                cy.log(`Total de libros después de eliminar: ${updatedBooks.length}`);
 
                cy.get('div.col.mb-2').should('have.length', updatedBooks.length);
 
                // Verificar que el libro eliminado ya no está en la vista
                cy.get('div.col.mb-2').each(($book) => {
                  cy.wrap($book).should('not.contain.text', bookWithoutAuthors.name);
                });
              });
          });
        });
    });
})


/* Archivo cypress/e2e/book-list.cy.ts */

Cypress permite interactuar con el DOM usando selectores CSS, similar a como lo hace Angular con By.css(). Utilizamos cy.get(selector) para seleccionar elementos y .should(condición) para hacer afirmaciones sobre ellos.

Para ejecutar la prueba debes:

Ahora desde VSCode, haz clic en la prueba ‘book-list' (como se ve en la captura que sigue) y ejecútala.

Ahora, analicemos todos los elementos de la prueba.

Luego de inicializar la configuración, se usa beforeEach, el cual se ejecuta antes de cada prueba. Aquí, Cypress visita la página de localhost (asignada como baseUrl en el archivo cypress.config.ts) para asegurarse de que estamos en el contexto correcto.

 beforeEach(() => {
    cy.visit('/')
  })

La primera prueba que se realiza consiste en hacer una petición GET a http://localhost:8080/api/books para obtener la lista de libros. Se verifica que la respuesta tenga un código 200 (éxito). Se guarda la lista de libros en booksFromBackend para usarla en las pruebas y se muestra un mensaje en el log de Cypress con la cantidad de libros obtenidos.

it('get back',()=> {
      cy.request('GET', 'http://localhost:8080/api/books')
        .then((response) => {
          expect(response.status).to.eq(200);
          booksFromBackend = response.body;
          cy.log(`Se obtuvieron ${booksFromBackend.length} libros del backend`);
        });
    });

Ahora analicemos las demás 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 del 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 el componente de listar libros esté presente en la vista:

 it('should display the book list', () => {
      cy.get('app-book-list').should('exist');
      cy.log('La página de libros cargó correctamente');
    });

Para la segunda prueba, vamos a verificar que se muestra la cantidad de libros correctos. Para esto la prueba busca todos los elementos <div class="col mb-2"> en la interfaz (cada uno representa un libro) y verifica que su número coincida con la cantidad de libros obtenidos del backend.

it('should display the correct number of books', () => {
      cy.get('div.col.mb-2')
        .should('have.length', booksFromBackend.length)
        .then(() => {
          cy.log(`Se muestran ${booksFromBackend.length} libros en la vista`);
        });
    });

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 a continuación:

it('should have the correct number of <div.card.p-2> elements', () => {
        cy.get('div.card.p-2').should('have.length', booksFromBackend.length);
      });

Además, cada libro tiene una imagen asociada. Esto implica que habrá en la vista etiquetas img (una por cada libro).

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

La prueba se ve de la siguiente forma:

it('should have the correct number of <img> elements', () => {
        cy.get('img').should('have.length', booksFromBackend.length);
      });

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">

Esa será entonces la siguiente prueba que especificaremos.

it('should have the correct number of <div.card-body> elements', () => {
        cy.get('div.card-body').should('have.length', booksFromBackend.length);
      });

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 asignará el contenido de la variable book.image y que en el atributo alt se asignará el contenido de la variable book.name.

La prueba que sigue verifica esa información. Nota que se recorre cada imagen y se verifica que su atributo src (URL de la imagen) coincida con el dato del backend y que el atributo alt contenga el nombre del libro:

it('should display correct image src and alt attributes', () => {
      cy.get('img').each(($img, index) => {
        cy.wrap($img)
          .should('have.attr', 'src', booksFromBackend[index].image)
          .should('have.attr', 'alt', booksFromBackend[index].name)
          .then(() => {
            cy.log(`Imagen correcta para el libro: ${booksFromBackend[index].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>

Además, 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>

Estas son las pruebas para verificar que los títulos y las editoriales coincidan con los datos del backend:

 it('should have h5 tag with the book.name', () => {
      cy.get('h5.card-title').each(($title, index) => {
        cy.wrap($title)
          .should('contain.text', booksFromBackend[index].name)
          .then(() => {
            cy.log(`Título correcto: ${booksFromBackend[index].name}`);
          });
      });
    });
  
    it('should have p tag with the book.editorial.name', () => {
      cy.get('p.card-text').each(($p, index) => {
        cy.wrap($p)
          .should('contain.text', booksFromBackend[index].editorial.name)
          .then(() => {
            cy.log(`Editorial correcta para el libro ${booksFromBackend[index].name}: ${booksFromBackend[index].editorial.name}`);
          });
      });
    });

Finalmente, queremos comprobar que si se elimina un elemento del arreglo books del componente, la vista se actualiza con ese cambio. La siguiente prueba verifica que la eliminación de libros en la aplicación funciona correctamente y que la lista se actualiza después de eliminar un libro sin autores (ya que esa es la regla establecida en el backend). Primero, se hace una petición GET al backend para obtener la lista de libros y se busca uno que no tenga autores asociados, ya que los libros con autores no pueden ser eliminados. Si no se encuentra un libro elegible, la prueba finaliza con un mensaje. Si se encuentra, se envía una petición DELETE para eliminarlo y se verifica que la respuesta del servidor tenga un estado 200 o 204, indicando éxito. Luego, se recarga la página y se hace una nueva petición GET para obtener la lista de libros actualizada, asegurando que la cantidad de libros mostrada en la interfaz coincida con la nueva lista del backend. También se comprueba que el libro eliminado ya no aparezca en la vista.

it('should correctly update the book list if a book is removed', () => {
      cy.request('GET', 'http://localhost:8080/api/books')
        .then((response) => {
          expect(response.status).to.eq(200);
          const books = response.body;
  
          cy.log(`Total de libros antes de eliminar: ${books.length}`);
  
          // Filtrar los libros que no tienen autores
          const bookWithoutAuthors = books.find((book: { authors: string | any[]; }) => !book.authors || book.authors.length === 0);
  
          if (!bookWithoutAuthors) {
            cy.log('No hay libros sin autores disponibles para eliminar');
            return;
          }
  
          cy.log(`Eliminando el libro: ${bookWithoutAuthors.name} (ID: ${bookWithoutAuthors.id})`);
  
          // Intentamos eliminar el libro sin autores
          cy.request({
            method: 'DELETE',
            url: `http://localhost:8080/api/books/${bookWithoutAuthors.id}`,
            failOnStatusCode: false,
          }).then((deleteResponse) => {
            expect(deleteResponse.status).to.be.oneOf([200, 204]);
  
            cy.log(`Libro eliminado con éxito: ${bookWithoutAuthors.name}`);
  
            //Verificamos que el libro ya no esté en la lista
            cy.reload();
            cy.request('GET', 'http://localhost:8080/api/books')
              .then((updatedResponse) => {
                expect(updatedResponse.status).to.eq(200);
                const updatedBooks = updatedResponse.body;
  
                cy.log(`Total de libros después de eliminar: ${updatedBooks.length}`);
  
                cy.get('div.col.mb-2').should('have.length', updatedBooks.length);
  
                // Verificar que el libro eliminado ya no está en la vista
                cy.get('div.col.mb-2').each(($book) => {
                  cy.wrap($book).should('not.contain.text', bookWithoutAuthors.name);
                });
              });
          });
        });
    });