在 Phoenix Monorepo 中构建基于变更检测的高效 BDD 测试流水线


CI流水线的执行时间从25分钟降到了3分钟,仅仅因为我们改变了在Monorepo中运行测试的方式。问题源于一个看似合理的架构:一个包含三个独立Phoenix应用的Elixir Umbrella项目,共享着一些内部库。每次提交,无论改动多小,GitHub Actions都会完整地重新获取、编译所有应用的依赖,然后运行全部测试套件。其中,基于Wallaby的BDD(行为驱动开发)测试又是最耗时的部分,它们需要启动一个真实的浏览器实例。这种“全量构建”策略在项目初期尚可接受,但随着应用和测试的增多,它变成了团队效率的巨大瓶셔颈。

痛点非常明确:90%的CI时间都浪费在了重复劳动上。一个只修改了orders服务文案的提交,不应该触发inventorybilling服务的完整测试。我们的目标是构建一个智能的测试流水线,它能够:

  1. 精确识别出变更影响了哪些应用。
  2. 只针对受影响的应用执行测试。
  3. 最大限度地重用已编译的依赖,避免在每个测试任务中重复mix deps.getmix deps.compile

初步构想与技术困境

最初的想法是利用CI的缓存机制。我们可以缓存每个应用的deps_build目录。但这在Monorepo的Umbrella结构下很快就遇到了麻烦。Elixir的构建工具Mix是为单个项目设计的,Umbrella项目虽然能将多个应用组织在一起,但在依赖管理上,每个子应用仍然有自己的mix.lock和依赖树。在CI中为每个应用单独设置和恢复缓存,逻辑复杂且收效甚微,因为核心的编译时间并没有被真正优化,多个应用间的共享依赖依然会被重复编译。

真正的瓶颈在于Mix的工作流。mix test命令背后隐藏了一系列动作:加载项目配置、解析依赖、编译代码、启动应用、最后才运行测试。我们需要一种方法,绕过这个重量级的项目加载过程,直接进入测试执行阶段,并且让这个过程能在一个预先准备好的、包含所有依赖的“干净”环境中运行。

最终方案:变更检测脚本 + 集中式测试执行器

我们的方案由两部分组成:一个用于变更检测的Shell脚本,以及一个用Elixir编写的、能够动态加载并测试指定应用的“测试执行器”。

1. Monorepo 结构定义

首先,明确我们的项目结构。这是一个标准的Elixir Umbrella项目。

.
├── apps
│   ├── billing_app       # Phoenix 应用: 计费
│   │   ├── lib
│   │   ├── test
│   │   └── mix.exs
│   ├── inventory_app     # Phoenix 应用: 库存
│   │   ├── lib
│   │   ├── test
│   │   └── mix.exs
│   ├── orders_app        # Phoenix 应用: 订单
│   │   ├── lib
│   │   ├── test
│   │   └── mix.exs
│   └── shared_kernel     # 共享的内部库
│       ├── lib
│       └── mix.exs
├── config
│   ├── config.exs
│   └── test.exs
├── mix.exs
└── test_runner           # 我们的集中式测试执行器
    ├── lib
    │   └── test_runner.ex
    ├── mix.exs
    └── run.exs           # 执行脚本

这里的test_runner是一个独立的Elixir应用,它的唯一职责就是驱动测试。它本身不包含任何业务逻辑。

2. 变更检测脚本 (ci/find_changed_apps.sh)

这个脚本是整个流程的大脑。它利用git命令找出当前分支与main分支之间的差异文件,然后根据文件路径判断哪些应用受到了影响。

在真实项目中,这个脚本需要处理更复杂的逻辑,例如一个共享库的变更应该触发所有依赖它的应用进行测试。但为了清晰地展示核心思想,我们先从一个简化版本开始。

#!/bin/bash
set -e

# 设置要比较的目标分支,通常是主分支
TARGET_BRANCH="origin/main"

# 获取自目标分支以来的变更文件列表
CHANGED_FILES=$(git diff --name-only "$TARGET_BRANCH" HEAD)

if [ -z "$CHANGED_FILES" ]; then
  echo "No changes detected. Exiting."
  exit 0
fi

echo "Detected changed files:"
echo "$CHANGED_FILES"

declare -A changed_apps

