小程序自动化测试与 CI/CD 实践

系统讲解微信小程序的自动化测试方案与持续集成交付流程,涵盖单元测试、端到端测试、微信 CI 集成与 GitHub Actions 自动化部署。

软件质量保障离不开系统化的测试与持续交付。微信小程序作为运行在微信客户端内的应用,其测试与部署流程既有与传统 Web/App 共通的测试方法论,也有因为平台封闭性带来的特殊挑战。本文将从小程序特有的测试工具链出发,系统讲解单元测试、端到端自动化测试、微信官方 CI 服务以及与第三方 CI/CD 平台的集成方案,帮助团队建立起可靠的质量门禁与交付流水线。

一、小程序测试体系概览

1.1 测试金字塔

小程序测试遵循经典的测试金字塔模型,从底层到顶层依次为:

层级测试类型工具/框架运行速度维护成本
底层单元测试Jest + miniprogram-simulate极快
中层集成测试Jest + 云函数本地测试
上层E2E 测试miniprogram-automator
顶层手动测试真机 + 开发者工具最慢最高

底层单元测试数量应最多,聚焦于单个函数或组件的逻辑正确性;中层集成测试验证模块间协作;上层 E2E 测试模拟真实用户操作流程;顶层手动测试用于探索性测试和用户体验评估。

1.2 测试工具链选型

工具用途官方维护
JestJavaScript 单元测试框架Facebook
miniprogram-simulate小程序组件单元测试微信官方
miniprogram-automator小程序 E2E 自动化测试微信官方
微信 CI云测服务、真机测试微信官方
GitHub Actions / JenkinsCI/CD 流水线社区/云厂商

二、单元测试

2.1 环境配置

npm install --save-dev jest miniprogram-simulate
// jest.config.js
module.exports = {
  testEnvironment: 'node',
  moduleFileExtensions: ['js', 'json'],
  testMatch: ['**/__tests__/**/*.test.js'],
  collectCoverageFrom: [
    'utils/**/*.js',
    'store/**/*.js',
    '!**/__tests__/**'
  ],
  coverageThreshold: {
    global: {
      branches: 70,
      functions: 80,
      lines: 80,
      statements: 80
    }
  }
};

2.2 工具函数单元测试

// utils/__tests__/format.test.js
const { formatPrice, formatDate, debounce } = require('../format');

describe('formatPrice', () => {
  test('formats integer price', () => {
    expect(formatPrice(100)).toBe('¥100.00');
  });

  test('formats decimal price', () => {
    expect(formatPrice(99.9)).toBe('¥99.90');
  });

  test('handles zero', () => {
    expect(formatPrice(0)).toBe('¥0.00');
  });

  test('rounds to two decimals', () => {
    expect(formatPrice(99.999)).toBe('¥100.00');
  });
});

describe('debounce', () => {
  jest.useFakeTimers();

  test('defers execution', () => {
    const fn = jest.fn();
    const debounced = debounce(fn, 300);

    debounced('arg1');
    expect(fn).not.toBeCalled();

    jest.advanceTimersByTime(300);
    expect(fn).toBeCalledWith('arg1');
    expect(fn).toBeCalledTimes(1);
  });

  test('cancels previous calls', () => {
    const fn = jest.fn();
    const debounced = debounce(fn, 300);

    debounced('first');
    jest.advanceTimersByTime(100);
    debounced('second');
    jest.advanceTimersByTime(300);

    expect(fn).toBeCalledWith('second');
    expect(fn).toBeCalledTimes(1);
  });
});

2.3 小程序组件单元测试

miniprogram-simulate 提供了在 Node.js 环境中模拟小程序组件渲染和行为的能力:

// components/star-rating/__tests__/star-rating.test.js
const simulate = require('miniprogram-simulate');
const path = require('path');

describe('StarRating Component', () => {
  const id = simulate.load(path.join(__dirname, '../star-rating'));

  test('renders correct number of stars', () => {
    const comp = simulate.render(id, { score: 3, maxScore: 5 });
    comp.attach(document.createElement('parent-wrapper'));
    
    const stars = comp.querySelectorAll('.star');
    expect(stars.length).toBe(5);
    
    const fullStars = comp.querySelectorAll('.star.full');
    expect(fullStars.length).toBe(3);
  });

  test('emits change event when interactive', () => {
    const onChange = jest.fn();
    const comp = simulate.render(id, { 
      score: 0, 
      interactive: true 
    });
    comp.attach(document.createElement('parent-wrapper'));
    
    comp.addEventListener('change', onChange);
    
    const thirdStar = comp.querySelectorAll('.star')[2];
    thirdStar.dispatchEvent('tap');
    
    expect(onChange).toBeCalled();
    expect(onChange.mock.calls[0][0].detail.score).toBe(3);
  });

  test('observer triggers on score change', async () => {
    const comp = simulate.render(id, { score: 2 });
    comp.setData({ score: 4 });
    
    await simulate.sleep(0);
    
    const fullStars = comp.querySelectorAll('.star.full');
    expect(fullStars.length).toBe(4);
  });
});

