Un código de estado 200 con nombres de campos incorrectos sigue siendo un bug, y la mayoría de los suites básicos de tests de API no lo detectan porque solo verifican el estado. A escala, los patrones que mantienen los suites confiables son la autenticación basada en fixtures (inyectar tokens, no repetir código de login en cada test), la validación de schema contra tipos de campos y campos obligatorios reales, y la cobertura completa del ciclo CRUD que verifica que la eliminación sea real, no solo una respuesta 204.

El fixture request vs page.request

Dos formas de hacer llamadas a la API en Playwright:

// 1. El fixture request: sin navegador, API pura
test('test de API', async ({ request }) => {
  const response = await request.get('/api/users');
});

// 2. page.request: comparte cookies con la página del navegador
test('API después del login en el navegador', async ({ page }) => {
  await page.goto('/login');
  await page.fill('[data-testid="email"]', 'user@test.com');
  await page.fill('[data-testid="password"]', 'ValidPass1');
  await page.click('[data-testid="submit"]');

  // Esta solicitud usa la sesión del navegador (cookies del login)
  const response = await page.request.get('/api/user/profile');
});

Usa el fixture request para tests de API puros. Usa page.request cuando necesitas la sesión autenticada del navegador.

Patrones de autenticación

Auth basada en token

test.describe('Tests de API autenticados', () => {
  let authToken: string;

  test.beforeAll(async ({ request }) => {
    const response = await request.post('/api/auth/login', {
      data: {
        email: 'admin@test.com',
        password: 'AdminPass1',
      },
    });

    expect(response.ok()).toBeTruthy();
    const body = await response.json();
    authToken = body.token;
  });

  test('obtener lista de usuarios', async ({ request }) => {
    const response = await request.get('/api/users', {
      headers: { Authorization: `Bearer ${authToken}` },
    });

    expect(response.status()).toBe(200);
  });

  test('crear usuario', async ({ request }) => {
    const response = await request.post('/api/users', {
      data: { email: 'nuevo@test.com', password: 'Pass1', role: 'member' },
      headers: { Authorization: `Bearer ${authToken}` },
    });

    expect(response.status()).toBe(201);
  });
});

Auth mediante fixtures

Mejor que beforeAll: los fixtures son más limpios y manejan el teardown:

// fixtures/index.ts
export const test = base.extend<{ authToken: string }>({
  authToken: async ({ request }, use) => {
    const response = await request.post('/api/auth/login', {
      data: { email: 'admin@test.com', password: 'AdminPass1' },
    });
    const { token } = await response.json();
    await use(token);
    // No se necesita teardown para tokens: expiran o se revocan solos
  },
});

Validación de schema

Verifica la estructura de la respuesta, no solo los códigos de estado:

import Ajv from 'ajv';

const ajv = new Ajv();

const userSchema = {
  type: 'object',
  required: ['id', 'email', 'role', 'createdAt'],
  properties: {
    id: { type: 'number' },
    email: { type: 'string', format: 'email' },
    role: { type: 'string', enum: ['admin', 'member', 'viewer'] },
    createdAt: { type: 'string' },
    name: { type: 'string' },
  },
  additionalProperties: false,
};

test('la respuesta de usuario coincide con el schema', async ({ request, authToken }) => {
  const response = await request.get('/api/users/1', {
    headers: { Authorization: `Bearer ${authToken}` },
  });

  const body = await response.json();
  const validate = ajv.compile(userSchema);
  const valid = validate(body);

  if (!valid) {
    throw new Error(`Validación de schema fallida: ${JSON.stringify(validate.errors)}`);
  }

  expect(valid).toBe(true);
});

Sin una librería, usa verificaciones manuales:

function validateUserSchema(body: unknown) {
  const user = body as Record<string, unknown>;

  expect(typeof user.id).toBe('number');
  expect(typeof user.email).toBe('string');
  expect(user.email).toMatch(/@/);
  expect(['admin', 'member', 'viewer']).toContain(user.role);
  expect(typeof user.createdAt).toBe('string');
  expect(() => new Date(user.createdAt as string)).not.toThrow();
}

