构建端到端可观测性测试 集成Playwright XState与SkyWalking Prometheus


一个看似寻常的周二下午,CI/CD流水线又一次亮起了红灯。失败的节点是e2e-checkout-flow。团队成员的第一反应是:“又是UI的锅?”,但截屏和视频录像显示页面渲染一切正常,所有按钮都可点击,数据也已填入。Playwright的日志只留下了一句冰冷的TimeoutError: waiting for selector ".order-success-page"。订单成功页最终没有出现。

前端排查了状态同步,后端检查了订单服务日志。半小时后,有人在数万条日志里找到一条可疑的WARN:支付服务gRPC调用超时。但这究竟是网络抖动,还是上游的用户服务数据格式错误导致的下游阻塞?这次失败是普遍问题还是个例?最关键的是,我们无法将这次失败的Playwright测试运行与后端集群中那海量的请求日志、Trace和Metrics精确关联起来。这才是问题的症结所在:端到端测试覆盖了用户场景,但它的观测能力仅停留在浏览器DOM层面,形成了一个巨大的调试黑洞。

我们需要一种新的测试范式,一种“可观测性驱动”的E2E测试。测试的断言不应仅是“DOM中是否存在某个元素”,而应扩展为“当用户完成支付操作后,在SkyWalking中是否能找到一条横跨3个微服务、状态为SUCCESS的完整Trace,并且Prometheus中orders_processed_total这个Metric是否正确递增”。

初步构想与技术选型

这个构想的核心是将E2E测试的执行上下文与后端的观测上下文打通。具体来说,就是在每一次Playwright测试启动时,生成一个唯一的标识符(我们称之为test-context-id),并以某种方式将其注入到被测的浏览器环境中。前端应用在发起所有API请求时,都必须携带这个test-context-id。后端服务接收到请求后,将此ID作为Trace的Tag或日志的元数据,一路传递下去。测试脚本在执行完关键业务步骤后,不再是傻等UI变化,而是拿着这个test-context-id反向查询SkyWalking和Prometheus的API,验证后端系统的行为是否符合预期。

为了实现这个闭环,我们的技术栈需要紧密协作:

  1. Playwright: 它的角色远不止模拟用户操作。我们需要利用其强大的网络拦截和脚本注入能力,在页面加载之初就植入test-context-id
  2. XState: 在这个场景下,它不仅仅是前端状态管理器。我们将利用它精确定义用户行为的每一个阶段。每个状态(如enteringShippingInfo, processingPayment)都可以成为Trace中的一个关键业务锚点(Span Tag),这让Trace不再是模糊的技术调用链,而是清晰的业务流程图。
  3. Styled-components: 作为UI层,它需要提供稳定、可预测的DOM结构,以便Playwright进行交互。它的作用相对辅助,但代表了真实项目中任何一种组件化框架。
  4. SkyWalking: 我们的分布式追踪系统。测试结果的“真相”之一就蕴含在它的Trace数据里。我们需要利用它的Browser SDK捕获前端行为,并利用其OAP(Observability Analysis Platform)的GraphQL API进行测试结果的验证。
  5. Prometheus: 另一半“真相”来源。关键业务操作的成功与否,最终会体现在核心业务指标(Metrics)上。测试需要能够查询Prometheus API,断言这些指标的变化。

步骤化实现:构建观测闭环

我们的目标是测试一个多步骤的订单提交流程。这个流程由XState管理,涉及前端React应用、一个Node.js BFF(Backend for Frontend)服务和一个订单处理微服务。

1. 基础设施准备:观测平台的搭建

在真实项目中,这些组件通常是独立部署的。为了本地演示,我们使用docker-compose快速拉起一个包含SkyWalking OAP、UI和Prometheus的环境。

# docker-compose.yml
version: '3.8'

services:
  prometheus:
    image: prom/prometheus:v2.45.0
    container_name: prometheus
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    command:
      - '--config.file=/etc/prometheus/prometheus.yml'
      - '--web.enable-lifecycle'

  oap:
    image: apache/skywalking-oap-server:9.5.0
    container_name: oap
    ports:
      - "11800:11800"
      - "12800:12800"
    environment:
      SW_STORAGE: h2
      SW_HEALTH_CHECKER: default
      SW_TELEMETRY: prometheus
      # 暴露Prometheus metrics
      SW_PROMETHEUS_FETCHER: default
      SW_PROMETHEUS_FETCHER_ENABLED: "true"

  ui:
    image: apache/skywalking-ui:9.5.0
    container_name: ui
    ports:
      - "8080:8080"
    depends_on:
      - oap
    environment:
      SW_OAP_ADDRESS: http://oap:12800

