DEV Community

Gustavo Ramirez
Gustavo Ramirez

Posted on

Playwright en AWS CodeBuild con contenedores custom para E2E serios

Correr Playwright en CodeBuild suena simple: instalas Playwright en el buildspec, ejecutas tests, reportas. Pero cuando tu suite crece a 300+ tests E2E, la cosa cambia. Los cold starts de instalar browsers cada build consumen minutos preciosos. Las imágenes default de CodeBuild tienen versiones que no siempre sincronizan con tu versión local. Y el paralelismo real requiere orquestación.

Este artículo es la configuración completa que llevo usando un año. Un contenedor Docker custom con Playwright preinstalado, CodeBuild ejecutando con paralelismo batch, y artefactos que sirven para debuggear fallos sin adivinar. La suite de 300 tests corre en 8 minutos en vez de los 40 que tomaba antes.

El problema con la imagen default de CodeBuild

La imagen aws/codebuild/standard:7.0 tiene Node pero no Playwright. Cada build hace:

# buildspec.yml (forma ingenua)
phases:
  install:
    runtime-versions:
      nodejs: 20
    commands:
      - npm ci
      - npx playwright install --with-deps chromium firefox webkit
  build:
    commands:
      - npx playwright test
Enter fullscreen mode Exit fullscreen mode

El npx playwright install --with-deps baja Chromium (170MB), Firefox (90MB), WebKit (80MB), más dependencias del sistema. En mi proyecto eso tomaba 3-5 minutos cada build. Con 30 builds al día, son ~2 horas diarias de CPU gastadas reinstalando browsers que no cambiaron.

La solución obvia: imagen Docker custom con todo preinstalado.

La imagen custom

# docker/playwright-codebuild/Dockerfile
FROM mcr.microsoft.com/playwright:v1.49.0-jammy

USER root

