Cuando test.extend tipa tus fixtures de Playwright, TypeScript sabe que loginPage es una instancia de LoginPage en cada test que lo importa, no any. Los errores de tipeo en nombres de métodos se detectan en tiempo de compilación, y el autocompletado funciona sin configuración por archivo. Este artículo cubre los patrones de TypeScript que importan a escala de suite: fixtures genéricos, tipos unión discriminados para estados de pedido y sesión, tipos utilitarios para factories de datos de prueba, narrowing de null para métodos del DOM, y las configuraciones de modo estricto más allá de strict: true.
Type aliases vs interfaces: la regla práctica
Eventualmente todos los tutoriales de TypeScript explican que las interfaces y los type aliases se superponen significativamente. Ambos pueden describir formas de objetos. Ambos soportan extensión. Ambos funcionan en los mismos lugares la mayor parte del tiempo. La regla útil para código de tests es más simple de lo que sugiere la especificación del lenguaje.
Usa interfaces cuando estés describiendo una forma de datos que otros tipos van a extender. Usa type aliases cuando estés construyendo uniones, intersecciones, o cualquier cosa que no sea una forma de objeto simple.
// Interface: una forma que tiene sentido extender
interface User {
id: number;
email: string;
role: 'admin' | 'viewer';
}
// Extender una interfaz: natural, legible
interface AdminUser extends User {
permissions: string[];
}
// Type alias: la opción correcta para uniones
type UserRole = 'admin' | 'viewer' | 'guest';
// Type alias: la opción correcta para intersecciones
type AuthenticatedUser = User & { token: string; expiresAt: Date };
// Type alias: la opción correcta para nombrar un primitivo o tupla
type UserId = number;
type Credentials = [string, string]; // [email, password]La razón por la que las interfaces funcionan mejor para formas extensibles es la fusión de declaraciones: puedes declarar la misma interfaz dos veces y TypeScript fusiona las declaraciones. Esto es útil en archivos de fixtures donde distintas partes de tu suite añaden propiedades al mismo tipo de fixture. Los type aliases no soportan fusión; una declaración duplicada es un error.
[!note]
Ni las interfaces ni los type aliases producen ninguna salida de JavaScript. Existen solo en la capa de TypeScript y se eliminan durante la compilación. No hay costo en tiempo de ejecución para ninguno de los dos.
El error a evitar: pasar tiempo debatiendo cuál usar. Elige interfaces para formas de objetos, type aliases para todo lo demás, y sigue adelante. Ambos funcionarán correctamente de cualquier manera. Es una decisión de organización de código, no de corrección.
Tipado de datos de prueba: usuarios, pedidos y objetos de formulario
Los fixtures de prueba dependen completamente de sus datos. Un objeto usuario con la forma incorrecta rompe silenciosamente cinco tests antes de que alguien lo note. TypeScript hace la forma explícita.
// types/test-data.ts
export interface UserCredentials {
email: string;
password: string;
}
export interface UserProfile {
id: number;
email: string;
firstName: string;
lastName: string;
role: 'admin' | 'editor' | 'viewer';
createdAt: string; // String de fecha ISO de la API
}
export interface Order {
id: string;
status: 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';
items: OrderItem[];
totalAmount: number;
currency: 'USD' | 'EUR' | 'GBP';
}
export interface OrderItem {
productId: string;
name: string;
quantity: number;
unitPrice: number;
}
export interface RegistrationForm {
firstName: string;
lastName: string;
email: string;
password: string;
confirmPassword: string;
acceptTerms: boolean;
}Con estas interfaces definidas, tus archivos de datos de prueba se auto-documentan y son verificados por el compilador:
// fixtures/test-users.ts
import { UserCredentials, UserProfile } from '../types/test-data';
export const adminCredentials: UserCredentials = {
email: 'admin@example.com',
password: 'Admin$ecure1!',
};
export const viewerProfile: UserProfile = {
id: 42,
email: 'viewer@example.com',
firstName: 'Alex',
lastName: 'Rivera',
role: 'viewer',
createdAt: '2025-01-15T09:00:00Z',
};
// TypeScript detecta esto de inmediato: 'superadmin' no es un rol válido
export const invalidUser: UserProfile = {
id: 99,
email: 'super@example.com',
firstName: 'Super',
lastName: 'Admin',
role: 'superadmin', // Error: Type '"superadmin"' is not assignable to type '"admin" | "editor" | "viewer"'
createdAt: '2025-03-01T00:00:00Z',
};Cuando el contrato de la API cambia y role obtiene un nuevo valor, lo añades a la interfaz una vez y el compilador marca cada lugar en tus tests que no maneja el nuevo caso.
Tipado de clases Page Object
Las clases Page Object se benefician del tipado explícito más que cualquier otra parte de una suite de tests. Un Page Object tipado documenta su propia API: la firma del constructor, qué acepta cada método y qué devuelve.
// pages/LoginPage.ts
import { Page, Locator } from '@playwright/test';
import { UserCredentials } from '../types/test-data';
export class LoginPage {
private readonly emailInput: Locator;
private readonly passwordInput: Locator;
private readonly submitButton: Locator;
private readonly errorAlert: Locator;
constructor(private readonly page: Page) {
this.emailInput = page.getByLabel('Email');
this.passwordInput = page.getByLabel('Password');
this.submitButton = page.getByRole('button', { name: 'Sign in' });
this.errorAlert = page.getByRole('alert');
}
async navigate(): Promise<void> {
await this.page.goto('/login');
}
async login(credentials: UserCredentials): Promise<void> {
await this.emailInput.fill(credentials.email);
await this.passwordInput.fill(credentials.password);
await this.submitButton.click();
}
async getErrorMessage(): Promise<string | null> {
if (await this.errorAlert.isVisible()) {
return this.errorAlert.textContent();
}
return null;
}
async isSubmitEnabled(): Promise<boolean> {
return this.submitButton.isEnabled();
}
}Hay varias cosas en esta clase que vale explicar. Los modificadores private readonly en los locators significan que no pueden reasignarse accidentalmente desde fuera de la clase. El parámetro del constructor private readonly page: Page es una sintaxis abreviada de TypeScript para declarar una propiedad y asignarla en un solo paso. El tipo de retorno Promise en getErrorMessage le dice a los llamadores que deben manejar ambos casos; el compilador avisará si usan el resultado como si nunca pudiera ser null.
// pages/DashboardPage.ts
import { Page, Locator } from '@playwright/test';
import { Order } from '../types/test-data';
export class DashboardPage {
constructor(private readonly page: Page) {}
async getOrderCount(): Promise<number> {
const rows = await this.page.getByRole('row').count();
return rows - 1; // restar la fila de encabezado
}
async getFirstOrderStatus(): Promise<Order['status']> {
const statusCell = this.page.getByRole('row').nth(1).getByTestId('status');
const text = await statusCell.textContent();
// El narrowing de tipos garantiza que devolvemos un status válido
return text?.toLowerCase() as Order['status'];
}
}El tipo de retorno Order['status'] es un tipo de acceso indexado: lee el tipo de la propiedad status directamente de la interfaz Order. Si cambias la unión de status en Order, el tipo de retorno aquí se actualiza automáticamente.
Fixtures genéricos: extendiendo PlaywrightTestArgs
Aquí es donde TypeScript paga su mayor dividendo en la infraestructura de tests. El test.extend de Playwright usa genéricos para garantizar que tus propiedades de fixture personalizadas tengan los tipos correctos en toda tu suite.
// fixtures/index.ts
import { test as base, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { DashboardPage } from '../pages/DashboardPage';
import { UserCredentials } from '../types/test-data';
import { adminCredentials } from './test-users';
// La forma de todos los fixtures personalizados
type AppFixtures = {
loginPage: LoginPage;
dashboardPage: DashboardPage;
authenticatedPage: DashboardPage;
};
// Fixtures con scope de worker: compartidos entre tests en un worker
type WorkerFixtures = {
adminUser: UserCredentials;
};
export const test = base.extend<AppFixtures, WorkerFixtures>({
// Fixtures con scope de página: recreados para cada test
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await loginPage.navigate();
await use(loginPage);
},
dashboardPage: async ({ page }, use) => {
await use(new DashboardPage(page));
},
// Un fixture que configura una sesión autenticada
authenticatedPage: async ({ page }, use) => {
const loginPage = new LoginPage(page);
await loginPage.navigate();
await loginPage.login(adminCredentials);
const dashboard = new DashboardPage(page);
await use(dashboard);
},
// Con scope de worker: creado una vez por proceso worker
adminUser: [async ({}, use) => {
await use(adminCredentials);
}, { scope: 'worker' }],
});
export { expect };Los parámetros genéricos le dicen a TypeScript exactamente qué tipos producen tus fixtures. Cuando un test desestructura { loginPage }, TypeScript sabe que es una instancia de LoginPage, no any. El autocompletado funciona. Los errores de tipeo en nombres de métodos se detectan en tiempo de compilación.
// tests/login.spec.ts
import { test, expect } from '../fixtures';
test('el login de admin muestra el dashboard', async ({ loginPage, authenticatedPage }) => {
// TypeScript sabe que loginPage es LoginPage — autocompletado completo
await loginPage.login({ email: 'admin@example.com', password: 'Admin$1!' });
// TypeScript sabe que authenticatedPage es DashboardPage
const count = await authenticatedPage.getOrderCount();
expect(count).toBeGreaterThan(0);
});[!tip]
Crea una exportación barrel enfixtures/index.tsque re-exportetestyexpectdesde tu archivo de fixtures. Los tests importan desde../fixturesen lugar de@playwright/test. Cuando añades nuevos page objects al tipo de fixture, todos los archivos de test que importan desde../fixtureslos ven automáticamente. Sin cambios necesarios en los archivos de test individuales.
Tipos utilitarios en código de tests
TypeScript incluye un conjunto de tipos utilitarios que transforman tipos existentes en nuevos. Cuatro de ellos aparecen regularmente en código de tests.
Partial hace que todas las propiedades de T sean opcionales. Úsalo cuando una función crea un objeto y quieres permitir que los llamadores sobrescriban propiedades específicas:
import { UserProfile } from '../types/test-data';
// Crea un perfil de usuario válido con valores por defecto sensatos
// Los llamadores pueden sobrescribir cualquier subconjunto de propiedades
function createTestUser(overrides: Partial<UserProfile> = {}): UserProfile {
return {
id: Math.floor(Math.random() * 10000),
email: `user${Date.now()}@example.com`,
firstName: 'Test',
lastName: 'User',
role: 'viewer',
createdAt: new Date().toISOString(),
...overrides,
};
}
// Usar valores por defecto
const defaultUser = createTestUser();
// Sobrescribir solo lo que importa para este test
const adminUser = createTestUser({ role: 'admin', email: 'admin@example.com' });Required hace que todas las propiedades sean obligatorias, lo contrario de Partial. Úsalo cuando estés construyendo una función que necesita que todos los campos estén presentes:
interface CheckoutForm {
firstName?: string;
lastName?: string;
address?: string;
city?: string;
cardNumber?: string;
}
// Esta función necesita todos los campos — Required<T> hace el contrato explícito
async function fillCheckoutForm(page: Page, data: Required<CheckoutForm>): Promise<void> {
await page.getByLabel('Nombre').fill(data.firstName);
await page.getByLabel('Apellido').fill(data.lastName);
// ...
}Pick crea un nuevo tipo con solo las propiedades que nombras. Úsalo cuando una función solo necesita un subconjunto de un tipo más grande:
import { UserProfile } from '../types/test-data';
async function verifyEmailDisplayed(
page: Page,
user: Pick<UserProfile, 'email' | 'firstName' | 'lastName'>
): Promise<void> {
await expect(page.getByText(user.email)).toBeVisible();
await expect(page.getByText(`${user.firstName} ${user.lastName}`)).toBeVisible();
}Record crea un tipo de objeto donde todas las claves son del tipo K y todos los valores son del tipo V. Aparece en factories de datos de prueba y definiciones de mocks de API:
import { Order } from '../types/test-data';
// Un mapa de pedidos de prueba nombrados — las claves son strings, los valores son objetos Order
const testOrders: Record<string, Order> = {
pendingOrder: {
id: 'ord-001',
status: 'pending',
items: [{ productId: 'p1', name: 'Widget', quantity: 2, unitPrice: 9.99 }],
totalAmount: 19.98,
currency: 'USD',
},
confirmedOrder: {
id: 'ord-002',
status: 'confirmed',
items: [{ productId: 'p2', name: 'Gadget', quantity: 1, unitPrice: 49.99 }],
totalAmount: 49.99,
currency: 'USD',
},
};
// TypeScript sabe que testOrders['pendingOrder'] es Order
const pending = testOrders['pendingOrder'];Tipos unión para estados de prueba
Muchos tests necesitan comportarse diferente dependiendo de si un usuario está logueado, qué rol tiene, o en qué estado está un registro. Los tipos unión modelan estos estados explícitamente.
// Los dos estados en que puede estar una sesión de usuario
type SessionState =
| { status: 'authenticated'; userId: number; role: 'admin' | 'editor' | 'viewer' }
| { status: 'guest' };
// Ciclo de vida del pedido — cada estado mapea a un conjunto de acciones de UI válidas
type OrderState =
| { status: 'pending'; canCancel: true; canShip: false }
| { status: 'confirmed'; canCancel: true; canShip: true }
| { status: 'shipped'; canCancel: false; canShip: false; trackingNumber: string }
| { status: 'delivered'; canCancel: false; canShip: false }
| { status: 'cancelled'; canCancel: false; canShip: false; cancelReason: string };Estas uniones discriminadas te permiten escribir funciones helper que se comportan diferente según el estado:
async function verifyOrderActions(page: Page, order: OrderState): Promise<void> {
const cancelButton = page.getByRole('button', { name: 'Cancelar pedido' });
const shipButton = page.getByRole('button', { name: 'Enviar pedido' });
if (order.status === 'shipped') {
// TypeScript sabe que order.trackingNumber existe aquí
await expect(page.getByText(order.trackingNumber)).toBeVisible();
await expect(cancelButton).not.toBeVisible();
} else if (order.status === 'confirmed') {
// TypeScript sabe que canShip es true aquí
await expect(shipButton).toBeEnabled();
await expect(cancelButton).toBeEnabled();
}
}La propiedad status actúa como discriminante: TypeScript la usa para reducir el tipo dentro de cada rama. Cuando accedés a order.trackingNumber dentro de la rama status === 'shipped', TypeScript sabe que esa propiedad existe en esa variante específica.
Type narrowing en tests
El type narrowing es cómo TypeScript refina un tipo amplio a uno específico dentro de un bloque condicional. Lo usas constantemente en código de tests sin necesariamente reconocerlo como concepto formal.
// typeof narrowing: distinguir tipos primitivos
function formatDisplayValue(value: string | number | boolean): string {
if (typeof value === 'string') {
return value.trim(); // TypeScript sabe que value es string aquí
}
if (typeof value === 'number') {
return value.toFixed(2); // TypeScript sabe que value es number aquí
}
return value ? 'Sí' : 'No'; // TypeScript sabe que value es boolean aquí
}// in narrowing: verificar existencia de propiedad en tipos unión
type ApiSuccess = { data: unknown; status: 'success' };
type ApiError = { message: string; code: number; status: 'error' };
type ApiResponse = ApiSuccess | ApiError;
function assertApiSuccess(response: ApiResponse): asserts response is ApiSuccess {
if (response.status === 'error') {
throw new Error(`API error ${response.code}: ${response.message}`);
}
}
async function testOrderCreation(): Promise<void> {
const response: ApiResponse = await createOrder({ productId: 'p1', quantity: 1 });
assertApiSuccess(response); // Lanza si es error, reduce el tipo si no
// TypeScript ahora sabe que response es ApiSuccess
expect(response.data).toBeDefined();
}// instanceof narrowing: útil al trabajar con instancias de clases
import { LoginPage, DashboardPage } from '../pages';
type AppPage = LoginPage | DashboardPage;
async function takeScreenshotWithContext(appPage: AppPage): Promise<void> {
if (appPage instanceof LoginPage) {
// TypeScript sabe que appPage es LoginPage aquí
await appPage.navigate();
} else {
// TypeScript sabe que appPage es DashboardPage aquí
const count = await appPage.getOrderCount();
console.log(`Dashboard mostrando ${count} pedidos`);
}
}El patrón que aparece con más frecuencia en fixtures de tests es el null narrowing: manejar el hecho de que los métodos del DOM como textContent() devuelven string | null:
async function getHeadingText(page: Page): Promise<string> {
const text = await page.getByRole('heading').first().textContent();
// Sin esta verificación, TypeScript da error: 'text' podría ser null
if (text === null) {
throw new Error('El elemento heading no tiene contenido de texto');
}
return text; // TypeScript sabe que text es string aquí
}Configuraciones de modo estricto que detectan bugs reales
La flag strict de TypeScript en tsconfig.json habilita un grupo de verificaciones que son individualmente configurables pero casi siempre vale la pena habilitar juntas.
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true
}
}strict: true es una abreviatura que habilita seis verificaciones individuales. Las dos que detectan más bugs en código de tests son strictNullChecks y noImplicitAny.
strictNullChecks hace que null y undefined sean separados de todos los demás tipos. Sin esto, string y string | null se tratan igual, lo que desactiva todo el propósito de la verificación de null.
// Con strictNullChecks: false (peligroso — el default antes del modo strict)
const text: string = null; // Sin error. Explosión en tiempo de ejecución al llamar text.trim()
// Con strictNullChecks: true
const text: string = null; // Error: Type 'null' is not assignable to type 'string'
const safeText: string | null = null; // Correcto — declaraste la posibilidadnoImplicitAny requiere tipos explícitos en cualquier lugar donde TypeScript no pueda inferirlos. En código de tests, esto detecta parámetros de función sin tipo antes de que se conviertan en bugs:
// noImplicitAny detecta esto
function fillForm(data) { // Error: Parameter 'data' implicitly has an 'any' type
// data podría ser cualquier cosa — sin protección
}
// Correcto: el tipo explícito te obliga a definir la forma
function fillForm(data: RegistrationForm) {
// TypeScript valida cada acceso a campo
}noUncheckedIndexedAccess vale habilitarlo por separado. Añade | undefined al acceso de elementos de array y a las firmas de índice de objetos, porque acceder a array[0] en un array vacío devuelve undefined en tiempo de ejecución:
// Sin noUncheckedIndexedAccess
const rows: string[] = [];
const first: string = rows[0]; // TypeScript lo permite, pero rows[0] es undefined en tiempo de ejecución
// Con noUncheckedIndexedAccess
const rows: string[] = [];
const first: string | undefined = rows[0]; // TypeScript te obliga a manejar undefined
if (first !== undefined) {
console.log(first.toUpperCase()); // Ahora es seguro
}[!warning]
Añadir estas configuraciones estrictas a una migración existente de JavaScript a TypeScript producirá muchos errores a la vez. Si estás convirtiendo un proyecto existente, añade primero"strict": truey corrige esos errores antes de habilitarnoUncheckedIndexedAccess. Intentar corregir todos los errores simultáneamente hace que la migración parezca interminable.
La flag exactOptionalPropertyTypes es útil para factories de datos de prueba. Sin ella, establecer una propiedad opcional en undefined explícitamente se trata igual que omitirla, cuando en realidad no son lo mismo al serializar a JSON:
interface UpdateRequest {
email?: string;
firstName?: string;
}
// Con exactOptionalPropertyTypes: true
const partial: UpdateRequest = { email: undefined }; // Error: undefined no es asignable a string
const correct: UpdateRequest = { email: 'new@example.com' }; // Correcto — incluí solo lo que estás actualizandoPreguntas frecuentes
¿Cuándo debería usar un parámetro de tipo genérico en lugar de un tipo específico?Cuando estás escribiendo una función o fixture que trabaja con múltiples tipos pero necesita preservar la relación entre el tipo de entrada y el tipo de salida. Si estás escribiendo una función factory que devuelve el tipo que pasas como parámetro, eso es un genérico. Si estás escribiendo una función que siempre trabaja con UserProfile, usa UserProfile directamente. No añadas genéricos para flexibilidad que todavía no necesitas.
!) en tests?
Ocasionalmente, sí. Cuando sabes por contexto que un valor no puede ser null pero TypeScript no puede verificarlo (por ejemplo, después de asertar que un elemento es visible), usar value! es razonable. El riesgo es usarlo para silenciar errores legítimos. Si te encuentras escribiendo ! frecuentemente, eso es una señal de que tus tipos no describen con precisión tus datos.
Prefiere la inferencia donde TypeScript puede determinar claramente el tipo: const user = createTestUser() no necesita una anotación si createTestUser tiene un tipo de retorno. Añade anotaciones explícitas en parámetros de funciones, tipos de retorno y propiedades de clases. Esto te da los beneficios de la verificación de tipos en los límites sin saturar cada línea.
type Foo = Bar e interface Foo extends Bar?
Ambos crean un tipo llamado Foo que incluye todas las propiedades de Bar. La diferencia práctica: la extensión de interfaces es más limpia cuando estás añadiendo propiedades a una forma existente y querés que la intención sea clara. La intersección de tipos (Foo = Bar & { extra: string }) es más flexible porque funciona con cualquier tipo, no solo interfaces. En código de tests, ambos están bien. Elige el que se lea más naturalmente.
Sí. La suite de tests suele ser el mejor lugar para introducir TypeScript en un equipo de JavaScript, porque el alcance es acotado y los beneficios son inmediatos. Playwright soporta TypeScript de forma nativa, los archivos de test son independientes, y los Page Objects tipados y las definiciones de fixtures sirven como documentación viva de las formas de datos de la aplicación. Los equipos frecuentemente empiezan con tests tipados y luego expanden TypeScript al código de la aplicación.
→ See also: Interfaces y Tipos de TypeScript para Page Object Model | Mejores Prácticas de TypeScript en el Código de Tests de Playwright | Page Object Model en Playwright: De Caótico a Mantenible