CI流水线的执行时间从25分钟降到了3分钟,仅仅因为我们改变了在Monorepo中运行测试的方式。问题源于一个看似合理的架构:一个包含三个独立Phoenix应用的Elixir Umbrella项目,共享着一些内部库。每次提交,无论改动多小,GitHub Actions都会完整地重新获取、编译所有应用的依赖,然后运行全部测试套件。其中,基于Wallaby的BDD(行为驱动开发)测试又是最耗时的部分,它们需要启动一个真实的浏览器实例。这种“全量构建”策略在项目初期尚可接受,但随着应用和测试的增多,它变成了团队效率的巨大瓶셔颈。
痛点非常明确:90%的CI时间都浪费在了重复劳动上。一个只修改了orders
服务文案的提交,不应该触发inventory
和billing
服务的完整测试。我们的目标是构建一个智能的测试流水线,它能够:
- 精确识别出变更影响了哪些应用。
- 只针对受影响的应用执行测试。
- 最大限度地重用已编译的依赖,避免在每个测试任务中重复
mix deps.get
和mix 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
文件。关键在于它包含了所有可能用到的测试依赖,比如ExUnit
和Wallaby
。
# 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
。这个脚本做了几件非常规但至关重要的事:
- 解析输入参数: 从命令行接收需要测试的应用列表。
- 动态修改代码路径: 使用
Code.prepend_path/1
在运行时将目标应用的已编译代码(_build
目录)和源代码(lib
目录)动态加入到Erlang虚拟机的代码加载路径中。这是避免重新编译的关键。 - 配置与启动: 动态加载目标应用的配置,并手动启动其Phoenix Endpoint,为Wallaby测试做准备。
- 程序化执行测试: 调用
ExUnit.run/1
,并精确指定只运行目标应用的测试文件。 - 资源清理: 保证测试结束后能优雅地关闭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
文件会包含以下几个关键步骤:
- 全局构建与缓存: 一个独立的job,负责编译整个Umbrella项目的所有依赖和应用代码。它的产物(主要是
_build
目录)会被缓存起来。这个job只有在mix.lock
文件变化时才需要花费大量时间,否则会因为缓存而秒过。 - 测试执行: 另一个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项目来说,我们这里实现的脚本化方案已经足够应对挑战,并且维护成本相对可控。