# Herramientas que CodeBuild espera
RUN apt-get update && apt-get install -y \
    git \
    curl \
    jq \
    unzip \
    ca-certificates \
    && rm -rf /var/lib/apt/lists/*

# AWS CLI v2
RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" \
    && unzip awscliv2.zip \
    && ./aws/install \
    && rm -rf awscliv2.zip aws/

# Node cache config para builds más rápidos
ENV NPM_CONFIG_CACHE=/tmp/.npm
ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright

WORKDIR /workspace

# Verificación que los browsers están listos
RUN npx playwright --version && \
    ls -la /ms-playwright
Enter fullscreen mode Exit fullscreen mode

La clave: uso la imagen oficial de Microsoft (mcr.microsoft.com/playwright) con los browsers preinstalados en /ms-playwright. Agrego AWS CLI, git, y herramientas que CodeBuild usa internamente.

Pushear a ECR

# scripts/push-image.sh
#!/bin/bash
set -e

REGION=${AWS_REGION:-us-east-1}
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
REPO_NAME=playwright-codebuild
TAG=${1:-v1.49.0}

# Crear repo si no existe
aws ecr describe-repositories --repository-names $REPO_NAME --region $REGION \
    || aws ecr create-repository --repository-name $REPO_NAME --region $REGION

# Login
aws ecr get-login-password --region $REGION \
    | docker login --username AWS --password-stdin \
    $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com

# Build y push
docker build -t $REPO_NAME:$TAG ./docker/playwright-codebuild/
docker tag $REPO_NAME:$TAG \
    $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPO_NAME:$TAG
docker push $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPO_NAME:$TAG

echo "Image pushed: $ACCOUNT_ID.dkr.ecr.$REGION.amazonaws.com/$REPO_NAME:$TAG"
Enter fullscreen mode Exit fullscreen mode

Corro este script cada vez que actualizo Playwright. El resto del tiempo, CodeBuild usa la imagen cacheada.

El CDK para el proyecto CodeBuild

// cdk/lib/e2e-pipeline-stack.ts
import { Stack, StackProps, Duration } from "aws-cdk-lib";
import {
  Project,
  Source,
  BuildEnvironment,
  LinuxBuildImage,
  ComputeType,
  BuildSpec,
  Cache,
  LocalCacheMode,
} from "aws-cdk-lib/aws-codebuild";
import { Bucket } from "aws-cdk-lib/aws-s3";
import { Repository } from "aws-cdk-lib/aws-ecr";
import { PolicyStatement } from "aws-cdk-lib/aws-iam";
import { Construct } from "constructs";

export class E2EPipelineStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const artifactsBucket = new Bucket(this, "E2EArtifacts", {
      lifecycleRules: [{ expiration: Duration.days(30) }],
    });

    const ecrRepo = Repository.fromRepositoryName(
      this,
      "PlaywrightImage",
      "playwright-codebuild"
    );

    const project = new Project(this, "E2EProject", {
      projectName: "e2e-tests",
      source: Source.gitHub({
        owner: "mi-org",
        repo: "mi-app",
        webhook: true,
        webhookFilters: [],
      }),
      environment: {
        buildImage: LinuxBuildImage.fromEcrRepository(ecrRepo, "v1.49.0"),
        computeType: ComputeType.LARGE,
        privileged: false,
      },
      cache: Cache.local(LocalCacheMode.CUSTOM, LocalCacheMode.SOURCE),
      buildSpec: BuildSpec.fromSourceFilename("buildspec-e2e.yml"),
      timeout: Duration.minutes(30),
      concurrentBuildLimit: 5,
    });

    // Permisos para subir artefactos
    artifactsBucket.grantReadWrite(project);

    // Permisos para leer secrets de los tests (URLs, credenciales de test user)
    project.addToRolePolicy(
      new PolicyStatement({
        actions: ["secretsmanager:GetSecretValue"],
        resources: [
          `arn:aws:secretsmanager:${this.region}:${this.account}:secret:e2e/*`,
        ],
      })
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Tres cosas importantes en esta configuración. ComputeType.LARGE da 8 vCPUs y 15GB RAM, suficiente para correr 4-6 workers de Playwright en paralelo. concurrentBuildLimit: 5 evita que 10 PRs al mismo tiempo saturen tu cuenta. Y Cache.local con CUSTOM permite cachear node_modules entre builds del mismo branch.

El buildspec completo

# buildspec-e2e.yml
version: 0.2

env:
  variables:
    PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
  secrets-manager:
    TEST_USER_EMAIL: e2e/test-users:email
    TEST_USER_PASSWORD: e2e/test-users:password

phases:
  install:
    commands:
      - echo "Playwright version check:"
      - npx playwright --version

      - echo "Installing project dependencies..."
      - npm ci --prefer-offline --no-audit

  pre_build:
    commands:
      - echo "Shard $SHARD_INDEX of $SHARD_TOTAL"
      - mkdir -p test-results/shard-$SHARD_INDEX

  build:
    commands:
      - echo "Running tests for shard $SHARD_INDEX/$SHARD_TOTAL"
      - |
        BASE_URL=$E2E_BASE_URL \
        CI=true \
        npx playwright test \
          --shard=$SHARD_INDEX/$SHARD_TOTAL \
          --reporter=list,html,json \
          --output=test-results/shard-$SHARD_INDEX \
          || echo "Tests failed, collecting artifacts"

  post_build:
    commands:
      - echo "Collecting artifacts..."
      - ls -la test-results/shard-$SHARD_INDEX

      - |
        if [ -d "playwright-report" ]; then
          aws s3 sync playwright-report \
            s3://$ARTIFACTS_BUCKET/$CODEBUILD_BUILD_ID/shard-$SHARD_INDEX/ \
            --quiet
          echo "Report: https://$ARTIFACTS_BUCKET.s3.amazonaws.com/$CODEBUILD_BUILD_ID/shard-$SHARD_INDEX/index.html"
        fi

      - |
        if [ -d "test-results/shard-$SHARD_INDEX" ]; then
          aws s3 sync test-results/shard-$SHARD_INDEX \
            s3://$ARTIFACTS_BUCKET/$CODEBUILD_BUILD_ID/shard-$SHARD_INDEX-traces/ \
            --quiet
        fi

artifacts:
  files:
    - 'test-results/**/*'
    - 'playwright-report/**/*'
  base-directory: .

cache:
  paths:
    - 'node_modules/**/*'
    - '/tmp/.npm/**/*'
Enter fullscreen mode Exit fullscreen mode

Tres detalles importantes.

Primero, uso secrets-manager de CodeBuild para inyectar credenciales de test. Los secrets van a variables de entorno sin que queden expuestos en logs.

Segundo, --shard=$SHARD_INDEX/$SHARD_TOTAL es nativo de Playwright. Si le paso --shard=2/4, solo corre un cuarto de los tests (los que le tocan al shard 2). Combinando esto con múltiples CodeBuild en paralelo, reparto la suite automáticamente.

Tercero, subo artefactos a S3 en post_build. El reporte HTML de Playwright queda accesible via URL pública, con traces (screenshots, videos, action logs) para cada fallo.

Orquestación con batch builds

CodeBuild soporta "batch builds" que lanzan múltiples builds en paralelo con config distinta. Esto es la clave para paralelismo real.