prometheus.yml配置简单,用于抓取我们后续后端服务暴露的指标。

# prometheus.yml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'bff-service'
    static_configs:
      - targets: ['host.docker.internal:3001'] # 在macOS/Windows上使用host.docker.internal

2. 后端服务:可被观测的API

我们创建两个简单的Node.js Express服务:BFF和Order Service。关键在于集成SkyWalking Node.js Agent和Prometheus客户端。

首先,安装依赖:
npm install express skywalking-backend-js prom-client

BFF Service (bff-service.js on port 3000)

// bff-service.js
// 启动命令: node -r skywalking-backend-js bff-service.js
require('skywalking-backend-js').start({
  serviceName: 'bff-service',
  collectorAddress: 'localhost:11800',
});

const express = require('express');
const axios = require('axios');
const promClient = require('prom-client');

const app = express();
app.use(express.json());

// Prometheus Metrics
const register = new promClient.Registry();
promClient.collectDefaultMetrics({ register });
const checkoutCounter = new promClient.Counter({
  name: 'bff_checkout_requests_total',
  help: 'Total number of checkout requests processed by BFF',
  labelNames: ['status'],
});
register.registerMetric(checkoutCounter);

app.post('/api/checkout', async (req, res) => {
  try {
    console.log('BFF: Received checkout request');
    // 调用订单服务
    const response = await axios.post('http://localhost:3002/api/create-order', req.body);
    checkoutCounter.inc({ status: 'success' });
    res.status(200).json(response.data);
  } catch (error) {
    console.error('BFF: Error during checkout', error.message);
    checkoutCounter.inc({ status: 'failure' });
    res.status(500).json({ message: 'Order creation failed' });
  }
});

// 暴露 metrics 接口
app.get('/metrics', async (req, res) => {
  res.set('Content-Type', register.contentType);
  res.end(await register.metrics());
});

app.listen(3000, () => console.log('BFF service listening on port 3000'));
// 注意:为了让Prometheus能抓取到,需要一个独立的metrics服务或端口
// 这里简化处理,实际项目应分开
const metricsApp = express();
metricsApp.get('/metrics', async (req, res) => {
  res.set('Content-Type', register.contentType);
  res.end(await register.metrics());
});
metricsApp.listen(3001, () => console.log('BFF metrics listening on port 3001'));

Order Service (order-service.js on port 3002)

这个服务逻辑类似,但它代表了调用链的下一环。

// order-service.js
// 启动命令: node -r skywalking-backend-js order-service.js
require('skywalking-backend-js').start({
  serviceName: 'order-service',
  collectorAddress: 'localhost:11800',
});

const express = require('express');

const app = express();
app.use(express.json());

app.post('/api/create-order', (req, res) => {
  console.log('Order Service: Received create order request', req.body);
  // 模拟一些业务处理
  if (!req.body.userId || !req.body.items) {
    console.error('Order Service: Invalid order data');
    return res.status(400).json({ message: 'Invalid order data' });
  }
  
  // 模拟数据库操作延迟
  setTimeout(() => {
    const orderId = `order_${Date.now()}`;
    console.log(`Order Service: Order ${orderId} created successfully`);
    res.status(201).json({ orderId });
  }, 500);
});

app.listen(3002, () => console.log('Order service listening on port 3002'));

这里的关键是node -r skywalking-backend-js。SkyWalking Agent通过预加载模块的方式,自动对httphttpsaxios等常用库进行AOP埋点,实现Trace的自动传递。

3. 前端应用:注入上下文与状态驱动

我们的React应用将使用XState来管理复杂的提交流程,并集成SkyWalking Browser SDK。

npm install xstate @xstate/react styled-components client-skywalking-web

Checkout State Machine (checkoutMachine.js)

// checkoutMachine.js
import { createMachine, assign } from 'xstate';

export const checkoutMachine = createMachine({
  id: 'checkout',
  initial: 'idle',
  context: {
    userId: 'user-123',
    items: ['item-a', 'item-b'],
    error: null,
    orderId: null,
  },
  states: {
    idle: {
      on: {
        SUBMIT: 'submitting',
      },
    },
    submitting: {
      invoke: {
        id: 'submitOrder',
        src: (context, event) => fetch('/api/checkout', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ userId: context.userId, items: context.items }),
        }).then(res => {
          if (!res.ok) {
            throw new Error(`Server responded with ${res.status}`);
          }
          return res.json();
        }),
        onDone: {
          target: 'success',
          actions: assign({ orderId: (context, event) => event.data.orderId }),
        },
        onError: {
          target: 'failure',
          actions: assign({ error: (context, event) => event.data.message }),
        },
      },
    },
    success: {
      type: 'final',
    },
    failure: {
      on: {
        RETRY: 'submitting',
      },
    },
  },
});

