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.
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. |
En particular Ud. debe:
Haga un fork al proyecto base indicado en el paso anterior y clone el repositorio bifurcado en su máquina local.
node_modules
en el nivel del root borrela.code .
npm install
desde la carpeta principal del proyectong s
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
.
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
.
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*/
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:
src/app/app.module.ts
@NgModule
e incluir en el arreglo del atributo imports
el nombre del módulo, es decir, de la clase BookModule
.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 */
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. |
BookModule
(archivo book.module.ts
) y agregue en el decorador, en el atributo declarations, el nombre del nuevo componente que en este caso es BookListComponent
. exports
cuyo valor es un arreglo que contiene el nombre del nuevo componente (lo exportamos porque lo vamos a utilizar desde el componente principal de la aplicación).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*/
Para invocar el componente de los libros dentro del HTML del componente principal, tenemos que seguir los siguientes pasos:
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
. 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
.
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 */
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.
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() {
}
}
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 { }
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
};
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:
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:
book
books
que debe estar obligatoriamente definida en la clase del componente (en este caso, BookListComponent
).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:
card p-2
.card-body
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