# 遍历每个变更文件,确定它属于哪个应用
for file in $CHANGED_FILES; do
  if [[ "$file" == apps/billing_app/* ]]; then
    changed_apps["billing_app"]=1
  elif [[ "$file" == apps/inventory_app/* ]]; then
    changed_apps["inventory_app"]=1
  elif [[ "$file" == apps/orders_app/* ]]; then
    changed_apps["orders_app"]=1
  elif [[ "$file" == apps/shared_kernel/* ]]; then
    # 如果共享库发生变化,所有应用都需要测试
    # 这是一个简化策略,在真实世界中可以做得更精细
    echo "Shared kernel changed. Marking all apps for testing."
    changed_apps["billing_app"]=1
    changed_apps["inventory_app"]=1
    changed_apps["orders_app"]=1
    break # 已经标记了所有应用,无需继续遍历
  fi
done

# 将需要测试的应用名拼接成一个字符串,用逗号分隔
apps_to_test=$(IFS=,; echo "${!changed_apps[*]}")

if [ -z "$apps_to_test" ]; then
  echo "No app-related changes detected."
  # 将一个特殊值输出到 GITHUB_OUTPUT,以便后续步骤可以跳过
  echo "apps_to_test=none" >> $GITHUB_OUTPUT
else
  echo "Apps to test: $apps_to_test"
  echo "apps_to_test=$apps_to_test" >> $GITHUB_OUTPUT
fi

这个脚本的核心是git diff和路径匹配。它最终会通过GITHUB_OUTPUT导出一个名为apps_to_test的变量,供后续的CI步骤使用。

3. 集中式测试执行器 (test_runner)

这是整个方案的技术核心。它是一个轻量级的Elixir脚本应用,不依赖任何上层业务应用,但它知道如何去“引导”并测试它们。

首先是它的mix.exs文件。关键在于它包含了所有可能用到的测试依赖,比如ExUnitWallaby

# test_runner/mix.exs
defmodule TestRunner.MixProject do
  use Mix.Project

  def project do
    [
      app: :test_runner,
      version: "0.1.0",
      elixirc_paths: elixirc_paths(Mix.env()),
      compilers: [:phoenix, :gettext] ++ Mix.compilers(),
      start_permanent: Mix.env() == :prod,
      # 所有的测试依赖都在这里集中管理
      deps: deps()
    ]
  end

  def application do
    [
      extra_applications: [:logger, :runtime_tools]
    ]
  end

  defp elixirc_paths(:test), do: ["lib", "test/support"]
  defp elixirc_paths(_), do: ["lib"]

  defp deps do
    [
      # BDD 测试框架
      {:wallaby, "~> 0.30.0", runtime: false, only: :test},
      # Wallaby 依赖的浏览器驱动
      {:selenium_remote_driver, "~> 0.3", runtime: false, only: :test},
      # Phoenix 应用测试需要
      {:phoenix, "~> 1.7.0"},
      {:gettext, "~> 0.20"},
      # 其他可能需要的测试库...
    ]
  end
end

接下来是执行器的主体脚本test_runner/run.exs。这个脚本做了几件非常规但至关重要的事:

  1. 解析输入参数: 从命令行接收需要测试的应用列表。
  2. 动态修改代码路径: 使用Code.prepend_path/1在运行时将目标应用的已编译代码(_build目录)和源代码(lib目录)动态加入到Erlang虚拟机的代码加载路径中。这是避免重新编译的关键。
  3. 配置与启动: 动态加载目标应用的配置,并手动启动其Phoenix Endpoint,为Wallaby测试做准备。
  4. 程序化执行测试: 调用ExUnit.run/1,并精确指定只运行目标应用的测试文件。
  5. 资源清理: 保证测试结束后能优雅地关闭Endpoint。
# test_runner/run.exs

defmodule TestRunner.CLI do
  @moduledoc """
  A centralized test runner for the Phoenix Monorepo.
  It dynamically loads and tests specified applications.
  """

  @umbrella_root Path.expand("../", __DIR__)

  def main(args) do
    # 从命令行参数解析需要测试的应用列表
    apps_to_test =
      case args do
        [apps_str] ->
          apps_str
          |> String.split(",", trim: true)
          |> Enum.filter(&(&1 != ""))
        _ ->
          IO.puts(:stderr, "Usage: mix run run.exs <app1,app2,...>")
          System.halt(1)
      end

    if Enum.empty?(apps_to_test) do
      IO.puts("No apps to test. Exiting successfully.")
      System.halt(0)
    end

    IO.puts("Preparing to test applications: #{inspect(apps_to_test)}")

    # 编译 test_runner 自身及其依赖,这一步在CI中会被缓存
    {_output, status} = System.cmd("mix", ["deps.get"], into: IO.stream(:stdio, :line), cd: Path.join(@umbrella_root, "test_runner"))
    if status != 0, do: System.halt(status)

    {_output, status} = System.cmd("mix", ["compile"], into: IO.stream(:stdio, :line), cd: Path.join(@umbrella_root, "test_runner"))
    if status != 0, do: System.halt(status)
    
    # 将 test_runner 自身的编译产物加入代码路径
    # 这是为了让脚本能使用 Wallaby 等依赖
    Code.prepend_path(Path.join([@umbrella_root, "test_runner", "_build", "test", "lib", "test_runner", "ebin"]))

    # 逐个测试指定的应用
    exit_code =
      apps_to_test
      |> Enum.reduce(0, fn app_name, acc_exit_code ->
        IO.puts("--- Running tests for #{app_name} ---")
        app_exit_code = run_tests_for(app_name)

        if acc_exit_code == 0, do: app_exit_code, else: acc_exit_code
      end)

    System.halt(exit_code)
  end

  defp run_tests_for(app_name) do
    app_path = Path.join([@umbrella_root, "apps", app_name])
    app_module = app_name |> Macro.camelize() |> String.to_atom()

    unless File.dir?(app_path) do
      IO.puts(:stderr, "Error: Application '#{app_name}' not found at #{app_path}")
      # 返回一个非零退出码表示失败
      return 1
    end

    # 1. 动态加载代码路径
    # 这是整个方案的核心,我们告诉BEAM去哪里找这个应用的已编译代码
    build_path = Path.join([@umbrella_root, "_build", "test", "lib", app_name, "ebin"])
    IO.puts("Prepending code path: #{build_path}")
    Code.prepend_path(build_path)
    
    # 加载共享库的代码路径
    shared_kernel_build_path = Path.join([@umbrella_root, "_build", "test", "lib", "shared_kernel", "ebin"])
    Code.prepend_path(shared_kernel_build_path)


    # 2. 加载应用配置并启动 BDD 测试依赖
    # 我们假设所有测试配置都在根目录的 config/test.exs 中
    Mix.Config.read!(Path.join(@umbrella_root, "config", "test.exs"))

    # 获取该应用的 Endpoint 模块
    endpoint_module = Module.concat(app_module, Web.Endpoint)

    # 启动Wallaby Session所需的Supervisor
    {:ok, _pid} = Wallaby.start_session(endpoint: endpoint_module)
    IO.puts("Started Wallaby session for #{inspect(endpoint_module)}")


    # 3. 执行测试
    test_path = Path.join(app_path, "test")
    IO.puts("Running ExUnit for path: #{test_path}")

    # 清除之前的测试结果
    ExUnit.Server.clear()
    
    # ExUnit.run/1 接受一个配置列表
    # 我们通过 :include 和 :exclude 来精确控制测试范围
    # :trace 选项可以在调试时非常有用
    exit_status = ExUnit.run(
      exclude: [:pending],
      # 只加载指定应用目录下的测试文件
      # 这里的实现是加载所有 *_test.exs 文件
      files: Path.wildcard(Path.join(test_path, "**/*_test.exs"))
    )
    
    # 4. 清理
    # 在真实项目中,这里可能需要更复杂的应用停止逻辑
    :ok = Wallaby.end_session()
    IO.puts("Stopped Wallaby session.")

    # 将 ExUnit 的退出状态转换为 shell 的退出码
    # 0 表示成功, 1 表示失败
    if exit_status == :ok, do: 0, else: 1
  end
end

TestRunner.CLI.main(System.argv())

这里的代码远比一个简单的mix test复杂,但它给予了我们精细的控制权。我们不再依赖Mix的项目上下文,而是创建了一个自己的、轻量级的测试上下文。

整合到CI流水线 (GitHub Actions)

最后一步是将变更检测脚本和测试执行器整合到CI工作流中。我们的.github/workflows/ci.yml文件会包含以下几个关键步骤:

  1. 全局构建与缓存: 一个独立的job,负责编译整个Umbrella项目的所有依赖和应用代码。它的产物(主要是_build目录)会被缓存起来。这个job只有在mix.lock文件变化时才需要花费大量时间,否则会因为缓存而秒过。
  2. 测试执行: 另一个job,依赖于上一步。它会:
    • 恢复缓存的_build目录。
    • 运行find_changed_apps.sh脚本来确定要测试的应用。
    • 如果需要测试的应用列表不为空,则调用test_runner/run.exs脚本执行测试。
# .github/workflows/ci.yml
name: Elixir CI

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

jobs:
  build:
    name: Build & Cache Dependencies
    runs-on: ubuntu-latest
    strategy:
      matrix:
        elixir: ['1.15.7']
        otp: ['26.1.2']
    
    steps:
    - uses: actions/checkout@v3
      with:
        fetch-depth: 0 # 需要完整历史来执行 git diff

    - name: Set up Elixir
      uses: erlef/setup-beam@v1
      with:
        elixir-version: ${{ matrix.elixir }}
        otp-version: ${{ matrix.otp }}

    - name: Restore dependencies cache
      uses: actions/cache@v3
      id: deps-cache
      with:
        path: |
          deps
          _build
        key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
        restore-keys: |
          ${{ runner.os }}-mix-

    - name: Install Dependencies
      if: steps.deps-cache.outputs.cache-hit != 'true'
      run: mix deps.get

    - name: Compile Project
      # 我们编译整个项目到 test 环境,以确保 _build 目录是完整的
      run: MIX_ENV=test mix compile

  test:
    name: Run BDD Tests
    needs: build # 依赖于 build job
    runs-on: ubuntu-latest
    strategy:
      matrix:
        elixir: ['1.15.7']
        otp: ['26.1.2']

    steps:
    - uses: actions/checkout@v3
      with:
        fetch-depth: 0 

    - name: Set up Elixir
      uses: erlef/setup-beam@v1
      with:
        elixir-version: ${{ matrix.elixir }}
        otp-version: ${{ matrix.otp }}

    # 关键:恢复由 build job 生成的缓存
    - name: Restore build cache
      uses: actions/cache@v3
      with:
        path: |
          deps
          _build
        key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }}
        restore-keys: |
          ${{ runner.os }}-mix-

    # 设置 Chrome Driver for Wallaby
    - name: Setup ChromeDriver
      uses: nanasess/setup-chromedriver@v1

    - name: Find changed apps
      id: changes
      run: |
        chmod +x ci/find_changed_apps.sh
        ./ci/find_changed_apps.sh

    - name: Run selective tests
      # 只有在 changes 步骤检测到需要测试的应用时才运行
      if: steps.changes.outputs.apps_to_test != 'none'
      run: |
        cd test_runner
        # 将需要测试的应用列表作为参数传递给执行脚本
        mix run run.exs ${{ steps.changes.outputs.apps_to_test }}

