Saltar al contenido principal

Custom Commands

Los Custom Commands permiten encapsular lógica reutilizable. Esto hace los tests más legibles y mantenibles.

¿Por qué Custom Commands?

ProblemaSolución
Código repetido en cada testExtraer a command reutilizable
Tests difíciles de leerComandos con nombres descriptivos
Cambios requieren editar muchos testsCambiar solo el command

Custom Commands del Proyecto

El proyecto Taller-Testing-Security tiene estos custom commands:

CommandPropósito
cy.mockLoginApi()Mockea la API de login
cy.mockDashboardApi()Mockea las APIs del dashboard
cy.loginByUI()Hace login llenando el formulario
cy.logout()Hace logout
cy.getByTestId()Selecciona por data-testid

Estructura de Archivos

cypress/
├── support/
│ ├── commands.ts # Definición de custom commands
│ ├── e2e.ts # Setup global (importa commands)
│ └── index.d.ts # Tipos TypeScript
├── fixtures/
│ ├── users.json
│ ├── aboutme.json
│ └── projects.json
└── e2e/
└── ...

Implementación de Commands

Paso 1: Definir los commands

// cypress/support/commands.ts

/// <reference types="cypress" />

// Token JWT válido (expira en 2030)
const MOCK_TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJfaWQiOiI1MDdmMWY3N2JjZjg2Y2Q3OTk0MzkwMTEiLCJlbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJpYXQiOjE3MDAwMDAwMDAsImV4cCI6MTkwMDAwMDAwMH0.Qs8nKjZ7GJXK7YjA_rOqwM7hK5dYWLNg8c3d_mLc8Z0';

// ============================================
// SELECTOR COMMANDS
// ============================================

Cypress.Commands.add('getByTestId', (testId: string) => {
return cy.get(`[data-testid="${testId}"]`);
});

// ============================================
// MOCK API COMMANDS
// ============================================

interface MockLoginOptions {
success?: boolean;
}

Cypress.Commands.add('mockLoginApi', (options: MockLoginOptions = { success: true }) => {
if (options.success) {
cy.intercept('POST', '**/auth/login', {
statusCode: 200,
body: {
token: MOCK_TOKEN,
user: {
_id: '507f1f77bcf86cd799439011',
email: 'test@example.com'
}
}
}).as('loginSuccess');
} else {
cy.intercept('POST', '**/auth/login', {
statusCode: 401,
body: {
error: 'Invalid credentials'
}
}).as('loginError');
}
});

interface MockDashboardOptions {
delay?: number;
error?: boolean;
}

Cypress.Commands.add('mockDashboardApi', (options: MockDashboardOptions = {}) => {
const { delay = 0, error = false } = options;

if (error) {
cy.intercept('GET', '**/v1/aboutme/', {
statusCode: 500,
body: { error: 'Server error' }
}).as('getAboutMe');

cy.intercept('GET', '**/v1/projects/', {
statusCode: 500,
body: { error: 'Server error' }
}).as('getProjects');
} else {
cy.intercept('GET', '**/v1/aboutme/', {
statusCode: 200,
fixture: 'aboutme.json',
delay: delay
}).as('getAboutMe');

cy.intercept('GET', '**/v1/projects/', {
statusCode: 200,
fixture: 'projects.json',
delay: delay
}).as('getProjects');
}
});

// ============================================
// AUTH COMMANDS
// ============================================

interface LoginOptions {
email?: string;
password?: string;
}

Cypress.Commands.add('loginByUI', (options: LoginOptions = {}) => {
const {
email = 'test@example.com',
password = 'password123'
} = options;

cy.mockLoginApi({ success: true });
cy.mockDashboardApi();

cy.visit('/login');
cy.get('input[name="email"]').type(email);
cy.get('input[name="password"]').type(password);
cy.get('input[type="submit"]').click();

cy.wait('@loginSuccess');
cy.url().should('include', '/admin');
});

Cypress.Commands.add('logout', () => {
// Limpiar localStorage
cy.window().then((win) => {
win.localStorage.removeItem('token');
});

// Navegar a home
cy.visit('/');
});

// Export vacío para TypeScript
export {};

Paso 2: Declarar tipos TypeScript

// cypress/support/index.d.ts

/// <reference types="cypress" />

declare namespace Cypress {
interface Chainable {
/**
* Selecciona un elemento por su data-testid
* @param testId - El valor del atributo data-testid
* @example cy.getByTestId('login-button')
*/
getByTestId(testId: string): Chainable<JQuery<HTMLElement>>;

/**
* Mockea la API de login
* @param options - { success: boolean }
* @example cy.mockLoginApi({ success: true })
*/
mockLoginApi(options?: { success?: boolean }): Chainable<null>;

/**
* Mockea las APIs del dashboard (aboutme y projects)
* @param options - { delay?: number, error?: boolean }
* @example cy.mockDashboardApi({ delay: 1000 })
*/
mockDashboardApi(options?: { delay?: number; error?: boolean }): Chainable<null>;

/**
* Hace login mediante la UI
* @param options - { email?: string, password?: string }
* @example cy.loginByUI({ email: 'user@test.com', password: 'pass123' })
*/
loginByUI(options?: { email?: string; password?: string }): Chainable<void>;

/**
* Hace logout limpiando el token
* @example cy.logout()
*/
logout(): Chainable<void>;
}
}

Paso 3: Importar en e2e.ts

// cypress/support/e2e.ts

import './commands';

// Opcional: configuración adicional
Cypress.on('uncaught:exception', () => {
// Prevenir fallo por errores no capturados
return false;
});

Usar Custom Commands en Tests

Test de login simplificado

// cypress/e2e/auth/login.cy.ts