App Component (App.js)

这里是集成的核心。我们需要初始化SkyWalking SDK,并确保它能获取到Playwright注入的test-context-id

// App.js
import React, { useEffect } from 'react';
import { useMachine } from '@xstate/react';
import styled from 'styled-components';
import ClientMonitor from 'skywalking-client-js';
import { checkoutMachine } from './checkoutMachine';

// Styled-components for stable selectors
const PageContainer = styled.div`
  padding: 2rem;
`;
const SubmitButton = styled.button`
  background-color: #007bff;
  color: white;
  padding: 10px 20px;
  border: none;
  cursor: pointer;
  &:disabled {
    background-color: #ccc;
  }
`;
const StatusMessage = styled.div`
  margin-top: 1rem;
  font-weight: bold;
  color: ${props => (props.error ? 'red' : 'green')};
`;

const OrderSuccessPage = styled.div.attrs({
  // This is a stable attribute for Playwright to target
  'data-testid': 'order-success-page',
})`
  border: 1px solid green;
  padding: 1rem;
`;

function App() {
  const [state, send] = useMachine(checkoutMachine);

  useEffect(() => {
    // 关键部分:初始化 SkyWalking Browser SDK
    // 从 window 对象中读取由 Playwright 注入的测试上下文
    const testContext = window.__E2E_TEST_CONTEXT__;
    
    ClientMonitor.init({
      service: 'frontend-app',
      pagePath: '/',
      serviceVersion: 'v1.0.0',
      collector: 'http://localhost:12800', // SkyWalking OAP BanyanDB a.k.a L1 receiver address
      // 如果存在测试上下文,将其作为 traceId 的一部分或 Tag
      // SkyWalking Browser SDK 允许通过 `traceId` 和 `traceSegmentId` 来关联
      // 这里我们使用一种更通用的方式:在 correlation-element 中添加元数据
      // 或者直接通过 header 传递
      // 此处,我们选择更可靠的 header 传递方式,在 fetch 中实现
    });
  }, []);

  // 包装 fetch/axios 以附加自定义 header
  // 在真实项目中,这应该在统一的API客户端中完成
  const originalFetch = window.fetch;
  window.fetch = function(...args) {
    const testContext = window.__E2E_TEST_CONTEXT__;
    if (testContext && testContext.traceId) {
      const headers = args[1]?.headers || {};
      args[1] = {
        ...args[1],
        headers: {
          ...headers,
          'X-Test-Context-Id': testContext.traceId,
          // SW的上下文传播header, 我们需要在这里手动注入,或者让SDK自动完成
          // 'sw8': ... // SDK会自动处理这个
        }
      };
      
      // SkyWalking Agent 可以识别 `X-Test-Context-Id` 并将其作为 Tag
      // 需要在 agent.config 中配置 `http.header.tags=X-Test-Context-Id`
    }
    return originalFetch.apply(this, args);
  };

  const isSubmitting = state.matches('submitting');

  return (
    <PageContainer>
      <h1>Checkout</h1>
      <SubmitButton
        data-testid="submit-order-button"
        onClick={() => send('SUBMIT')}
        disabled={isSubmitting}
      >
        {isSubmitting ? 'Processing...' : 'Submit Order'}
      </SubmitButton>

      {state.matches('success') && (
        <OrderSuccessPage className="order-success-page">
          <h2>Order Successful!</h2>
          <p>Order ID: {state.context.orderId}</p>
        </OrderSuccessPage>
      )}

      {state.matches('failure') && (
        <StatusMessage error data-testid="error-message">
          Error: {state.context.error}
          <button onClick={() => send('RETRY')}>Retry</button>
        </StatusMessage>
      )}
    </PageContainer>
  );
}

export default App;

这里的核心改动是:

  1. useEffect中初始化ClientMonitor
  2. 通过重写window.fetch来拦截所有API请求,并附加一个自定义的X-Test-Context-Id头。这个头的值来源于window.__E2E_TEST_CONTEXT__.traceId,而这个全局对象将由Playwright在测试开始时注入。

4. Playwright测试:驱动、注入与验证

这是整个流程的指挥中心。

npm install -D @playwright/test uuid graphql graphql-request

playwright.config.ts

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:5173', // 前端开发服务器地址
    trace: 'on-first-retry',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
  ],
});

e2e/checkout.spec.ts