这个CI配置清晰地分离了“构建”和“测试”两个关注点。build job承担了耗时的编译工作,并且其结果可以被高效缓存。test job则变得非常轻量,它只做决策和执行,几乎所有的编译产物都直接来自缓存。

流程可视化

旧的流程和新流程的对比可以用Mermaid图直观表示。

旧的、低效的CI流程:

graph TD
    A[Push Commit] --> B{CI Trigger};
    B --> C1[Setup Env for App A];
    C1 --> D1[Deps Get & Compile for A];
    D1 --> E1[Run Tests for A];
    
    B --> C2[Setup Env for App B];
    C2 --> D2[Deps Get & Compile for B];
    D2 --> E2[Run Tests for B];

    B --> C3[Setup Env for App C];
    C3 --> D3[Deps Get & Compile for C];
    D3 --> E3[Run Tests for C];

    E1 --> F{Report};
    E2 --> F;
    E3 --> F;

新的、高效的CI流程:

graph TD
    subgraph "Job 1: Build & Cache"
        A[Push Commit] --> B{CI Trigger};
        B --> C{Check mix.lock Hash};
        C -- Cache Miss --> D[Full Deps Get & Compile];
        D --> E[Cache _build & deps];
        C -- Cache Hit --> F[Restore Cache - Fast];
        F --> G[End Job 1];
        E --> G;
    end

    subgraph "Job 2: Selective Test"
        G --> H[Restore _build & deps from Cache];
        H --> I[Run change_detection.sh];
        I --> J{Apps to Test?};
        J -- Yes --> K[Run test_runner with App List];
        K --> L{Report Results};
        J -- No --> M[Skip Tests - Success];
        M --> L;
    end

局限性与未来展望

这套方案虽然极大地提升了CI效率,但并非没有缺点。一个常见的错误是,当开发者未能正确更新变更检测脚本的逻辑时(例如,新增了一个共享库但忘记在脚本中添加规则),可能会导致测试覆盖不全。这要求团队必须维护find_changed_apps.sh的准确性。

此外,我们的变更检测逻辑还比较粗糙。它基于文件路径,而不是代码的实际依赖关系图。一个更先进的系统或许能够通过静态分析Elixir代码,构建出模块间的精确依赖图,从而实现更细粒度的变更影响分析。例如,只修改了shared_kernel中一个未被billing_app使用的模块,理论上就不需要测试billing_app。但这会引入巨大的实现复杂性,目前这种基于目录的策略是在简单性和效率之间的一个务实权衡。

对于更大型的、跨语言的Monorepo,可能会考虑引入像Bazel或Buck这样的通用构建系统。它们能提供更强大、更精确的依赖分析和缓存能力。然而,将其整合进Elixir生态需要大量的工作和定制,对于大多数中等规模的Elixir项目来说,我们这里实现的脚本化方案已经足够应对挑战,并且维护成本相对可控。


  目录