test('validación de schema de usuario', async ({ request, authToken }) => {
  const response = await request.get('/api/users/1', {
    headers: { Authorization: `Bearer ${authToken}` },
  });

  const body = await response.json();
  validateUserSchema(body);
});

Ciclo de vida CRUD en los tests

Testea el ciclo completo crear → leer → actualizar → eliminar:

test.describe('CRUD de usuarios', () => {
  const authHeaders = () => ({
    Authorization: `Bearer ${authToken}`,
  });

  let createdUserId: number;
  const userData = {
    email: `crud_test_${Date.now()}@test.com`,
    password: 'ValidPass1',
    name: 'Usuario de Test CRUD',
    role: 'member',
  };

  test('CREAR — POST /api/users', async ({ request }) => {
    const response = await request.post('/api/users', {
      data: userData,
      headers: authHeaders(),
    });

    expect(response.status()).toBe(201);

    const body = await response.json();
    expect(body.email).toBe(userData.email);
    expect(body.id).toBeDefined();
    expect(body.password).toBeUndefined(); // la contraseña nunca debe devolverse

    createdUserId = body.id;
  });

  test('LEER — GET /api/users/:id', async ({ request }) => {
    const response = await request.get(`/api/users/${createdUserId}`, {
      headers: authHeaders(),
    });

    expect(response.status()).toBe(200);

    const body = await response.json();
    expect(body.id).toBe(createdUserId);
    expect(body.email).toBe(userData.email);
  });

  test('ACTUALIZAR — PUT /api/users/:id', async ({ request }) => {
    const response = await request.put(`/api/users/${createdUserId}`, {
      data: { name: 'Nombre Actualizado' },
      headers: authHeaders(),
    });

    expect(response.status()).toBe(200);

    const body = await response.json();
    expect(body.name).toBe('Nombre Actualizado');
    expect(body.email).toBe(userData.email); // el email no cambia
  });

  test('ELIMINAR — DELETE /api/users/:id', async ({ request }) => {
    const response = await request.delete(`/api/users/${createdUserId}`, {
      headers: authHeaders(),
    });

    expect(response.status()).toBe(204); // sin contenido
  });

  test('VERIFICAR ELIMINACIÓN — GET devuelve 404', async ({ request }) => {
    const response = await request.get(`/api/users/${createdUserId}`, {
      headers: authHeaders(),
    });

    expect(response.status()).toBe(404);
  });
});

Testear casos de error

El camino feliz no es suficiente:

test.describe('Manejo de errores', () => {
  test('401 — token de auth faltante', async ({ request }) => {
    const response = await request.get('/api/users');
    expect(response.status()).toBe(401);

    const body = await response.json();
    expect(body.error).toBeDefined();
  });

  test('403 — permisos insuficientes', async ({ request, memberToken }) => {
    // Un member intentando acceder a un endpoint de admin
    const response = await request.get('/api/admin/logs', {
      headers: { Authorization: `Bearer ${memberToken}` },
    });
    expect(response.status()).toBe(403);
  });

  test('404 — recurso inexistente', async ({ request, authToken }) => {
    const response = await request.get('/api/users/999999', {
      headers: { Authorization: `Bearer ${authToken}` },
    });
    expect(response.status()).toBe(404);

    const body = await response.json();
    expect(body.message).toContain('not found');
  });

  test('400 — body de solicitud inválido', async ({ request, authToken }) => {
    const response = await request.post('/api/users', {
      data: { email: 'no-es-un-email', password: '123' }, // inválido
      headers: { Authorization: `Bearer ${authToken}` },
    });

    expect(response.status()).toBe(400);

    const body = await response.json();
    expect(body.errors).toBeDefined();
    expect(body.errors).toBeInstanceOf(Array);
  });

  test('409 — recurso duplicado', async ({ request, authToken }) => {
    const userData = { email: 'duplicado@test.com', password: 'ValidPass1' };

    // Crear el primero
    await request.post('/api/users', {
      data: userData,
      headers: { Authorization: `Bearer ${authToken}` },
    });

    // Crear el duplicado
    const response = await request.post('/api/users', {
      data: userData,
      headers: { Authorization: `Bearer ${authToken}` },
    });

    expect(response.status()).toBe(409);
  });
});

