12-testing.md
Testing · visión
Task Cloud tiene tres capas de prueba más un job de GitHub Actions. Todas se pueden correr en local y en CI sin Neon real (excepto el smoke HTTP opcional pnpm test:api).
| Capa | Runner | Qué cubre | Comando |
|---|---|---|---|
| Unitario web | Jasmine + Karma (ChromeHeadless) | Componentes y servicios Angular (77 tests en 17 specs) | pnpm test:ci |
| Unitario API | Vitest | PIN, JWT, guards, ownership (21 tests en 6 ficheros) | pnpm test:api |
| Integración API | Vitest + Supertest + @nestjs/testing | HTTP real contra Nest, Neon mockeado (9 tests) | pnpm test:integration |
| E2E | Playwright (Chromium) | PWA: título, crear tarea, filtro de estado, pestaña Ajustes (4 tests) | pnpm test:e2e |
| Smoke Neon (opcional) | apps/api/scripts/test-api.mjs | API viva + DATABASE_URL | pnpm --filter @task-cloud/api test:api |
Además, pnpm test:scripts corre 28 tests de node:test sobre los scripts de scripts/. Comprobado el 20 de octubre de 2026: unitarios, integración y scripts pasan en local, y el e2e pasa en CI.
CI (.github/workflows/ci.yml):
- Job quality — lint, unit web, unit API, integración API,
build:api. - Job e2e — instala Chromium y lanza Playwright (el
webServerarranca Angular conCI=true).
No hace falta un Postgres en Actions: la integración sustituye DatabaseService por un fake. El e2e no depende de que /api responda para pintar el header.
Siguientes documentos: unitarios · integración · e2e · CI.
13-testing-unit.md
Testing · unitarios
Los unitarios instancian la clase sin HTTP y sin Neon. En la API se usa Vitest (globals: true). En la web, Jasmine/Karma.
PIN (apps/api/src/auth/pin.util.spec.ts)
El lookup del PIN es HMAC-SHA256 con PIN_PEPPER. El hash persistido es bcrypt, con un fallback SHA-256 de la era anterior.
it('accepts exactly 8 digits', () => {
expect(isValidPin('12345678')).toBe(true);
expect(isValidPin('1234567')).toBe(false);
expect(isValidPin('1234567a')).toBe(false);
});
it('verifies a bcrypt PIN hash', async () => {
const hash = await hashPin('87654321');
expect(await verifyPinHash('87654321', hash)).toBe(true);
expect(await verifyPinHash('00000000', hash)).toBe(false);
});
JWT (apps/api/src/auth/auth.service.spec.ts)
AuthService se construye a mano con un ConfigService falso. Así se firma y se verifica un token sin Nest.
const token = await auth.createAccessToken({
userId: 42,
sessionId: 'sid-1',
expiresAt: new Date(Date.now() + 60_000),
});
const claims = await auth.verifyAccessToken(token);
expect(claims.userId).toBe(42);
expect(claims.sessionId).toBe('sid-1');
extractBearerToken solo acepta el esquema Bearer:
expect(auth.extractBearerToken('Bearer abc.def')).toBe('abc.def');
expect(auth.extractBearerToken('Basic nope')).toBe('');
Ownership (apps/api/src/users/users.service.spec.ts)
Si el actorId no coincide, el servicio lanza NotFoundException (no 403: no se confirma que el recurso existe).
it('hides another user behind 404', () => {
expect(() => users.getOwnUser(1, 99)).toThrow(NotFoundException);
});
El mismo criterio está en TasksService.updateTaskForUser / deleteTaskForUser.
Guard (apps/api/src/auth/auth.guard.spec.ts)
El Reflector decide si la ruta es @Public(). Sin token y sin @Public(), el guard responde 401.
it('lets public handlers through', async () => {
const reflector = { getAllAndOverride: () => true } as unknown as Reflector;
const guard = new AuthGuard(auth, reflector);
await expect(guard.canActivate(ctx())).resolves.toBe(true);
});
Web — filtro de estado (apps/web/src/app/core/services/task.service.spec.ts)
Ionic Storage se sustituye por un Map. changeFilter() recorre All → Done → Pending.
expect(service.filter()).toBe(StatusEnum.All);
service.changeFilter();
expect(service.filter()).toBe(StatusEnum.Done);
Al guardar, una tarea sin priority queda en medium y tags en []. Los tags se recortan y pasan a minúsculas (" Work " → "work"). El filtro hidrata Ionic Storage:
store.set('filter', StatusEnum.Done);
await service.init();
expect(service.filter()).toBe(StatusEnum.Done);
Web — lista (apps/web/src/app/tabs/tab-list/tab-list.page.spec.ts)
TabListPage se monta con los servicios sustituidos por mocks (TaskService con signals, MatSnackBar, MatDialog, InstallService). Cubre la búsqueda por título, nota y etiqueta; los contadores de cada estado dentro del filtro activo; la prioridad escrita como palabra en la fila; añadir desde el borrador del campo de captura; la edición en línea; y el borrado con Deshacer.
El test de la tarea tachada comprueba que la fila se queda entre las pendientes mientras se dibuja el trazo y pasa a Hechas a los 650 ms. Usa el reloj de Jasmine y no fakeAsync: al final de un fakeAsync se vacían todos los temporizadores, incluido el timeout de carga de los chunks que Ionic pide al pintar la fila por primera vez, y Karma registraría ChunkLoadError.
jasmine.clock().install();
taskServiceMock.tasks.set([makeTask({ id: 1, title: 'Water the plants' })]);
component['toggleTaskState'](1);
expect(ids(component['openTasks']())).toEqual([1]);
jasmine.clock().tick(650);
expect(ids(component['doneTasks']())).toEqual([1]);
jasmine.clock().uninstall();
El botón de instalar se prueba con los tres métodos de InstallService: oculto si no se puede instalar, abre el diálogo del navegador si lo hay, y abre la guía de iOS en otro caso.
Web — instalación (apps/web/src/app/core/services/install.service.spec.ts)
Platform de Ionic se sustituye por un objeto que responde is('ios') / is('pwa'), y el evento de Chromium se simula sobre window:
it('installs through the browser prompt and stops offering it', async () => {
const service = createService('android');
const event = offerInstallPrompt('accepted');
expect(await service.promptInstall()).toBeTrue();
expect(event.prompt).toHaveBeenCalledTimes(1);
expect(service.method()).toBe('none');
});
También cubre que el banner propio del navegador se retiene (preventDefault), que tras rechazar se espera un evento nuevo, que appinstalled oculta el botón, que dentro de la app instalada no se ofrece nada y que ocultar el botón se recuerda entre arranques. InstallGuideComponent tiene su propio spec: tres pasos en orden, «Entendido» cierra y «Ocultar el botón de instalar» lo oculta y cierra.
Web — formularios y controles
TaskComposerComponent: los detalles quedan plegados hasta que se escribe algo, no añade un título vacío, entrega un borrador recortado con las etiquetas ya parseadas, y tras añadir se vacía y deja el campo listo.TaskEditorComponent: parte de la tarea y enfoca su título, guarda los campos editados y se cierra con Escape.SegmentedControlComponent:radiogroupcon nombre y una opción marcada, solo la marcada en el orden de tabulación, selección con clic y con flechas (con vuelta al principio) y contador junto a cada etiqueta.TagFieldComponent: sugiere las etiquetas conocidas, filtra por la que se está escribiendo, no repite las ya escritas y añade la sugerencia lista para la siguiente.- Helpers de
types/task.ts: etiquetas en minúsculas, sin duplicados y como máximo seis; prioridades en inglés y español conmediumpor defecto; orden por prioridad y después por fecha.
Web — tema e idioma
ThemeService activa a la vez la paleta oscura de la app y la de Ionic, y ajusta el color de la barra del navegador al de la cabecera. LanguageService actualiza lang en <html> para los lectores de pantalla e ignora idiomas que la app no incluye.
14-testing-integration.md
Testing · integración
Archivo: apps/api/test/app.integration.spec.ts.
Comando: pnpm test:integration.
Levanta Nest de verdad (Test.createTestingModule + configureApp) y habla por HTTP con Supertest. Neon no entra: se hace overrideProvider(DatabaseService).
Antes de importar AppModule se fuerzan las env que validateEnv exige al boot (apps/api/test/setup-env.ts). No se usa ||=: un JWT corto heredado del .env local rompería el arranque.
process.env.DATABASE_URL = 'postgresql://task:cloud@localhost/taskcloud';
process.env.JWT_SECRET = 'integration-jwt-secret-32-chars-ok';
process.env.PIN_PEPPER = 'integration-pin-pepper-32-chars-ok';
process.env.ALLOWED_ORIGINS = 'http://127.0.0.1:4200';
Vitest transpila los decoradores de Nest con SWC (unplugin-swc + decoratorMetadata: true en vitest.integration.config.ts). Sin metadata, Reflector y DatabaseService llegan undefined al TestingModule.
Fake de base de datos
const fakeDb = {
getSql: () => Object.assign(async () => [], {}) as never,
ping: async () => undefined,
ensureAuthSchema: async () => undefined,
};
configureApp aplica el mismo ValidationPipe, filtro, helmet y request-id que producción. Si un test no llama a configureApp, el prefix /api y el pipe no existen — ese es el fallo clásico de e2e Nest.
Contratos que se afirman
it('GET /api/health returns the product payload', async () => {
const res = await request(app.getHttpServer()).get('/api/health').expect(200);
expect(res.body).toEqual({ ok: true, service: 'task-cloud-nest-api' });
expect(res.headers['x-request-id']).toBeTruthy();
});
it('rejects an unauthenticated task list', async () => {
await request(app.getHttpServer()).get('/api/tasks').expect(401);
});
it('rejects a short PIN at the DTO boundary', async () => {
await request(app.getHttpServer())
.post('/api/auth/login')
.send({ pin: '12' })
.expect(400);
});
it('rejects extra fields on login (forbidNonWhitelisted)', async () => {
await request(app.getHttpServer())
.post('/api/auth/login')
.send({ pin: '12345678', admin: true })
.expect(400);
});
Un PIN válido pero sin filas en el fake ([]) produce 401 — el login llega al servicio, no se queda en el DTO.
También se afirma GET /api (mismo payload que health), GET /api/auth/me y POST /api/tasks sin Bearer (401), y el header Helmet x-content-type-options: nosniff.
Qué no cubre esta capa
El smoke apps/api/scripts/test-api.mjs sí habla con Neon real (register → tasks → logout). No corre en GitHub Actions para no depender de secretos. Úsalo en local con pnpm api + pnpm --filter @task-cloud/api test:api.
15-testing-e2e.md
Testing · e2e
Playwright en la raíz del monorepo. Config: playwright.config.ts. Specs: e2e/home.spec.ts.
pnpm test:e2e
En CI, Playwright arranca Angular solo:
webServer: {
command: 'pnpm --filter @task-cloud/web start',
url: 'http://127.0.0.1:4200',
env: { CI: 'true', API_BASE_URL: '/api' },
}
CI=true hace que apps/web/scripts/set-env.js genere environment.local.ts sin copiar .env (no hay secretos en Actions).
Specs reales
async function openPwa(page: Page) {
await page.goto('/');
await expect(page.locator('ion-tab-bar')).toBeVisible({ timeout: 20_000 });
}
test('renders the product title in the header', async ({ page }) => {
await openPwa(page);
await expect(page.locator('h1.header-title')).toContainText(/Task Cloud/i);
});
test('shows the All filter control once there is a task', async ({ page }) => {
await openPwa(page);
const title = page.locator('form.task-form .composer-title');
await title.fill('E2E filter');
await title.press('Enter');
await expect(page.getByRole('radio', { name: /All/ })).toBeVisible();
});
test('can open the Options tab', async ({ page }) => {
await openPwa(page);
await page.locator('ion-tab-button[tab="options"]').click();
await expect(page).toHaveURL(/options/i);
await expect(page.locator('app-tab-options h1')).toContainText(/settings|ajustes/i);
});
test('creates a local task from the capture field', async ({ page }) => {
await openPwa(page);
const form = page.locator('form.task-form');
await expect(form).toBeVisible();
await form.locator('.composer-title').fill('E2E milk');
await form.evaluate((el) => (el as HTMLFormElement).requestSubmit());
await expect(page.locator('.task-title').filter({ hasText: 'E2E milk' })).toBeVisible({
timeout: 15_000,
});
});
Ionic no expone role="tab" de forma fiable en ion-tab-button, por eso el locator usa el atributo tab="options" (la ruta sigue siendo /tabs/options, aunque la pestaña se llame Ajustes). El título se afirma con h1.header-title (el h1 dentro de ion-toolbar no siempre entra en el árbol de heading). El filtro de estado es un control segmentado con role="radiogroup", así que se busca por getByRole('radio'); solo aparece cuando hay al menos una tarea.
La creación e2e es offline: escribe en el campo de captura de la cabecera y afirma .task-title. No hace falta Neon.
Esta suite también pilla regresiones de arranque:
provideTranslateHttpLoader()va enprovidersdeAppModule, no enTranslateModule.forRoot({ loader }). Si no,NG0201: No provider found for TranslateLoader.- En pnpm,
preserveSymlinks: trueenangular.json+PathLocationStrategyevitanNG0203al inyectarLocationStrategy(la PWA quedaba en<ion-app></ion-app>vacío).
La PWA pinta lista y ajustes offline. No se afirma sync/PIN en e2e de CI (eso es el smoke Neon).
16-testing-ci.md
Testing · GitHub Actions
Workflow: .github/workflows/ci.yml. Se dispara en push y pull_request a main.
Job quality
- name: Lint web
run: pnpm lint:ci
- name: Run web unit tests
run: pnpm test:ci
- name: Run API unit tests
run: pnpm test:api
- name: Run API integration tests
run: pnpm test:integration
- name: Build Nest API
run: pnpm build:api
pnpm/action-setup@v4 lee packageManager: pnpm@11.21.0. Node 22 + cache de pnpm. pnpm install --frozen-lockfile falla si el lock no está commiteado.
Job e2e
- name: Install Playwright Chromium
run: pnpm exec playwright install --with-deps chromium
- name: Run end-to-end tests
run: pnpm test:e2e
env:
CI: true
API_BASE_URL: /api
Chromium se instala en el runner; no se sube al repo. retries: 2 solo en CI (playwright.config.ts).
Por qué no hay errores de entorno
| Riesgo | Mitigación |
|---|---|
validateEnv exige secretos | test/setup-env.ts asigna JWT/PIN de 32+ caracteres antes de importar AppModule |
| Decoradores Nest sin metadata | unplugin-swc con decoratorMetadata: true en vitest.integration.config.ts |
| Build nativo de SWC | pnpm-workspace.yaml → allowBuilds["@swc/core"] = true |
| Neon no está en Actions | DatabaseService mockeado |
set-env.js pide .env | CI=true genera environments y sale 0 |
| Angular tarda en servir | webServer.timeout: 180000 y toBeVisible({ timeout: 20000 }) |
| Lockfile desfasado | --frozen-lockfile + commit de pnpm-lock.yaml |
Si un job rojo es flaky de Playwright, el primer sitio a mirar es el locator de Ionic (preferir ion-tab-button[tab=…] a getByRole('tab')).