2.4 云函数单元测试

云函数本质上是 Node.js 函数,可以直接使用 Jest 测试:

// cloudfunctions/login/__tests__/login.test.js
const cloud = require('wx-server-sdk');
jest.mock('wx-server-sdk');

describe('Login Cloud Function', () => {
  const mockDb = {
    collection: jest.fn(() => mockDb),
    where: jest.fn(() => mockDb),
    get: jest.fn(),
    add: jest.fn()
  };

  beforeEach(() => {
    cloud.init.mockReturnValue(undefined);
    cloud.getWXContext.mockReturnValue({
      OPENID: 'test_openid_123',
      APPID: 'test_appid'
    });
    cloud.database.mockReturnValue(mockDb);
  });

  test('creates new user if not exists', async () => {
    mockDb.get.mockResolvedValue({ data: [] });
    mockDb.add.mockResolvedValue({ _id: 'new_user_id' });

    const login = require('../index');
    const result = await login.main({ userInfo: { nickName: 'Test' } });

    expect(result.success).toBe(true);
    expect(result.data.openid).toBe('test_openid_123');
    expect(mockDb.add).toBeCalled();
  });

  test('returns existing user', async () => {
    const existingUser = { _id: 'existing_id', nickName: 'Test' };
    mockDb.get.mockResolvedValue({ data: [existingUser] });

    const login = require('../index');
    const result = await login.main({});

    expect(result.success).toBe(true);
    expect(result.data.user._id).toBe('existing_id');
    expect(mockDb.add).not.toBeCalled();
  });
});

三、端到端(E2E)测试

3.1 miniprogram-automator

微信官方提供的 miniprogram-automator 可以在开发者工具或真机上自动执行小程序操作,是 E2E 测试的核心工具。

npm install --save-dev miniprogram-automator
// e2e/order-flow.test.js
const automator = require('miniprogram-automator');

describe('Order Flow E2E', () => {
  let miniProgram;

  beforeAll(async () => {
    miniProgram = await automator.launch({
      projectPath: path.resolve(__dirname, '../'),
      cliPath: '/Applications/wechatwebdevtools.app/Contents/MacOS/cli'
    });
  }, 30000);

  afterAll(async () => {
    await miniProgram.close();
  });

  test('complete purchase flow', async () => {
    // 1. 打开首页
    const page = await miniProgram.reLaunch('/pages/index/index');
    await page.waitFor(500);

    // 2. 点击第一个商品
    const firstProduct = await page.$('.product-item');
    await firstProduct.tap();
    await page.waitFor(1000);

    // 3. 进入详情页,验证商品信息
    const pagePath = await page.path;
    expect(pagePath).toContain('/pages/detail/detail');

    const title = await page.$eval('.product-title', el => el.innerText);
    expect(title).toBeTruthy();

    const price = await page.$eval('.product-price', el => el.innerText);
    expect(price).toMatch(/¥\d+\.\d{2}/);

    // 4. 点击加入购物车
    const addBtn = await page.$('.add-cart-btn');
    await addBtn.tap();
    await page.waitFor(500);

    const toast = await page.$eval('.toast', el => el.innerText);
    expect(toast).toContain('已加入购物车');

    // 5. 跳转到购物车页
    const cartTab = await page.$('.tab-cart');
    await cartTab.tap();
    await page.waitFor(800);

    // 6. 验证购物车有商品
    const cartItems = await page.$$('.cart-item');
    expect(cartItems.length).toBeGreaterThan(0);
  }, 60000);
});

3.2 E2E 测试最佳实践

  • 数据隔离:每次测试前重置测试数据库到已知状态,测试间不互相影响
  • 等待策略:优先使用元素可见性等待而非固定时间等待
  • 选择性执行:将 E2E 测试按模块分组,CI 中全量执行,本地仅执行相关模块
  • 截图与录屏:失败时自动截图保存,便于排查问题
  • Mock 外部依赖:支付、地图等外部服务应在测试环境中使用 Mock 实现

四、微信 CI 云测服务

微信官方提供了免费的云测服务,支持在真机上执行自动化测试:

4.1 配置云测计划

# .ma-ci.yml
name: Miniprogram CI
on:
  push:
    branches: [main, develop]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node
        uses: actions/setup-node@v3
        with:
          node-version: '18'
          
      - name: Install dependencies
        run: npm ci
        
      - name: Run unit tests
        run: npm run test:unit -- --coverage
        
      - name: Upload coverage
        uses: codecov/codecov-action@v3

  build:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Build miniprogram
        run: npm run build
        
      - name: Upload to WeChat CI
        uses: wechat-miniprogram/ci-upload-action@v1
        with:
          appid: ${{ secrets.WECHAT_APPID }}
          private-key: ${{ secrets.WECHAT_PRIVATE_KEY }}
          version: ${{ github.run_number }}
          desc: 'CI build from ${{ github.sha }}'