// e2e/checkout.spec.ts
import { test, expect, Page } from '@playwright/test';
import { v4 as uuidv4 } from 'uuid';
import { GraphQLClient, gql } from 'graphql-request';

// --- Observability Helper Functions ---
const SKYWALKING_GQL_ENDPOINT = 'http://localhost:12800/graphql';
const PROMETHEUS_API_ENDPOINT = 'http://localhost:9090/api/v1';

const client = new GraphQLClient(SKYWALKING_GQL_ENDPOINT);

async function getTraceById(traceId: string): Promise<any> {
  const query = gql`
    query ($traceId: String!) {
      trace: queryTrace(traceId: $traceId) {
        spans {
          traceId
          serviceCode
          endpointName
          isError
          tags {
            key
            value
          }
        }
      }
    }
  `;
  try {
    const data = await client.request(query, { traceId });
    return data;
  } catch (error) {
    console.error('Failed to query SkyWalking for trace:', error);
    return null;
  }
}

async function getMetricValue(metricName: string, labels: Record<string, string> = {}): Promise<number> {
    const labelSelectors = Object.entries(labels)
        .map(([key, value]) => `${key}="${value}"`)
        .join(',');
    const query = `${metricName}{${labelSelectors}}`;
    const url = `${PROMETHEUS_API_ENDPOINT}/query?query=${encodeURIComponent(query)}`;

    try {
        const response = await fetch(url);
        const data = await response.json();
        if (data.status === 'success' && data.data.result.length > 0) {
            return parseFloat(data.data.result[0].value[1]);
        }
        return 0; // Return 0 if metric not found
    } catch (error) {
        console.error('Failed to query Prometheus for metric:', error);
        return 0;
    }
}

// A helper to poll until a condition is met
async function poll<T>(
    fn: () => Promise<T>,
    validate: (result: T) => boolean,
    interval = 1000,
    timeout = 10000
): Promise<T> {
    const startTime = Date.now();
    while (Date.now() - startTime < timeout) {
        const result = await fn();
        if (validate(result)) {
            return result;
        }
        await new Promise(resolve => setTimeout(resolve, interval));
    }
    throw new Error(`Polling timed out after ${timeout}ms`);
}


// --- The Test ---
test.describe('Observability-driven Checkout Flow', () => {
  let traceId: string;

  test.beforeEach(async ({ page }) => {
    traceId = uuidv4();
    // 关键:在页面加载任何脚本之前,注入我们的测试上下文
    await page.addInitScript((context) => {
      window.__E2E_TEST_CONTEXT__ = context;
    }, { traceId });

    // 监控 X-Test-Context-Id 是否被正确发送
    await page.route('**/api/checkout', (route) => {
      const headers = route.request().headers();
      expect(headers['x-test-context-id']).toBe(traceId);
      route.continue();
    });
  });

  test('should complete the checkout and verify backend trace and metrics', async ({ page }) => {
    // 1. UI Interaction
    await page.goto('/');
    
    // 获取初始Metric值
    const initialSuccessCount = await getMetricValue('bff_checkout_requests_total', { status: 'success' });

    await page.getByTestId('submit-order-button').click();

    // 等待UI更新,这是传统E2E测试的部分
    const successPage = page.getByTestId('order-success-page');
    await expect(successPage).toBeVisible({ timeout: 10000 });
    await expect(successPage.locator('h2')).toHaveText('Order Successful!');

    // 2. Observability Verification
    // 现在,我们不再仅仅相信UI。我们去验证后端到底发生了什么。
    console.log(`Verifying backend state for traceId: ${traceId}`);

    // 验证 SkyWalking Trace
    const finalTrace = await poll(
        () => getTraceById(traceId),
        (trace) => trace?.trace?.spans?.length >= 2, // 等待至少2个span出现 (frontend -> bff)
        1000,
        15000
    );

    expect(finalTrace).not.toBeNull();
    const spans = finalTrace.trace.spans;
    expect(spans.length).toBeGreaterThanOrEqual(2); // 应该有 browser, bff, order-service

    // 校验Trace的完整性和正确性
    const serviceNames = new Set(spans.map(s => s.serviceCode));
    expect(serviceNames).toContain('frontend-app');
    expect(serviceNames).toContain('bff-service');
    expect(serviceNames).toContain('order-service');

    // 校验所有span都是成功的
    spans.forEach(span => {
      expect(span.isError).toBe(false);
    });

    // 验证 Prometheus Metric
    await poll(
      async () => getMetricValue('bff_checkout_requests_total', { status: 'success' }),
      (currentValue) => currentValue === initialSuccessCount + 1,
      1000,
      15000
    );
    const finalSuccessCount = await getMetricValue('bff_checkout_requests_total', { status: 'success' });
    expect(finalSuccessCount).toBe(initialSuccessCount + 1);
  });
});

