feat: enhance tests with router, HTTP client, and animations support

This commit is contained in:
Jesus Navalon
2026-07-12 22:40:48 +02:00
parent cd2ddd3cb0
commit a66af1d056
11 changed files with 478 additions and 113 deletions
+23
View File
@@ -8,6 +8,7 @@
"name": "mobile-store",
"version": "0.0.0",
"dependencies": {
"@angular/animations": "v20-lts",
"@angular/common": "v20-lts",
"@angular/compiler": "v20-lts",
"@angular/core": "v20-lts",
@@ -681,6 +682,20 @@
"typescript": "*"
}
},
"node_modules/@angular/animations": {
"version": "20.3.26",
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.26.tgz",
"integrity": "sha512-hfNrX19v8xs/usNkELSqc6q5IwBfS2GGW8sQ4OMpxAmLZDJwaLUxkj48t8VYhUFezsIfzR+sDEi6ZQBOaMIYug==",
"dependencies": {
"tslib": "^2.3.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@angular/core": "20.3.26"
}
},
"node_modules/@angular/build": {
"version": "20.3.32",
"resolved": "https://registry.npmjs.org/@angular/build/-/build-20.3.32.tgz",
@@ -10824,6 +10839,14 @@
"@angular-eslint/bundled-angular-compiler": "21.0.1"
}
},
"@angular/animations": {
"version": "20.3.26",
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.26.tgz",
"integrity": "sha512-hfNrX19v8xs/usNkELSqc6q5IwBfS2GGW8sQ4OMpxAmLZDJwaLUxkj48t8VYhUFezsIfzR+sDEi6ZQBOaMIYug==",
"requires": {
"tslib": "^2.3.0"
}
},
"@angular/build": {
"version": "20.3.32",
"resolved": "https://registry.npmjs.org/@angular/build/-/build-20.3.32.tgz",
+1
View File
@@ -23,6 +23,7 @@
},
"private": true,
"dependencies": {
"@angular/animations": "v20-lts",
"@angular/common": "v20-lts",
"@angular/compiler": "v20-lts",
"@angular/core": "v20-lts",
+13 -2
View File
@@ -1,10 +1,21 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClientTesting } from '@angular/common/http/testing';
import { provideNoopAnimations } from '@angular/platform-browser/animations';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
provideNoopAnimations(),
],
}).compileComponents();
});
@@ -14,10 +25,10 @@ describe('App', () => {
expect(app).toBeTruthy();
});
it('should render title', () => {
it('should render the header', () => {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, mobile-store');
expect(compiled.querySelector('app-header')).toBeTruthy();
});
});
@@ -1,23 +1,70 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideNoopAnimations } from '@angular/platform-browser/animations';
import { ProductActions } from './product-actions';
import { ProductOptions } from '../../../core/models/product.model';
import {environment} from '../../../../environments/environment';
describe('ProductActions', () => {
let component: ProductActions;
let fixture: ComponentFixture<ProductActions>;
let httpMock: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductActions]
})
.compileComponents();
const options: ProductOptions = {
colors: [
{ code: 1, name: 'Negro' },
{ code: 2, name: 'Blanco' }
],
storages: [
{ code: 10, name: '16GB' },
{ code: 20, name: '32GB' }
]
};
fixture = TestBed.createComponent(ProductActions);
component = fixture.componentInstance;
fixture.detectChanges();
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
imports: [ProductActions],
providers: [provideHttpClient(), provideHttpClientTesting(), provideNoopAnimations()]
});
httpMock = TestBed.inject(HttpTestingController);
});
it('should create', () => {
expect(component).toBeTruthy();
afterEach(() => {
httpMock.verify();
localStorage.clear();
});
it('preselect the first color and storage option', () => {
const fixture = TestBed.createComponent(ProductActions);
fixture.componentRef.setInput('productId', '1');
fixture.componentRef.setInput('options', options);
fixture.detectChanges();
expect(fixture.componentInstance.selectedColorCode()).toBe(1);
expect(fixture.componentInstance.selectedStorageCode()).toBe(10);
});
it('add the product to the cart with the current selection and emit added', async () => {
const fixture = TestBed.createComponent(ProductActions);
fixture.componentRef.setInput('productId', '1');
fixture.componentRef.setInput('options', options);
fixture.detectChanges();
let addedEmitted = false;
fixture.componentInstance.added.subscribe(() => (addedEmitted = true));
fixture.componentInstance.selectedColorCode.set(2);
fixture.componentInstance.selectedStorageCode.set(20);
const promise = fixture.componentInstance.onAddToCart();
const req = httpMock.expectOne(`${environment.apiBaseUrl}/cart`);
expect(req.request.body).toEqual({ id: '1', colorCode: 2, storageCode: 20 });
req.flush({ count: 1 });
await promise;
expect(addedEmitted).toBeTrue();
});
});
@@ -1,23 +1,64 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing';
import { ProductDescription } from './product-description';
import { ProductDetail } from '../../../core/models/product.model';
describe('ProductDescription', () => {
let component: ProductDescription;
let fixture: ComponentFixture<ProductDescription>;
const product: ProductDetail = {
id: '1',
brand: 'Acer',
model: 'Iconia Talk S',
price: '170',
imgUrl: 'a.jpg',
cpu: 'Quad-core',
ram: '2 GB RAM',
displayResolution: '7.0 inches',
displaySize: '720 x 1280 pixels',
primaryCamera: ['13 MP'],
secondaryCmera: ['2 MP'],
dimentions: '191.7 x 101 x 9.4 mm',
weight: '260',
options: { colors: [], storages: [] }
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductDescription]
})
.compileComponents();
fixture = TestBed.createComponent(ProductDescription);
component = fixture.componentInstance;
fixture.detectChanges();
beforeEach(() => {
TestBed.configureTestingModule({ imports: [ProductDescription] });
});
it('should create', () => {
expect(component).toBeTruthy();
it('Show rows with the fields present, omitting the missing ones.', () => {
const fixture = TestBed.createComponent(ProductDescription);
fixture.componentRef.setInput('product', product);
fixture.detectChanges();
const text: string = fixture.nativeElement.textContent;
expect(text).toContain('Brand');
expect(text).toContain('Acer');
expect(text).toContain('CPU');
// Campo ausente (os) no debe generar una fila.
expect(text).not.toContain('OS');
});
it('label displayResolution/displaySize according to their actual content (API names swapped)', () => {
const fixture = TestBed.createComponent(ProductDescription);
fixture.componentRef.setInput('product', product);
fixture.detectChanges();
const rows = fixture.componentInstance.rows();
const tamano = rows.find((r) => r.label === 'Screen size');
const resolucion = rows.find((r) => r.label === 'Resolution');
expect(tamano?.value).toBe('7.0 inches');
expect(resolucion?.value).toBe('720 x 1280 pixels');
});
it('Combine primaryCamera and secondaryCamera into a single camera row.', () => {
const fixture = TestBed.createComponent(ProductDescription);
fixture.componentRef.setInput('product', product);
fixture.detectChanges();
const rows = fixture.componentInstance.rows();
const camaras = rows.find((r) => r.label === 'Cameras');
expect(camaras?.value).toBe('13 MP, 2 MP');
});
});
@@ -1,23 +1,70 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideNoopAnimations } from '@angular/platform-browser/animations';
import { ProductDetail } from './product-detail';
import { ProductDetail as ProductDetailModel } from '../../core/models/product.model';
import {environment} from '../../../environments/environment';
describe('ProductDetail', () => {
let component: ProductDetail;
let fixture: ComponentFixture<ProductDetail>;
let httpMock: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductDetail]
})
.compileComponents();
const detail: ProductDetailModel = {
id: '1',
brand: 'Acer',
model: 'Iconia Talk S',
price: '170',
imgUrl: 'a.jpg',
options: { colors: [{ code: 1, name: 'Negro' }], storages: [{ code: 1, name: '16GB' }] }
};
fixture = TestBed.createComponent(ProductDetail);
component = fixture.componentInstance;
fixture.detectChanges();
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
imports: [ProductDetail],
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
provideNoopAnimations()
]
});
httpMock = TestBed.inject(HttpTestingController);
});
it('should create', () => {
expect(component).toBeTruthy();
afterEach(() => {
httpMock.verify();
localStorage.clear();
});
it('loads and show the product detail using the id', async () => {
const fixture = TestBed.createComponent(ProductDetail);
fixture.componentRef.setInput('id', '1');
fixture.detectChanges();
httpMock.expectOne(`${environment.apiBaseUrl}/product/1`).flush(detail);
await fixture.whenStable();
fixture.detectChanges();
const text: string = fixture.nativeElement.textContent;
expect(text).toContain('Acer');
expect(text).toContain('Iconia Talk S');
expect(fixture.nativeElement.querySelector('app-product-actions')).toBeTruthy();
});
it('it shows an error message if API call fails', async () => {
const fixture = TestBed.createComponent(ProductDetail);
fixture.componentRef.setInput('id', '1');
fixture.detectChanges();
httpMock
.expectOne(`${environment.apiBaseUrl}/product/1`)
.flush('error', { status: 404, statusText: 'Not Found' });
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.componentInstance.error()).toBeTruthy();
});
});
@@ -1,23 +1,20 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing';
import { ProductImage } from './product-image';
describe('ProductImage', () => {
let component: ProductImage;
let fixture: ComponentFixture<ProductImage>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductImage]
})
.compileComponents();
fixture = TestBed.createComponent(ProductImage);
component = fixture.componentInstance;
fixture.detectChanges();
beforeEach(() => {
TestBed.configureTestingModule({ imports: [ProductImage] });
});
it('should create', () => {
expect(component).toBeTruthy();
it('it renders the image with proper src and alt', () => {
const fixture = TestBed.createComponent(ProductImage);
fixture.componentRef.setInput('imgUrl', 'https://example.com/a.jpg');
fixture.componentRef.setInput('alt', 'Acer Iconia');
fixture.detectChanges();
const img: HTMLImageElement = fixture.nativeElement.querySelector('img');
expect(img.src).toBe('https://example.com/a.jpg');
expect(img.alt).toBe('Acer Iconia');
});
});
@@ -1,23 +1,46 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing';
import { provideNoopAnimations } from '@angular/platform-browser/animations';
import { ProductItem } from './product-item';
import { Product } from '../../../core/models/product.model';
describe('ProductItem', () => {
let component: ProductItem;
let fixture: ComponentFixture<ProductItem>;
const product: Product = {
id: '1',
brand: 'Acer',
model: 'Iconia Talk S',
price: '170',
imgUrl: 'https://example.com/a.jpg'
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductItem]
})
.compileComponents();
fixture = TestBed.createComponent(ProductItem);
component = fixture.componentInstance;
fixture.detectChanges();
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ProductItem],
providers: [provideNoopAnimations()]
});
});
it('should create', () => {
expect(component).toBeTruthy();
it('it shows brand, model and product price', () => {
const fixture = TestBed.createComponent(ProductItem);
fixture.componentRef.setInput('product', product);
fixture.detectChanges();
const text: string = fixture.nativeElement.textContent;
expect(text).toContain('Acer');
expect(text).toContain('Iconia Talk S');
expect(text).toContain('170');
});
it('it emits productSelected with the product when clicking on it', () => {
const fixture = TestBed.createComponent(ProductItem);
fixture.componentRef.setInput('product', product);
fixture.detectChanges();
let selected: Product | undefined;
fixture.componentInstance.productSelected.subscribe((p) => (selected = p));
fixture.nativeElement.querySelector('mat-card').click();
expect(selected).toEqual(product);
});
});
@@ -1,23 +1,159 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter, Router } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideNoopAnimations } from '@angular/platform-browser/animations';
import { ProductList } from './product-list';
import { Product } from '../../core/models/product.model';
import {environment} from '../../../environments/environment';
describe('ProductList', () => {
let component: ProductList;
let fixture: ComponentFixture<ProductList>;
let httpMock: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductList]
})
.compileComponents();
const products: Product[] = [
{ id: '1', brand: 'Acer', model: 'Iconia Talk S', price: '170', imgUrl: 'a.jpg' },
{ id: '2', brand: 'Samsung', model: 'Galaxy S9', price: '300', imgUrl: 'b.jpg' }
];
fixture = TestBed.createComponent(ProductList);
component = fixture.componentInstance;
fixture.detectChanges();
/** Genera `count` productos únicos, útil para probar la paginación del scroll infinito. */
function buildProducts(count: number): Product[] {
return Array.from({ length: count }, (_, i) => ({
id: `${i + 1}`,
brand: i % 2 === 0 ? 'Acer' : 'Samsung',
model: `Model ${i + 1}`,
price: '100',
imgUrl: `img${i + 1}.jpg`
}));
}
/** Fabrica un Event mínimo con las propiedades de scroll que lee onScroll(). */
function makeScrollEvent(scrollTop: number, clientHeight: number, scrollHeight: number): Event {
return { target: { scrollTop, clientHeight, scrollHeight } } as unknown as Event;
}
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
imports: [ProductList],
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
provideNoopAnimations()
]
});
httpMock = TestBed.inject(HttpTestingController);
});
it('should create', () => {
expect(component).toBeTruthy();
afterEach(() => {
httpMock.verify();
localStorage.clear();
});
async function createAndLoad(mockProducts: Product[] = products) {
const fixture = TestBed.createComponent(ProductList);
Object.assign(fixture.nativeElement.style, {
display: 'flex',
flexDirection: 'column',
height: '400px'
});
fixture.detectChanges();
httpMock.expectOne(`${environment.apiBaseUrl}/product`).flush(mockProducts);
await fixture.whenStable();
fixture.detectChanges();
return fixture;
}
it('loads and shows all producst when load', async () => {
const fixture = await createAndLoad();
expect(fixture.componentInstance.filteredProducts().length).toBe(2);
expect(fixture.nativeElement.querySelectorAll('app-product-item').length).toBe(2);
});
it('filtering in live time', async () => {
const fixture = await createAndLoad();
fixture.componentInstance.onSearchTermChange('Samsung');
fixture.detectChanges();
const filtered = fixture.componentInstance.filteredProducts();
expect(filtered.length).toBe(1);
expect(filtered[0].brand).toBe('Samsung');
});
it('goes to detail view', async () => {
const fixture = await createAndLoad();
const router = TestBed.inject(Router);
const navigateSpy = spyOn(router, 'navigate');
fixture.componentInstance.onSelectProduct(products[0]);
expect(navigateSpy).toHaveBeenCalledWith(['/product', '1']);
});
it('Error message when api fails', async () => {
const fixture = TestBed.createComponent(ProductList);
fixture.detectChanges();
httpMock
.expectOne(`${environment.apiBaseUrl}/product`)
.flush('error', { status: 500, statusText: 'Server Error' });
await fixture.whenStable();
fixture.detectChanges();
expect(fixture.componentInstance.error()).toBeTruthy();
});
describe('infinite scroll', () => {
it('Only 20 products when too much products', async () => {
const fixture = await createAndLoad(buildProducts(45));
expect(fixture.componentInstance.visibleProducts().length).toBe(20);
expect(fixture.componentInstance.hasMore()).toBeTrue();
expect(fixture.nativeElement.querySelectorAll('app-product-item').length).toBe(20);
});
it('loads when close to last element edge', async () => {
const fixture = await createAndLoad(buildProducts(45));
// scrollTop + clientHeight a menos de 200px del final -> carga más.
fixture.componentInstance.onScroll(makeScrollEvent(1000, 500, 1650));
fixture.detectChanges();
expect(fixture.componentInstance.visibleProducts().length).toBe(40);
});
it('It does not load more items if it is not close to the limit boundary', async () => {
const fixture = await createAndLoad(buildProducts(45));
fixture.componentInstance.onScroll(makeScrollEvent(0, 500, 3000));
fixture.detectChanges();
expect(fixture.componentInstance.visibleProducts().length).toBe(20);
});
it('It does not exceed the total number of filtered products when revealing more batches.', async () => {
const fixture = await createAndLoad(buildProducts(25));
fixture.componentInstance.onScroll(makeScrollEvent(1000, 500, 1650));
fixture.detectChanges();
expect(fixture.componentInstance.visibleProducts().length).toBe(25);
expect(fixture.componentInstance.hasMore()).toBeFalse();
});
it('Reset pagination to 20 when the search term changes.', async () => {
const fixture = await createAndLoad(buildProducts(45));
fixture.componentInstance.onScroll(makeScrollEvent(1000, 500, 1650));
fixture.detectChanges();
expect(fixture.componentInstance.visibleProducts().length).toBe(40);
fixture.componentInstance.onSearchTermChange('acer');
fixture.detectChanges();
expect(fixture.componentInstance.visibleProducts().length).toBe(20);
});
});
});
@@ -1,23 +1,27 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing';
import { provideNoopAnimations } from '@angular/platform-browser/animations';
import { ProductSearch } from './product-search';
describe('ProductSearch', () => {
let component: ProductSearch;
let fixture: ComponentFixture<ProductSearch>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ProductSearch]
})
.compileComponents();
fixture = TestBed.createComponent(ProductSearch);
component = fixture.componentInstance;
fixture.detectChanges();
beforeEach(() => {
TestBed.configureTestingModule({
imports: [ProductSearch],
providers: [provideNoopAnimations()]
});
});
it('should create', () => {
expect(component).toBeTruthy();
it('emits searchChange with the inserted text', () => {
const fixture = TestBed.createComponent(ProductSearch);
fixture.detectChanges();
const emitted: string[] = [];
fixture.componentInstance.searchChange.subscribe((value) => emitted.push(value));
const input: HTMLInputElement = fixture.nativeElement.querySelector('input');
input.value = 'Samsung';
input.dispatchEvent(new Event('input'));
expect(emitted).toEqual(['Samsung']);
});
});
+49 -14
View File
@@ -1,23 +1,58 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { provideNoopAnimations } from '@angular/platform-browser/animations';
import { Header } from './header';
import { CartService } from '../../core/services/cart.service';
import {environment} from '../../../environments/environment';
describe('Header', () => {
let component: Header;
let fixture: ComponentFixture<Header>;
let httpMock: HttpTestingController;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Header]
})
.compileComponents();
fixture = TestBed.createComponent(Header);
component = fixture.componentInstance;
fixture.detectChanges();
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
imports: [Header],
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
provideNoopAnimations()
]
});
httpMock = TestBed.inject(HttpTestingController);
});
it('should create', () => {
expect(component).toBeTruthy();
afterEach(() => {
httpMock.verify();
localStorage.clear();
});
it('successfully created', () => {
const fixture = TestBed.createComponent(Header);
expect(fixture.componentInstance).toBeTruthy();
});
it('it shows home and cart icon', () => {
const fixture = TestBed.createComponent(Header);
fixture.detectChanges();
const compiled: HTMLElement = fixture.nativeElement;
expect(compiled.querySelector('.app-header__brand')).toBeTruthy();
expect(compiled.querySelector('button[aria-label="Cart"]')).toBeTruthy();
});
it('it shows the counter in the cart', async () => {
const fixture = TestBed.createComponent(Header);
const cartService = TestBed.inject(CartService);
const promise = cartService.addToCart({ id: '1', colorCode: 1, storageCode: 1 });
httpMock.expectOne(`${environment.apiBaseUrl}/cart`).flush({ count: 7 });
await promise;
fixture.detectChanges();
expect(fixture.componentInstance.cartCount()).toBe(7);
});
});