End-to-end (E2E) testing is critical for confirming that user registration, database interactions, and serverless logic work in harmony. However, writing E2E tests for serverless databases like Google Cloud Firestore or Firebase Authentication is traditionally a developer nightmare.
Testing against live production or staging cloud environments introduces high latency, cost overheads, network flakiness, and state pollution (where concurrent tests corrupt each other's test data).
To achieve fast, free, and 100% deterministic E2E testing, you need a local loop: Playwright executed against the Firebase Local Emulator Suite.
This article details how to orchestrate a fully automated testing pipeline using Playwright and Firebase Emulators, leveraging proven architectural patterns from the Tetangga App community portal.
The Core Strategy: Standardizing on the Loopback
To prevent flaky tests and environment drift, our testing loop operates on three absolute principles:
- IPv4 Loopback Standardization: Never use
localhostin emulator connection scripts. On containerized builders or different operating systems,localhostcan dynamically resolve to the IPv6 loopback (::1), causing suddenECONNREFUSEDconnection drops. Always use127.0.0.1. - Single-Worker Execution: Parallel browser testing is great for static pages, but fatal when writing to a shared local database. Run your tests with a single worker (
workers: 1) to guarantee isolated execution state. - Automated Server Lifecycles: Use the Firebase Emulator CLI wrapper to boot the database, start the Next.js dev server, execute the tests, reset states, and tear down all background processes cleanly in a single script.
๐ Step 1: Connecting Firebase to Local Emulators
Your client-side Firebase initialization script must dynamically check an environment flag (e.g., NEXT_PUBLIC_USE_FIREBASE_EMULATORS) to determine whether it should connect to production or local emulators.
Here is the robust initialization script:
// src/lib/firebase.ts
import { initializeApp, getApps, getApp } from "firebase/app";
import { getAuth, connectAuthEmulator } from "firebase/auth";
import { getFunctions, connectFunctionsEmulator } from "firebase/functions";
import { getFirestore, connectFirestoreEmulator, initializeFirestore } from "firebase/firestore";
const firebaseConfig = {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY || "dummy_api_key",
authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN || "local-testing.firebaseapp.com",
projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID || "local-testing",
storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET || "local-testing.appspot.com",
messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID || "dummy_sender",
appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID || "dummy_app"
};
const app = !getApps().length ? initializeApp(firebaseConfig) : getApp();
const useEmulators = process.env.NEXT_PUBLIC_USE_FIREBASE_EMULATORS === "true";
const auth = getAuth(app);
const functions = getFunctions(app);
// 1. SOLVING FIRESTORE WEBSOCKET DROPOUT:
// When running E2E tests, standard websockets can drop under heavy CPU load.
// Enforcing experimentalForceLongPolling keeps the connection robust.
const db = useEmulators
? initializeFirestore(app, { experimentalForceLongPolling: true }, "local-testing")
: getFirestore(app, "local-testing");
if (useEmulators) {
console.log("๐ Connecting to Firebase Local Emulators...");
// Always use explicit IPv4 loopback to bypass IPv6 resolution flakiness
const emulatorHost = "127.0.0.1";
connectAuthEmulator(auth, `http://${emulatorHost}:9099`);
connectFirestoreEmulator(db, emulatorHost, 8080);
connectFunctionsEmulator(functions, emulatorHost, 5001);
}
export { app, auth, db, functions };
๐ ๏ธ Step 2: Configuring Playwright for Single-Worker Determinism
Open your playwright.config.ts and set up the configuration. It must block multiple workers to prevent database write conflicts and define a webServer block that automatically manages the Next.js lifecycle.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
const isCI = !!process.env.CI;
export default defineConfig({
testDir: './tests/e2e',
// 1. FORCE SINGLE WORKER:
// Multiple browsers writing to a single emulator instance concurrently will corrupt
// each other's assertions. Enforce a single worker thread.
workers: 1,
fullyParallel: false,
timeout: 60000,
expect: { timeout: 15000 },
use: {
baseURL: 'http://127.0.0.1:3000',
trace: 'on-first-retry',
ignoreHTTPSErrors: true, // Ignore self-signed local SSL certs
serviceWorkers: 'block', // Block service workers for deterministic mock APIs
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
// 2. AUTOMATIC NEXT.JS LIFECYCLE:
// Playwright will boot Next.js on port 3000 and wait for the /en route to resolve.
// If a server is already running in your terminal, it will reuse it without port conflicts.
webServer: {
command: 'npx next dev --hostname 127.0.0.1 --port 3000',
url: 'http://127.0.0.1:3000/en',
reuseExistingServer: !isCI,
timeout: 120000,
},
});
๐ Step 3: Orchestrating the Loop via the Firebase Emulator CLI
To execute E2E tests, we must not start the Next.js server manually. Instead, we use the emulators:exec command provided by firebase-tools.
This wrapper command:
- Spins up the Firebase Emulators (Auth, Firestore, Functions, UI).
- Sets the
NEXT_PUBLIC_USE_FIREBASE_EMULATORS=trueenvironment flag. - Fires the
webServercommand in Playwright, which boots Next.js with emulator connections. - Executes the entire test suite.
- Shuts down and cleans up all background Node and Java emulator processes gracefully upon completion.
Add this orchestrator to your root package.json scripts:
{
"scripts": {
"e2e": "./scripts/run-e2e.sh",
"e2e:emulated": "npx firebase-tools emulators:exec --project local-testing \"NEXT_PUBLIC_USE_FIREBASE_EMULATORS=true npm run e2e\""
}
}
And structure your ./scripts/run-e2e.sh wrapper script:
#!/bin/bash
set -e
echo "๐ Starting Playwright emulated test run..."
# Bypasses local self-signed certificate rejections in Node/Playwright
export NODE_TLS_REJECT_UNAUTHORIZED=0
# Execute Playwright E2E suite
npx playwright test "$@"
๐งช Step 4: Ensuring Database Cleanliness Between Tests
Because a local emulator stores persistent data in memory during its runtime session, you must reset the database between test suites to keep tests fully isolated.
You can easily trigger a database reset programmatically inside your Playwright test fixtures by targeting Firestore's hidden emulator management REST endpoint:
// tests/e2e/helpers.ts
import { test as base } from '@playwright/test';
// Wipe the Firestore database via REST API to ensure clean runs
export async function clearLocalFirestore() {
const projectId = "local-testing";
const res = await fetch(
`http://127.0.0.1:8080/emulator/v1/projects/${projectId}/databases/(default)/documents`,
{ method: 'DELETE' }
);
if (!res.ok) {
throw new Error("Failed to clear Firestore local emulator database.");
}
console.log("๐งน Local Firestore database wiped clean.");
}
// Wipe all mock users in the Auth emulator
export async function clearLocalAuth() {
const projectId = "local-testing";
const res = await fetch(
`http://127.0.0.1:9099/emulator/v1/projects/${projectId}/accounts`,
{ method: 'DELETE' }
);
if (!res.ok) {
throw new Error("Failed to clear Firebase Auth local emulator.");
}
console.log("๐งน Local Auth database wiped clean.");
}
Hook these helpers into the beforeEach block of your test specs:
// tests/e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
import { clearLocalFirestore, clearLocalAuth } from './helpers';
test.beforeEach(async () => {
await clearLocalFirestore();
await clearLocalAuth();
});
test('User can successfully sign up passwordlessly', async ({ page }) => {
await page.goto('/en/auth/signup');
await page.fill('input[type="tel"]', '+15555555555');
await page.click('button[type="submit"]');
// Your emulators will seamlessly record this registration locally,
// totally decoupled from your production database.
await expect(page.locator('text=Verification code sent')).toBeVisible();
});
Summary of Completed Engineering Gains
By implementing the Playwright + Firebase Local Emulator loop, you achieve:
- Flack-Free Determinism: Forced single-worker thread coupled with programmatic database purges between tests means your E2E suite produces identical, reliable results every run.
- Speed & Scalability: Zero round-trip cloud overheads. Database transactions and function executions occur locally inside a high-speed loop.
- Zero Cost: Thousands of tests can run concurrently in your local CI pipelines without consuming Google Cloud quotas or incurring utility bills.