Sitemap
Tj Marlon

Tj Marlon

Testing en Flutter Web, parte 2: integration tests, permisos y Playwright

9 min readOct 25, 2026

--

ChromeDriver, diálogos de cámara y un canvas que Playwright no puede ver: lo que aprendí llevando los integration tests de una app Flutter al navegador

En la parte 1 vimos que los widget tests casi no cambian en web. Los integration tests sí: cambian el comando, la herramienta y las trampas. Y esas trampas casi nunca dan un error claro. Te dejan mirando una terminal que espera algo que nunca llega.

Todo lo que cuento lo ejecuté en macOS con Chrome y ChromeDriver 155 y Flutter 3.47.3. Cuando algo no lo probé, lo digo. Los nombres de archivos y clases de los ejemplos son genéricos: adáptalos a tu proyecto.

En resumen:

  • En web, flutter test integration_test no funciona. Usa flutter drive con ChromeDriver.
  • La documentación oficial usa -d chrome; a mí se me quedó colgado y -d web-server funcionó a la primera. Con --no-headless ves el navegador.
  • ChromeDriver tiene que corresponder a la versión mayor de tu Chrome, e instalarlo no lo agrega al PATH.
  • En headless, Chrome me negó los permisos de cámara al instante; con la ventana visible, el test esperó a que una persona aceptara el diálogo. Dos flags de Chrome lo resuelven en los dos modos.
  • Playwright solo encuentra tus botones si activas Semantics.
  • Para escribir menos código: un robot compartido, un solo driver y un adaptador pequeño por plataforma.

flutter drive, no flutter test

En Android o iOS basta con flutter test integration_test. En web, ese comando responde:

Web devices are not supported for integration tests yet.

En web intervienen tres piezas:

  1. flutter drive compila y sirve tu archivo de integration_test/.
  2. ChromeDriver abre Chrome y carga esa URL.
  3. El archivo de test_driver/ corre en tu máquina (el host) y recibe los resultados.

Empieza por un smoke test

// integration_test/web_smoke_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('Flutter responde en Chrome', (tester) async {
int taps = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) => ElevatedButton(
onPressed: () => setState(() => taps++),
child: Text('Toques: $taps'),
),
),
),
),
);
await tester.tap(find.text('Toques: 0'));
await tester.pump();
expect(find.text('Toques: 1'), findsOneWidget);
});
}

Si este test falla, el problema es el entorno (ChromeDriver, versiones), no tu app. Empezar por aquí me ahorró horas.

Un solo driver para todo

// test_driver/integration_test.dart
import 'package:integration_test/integration_test_driver.dart';
Future<void> main() => integrationDriver();

Pasa siempre --driver de forma explícita. Así un único driver sirve para todos tus tests web.

ChromeDriver: donde se cae casi todo el mundo

La versión mayor tiene que coincidir con la de tu Chrome. chromedriver@stable puede no ser tu versión. Y el parche exacto de tu Chrome puede no estar publicado en Chrome for Testing, así que instala por versión mayor:

# macOS. @puppeteer/browsers 3.x requiere Node 22.12 o superior
CHROME_MAJOR=$("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --version | awk '{print $3}' | cut -d. -f1)
npx --yes @puppeteer/browsers install "chromedriver@$CHROME_MAJOR" --path .dart_tool/chromedriver

En mi caso, para Chrome 155.0.8059.86 instaló ChromeDriver 155.0.8059.84, y funcionó sin problemas.

Instalarlo no lo agrega al PATH. Por eso chromedriver --port=4444 responde command not found. Yo lo guardo en .dart_tool/chromedriver/, que git ya ignora, y lo lanzo con un target de Make:

CHROMEDRIVER_BIN ?= $(shell find .dart_tool/chromedriver -type f -name chromedriver -print -quit 2>/dev/null)
chromedriver:
@test -n "$(CHROMEDRIVER_BIN)" || { echo "ChromeDriver no encontrado"; exit 1; }
"$(CHROMEDRIVER_BIN)" --port=4444

Cuando Chrome cambie de versión mayor, reinstálalo. Para CI, fija una pareja Chrome + ChromeDriver de Chrome for Testing.

Ejecutar

# Terminal 1
make chromedriver
# Terminal 2
flutter drive \
--driver=test_driver/integration_test.dart \
--target=integration_test/web_smoke_test.dart \
-d web-server
# Para ver el navegador, agrega --no-headless

Dos cosas que me pasaron. La documentación oficial usa -d chrome, pero en mi máquina ese comando se quedó 85 segundos en Waiting for connection from debug service on Chrome y nunca arrancó; -d web-server funcionó a la primera. Y una vez vi SessionNotCreatedException ... Unable to receive message from renderer al crear la sesión; en el reintento no apareció. Antes de cambiar nada, reintenta.

Probar una pantalla real sin backend

El smoke test valida el entorno, no tu app. Para montar una pantalla real sin depender del backend:

  • Sobrescribe providers. En mi caso con Riverpod: idioma, conectividad, biometría y el estado inicial de la pantalla.
  • Carga las traducciones desde el asset con rootBundle.loadString(...). Aquí sí funciona, a diferencia de flutter test --platform chrome (ver la parte 1).
  • Controla el loading con un Completer. Verificas que aparece el loading, lo completas y verificas el formulario.
  • Inicializa lo que normalmente hace main(). Me apareció un LateInitializationError porque la navegación global se configura al arrancar la app. Si montas la pantalla aislada, el harness tiene que hacerlo.

Un ejemplo ilustrativo (no compila tal cual: los providers y las clases son de ejemplo):

final ready = Completer<InitState>();
await tester.pumpWidget(
ProviderScope(
overrides: [
translationsProvider.overrideWith(() => TestTranslations(json)),
networkProvider.overrideWith(OnlineNotifier.new),
initProvider.overrideWith((_) => ready.future),
],
child: const MaterialApp(home: SignInPage()),
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
ready.complete(const InitState(ready: true));
await tester.pumpAndSettle();
final robot = SignInRobot(tester);
robot.expectFormVisible();
await robot.tapSubmit();
robot.expectEmailError('Ingresa tu correo');

Así validé en Chrome el loading, el formulario traducido, la validación de campos vacíos y el botón de mostrar u ocultar la contraseña, sin enviar credenciales a ningún backend.

Para las demos con --no-headless quería ver cada paso, así que agregué pausas entre ellos. Para que no se cuelen en CI, las controlo con un --dart-define:

const bool _demoPauses = bool.fromEnvironment('DEMO_PAUSES');
Future<void> _showStep(WidgetTester tester) async {
if (!_demoPauses) return;
await tester.runAsync(() => Future<void>.delayed(const Duration(seconds: 2)));
}

Sin el define, el test corre a velocidad normal; con --dart-define=DEMO_PAUSES=true, se puede seguir con la vista.

Permisos del navegador: la cámara

integration_test controla el árbol de widgets, pero no puede hacer clic en los diálogos nativos de Chrome. Lo probé con un test que llama a getUserMedia (la cámara) desde package:web:

  • Headless, sin flags: NotAllowedError: Permission denied, al instante.
  • Visible, sin flags: aparece el diálogo nativo y el test espera a que una persona lo acepte.
  • Con flags, en cualquiera de los dos modos: el permiso se concede sin diálogo.

Los flags se le pasan a Chrome a través de flutter drive:

flutter drive \
--driver=test_driver/integration_test.dart \
--target=integration_test/web_camera_test.dart \
-d web-server \
--web-browser-flag=--use-fake-ui-for-media-stream \
--web-browser-flag=--use-fake-device-for-media-stream

El primero evita el diálogo de permisos y elige el dispositivo por defecto. El segundo reemplaza la cámara y el micrófono por dispositivos falsos, así que no necesitas hardware y funciona igual en CI. La documentación de Chromium sugiere --auto-accept-camera-and-microphone-capture como alternativa más acotada al primero; no la probé.

Get Tj Marlon’s stories in your inbox

Join Medium for free to get updates from this writer.

Tres cosas que aprendí:

  • En mi prueba headless no se quedó esperando: negó al instante. Si tu test falla con NotAllowedError en CI pero pasa en tu máquina con la ventana abierta, revisa esto primero.
  • En modo visible sin flags, el test depende de que alguien haga clic. En mi prueba pasó solo porque acepté el diálogo a mano. Eso no es repetible. Usa los flags siempre y tendrás el mismo comportamiento en los dos modos.
  • Las opciones de Chrome no van a chromedriver. Van en --web-browser-flag, que flutter drive le pasa a Chrome. Ojo: es una opción oculta, así que no aparece en flutter drive -h; la ves con flutter drive -h -v.

Hay otra alternativa: conceder permisos desde el driver con el Chrome DevTools Protocol, usando Browser.setPermission (el antiguo Browser.grantPermissions está marcado como obsoleto). No la probé, porque con los flags me alcanzó y es mucho más simple.

Playwright: para lo que vive fuera de Flutter

Hay cosas que integration_test no ve: el botón "atrás" del navegador, el historial, iframes externos, un SSO u OAuth que redirige a otro dominio, cookies entre dominios. Para eso existe Playwright.

El problema es que Flutter dibuja en un canvas, así que Playwright no “ve” tus botones. Se apoya en el árbol de Semantics que Flutter Web expone en el DOM, y ese árbol no está activo por defecto. Lo comprobé compilando la misma app dos veces: con ensureSemantics(), getByRole('button', { name: 'Increment' }) encontró el botón, hizo clic y vio el contador en 1; sin Semantics, timeout a los 10 segundos.

Actívalo solo en los builds de E2E con un --dart-define:

const bool enableSemantics = bool.fromEnvironment('E2E_SEMANTICS');
void main() {
WidgetsFlutterBinding.ensureInitialized();
if (enableSemantics) {
SemanticsBinding.instance.ensureSemantics();
}
runApp(const MyApp());
}
flutter build web --dart-define=E2E_SEMANTICS=true
// Playwright, usando el Chrome que ya tienes instalado (sin descargar navegadores)
import { chromium } from 'playwright-core';
const browser = await chromium.launch({ channel: 'chrome' });
const page = await browser.newPage();
await page.goto('http://localhost:8080');
await page.locator('flutter-view').waitFor();
await page.getByRole('button', { name: 'Increment' }).click();
await page.getByText('Count: 1').waitFor();

Algunas notas:

  • Un ElevatedButton con un Text hijo ya expone ese texto como nombre accesible. Semantics(label: ...) solo hace falta donde no hay texto (íconos, imágenes) o cuando quieres un selector estable que no dependa del texto visible.
  • Espera a que exista flutter-view antes de buscar elementos.
  • playwright-core con channel: 'chrome' usa tu Chrome instalado, así evitas descargar navegadores. Revisa la versión de Node que pide la versión actual de Playwright: hoy es Node 22, 24 o 26.
  • De paso, lo que haces por Playwright mejora la accesibilidad real de la app.

Cómo escribir menos código

El error típico es pensar “tengo que escribir todos los tests otra vez para web”. No hace falta.

Un robot compartido

El patrón Robot encapsula los finders y las acciones (enterEmail, tapSubmit, expectEmailError). La trampa en web: mi integration test móvil importaba el robot desde ../test/..., y en web eso no compila:

Error when reading ../test/...

La solución fue mover la implementación a integration_test/support/ y que test/ la re-exporte:

integration_test/
support/
sign_in_robot.dart ← la implementación real
test/
support/
sign_in_robot.dart ← export '../../integration_test/support/sign_in_robot.dart';

Ahora hay una sola implementación (eliminé más de cien líneas duplicadas), y la usan los widget tests, el integration test móvil y el web.

Un solo driver

Si todos tus drivers son Future<void> main() => integrationDriver();, no necesitas uno por test. Pasa --driver explícito y reutilízalo. Crea otro driver solo si necesitas preparar algo distinto en el host.

El mismo cuerpo, con un adaptador pequeño por plataforma

Un integration test puede correr en móvil y en web si todo lo que importa compila en ambos y el flujo es el mismo: cambian el comando y el driver, no el test. Cuando el arranque difiere (en móvil hay onboarding y plugins nativos; en web, sin sesión, vas directo al login), comparte el robot y los fixtures, y crea dos entradas pequeñas que solo preparan la plataforma.

Comandos con nombre, no de memoria

test-web-smoke:
flutter drive --driver=test_driver/integration_test.dart --target=integration_test/web_smoke_test.dart -d web-server
test-web-smoke-visible:
flutter drive --driver=test_driver/integration_test.dart --target=integration_test/web_smoke_test.dart -d web-server --no-headless

Y una convención de nombres: mis tests web empiezan con web_, así el script que corre los integration tests móviles los salta automáticamente.

Variantes en lugar de copiar y pegar

Para responsive, usa ValueVariant en un widget test de VM (ver la parte 1): un test con tres tamaños que corre en milisegundos. No hace falta un integration test por cada ancho.

¿Y si tu app es solo Flutter Web?

Casi nada cambia, y correr todo en Chrome es un error:

  1. Unit tests y lógica de negocio: en la VM, siempre. Si tu dominio es Dart puro, no hay razón para pagar el arranque de Chrome.
  2. Widget tests: en la VM por defecto, aunque la app nunca vaya a correr en móvil. El stub del import condicional no es “código para móvil”: es lo que mantiene tus tests rápidos.
  3. --platform chrome solo para lo que toca el navegador y para unos pocos goldens de referencia (parte 1), recordando que esa opción está deprecada.
  4. Integration tests con flutter drive para los flujos críticos: login, pago, el camino principal. Pocos y valiosos.
  5. Playwright para el E2E de historial, redirecciones, iframes y cookies.

La pirámide para web queda así: muchos unit y widget tests en la VM, algunos widget y golden tests en Chrome, pocos integration tests con flutter drive y muy pocos E2E con Playwright.

Diagnóstico rápido

  • Web devices are not supported for integration tests yet.: usaste flutter test. Usa flutter drive.
  • Unable to start a WebDriver session: no hay un ChromeDriver escuchando en el puerto 4444 (o en el que pasaste con --driver-port).
  • SessionNotCreatedException al arrancar: si menciona la versión, ChromeDriver no corresponde a tu Chrome. Si menciona el renderer, a mí me pasó una vez y no se repitió: reintenta.
  • command not found: chromedriver: quedó fuera del PATH. Usa la ruta completa o un target de Make.
  • Waiting for connection from debug service on Chrome sin fin: prueba con -d web-server.
  • Error when reading ../test/...: el target web importa soporte de fuera de integration_test/. Muévelo a integration_test/support/.
  • LateInitializationError al montar una pantalla aislada: algo que configura main() no se inicializó en el harness.
  • NotAllowedError: Permission denied en CI: headless niega los permisos. Usa --web-browser-flag=--use-fake-ui-for-media-stream.
  • Playwright no encuentra ningún elemento: Semantics no está activo. Llama a ensureSemantics() en el build de E2E.
  • El test pasa, pero el SSO o el portal real falla: dominio, HTTPS, cookies o CORS. localhost no reproduce eso.

Lo que me llevo

Los integration tests en web no son más difíciles que en móvil. Son distintos, y casi todo el dolor está en el entorno: la versión de ChromeDriver, el dispositivo correcto en flutter drive y los permisos del navegador. Una vez resuelto eso con un smoke test, el resto es el mismo Flutter de siempre, con un robot compartido y comandos con nombre.

Lo que todavía no resolví es llevar esto a un pipeline de CI y fijar ahí la pareja Chrome/ChromeDriver. Si ya lo hiciste en GitHub Actions, GitLab u otro sistema de CI, cuéntame en los comentarios cómo te fue.

--

--