Testing de paginación

test.describe('Paginación', () => {
  test('devuelve el tamaño de página correcto', async ({ request, authToken }) => {
    const response = await request.get('/api/users?page=1&limit=10', {
      headers: { Authorization: `Bearer ${authToken}` },
    });

    const body = await response.json();

    expect(body.data).toHaveLength(10);
    expect(body.page).toBe(1);
    expect(body.limit).toBe(10);
    expect(typeof body.total).toBe('number');
    expect(typeof body.totalPages).toBe('number');
  });

  test('la última página tiene menos ítems', async ({ request, authToken }) => {
    // Obtener el total primero
    const firstResp = await request.get('/api/users?page=1&limit=10', {
      headers: { Authorization: `Bearer ${authToken}` },
    });
    const { total, totalPages } = await firstResp.json();

    // Obtener la última página
    const lastResp = await request.get(`/api/users?page=${totalPages}&limit=10`, {
      headers: { Authorization: `Bearer ${authToken}` },
    });
    const lastPage = await lastResp.json();

    const expectedLastPageCount = total % 10 || 10;
    expect(lastPage.data).toHaveLength(expectedLastPageCount);
  });

  test('página más allá del total devuelve vacío', async ({ request, authToken }) => {
    const response = await request.get('/api/users?page=9999&limit=10', {
      headers: { Authorization: `Bearer ${authToken}` },
    });

    const body = await response.json();
    expect(body.data).toHaveLength(0);
  });
});

Combinar setup vía API con tests de UI

El patrón más potente: usar la API para preparar el estado y luego verificar a través de la UI:

test('el usuario recién creado aparece en el panel de admin', async ({ page, request, authToken }) => {
  // 1. Crear el usuario vía API (rápido y confiable)
  const userData = {
    email: `nuevo_${Date.now()}@test.com`,
    password: 'ValidPass1',
    name: 'Nuevo Usuario API',
    role: 'member',
  };

  const createResp = await request.post('/api/users', {
    data: userData,
    headers: { Authorization: `Bearer ${authToken}` },
  });
  const { id } = await createResp.json();

  // 2. Verificar vía UI (lo que ven los usuarios reales)
  await page.goto('/login');
  await page.fill('[data-testid="email"]', 'admin@test.com');
  await page.fill('[data-testid="password"]', 'AdminPass1');
  await page.click('[data-testid="submit"]');

  await page.goto('/admin/users');
  await page.fill('[data-testid="search"]', userData.email);

  await expect(page.getByTestId('user-row').first()).toContainText(userData.name);

  // 3. Limpiar vía API (rápido y confiable)
  await request.delete(`/api/users/${id}`, {
    headers: { Authorization: `Bearer ${authToken}` },
  });
});

Aserciones sobre tiempo de respuesta

test('el endpoint de lista responde dentro del SLA', async ({ request, authToken }) => {
  const startTime = Date.now();

  const response = await request.get('/api/users?limit=100', {
    headers: { Authorization: `Bearer ${authToken}` },
  });

  const duration = Date.now() - startTime;

  expect(response.status()).toBe(200);
  expect(duration).toBeLessThan(500); // SLA de 500ms
});

Resumen

Patrones que se sostienen a escala:

1. Usa fixtures para tokens de autenticación: inyectalos, no repitas el código de login

2. Valida schemas: un estado 200 con campos incorrectos sigue siendo un bug

3. Testea CRUD en orden: crear, leer, actualizar, eliminar como un ciclo de vida

4. Testea los casos de error: 401, 403, 404, 400, 409 son tan importantes como el 200

5. Usa la API para el setup/teardown en tests de UI: más rápido y confiable que el setup vía UI

6. Aísla los datos: email único por test, eliminar después

→ See also: Pruebas de API con el APIRequestContext de Playwright (Sin Postman) | Autenticación en Tests de API: Claves API, Tokens Bearer, OAuth2, JWT | Pruebas de API GraphQL con Playwright: Consultas, Mutaciones y Manejo de Errores