describe('Login', () => {

it('debe hacer login exitoso', () => {
// Usa el custom command
cy.loginByUI();

// Verifica que estamos en admin
cy.url().should('include', '/admin');
});

it('debe hacer login con usuario específico', () => {
cy.loginByUI({
email: 'admin@example.com',
password: 'adminpass'
});

cy.url().should('include', '/admin');
});
});

Test de dashboard simplificado

// cypress/e2e/dashboard/dashboard.cy.ts

describe('Dashboard', () => {

beforeEach(() => {
cy.mockDashboardApi();
});

it('debe cargar datos', () => {
cy.visit('/dashboard');
cy.wait(['@getAboutMe', '@getProjects']);

cy.getByTestId('user-profile').should('be.visible');
cy.getByTestId('projects-list').should('be.visible');
});

it('debe mostrar loading', () => {
cy.mockDashboardApi({ delay: 1000 });

cy.visit('/dashboard');
cy.getByTestId('loading').should('be.visible');

cy.wait(['@getAboutMe', '@getProjects']);
cy.getByTestId('loading').should('not.exist');
});

it('debe manejar errores', () => {
cy.mockDashboardApi({ error: true });

cy.visit('/dashboard');
cy.contains(/error/i).should('be.visible');
});
});

Test de flujo completo

// cypress/e2e/flows/admin-flow.cy.ts

describe('Flujo de Admin', () => {

beforeEach(() => {
cy.loginByUI();
});

afterEach(() => {
cy.logout();
});

it('debe editar perfil', () => {
// Mockear update
cy.intercept('PUT', '**/v1/aboutme/**', {
statusCode: 200
}).as('updateProfile');

cy.getByTestId('edit-profile-button').click();
cy.get('input[name="name"]').clear().type('Nuevo Nombre');
cy.getByTestId('save-button').click();

cy.wait('@updateProfile');
cy.contains('Nuevo Nombre').should('be.visible');
});

it('debe crear proyecto', () => {
cy.intercept('POST', '**/v1/projects/', {
statusCode: 201,
body: { _id: 'new-id', title: 'Proyecto Nuevo' }
}).as('createProject');

cy.getByTestId('new-project-button').click();
cy.get('input[name="title"]').type('Proyecto Nuevo');
cy.getByTestId('save-button').click();

cy.wait('@createProject');
});
});

Crear Commands Adicionales

Command para verificar toast/notificación

// cypress/support/commands.ts

Cypress.Commands.add('shouldShowToast', (message: string, type: 'success' | 'error' = 'success') => {
cy.get(`[data-testid="toast-${type}"]`)
.should('be.visible')
.and('contain', message);
});

Command para llenar formulario completo

Cypress.Commands.add('fillProjectForm', (project: {
title: string;
description: string;
technologies?: string[];
}) => {
cy.get('input[name="title"]').clear().type(project.title);
cy.get('textarea[name="description"]').clear().type(project.description);

if (project.technologies) {
project.technologies.forEach(tech => {
cy.get('input[name="technology"]').type(tech);
cy.getByTestId('add-tech-button').click();
});
}
});

Usar los nuevos commands

it('debe crear proyecto', () => {
cy.intercept('POST', '**/v1/projects/', { statusCode: 201 }).as('create');

cy.fillProjectForm({
title: 'Mi Proyecto',
description: 'Descripción del proyecto',
technologies: ['React', 'TypeScript']
});

cy.getByTestId('save-button').click();
cy.wait('@create');
cy.shouldShowToast('Proyecto creado', 'success');
});

Commands que Retornan Valores

Obtener token del localStorage

Cypress.Commands.add('getToken', () => {
return cy.window().then((win) => {
return win.localStorage.getItem('token');
});
});

// Uso
cy.getToken().then((token) => {
expect(token).to.not.be.null;
});

Obtener estado de la app

Cypress.Commands.add('getAppState', () => {
return cy.window().its('__APP_STATE__');
});

// Uso
cy.getAppState().then((state) => {
expect(state.user).to.exist;
});

Child Commands

Child commands operan sobre el subject anterior:

Cypress.Commands.add(
'shouldBeVisible',
{ prevSubject: 'element' },
(subject) => {
return cy.wrap(subject).should('be.visible');
}
);

// Uso
cy.getByTestId('button').shouldBeVisible();

Mejores Prácticas

✅ Hacer

// Nombres descriptivos
cy.loginByUI();
cy.mockDashboardApi();

// Opciones con defaults
cy.mockLoginApi({ success: true });
cy.mockDashboardApi({ delay: 100 });

// Documentar con JSDoc
/**
* Hace login mediante la UI
* @param options - Credenciales opcionales
*/
Cypress.Commands.add('loginByUI', ...);

❌ Evitar

// Nombres genéricos
cy.doLogin();
cy.setup();

// Hardcodear valores
Cypress.Commands.add('login', () => {
cy.get('input').type('test@test.com'); // No configurable
});

// Commands que hacen demasiado
cy.setupEverything(); // Muy acoplado

Debugging Commands

// Ver qué hace el command
cy.log('🔐 Ejecutando loginByUI');
cy.loginByUI();

// Pausar para debug
Cypress.Commands.add('loginByUI', () => {
cy.log('Starting login...');
cy.pause(); // Debug
// ...resto del command
});

Resumen

TipoEjemploUso
Parent commandcy.loginByUI()Inicia chain
Child command.shouldBeVisible()Requiere subject
Mock commandcy.mockLoginApi()Prepara interceptores
Selector commandcy.getByTestId()Simplifica selección

Archivos del Proyecto

Los archivos de soporte están en:

ArchivoPropósito
cypress/support/commands.tsDefinición de commands
cypress/support/index.d.tsTipos TypeScript
cypress/support/e2e.tsSetup que importa todo
cypress/fixtures/*.jsonDatos mock