4.2 微信 CI 上传

// scripts/ci-upload.js
const ci = require('miniprogram-ci');

(async () => {
  const project = new ci.Project({
    appid: process.env.WECHAT_APPID,
    type: 'miniProgram',
    projectPath: './dist',
    privateKeyPath: './private.key',
    ignores: ['node_modules/**/*']
  });

  const result = await ci.upload({
    project,
    version: process.env.VERSION || '1.0.0',
    desc: process.env.DESC || 'CI upload',
    setting: {
      es6: true,
      es7: true,
      minify: true,
      autoPrefixWXSS: true
    }
  });

  console.log('Upload result:', result);
})();

五、GitHub Actions 完整流水线

# .github/workflows/miniprogram-ci.yml
name: Miniprogram CI/CD

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run lint
      - run: npm run typecheck

  test:
    runs-on: ubuntu-latest
    needs: lint
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run test:unit -- --coverage --ci
      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/lcov.info

  test-cloud:
    runs-on: ubuntu-latest
    needs: test
    # 仅在 main 分支上运行云测试
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run test:e2e
        env:
          WECHAT_APPID: ${{ secrets.WECHAT_APPID }}

  build:
    runs-on: ubuntu-latest
    needs: [lint, test]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run build:prod
      - name: Upload dist artifact
        uses: actions/upload-artifact@v3
        with:
          name: miniprogram-dist
          path: ./dist/**

  deploy-preview:
    runs-on: ubuntu-latest
    needs: build
    if: github.event_name == 'pull_request'
    steps:
      - uses: actions/download-artifact@v3
        with:
          name: miniprogram-dist
          path: ./dist
      - name: Upload preview
        run: |
          node scripts/ci-upload.js
        env:
          WECHAT_APPID: ${{ secrets.WECHAT_APPID }}
          WECHAT_PRIVATE_KEY: ${{ secrets.WECHAT_PRIVATE_KEY }}
          VERSION: pr-${{ github.event.number }}-${{ github.run_number }}
          DESC: "PR #${{ github.event.number }} preview"

  deploy-production:
    runs-on: ubuntu-latest
    needs: [build, test-cloud]
    if: github.ref == 'refs/heads/main'
    environment: production
    steps:
      - uses: actions/download-artifact@v3
        with:
          name: miniprogram-dist
          path: ./dist
      - name: Upload to WeChat
        run: node scripts/ci-upload.js
        env:
          WECHAT_APPID: ${{ secrets.WECHAT_APPID }}
          WECHAT_PRIVATE_KEY: ${{ secrets.WECHAT_PRIVATE_KEY }}
          VERSION: ${{ github.run_number }}
          DESC: "Production release ${{ github.sha }}"

六、测试数据与环境管理

6.1 测试数据库隔离

// tests/setup.js
const { setup: setupDevServer } = require('jest-dev-server');

module.exports = async () => {
  // 启动本地云函数模拟器
  await setupDevServer({
    command: 'npx cloudbase-emulator run --db test',
    launchTimeout: 30000,
    port: 9229
  });

  // 初始化测试数据
  const testData = require('./fixtures/test-data.json');
  await seedDatabase(testData);
};

module.exports = async () => {
  // 清理测试数据
  await cleanupDatabase();
};

6.2 Mock 策略

// tests/setupMocks.js
global.wx = {
  request: jest.fn(),
  getStorageSync: jest.fn(),
  setStorageSync: jest.fn(),
  showToast: jest.fn(),
  showLoading: jest.fn(),
  hideLoading: jest.fn(),
  showModal: jest.fn(() => Promise.resolve({ confirm: true })),
  getSystemInfoSync: jest.fn(() => ({
    windowWidth: 375,
    windowHeight: 667,
    pixelRatio: 2
  })),
  cloud: {
    init: jest.fn(),
    callFunction: jest.fn(),
    database: jest.fn(() => ({
      collection: jest.fn(),
      command: {}
    }))
  }
};

七、总结

小程序的自动化测试与 CI/CD 建设需要结合平台特性进行适配。单元测试通过 Jest 和 miniprogram-simulate 覆盖工具函数和组件逻辑;E2E 测试通过 miniprogram-automator 模拟真实用户操作;微信 CI 云测服务提供了真机验证能力;GitHub Actions 则串联起代码检查、测试执行、构建打包和自动部署的完整流水线。

建立的测试体系应当是增量演进的:初期以单元测试为主,保障核心业务逻辑的正确性;随着项目成熟,逐步补充集成测试和 E2E 测试,覆盖关键用户路径;最终形成完善的 CI/CD 流水线,每次代码提交都经过自动化的质量门禁,只有通过全部检查后才允许部署到生产环境。这种「左移」的质量保障策略可以大幅降低线上缺陷率和修复成本。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「miniprogram」更多文章

  1. 小程序全栈项目实战:从零构建电商应用
  2. 小程序安全与合规实践
  3. 小程序性能优化全景指南