# buildspec-e2e-batch.yml
version: 0.2

batch:
  fast-fail: false
  build-list:
    - identifier: shard_1
      env:
        variables:
          SHARD_INDEX: "1"
          SHARD_TOTAL: "4"
    - identifier: shard_2
      env:
        variables:
          SHARD_INDEX: "2"
          SHARD_TOTAL: "4"
    - identifier: shard_3
      env:
        variables:
          SHARD_INDEX: "3"
          SHARD_TOTAL: "4"
    - identifier: shard_4
      env:
        variables:
          SHARD_INDEX: "4"
          SHARD_TOTAL: "4"

# Cada build corre el buildspec-e2e.yml con su SHARD_INDEX
Enter fullscreen mode Exit fullscreen mode

Al trigger del webhook, CodeBuild lanza 4 builds en paralelo, cada uno corriendo 1/4 de la suite. Si cada shard toma 8 minutos, la suite total termina en 8 minutos (no 32).

Para habilitar batch builds en CDK:

// dentro del Project
batchBuildConfig: {
  serviceRole: project.role!,
  combineArtifacts: true,
  restrictions: {
    maximumBuildsAllowed: 5,
    computeTypesAllowed: [ComputeType.LARGE, ComputeType.MEDIUM],
  },
},
Enter fullscreen mode Exit fullscreen mode

Playwright config para shards

// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";

export default defineConfig({
  testDir: "./e2e",
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 4 : undefined,
  reporter: [
    ["list"],
    ["html", { open: "never" }],
    ["json", { outputFile: "test-results/results.json" }],
  ],
  use: {
    baseURL: process.env.E2E_BASE_URL,
    trace: "retain-on-failure",
    screenshot: "only-on-failure",
    video: "retain-on-failure",
    viewport: { width: 1280, height: 720 },
  },
  projects: [
    {
      name: "chromium",
      use: { ...devices["Desktop Chrome"] },
    },
    {
      name: "firefox",
      use: { ...devices["Desktop Firefox"] },
    },
    {
      name: "webkit",
      use: { ...devices["Desktop Safari"] },
    },
  ],
  outputDir: "test-results",
});
Enter fullscreen mode Exit fullscreen mode

trace: "retain-on-failure" guarda el trace solo cuando un test falla. El trace incluye screenshots de cada acción, network logs, y timeline. Puedes reproducir exactamente qué pasó descargando el .zip y abriéndolo con npx playwright show-trace trace.zip.

Merge de reportes

Con 4 shards generando reportes separados, quiero un reporte consolidado al final. Para eso agrego un build final que corre después de los shards:

# buildspec-e2e-merge.yml
version: 0.2

batch:
  build-list:
    - identifier: shard_1
      # ... como antes
    - identifier: shard_2
      # ...
    - identifier: shard_3
      # ...
    - identifier: shard_4
      # ...
    - identifier: merge_reports
      depend-on:
        - shard_1
        - shard_2
        - shard_3
        - shard_4
      buildspec: buildspec-merge.yml
      env:
        compute-type: BUILD_GENERAL1_SMALL
Enter fullscreen mode Exit fullscreen mode

El buildspec-merge descarga todos los reportes de S3 y los mergea:

# buildspec-merge.yml
version: 0.2

phases:
  install:
    commands:
      - npm install -g playwright-merge-html-reports

  build:
    commands:
      - mkdir -p merged-report
      - aws s3 sync s3://$ARTIFACTS_BUCKET/$CODEBUILD_INITIATOR/ ./all-shards/
      - playwright-merge-html-reports all-shards/ -o merged-report/
      - aws s3 sync merged-report/ \
          s3://$ARTIFACTS_BUCKET/$CODEBUILD_INITIATOR/merged/ --quiet
      - echo "Merged report: https://$ARTIFACTS_BUCKET.s3.amazonaws.com/$CODEBUILD_INITIATOR/merged/index.html"
Enter fullscreen mode Exit fullscreen mode

Notificaciones a Slack al fallar

El buildspec también dispara notificaciones cuando hay fallos. Uso EventBridge + SNS + un Lambda de formateo:

// cdk/lib/notifications-stack.ts
import { Rule } from "aws-cdk-lib/aws-events";
import { LambdaFunction } from "aws-cdk-lib/aws-events-targets";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";

const notifyFn = new NodejsFunction(this, "NotifyFailure", {
  entry: "lambdas/notify-failure.ts",
  environment: {
    SLACK_WEBHOOK_URL: process.env.SLACK_WEBHOOK!,
  },
});