这个测试文件的革命性在于,它分为两个阶段:UI交互和可观测性验证。

  • 注入: page.addInitScript 是实现这一切的魔法。它在页面上下文中,在所有其他脚本执行之前运行,确保我们的__E2E_TEST_CONTEXT__在应用初始化时就已存在。
  • 驱动: 和普通测试一样,点击按钮,等待DOM变化。
  • 验证: 这是核心差异。我们编写了getTraceByIdgetMetricValue辅助函数,直接查询观测平台的API。并且使用了一个poll函数,因为观测数据的采集和处理是异步的,我们需要轮询等待数据就绪。断言的目标不再是UI,而是Trace的结构和Metric的值。

5. 流程图:串联所有组件

下面是整个数据流的完整图景:

sequenceDiagram
    participant TestRunner as Playwright Runner
    participant Browser
    participant ReactApp as React App (with XState)
    participant BFF as BFF Service (Node.js)
    participant OrderSvc as Order Service (Node.js)
    participant SkyWalking as SkyWalking OAP
    participant Prometheus

    TestRunner->>Browser: page.addInitScript({ traceId: 'xyz' })
    TestRunner->>Browser: page.goto('/')
    Browser->>ReactApp: 初始化, 读取 window.__E2E_TEST_CONTEXT__
    Note right of ReactApp: SkyWalking SDK 初始化
    
    TestRunner->>Browser: 点击 "Submit Order"
    ReactApp->>ReactApp: XState 进入 'submitting' 状态
    ReactApp->>BFF: POST /api/checkout (Header: X-Test-Context-Id: 'xyz')
    Note over ReactApp,BFF: SkyWalking SDK 创建 Entry Span 并传播上下文

    BFF->>OrderSvc: POST /api/create-order
    Note over BFF,OrderSvc: SkyWalking Agent 自动传播 Trace Context
    BFF->>Prometheus: Increment checkout_requests_total{status="success"}
    OrderSvc-->>BFF: Response 201
    BFF-->>ReactApp: Response 200
    
    ReactApp->>ReactApp: XState 进入 'success' 状态
    ReactApp->>Browser: 渲染成功页面
    
    TestRunner->>Browser: 断言UI成功
    Note over TestRunner: UI断言通过
    
    loop 轮询等待数据
        TestRunner->>SkyWalking: GraphQL Query (traceId: 'xyz')
        SkyWalking-->>TestRunner: Trace data (or null)
    end
    TestRunner->>TestRunner: 断言Trace结构、服务、状态
    
    loop 轮询等待数据
        TestRunner->>Prometheus: API Query (metric: checkout_requests_total)
        Prometheus-->>TestRunner: Metric value
    end
    TestRunner->>TestRunner: 断言Metric值已递增

当前方案的局限性与未来展望

这种将可观测性深度集成到E2E测试中的方法,极大地提升了测试的深度和调试效率。当测试失败时,我们不仅得到一张UI截图,还能得到一个精确的traceId,可以直接在SkyWalking中定位到失败的根本原因,无论是代码错误、服务超时还是网络问题。

然而,这个方案并非银弹。它引入了新的复杂度和依赖:

  1. 环境依赖: E2E测试现在强依赖于一个完整的、可观测的后端环境(包括SkyWalking和Prometheus)。这增加了测试环境的维护成本。对于某些只需要测试纯前端交互的场景,这可能是过度设计。
  2. 测试脆弱性: 如果观测平台本身不稳定(例如,数据采集延迟过高),可能会导致测试用例不稳定。poll机制中的超时和间隔需要根据实际环境仔细调优。
  3. 增加了测试编写的复杂度: 测试工程师不仅需要了解业务和前端DOM,还需要理解后端的Trace和Metrics模型,知道该查询什么、断言什么。

未来的迭代方向可以集中在工程化和标准化上。我们可以将poll, getTraceById, getMetricValue等函数封装成一个独立的测试库,提供更简洁的API,例如expect(traceId).toHaveCompleteTrace(['service-a', 'service-b'])expect(metric('my_metric')).toIncreaseBy(1)。此外,可以探索与混沌工程平台的结合,在E2E测试执行期间主动注入故障,然后利用这套观测验证机制,来检验系统的熔断、降级和告警策略是否如预期般工作。这会让我们的自动化测试能力,从“验证正确性”真正迈向“保障韧性”的更高层次。


  目录