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 | pnpm test:ci |
| Unitario API | Vitest | PIN, JWT, guards, ownership | pnpm test:api |
| Integración API | Vitest + Supertest + @nestjs/testing | HTTP real contra Nest, Neon mockeado | pnpm test:integration |
| E2E | Playwright (Chromium) | PWA: título, filtro, tab Options | pnpm test:e2e |
| Smoke Neon (opcional) | apps/api/scripts/test-api.mjs | API viva + DATABASE_URL | pnpm --filter @task-cloud/api test:api |
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);
TabListPage (Jasmine) cubre búsqueda por título/descripcion/tag, el overlay de crear y que el chip de prioridad no se pinte como badge.
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', async ({ page }) => {
await openPwa(page);
await expect(page.locator('.filter-button')).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|configuracion/i);
});
test('creates a local task from the overlay form', async ({ page }) => {
await openPwa(page);
const form = page.locator('form.task-form');
await form.locator('input[matInput]').first().fill('E2E milk');
await form.evaluate((el) => (el as HTMLFormElement).requestSubmit());
await expect(page.locator('.task-title').filter({ hasText: 'E2E milk' })).toBeVisible();
});
Ionic no expone role="tab" de forma fiable en ion-tab-button, por eso el locator usa el atributo tab="options". El título se afirma con h1.header-title (el h1 dentro de ion-toolbar no siempre entra en el árbol de heading).
La creación e2e es offline: rellena el overlay de tab-list 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 opciones 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')).