new Rule(this, "E2EFailureRule", {
  eventPattern: {
    source: ["aws.codebuild"],
    detailType: ["CodeBuild Build State Change"],
    detail: {
      "project-name": ["e2e-tests"],
      "build-status": ["FAILED"],
    },
  },
  targets: [new LambdaFunction(notifyFn)],
});
Enter fullscreen mode Exit fullscreen mode

El Lambda formatea el evento y postea en Slack con link al reporte:

// lambdas/notify-failure.ts
export const handler = async (event: any) => {
  const { "build-id": buildId, "project-name": project } = event.detail;
  const shortId = buildId.split("/").pop()?.split(":").pop();
  const reportUrl = `https://${process.env.BUCKET}.s3.amazonaws.com/${buildId}/merged/index.html`;

  await fetch(process.env.SLACK_WEBHOOK_URL!, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      blocks: [
        {
          type: "section",
          text: {
            type: "mrkdwn",
            text: `:x: E2E tests failed on *${project}*\nBuild: \`${shortId}\`\n<${reportUrl}|Ver reporte completo>`,
          },
        },
      ],
    }),
  });
};
Enter fullscreen mode Exit fullscreen mode

Números reales del setup

Proyecto con 340 tests Playwright en Chromium + Firefox + WebKit. Antes del setup custom:

Fase Tiempo antes Tiempo después
Install Playwright browsers 3.5 min 0 (preinstalados)
npm ci 2 min 25s (con cache local)
Run tests serial 34 min -
Run tests shard 1/4 paralelo - 8 min
Total wall-clock 39.5 min 8.5 min

Reducción de 80% en tiempo, con la suite completa corriendo en cada PR.

Lo que aprendí afinando esto

1. Las imágenes de Microsoft se actualizan cada release de Playwright.
Un día actualicé Playwright en el proyecto y los tests empezaron a fallar en CI pero no localmente. La imagen de CodeBuild usaba Playwright 1.45 pero el package.json ya estaba en 1.47. Cree un pre-commit que valida que los dos matchean, o el build falla rápido con mensaje claro.

2. El cache local de CodeBuild es frágil.
Cache.local(LocalCacheMode.CUSTOM) funciona pero no siempre. Si el build corre en una instancia nueva del pool de CodeBuild, el cache está vacío. Para garantías, usa S3 cache, aunque es más lento. Para pragmatismo, acepto que a veces el cache no está y tomo el hit.

3. Los secrets del Secrets Manager suben el costo de builds.
Cada build con secrets mana hace API calls. Para 30 builds/día con 5 secrets cada uno, son 4,500 calls/mes extras. Si tienes muchos secrets, usa Parameter Store (gratis hasta cierto límite) o inyecta vía variables de entorno del proyecto CodeBuild para secretos no críticos.

4. El timeout de CodeBuild cuenta el pull de imagen.
Si tu imagen pesa 2GB, el primer pull en una instancia nueva toma 2-3 minutos que cuentan contra tu timeout de 30 minutos. Tuve builds que timeouteaban no porque los tests tardaran, sino porque la imagen tardó mucho. Solución: mantener la imagen chica (la mía pesa 1.1GB) y habilitar image pull cache en la config del proyecto.

5. fail-fast apagado cambia la estrategia de debugging.
Con fast-fail: false en batch builds, todos los shards terminan aunque uno falle. Esto te da visibilidad completa del estado de la suite, pero también significa que tardas más en detectar "está roto". Para main branch uso fail-fast: true (rápido), para PRs fail-fast: false (completo).

6. Videos y traces ocupan espacio.
340 tests con video en fallos generan gigas cuando hay mala semana. Configuré lifecycle en el bucket de artefactos para borrar a los 30 días. También limito video: "retain-on-failure" en vez de on para grabar solo cuando hay algo que mirar.

Cuándo NO usar este setup

Si tu suite tiene menos de 20 tests, la infra es sobrecosto. Ejecutar en GitHub Actions con el action oficial de Playwright es más simple.

Si tu equipo no tiene experiencia con Docker y CI, mantener imágenes custom agrega fricción. Acepta imágenes default de CodeBuild con un poco más de tiempo de build al principio, y migra custom cuando duela lo suficiente.

Si tus tests dependen de servicios muy específicos (drivers para Selenium Grid, browsers legacy), considera AWS Device Farm o un servicio dedicado como BrowserStack. Playwright en CodeBuild cubre el 90% pero no el 100% de casos.


En el próximo artículo: bundle splitting guiado por métricas reales de CloudWatch RUM. Cómo dejar de adivinar qué chunks crear y usar datos de usuarios reales para decidir la arquitectura de tus bundles.

Top comments (0)