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", "name": "mobile-store",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@angular/animations": "v20-lts",
"@angular/common": "v20-lts", "@angular/common": "v20-lts",
"@angular/compiler": "v20-lts", "@angular/compiler": "v20-lts",
"@angular/core": "v20-lts", "@angular/core": "v20-lts",
@@ -681,6 +682,20 @@
"typescript": "*" "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": { "node_modules/@angular/build": {
"version": "20.3.32", "version": "20.3.32",
"resolved": "https://registry.npmjs.org/@angular/build/-/build-20.3.32.tgz", "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-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": { "@angular/build": {
"version": "20.3.32", "version": "20.3.32",
"resolved": "https://registry.npmjs.org/@angular/build/-/build-20.3.32.tgz", "resolved": "https://registry.npmjs.org/@angular/build/-/build-20.3.32.tgz",
+1
View File
@@ -23,6 +23,7 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/animations": "v20-lts",
"@angular/common": "v20-lts", "@angular/common": "v20-lts",
"@angular/compiler": "v20-lts", "@angular/compiler": "v20-lts",
"@angular/core": "v20-lts", "@angular/core": "v20-lts",
+13 -2
View File
@@ -1,10 +1,21 @@
import { TestBed } from '@angular/core/testing'; 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'; import { App } from './app';
describe('App', () => { describe('App', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [App], imports: [App],
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
provideNoopAnimations(),
],
}).compileComponents(); }).compileComponents();
}); });
@@ -14,10 +25,10 @@ describe('App', () => {
expect(app).toBeTruthy(); expect(app).toBeTruthy();
}); });
it('should render title', () => { it('should render the header', () => {
const fixture = TestBed.createComponent(App); const fixture = TestBed.createComponent(App);
fixture.detectChanges(); fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement; 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 { ProductActions } from './product-actions';
import { ProductOptions } from '../../../core/models/product.model';
import {environment} from '../../../../environments/environment';
describe('ProductActions', () => { describe('ProductActions', () => {
let component: ProductActions; let httpMock: HttpTestingController;
let fixture: ComponentFixture<ProductActions>;
beforeEach(async () => { const options: ProductOptions = {
await TestBed.configureTestingModule({ colors: [
imports: [ProductActions] { code: 1, name: 'Negro' },
}) { code: 2, name: 'Blanco' }
.compileComponents(); ],
storages: [
{ code: 10, name: '16GB' },
{ code: 20, name: '32GB' }
]
};
fixture = TestBed.createComponent(ProductActions); beforeEach(() => {
component = fixture.componentInstance; localStorage.clear();
TestBed.configureTestingModule({
imports: [ProductActions],
providers: [provideHttpClient(), provideHttpClientTesting(), provideNoopAnimations()]
});
httpMock = TestBed.inject(HttpTestingController);
});
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(); fixture.detectChanges();
expect(fixture.componentInstance.selectedColorCode()).toBe(1);
expect(fixture.componentInstance.selectedStorageCode()).toBe(10);
}); });
it('should create', () => { it('add the product to the cart with the current selection and emit added', async () => {
expect(component).toBeTruthy(); 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 { ProductDescription } from './product-description';
import { ProductDetail } from '../../../core/models/product.model';
describe('ProductDescription', () => { describe('ProductDescription', () => {
let component: ProductDescription; const product: ProductDetail = {
let fixture: ComponentFixture<ProductDescription>; 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 () => { beforeEach(() => {
await TestBed.configureTestingModule({ TestBed.configureTestingModule({ imports: [ProductDescription] });
imports: [ProductDescription] });
})
.compileComponents();
fixture = TestBed.createComponent(ProductDescription); it('Show rows with the fields present, omitting the missing ones.', () => {
component = fixture.componentInstance; const fixture = TestBed.createComponent(ProductDescription);
fixture.componentRef.setInput('product', product);
fixture.detectChanges(); 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('should create', () => { it('label displayResolution/displaySize according to their actual content (API names swapped)', () => {
expect(component).toBeTruthy(); 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 } from './product-detail';
import { ProductDetail as ProductDetailModel } from '../../core/models/product.model';
import {environment} from '../../../environments/environment';
describe('ProductDetail', () => { describe('ProductDetail', () => {
let component: ProductDetail; let httpMock: HttpTestingController;
let fixture: ComponentFixture<ProductDetail>;
beforeEach(async () => { const detail: ProductDetailModel = {
await TestBed.configureTestingModule({ id: '1',
imports: [ProductDetail] brand: 'Acer',
}) model: 'Iconia Talk S',
.compileComponents(); price: '170',
imgUrl: 'a.jpg',
options: { colors: [{ code: 1, name: 'Negro' }], storages: [{ code: 1, name: '16GB' }] }
};
fixture = TestBed.createComponent(ProductDetail); beforeEach(() => {
component = fixture.componentInstance; localStorage.clear();
TestBed.configureTestingModule({
imports: [ProductDetail],
providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
provideNoopAnimations()
]
});
httpMock = TestBed.inject(HttpTestingController);
});
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(); 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('should create', () => { it('it shows an error message if API call fails', async () => {
expect(component).toBeTruthy(); 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'; import { ProductImage } from './product-image';
describe('ProductImage', () => { describe('ProductImage', () => {
let component: ProductImage; beforeEach(() => {
let fixture: ComponentFixture<ProductImage>; TestBed.configureTestingModule({ imports: [ProductImage] });
});
beforeEach(async () => { it('it renders the image with proper src and alt', () => {
await TestBed.configureTestingModule({ const fixture = TestBed.createComponent(ProductImage);
imports: [ProductImage] fixture.componentRef.setInput('imgUrl', 'https://example.com/a.jpg');
}) fixture.componentRef.setInput('alt', 'Acer Iconia');
.compileComponents();
fixture = TestBed.createComponent(ProductImage);
component = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
});
it('should create', () => { const img: HTMLImageElement = fixture.nativeElement.querySelector('img');
expect(component).toBeTruthy(); 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 { ProductItem } from './product-item';
import { Product } from '../../../core/models/product.model';
describe('ProductItem', () => { describe('ProductItem', () => {
let component: ProductItem; const product: Product = {
let fixture: ComponentFixture<ProductItem>; id: '1',
brand: 'Acer',
model: 'Iconia Talk S',
price: '170',
imgUrl: 'https://example.com/a.jpg'
};
beforeEach(async () => { beforeEach(() => {
await TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ProductItem] imports: [ProductItem],
}) providers: [provideNoopAnimations()]
.compileComponents(); });
});
fixture = TestBed.createComponent(ProductItem); it('it shows brand, model and product price', () => {
component = fixture.componentInstance; const fixture = TestBed.createComponent(ProductItem);
fixture.componentRef.setInput('product', product);
fixture.detectChanges(); fixture.detectChanges();
const text: string = fixture.nativeElement.textContent;
expect(text).toContain('Acer');
expect(text).toContain('Iconia Talk S');
expect(text).toContain('170');
}); });
it('should create', () => { it('it emits productSelected with the product when clicking on it', () => {
expect(component).toBeTruthy(); 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 { ProductList } from './product-list';
import { Product } from '../../core/models/product.model';
import {environment} from '../../../environments/environment';
describe('ProductList', () => { describe('ProductList', () => {
let component: ProductList; let httpMock: HttpTestingController;
let fixture: ComponentFixture<ProductList>;
beforeEach(async () => { const products: Product[] = [
await TestBed.configureTestingModule({ { id: '1', brand: 'Acer', model: 'Iconia Talk S', price: '170', imgUrl: 'a.jpg' },
imports: [ProductList] { id: '2', brand: 'Samsung', model: 'Galaxy S9', price: '300', imgUrl: 'b.jpg' }
}) ];
.compileComponents();
fixture = TestBed.createComponent(ProductList); /** Genera `count` productos únicos, útil para probar la paginación del scroll infinito. */
component = fixture.componentInstance; 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);
});
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(); 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('should create', () => { it('filtering in live time', async () => {
expect(component).toBeTruthy(); 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'; import { ProductSearch } from './product-search';
describe('ProductSearch', () => { describe('ProductSearch', () => {
let component: ProductSearch; beforeEach(() => {
let fixture: ComponentFixture<ProductSearch>; TestBed.configureTestingModule({
imports: [ProductSearch],
providers: [provideNoopAnimations()]
});
});
beforeEach(async () => { it('emits searchChange with the inserted text', () => {
await TestBed.configureTestingModule({ const fixture = TestBed.createComponent(ProductSearch);
imports: [ProductSearch]
})
.compileComponents();
fixture = TestBed.createComponent(ProductSearch);
component = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
});
it('should create', () => { const emitted: string[] = [];
expect(component).toBeTruthy(); 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']);
}); });
}); });
+47 -12
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 { Header } from './header';
import { CartService } from '../../core/services/cart.service';
import {environment} from '../../../environments/environment';
describe('Header', () => { describe('Header', () => {
let component: Header; let httpMock: HttpTestingController;
let fixture: ComponentFixture<Header>;
beforeEach(async () => { beforeEach(() => {
await TestBed.configureTestingModule({ localStorage.clear();
imports: [Header] TestBed.configureTestingModule({
}) imports: [Header],
.compileComponents(); providers: [
provideRouter([]),
provideHttpClient(),
provideHttpClientTesting(),
provideNoopAnimations()
]
});
httpMock = TestBed.inject(HttpTestingController);
});
fixture = TestBed.createComponent(Header); afterEach(() => {
component = fixture.componentInstance; 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(); fixture.detectChanges();
const compiled: HTMLElement = fixture.nativeElement;
expect(compiled.querySelector('.app-header__brand')).toBeTruthy();
expect(compiled.querySelector('button[aria-label="Cart"]')).toBeTruthy();
}); });
it('should create', () => { it('it shows the counter in the cart', async () => {
expect(component).toBeTruthy(); 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);
}); });
}); });